Coverage for custom_components / remote_logger / remote_logger.py: 93%

122 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-22 21:55 +0000

1"""The remote_logger integration: ship HA system_log_event to an OTLP collector or syslog server.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import contextlib 

7import logging 

8from functools import partial 

9from typing import TYPE_CHECKING, Any 

10 

11import voluptuous as vol 

12from homeassistant.components.system_log import EVENT_SYSTEM_LOG 

13from homeassistant.const import EVENT_HOMEASSISTANT_STOP 

14from homeassistant.core import EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_FINAL_WRITE, SupportsResponse, callback 

15 

16from custom_components.remote_logger.handler import ExportingLogHandler 

17 

18from .const import ( 

19 BACKEND_SYSLOG, 

20 CONF_BACKEND, 

21 CONF_CUSTOM_EVENTS, 

22 CONF_EVENT_BASED_LOGGING, 

23 CONF_LOG_HA_CORE_ACTIVITY, 

24 CONF_LOG_HA_CORE_CHANGES, 

25 CONF_LOG_HA_EVENT_BODY, 

26 CONF_LOG_HA_FULL_STATE_CHANGES, 

27 CONF_LOG_HA_LIFECYCLE, 

28 CONF_LOG_HA_STATE_CHANGES, 

29 CONF_LOG_LEVEL, 

30 CORE_ACTIVITY_EVENTS, 

31 CORE_CHANGE_EVENTS, 

32 CORE_STATE_EVENTS, 

33 DEFAULT_LOG_LEVEL, 

34 DOMAIN, 

35 LIFECYCLE_EVENTS, 

36 PLATFORMS, 

37) 

38from .otel.exporter import OtlpLogExporter 

39from .syslog.exporter import SyslogExporter 

40 

41REF_CANCEL_LISTENERS = "cancel_listeners" 

42REF_FLUSH_TASK = "flush_task" 

43REF_EXPORTER = "exporter" 

44REF_LOG_HANDLER = "log_handler" 

45 

46SERVICE_SEND_LOG = "send_log" 

47SERVICE_SEND_LOG_SCHEMA = vol.Schema({ 

48 vol.Required("event"): str, 

49 vol.Required("message"): str, 

50 vol.Optional("level", default="INFO"): vol.In(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), 

51 vol.Optional("attributes"): dict, 

52}) 

53 

54SERVICE_FLUSH = "flush" 

55 

56SERVICE_LAST_LOG = "last_log" 

57SERVICE_LAST_LOG_SCHEMA = vol.Schema({ 

58 vol.Required("config_entry_id"): str, 

59}) 

60 

61if TYPE_CHECKING: 

62 from collections.abc import Callable 

63 

64 from homeassistant.config_entries import ConfigEntry 

65 from homeassistant.core import HomeAssistant, ServiceCall 

66 

67 from custom_components.remote_logger.exporter import LogSubmission 

68 

69_LOGGER = logging.getLogger(__name__) 

70 

71 

72async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 

73 """Reload the entry when options are updated.""" 

74 await hass.config_entries.async_reload(entry.entry_id) 

75 

76 

77async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 

78 """Set up remote logs from a config entry.""" 

79 backend = entry.data.get(CONF_BACKEND) 

80 

81 exporter: OtlpLogExporter | SyslogExporter 

82 if backend == BACKEND_SYSLOG: 

83 exporter = SyslogExporter(hass, entry) 

84 label: str = exporter.endpoint_desc 

85 else: 

86 exporter = OtlpLogExporter(hass, entry) 

87 label = exporter.endpoint_url 

88 

89 # Options take precedence over initial data for the three event-subscription keys 

90 opts = {**entry.data, **entry.options} 

91 

92 async def _flush_on_stop(_: Any) -> None: 

93 await exporter.disable_buffer() 

94 

95 cancel_listeners: list[Callable[[], None]] = [ 

96 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _flush_on_stop), 

97 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _flush_on_stop), 

98 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_FINAL_WRITE, _flush_on_stop), 

99 entry.add_update_listener(_async_update_listener), 

100 ] 

101 

102 log_handler: logging.Handler | None = None 

103 event_based_logging: bool = bool(opts.get(CONF_EVENT_BASED_LOGGING, False)) 

104 if event_based_logging: 

105 cancel_listeners.append(hass.bus.async_listen(EVENT_SYSTEM_LOG, exporter.handle_event)) 

106 else: 

107 log_level_name: str = opts.get(CONF_LOG_LEVEL, DEFAULT_LOG_LEVEL) 

108 log_handler = ExportingLogHandler(hass, exporter.handle_entry) 

109 log_handler.setLevel(getattr(logging, log_level_name, logging.INFO)) 

110 logging.root.addHandler(log_handler) 

111 

112 _LOGGER.info("remote_logger: exporting %s to %s", backend, label) 

113 

114 event_body: bool = bool(opts.get(CONF_LOG_HA_EVENT_BODY)) 

115 

116 if opts.get(CONF_LOG_HA_LIFECYCLE): 

117 cancel_listeners.extend( 

118 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, event_body=event_body)) for et in LIFECYCLE_EVENTS 

119 ) 

120 _LOGGER.info("remote_logger: listening for HA lifecycle events") 

121 

122 if opts.get(CONF_LOG_HA_CORE_CHANGES): 

123 cancel_listeners.extend( 

124 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, event_body=event_body)) for et in CORE_CHANGE_EVENTS 

125 ) 

126 _LOGGER.info("remote_logger: listening for HA core config events") 

127 

128 if opts.get(CONF_LOG_HA_STATE_CHANGES): 

129 cancel_listeners.extend( 

130 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, state_only=True, event_body=event_body)) 

131 for et in CORE_STATE_EVENTS 

132 ) 

133 _LOGGER.info("remote_logger: listening for HA state changes") 

134 

135 if opts.get(CONF_LOG_HA_FULL_STATE_CHANGES): 

136 cancel_listeners.extend( 

137 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, state_only=False, event_body=event_body)) 

138 for et in CORE_STATE_EVENTS 

139 ) 

140 _LOGGER.info("remote_logger: listening for HA state changes") 

141 

142 if opts.get(CONF_LOG_HA_CORE_ACTIVITY): 

143 cancel_listeners.extend( 

144 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, event_body=event_body)) 

145 for et in CORE_ACTIVITY_EVENTS 

146 ) 

147 _LOGGER.info("remote_logger: listening for HA core activity") 

148 

149 for et in opts.get(CONF_CUSTOM_EVENTS, []): 

150 if et.strip(): 

151 cancel_listeners.append(hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, event_body=event_body))) 

152 _LOGGER.info("remote_logger: Subscribed to custom event %s", et) 

153 

154 flush_task: asyncio.Task[None] = asyncio.create_task(exporter.flush_loop()) 

155 

156 hass.data.setdefault(DOMAIN, {}) 

157 hass.data[DOMAIN][entry.entry_id] = { 

158 REF_CANCEL_LISTENERS: cancel_listeners, 

159 REF_FLUSH_TASK: flush_task, 

160 REF_EXPORTER: exporter, 

161 REF_LOG_HANDLER: log_handler, 

162 } 

163 

164 if not hass.services.has_service(DOMAIN, SERVICE_SEND_LOG): 

165 hass.services.async_register( 

166 DOMAIN, SERVICE_SEND_LOG, partial(handle_send_log, hass.data[DOMAIN]), schema=SERVICE_SEND_LOG_SCHEMA 

167 ) 

168 

169 if not hass.services.has_service(DOMAIN, SERVICE_FLUSH): 

170 hass.services.async_register(DOMAIN, SERVICE_FLUSH, partial(handle_flush, hass.data[DOMAIN])) 

171 

172 if not hass.services.has_service(DOMAIN, SERVICE_LAST_LOG): 

173 hass.services.async_register( 

174 DOMAIN, 

175 SERVICE_LAST_LOG, 

176 partial(handle_last_log, hass.data[DOMAIN]), 

177 schema=SERVICE_LAST_LOG_SCHEMA, 

178 supports_response=SupportsResponse.ONLY, 

179 ) 

180 

181 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 

182 

183 return True 

184 

185 

186async def handle_flush(domain_data: dict[str, Any], _call: ServiceCall) -> None: 

187 for entry in domain_data.values(): 

188 await entry[REF_EXPORTER].flush() 

189 

190 

191@callback 

192def handle_last_log(domain_data: dict[str, Any], call: ServiceCall) -> dict[str, Any]: 

193 entry_id: str | None = call.data.get("config_entry_id") 

194 entry = None 

195 if entry_id: 

196 entry = domain_data.get(entry_id) 

197 if entry is None: 

198 return {} 

199 submission: LogSubmission | None = entry[REF_EXPORTER].last_sent_payload 

200 if submission is None: 

201 return {} 

202 return submission.for_display() 

203 

204 

205@callback 

206def handle_send_log(domain_data: dict[str, Any], call: ServiceCall) -> None: 

207 message: str = call.data["message"] 

208 level: str = call.data["level"] 

209 event_name: str = call.data["event"] 

210 attributes: dict[str, Any] | None = call.data.get("attributes") 

211 for entry in domain_data.values(): 

212 entry[REF_EXPORTER].log_direct(event_name, message, level, attributes) 

213 

214 

215async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 

216 """Unload remote_logger config entry. 

217 

218 https://developers.home-assistant.io/docs/config_entries_index#unloading-entries 

219 """ 

220 await hass.config_entries.async_unload_platforms(entry, ["sensor"]) 

221 data = hass.data[DOMAIN].pop(entry.entry_id, None) 

222 if data is None: 

223 return True 

224 

225 for cancel in data.get(REF_CANCEL_LISTENERS, []): 

226 try: 

227 cancel() 

228 except Exception as e: 

229 _LOGGER.warning("remote_logger: Failed to cancel listener on unload: %s", e) 

230 

231 handler = data.get(REF_LOG_HANDLER) 

232 if handler is not None: 

233 logging.root.removeHandler(handler) 

234 

235 if data.get(REF_FLUSH_TASK): 

236 data[REF_FLUSH_TASK].cancel() 

237 with contextlib.suppress(asyncio.CancelledError): 

238 await data[REF_FLUSH_TASK] 

239 

240 if data.get(REF_EXPORTER): 

241 await data[REF_EXPORTER].flush() 

242 await data[REF_EXPORTER].close() 

243 

244 _LOGGER.info("remote_logger: unloaded, flushed remaining logs") 

245 return True