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

120 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-10-06 00:18 +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 dataclasses import dataclass 

9from functools import partial 

10from typing import TYPE_CHECKING, Any 

11 

12import voluptuous as vol 

13from homeassistant.components.system_log import EVENT_SYSTEM_LOG 

14from homeassistant.const import EVENT_HOMEASSISTANT_STOP 

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

16 

17from custom_components.remote_logger.handler import ExportingLogHandler 

18 

19from .const import ( 

20 BACKEND_SYSLOG, 

21 CONF_BACKEND, 

22 CONF_CUSTOM_EVENTS, 

23 CONF_EVENT_BASED_LOGGING, 

24 CONF_LOG_HA_CORE_ACTIVITY, 

25 CONF_LOG_HA_CORE_CHANGES, 

26 CONF_LOG_HA_EVENT_BODY, 

27 CONF_LOG_HA_FULL_STATE_CHANGES, 

28 CONF_LOG_HA_LIFECYCLE, 

29 CONF_LOG_HA_STATE_CHANGES, 

30 CONF_LOG_LEVEL, 

31 CORE_ACTIVITY_EVENTS, 

32 CORE_CHANGE_EVENTS, 

33 CORE_STATE_EVENTS, 

34 DEFAULT_LOG_LEVEL, 

35 DOMAIN, 

36 LIFECYCLE_EVENTS, 

37 PLATFORMS, 

38) 

39from .otel.exporter import OtlpLogExporter 

40from .syslog.exporter import SyslogExporter 

41 

42SERVICE_SEND_LOG = "send_log" 

43SERVICE_SEND_LOG_SCHEMA = vol.Schema({ 

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

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

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

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

48}) 

49 

50SERVICE_FLUSH = "flush" 

51 

52SERVICE_LAST_LOG = "last_log" 

53SERVICE_LAST_LOG_SCHEMA = vol.Schema({ 

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

55}) 

56 

57if TYPE_CHECKING: 

58 from collections.abc import Callable 

59 

60 from homeassistant.config_entries import ConfigEntry 

61 from homeassistant.core import HomeAssistant, ServiceCall 

62 

63 from custom_components.remote_logger.exporter import LogExporter, LogSubmission 

64 

65_LOGGER = logging.getLogger(__name__) 

66 

67 

68@dataclass 

69class RemoteLoggerData: 

70 """Runtime state for a loaded remote_logger config entry.""" 

71 

72 exporter: LogExporter 

73 flush_task: asyncio.Task[None] 

74 cancel_listeners: list[Callable[[], None]] 

75 log_handler: logging.Handler | None 

76 

77 

78type RemoteLoggerConfigEntry = ConfigEntry[RemoteLoggerData] 

79 

80 

81async def _async_update_listener(hass: HomeAssistant, entry: RemoteLoggerConfigEntry) -> None: 

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

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

84 

85 

86async def async_setup_entry(hass: HomeAssistant, entry: RemoteLoggerConfigEntry) -> bool: 

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

88 backend = entry.data.get(CONF_BACKEND) 

89 

90 exporter: OtlpLogExporter | SyslogExporter 

91 if backend == BACKEND_SYSLOG: 

92 exporter = SyslogExporter(hass, entry) 

93 label: str = exporter.endpoint_desc 

94 else: 

95 exporter = OtlpLogExporter(hass, entry) 

96 label = exporter.endpoint_url 

97 

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

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

100 

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

102 await exporter.disable_buffer() 

103 

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

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

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

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

108 entry.add_update_listener(_async_update_listener), 

109 ] 

110 

111 log_handler: logging.Handler | None = None 

112 event_based_logging: bool = bool(opts.get(CONF_EVENT_BASED_LOGGING)) 

113 if event_based_logging: 

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

115 else: 

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

117 log_handler = ExportingLogHandler(hass, exporter.handle_entry) 

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

119 logging.root.addHandler(log_handler) 

120 

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

122 

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

124 

125 if opts.get(CONF_LOG_HA_LIFECYCLE): 

126 cancel_listeners.extend( 

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

128 ) 

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

130 

131 if opts.get(CONF_LOG_HA_CORE_CHANGES): 

132 cancel_listeners.extend( 

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

134 ) 

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

136 

137 if opts.get(CONF_LOG_HA_STATE_CHANGES): 

138 cancel_listeners.extend( 

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

140 for et in CORE_STATE_EVENTS 

141 ) 

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

143 

144 if opts.get(CONF_LOG_HA_FULL_STATE_CHANGES): 

145 cancel_listeners.extend( 

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

147 for et in CORE_STATE_EVENTS 

148 ) 

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

150 

151 if opts.get(CONF_LOG_HA_CORE_ACTIVITY): 

152 cancel_listeners.extend( 

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

154 for et in CORE_ACTIVITY_EVENTS 

155 ) 

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

157 

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

159 if et.strip(): 

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

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

162 

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

164 

165 entry.runtime_data = RemoteLoggerData( 

166 exporter=exporter, 

167 flush_task=flush_task, 

168 cancel_listeners=cancel_listeners, 

169 log_handler=log_handler, 

170 ) 

171 

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

173 hass.services.async_register( 

174 DOMAIN, 

175 SERVICE_SEND_LOG, 

176 handle_send_log, 

177 schema=SERVICE_SEND_LOG_SCHEMA, 

178 ) 

179 

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

181 hass.services.async_register(DOMAIN, SERVICE_FLUSH, handle_flush) 

182 

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

184 hass.services.async_register( 

185 DOMAIN, 

186 SERVICE_LAST_LOG, 

187 handle_last_log, 

188 schema=SERVICE_LAST_LOG_SCHEMA, 

189 supports_response=SupportsResponse.ONLY, 

190 ) 

191 

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

193 

194 return True 

195 

196 

197def _loaded_entries(hass: HomeAssistant) -> list[RemoteLoggerConfigEntry]: 

198 return hass.config_entries.async_loaded_entries(DOMAIN) 

199 

200 

201async def handle_flush(call: ServiceCall) -> None: 

202 for entry in _loaded_entries(call.hass): 

203 await entry.runtime_data.exporter.flush() 

204 

205 

206@callback 

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

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

209 entry = next((e for e in _loaded_entries(call.hass) if e.entry_id == entry_id), None) 

210 if entry is None: 

211 return {} 

212 submission: LogSubmission | None = entry.runtime_data.exporter.last_sent_payload 

213 if submission is None: 

214 return {} 

215 return submission.for_display() 

216 

217 

218@callback 

219def handle_send_log(call: ServiceCall) -> None: 

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

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

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

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

224 for entry in _loaded_entries(call.hass): 

225 entry.runtime_data.exporter.log_direct(event_name, message, level, attributes) 

226 

227 

228async def async_unload_entry(hass: HomeAssistant, entry: RemoteLoggerConfigEntry) -> bool: 

229 """Unload remote_logger config entry. 

230 

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

232 """ 

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

234 data = entry.runtime_data 

235 

236 for cancel in data.cancel_listeners: 

237 try: 

238 cancel() 

239 except Exception as e: # ruff: ignore[blind-except] 

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

241 

242 if data.log_handler is not None: 

243 logging.root.removeHandler(data.log_handler) 

244 

245 data.flush_task.cancel() 

246 with contextlib.suppress(asyncio.CancelledError): 

247 await data.flush_task 

248 

249 await data.exporter.flush() 

250 await data.exporter.close() 

251 

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

253 return True