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
« 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."""
3from __future__ import annotations
5import asyncio
6import contextlib
7import logging
8from functools import partial
9from typing import TYPE_CHECKING, Any
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
16from custom_components.remote_logger.handler import ExportingLogHandler
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
41REF_CANCEL_LISTENERS = "cancel_listeners"
42REF_FLUSH_TASK = "flush_task"
43REF_EXPORTER = "exporter"
44REF_LOG_HANDLER = "log_handler"
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})
54SERVICE_FLUSH = "flush"
56SERVICE_LAST_LOG = "last_log"
57SERVICE_LAST_LOG_SCHEMA = vol.Schema({
58 vol.Required("config_entry_id"): str,
59})
61if TYPE_CHECKING:
62 from collections.abc import Callable
64 from homeassistant.config_entries import ConfigEntry
65 from homeassistant.core import HomeAssistant, ServiceCall
67 from custom_components.remote_logger.exporter import LogSubmission
69_LOGGER = logging.getLogger(__name__)
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)
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)
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
89 # Options take precedence over initial data for the three event-subscription keys
90 opts = {**entry.data, **entry.options}
92 async def _flush_on_stop(_: Any) -> None:
93 await exporter.disable_buffer()
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 ]
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)
112 _LOGGER.info("remote_logger: exporting %s to %s", backend, label)
114 event_body: bool = bool(opts.get(CONF_LOG_HA_EVENT_BODY))
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")
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")
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")
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")
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")
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)
154 flush_task: asyncio.Task[None] = asyncio.create_task(exporter.flush_loop())
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 }
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 )
169 if not hass.services.has_service(DOMAIN, SERVICE_FLUSH):
170 hass.services.async_register(DOMAIN, SERVICE_FLUSH, partial(handle_flush, hass.data[DOMAIN]))
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 )
181 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
183 return True
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()
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()
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)
215async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
216 """Unload remote_logger config entry.
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
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)
231 handler = data.get(REF_LOG_HANDLER)
232 if handler is not None:
233 logging.root.removeHandler(handler)
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]
240 if data.get(REF_EXPORTER):
241 await data[REF_EXPORTER].flush()
242 await data[REF_EXPORTER].close()
244 _LOGGER.info("remote_logger: unloaded, flushed remaining logs")
245 return True