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
« 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."""
3from __future__ import annotations
5import asyncio
6import contextlib
7import logging
8from dataclasses import dataclass
9from functools import partial
10from typing import TYPE_CHECKING, Any
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
17from custom_components.remote_logger.handler import ExportingLogHandler
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
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})
50SERVICE_FLUSH = "flush"
52SERVICE_LAST_LOG = "last_log"
53SERVICE_LAST_LOG_SCHEMA = vol.Schema({
54 vol.Required("config_entry_id"): str,
55})
57if TYPE_CHECKING:
58 from collections.abc import Callable
60 from homeassistant.config_entries import ConfigEntry
61 from homeassistant.core import HomeAssistant, ServiceCall
63 from custom_components.remote_logger.exporter import LogExporter, LogSubmission
65_LOGGER = logging.getLogger(__name__)
68@dataclass
69class RemoteLoggerData:
70 """Runtime state for a loaded remote_logger config entry."""
72 exporter: LogExporter
73 flush_task: asyncio.Task[None]
74 cancel_listeners: list[Callable[[], None]]
75 log_handler: logging.Handler | None
78type RemoteLoggerConfigEntry = ConfigEntry[RemoteLoggerData]
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)
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)
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
98 # Options take precedence over initial data for the three event-subscription keys
99 opts = {**entry.data, **entry.options}
101 async def _flush_on_stop(_: Any) -> None:
102 await exporter.disable_buffer()
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 ]
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)
121 _LOGGER.info("remote_logger: exporting %s to %s", backend, label)
123 event_body: bool = bool(opts.get(CONF_LOG_HA_EVENT_BODY))
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")
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")
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")
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")
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")
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)
163 flush_task: asyncio.Task[None] = asyncio.create_task(exporter.flush_loop())
165 entry.runtime_data = RemoteLoggerData(
166 exporter=exporter,
167 flush_task=flush_task,
168 cancel_listeners=cancel_listeners,
169 log_handler=log_handler,
170 )
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 )
180 if not hass.services.has_service(DOMAIN, SERVICE_FLUSH):
181 hass.services.async_register(DOMAIN, SERVICE_FLUSH, handle_flush)
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 )
192 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
194 return True
197def _loaded_entries(hass: HomeAssistant) -> list[RemoteLoggerConfigEntry]:
198 return hass.config_entries.async_loaded_entries(DOMAIN)
201async def handle_flush(call: ServiceCall) -> None:
202 for entry in _loaded_entries(call.hass):
203 await entry.runtime_data.exporter.flush()
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()
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)
228async def async_unload_entry(hass: HomeAssistant, entry: RemoteLoggerConfigEntry) -> bool:
229 """Unload remote_logger config entry.
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
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)
242 if data.log_handler is not None:
243 logging.root.removeHandler(data.log_handler)
245 data.flush_task.cancel()
246 with contextlib.suppress(asyncio.CancelledError):
247 await data.flush_task
249 await data.exporter.flush()
250 await data.exporter.close()
252 _LOGGER.info("remote_logger: unloaded, flushed remaining logs")
253 return True