1
mirror of https://github.com/home-assistant/core synced 2024-08-02 23:40:32 +02:00
ha-core/homeassistant/components/system_log/__init__.py
2022-07-14 11:05:52 +02:00

250 lines
7.8 KiB
Python

"""Support for system log."""
from collections import OrderedDict, deque
import logging
import re
import traceback
import voluptuous as vol
from homeassistant import __path__ as HOMEASSISTANT_PATH
from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE
from homeassistant.core import HomeAssistant, ServiceCall, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
CONF_MAX_ENTRIES = "max_entries"
CONF_FIRE_EVENT = "fire_event"
CONF_MESSAGE = "message"
CONF_LEVEL = "level"
CONF_LOGGER = "logger"
DATA_SYSTEM_LOG = "system_log"
DEFAULT_MAX_ENTRIES = 50
DEFAULT_FIRE_EVENT = False
DOMAIN = "system_log"
EVENT_SYSTEM_LOG = "system_log_event"
SERVICE_CLEAR = "clear"
SERVICE_WRITE = "write"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(
CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES
): cv.positive_int,
vol.Optional(CONF_FIRE_EVENT, default=DEFAULT_FIRE_EVENT): cv.boolean,
}
)
},
extra=vol.ALLOW_EXTRA,
)
SERVICE_CLEAR_SCHEMA = vol.Schema({})
SERVICE_WRITE_SCHEMA = vol.Schema(
{
vol.Required(CONF_MESSAGE): cv.string,
vol.Optional(CONF_LEVEL, default="error"): vol.In(
["debug", "info", "warning", "error", "critical"]
),
vol.Optional(CONF_LOGGER): cv.string,
}
)
def _figure_out_source(record, call_stack, paths_re):
# If a stack trace exists, extract file names from the entire call stack.
# The other case is when a regular "log" is made (without an attached
# exception). In that case, just use the file where the log was made from.
if record.exc_info:
stack = [(x[0], x[1]) for x in traceback.extract_tb(record.exc_info[2])]
else:
index = -1
for i, frame in enumerate(call_stack):
if frame[0] == record.pathname:
index = i
break
if index == -1:
# For some reason we couldn't find pathname in the stack.
stack = [(record.pathname, record.lineno)]
else:
stack = call_stack[0 : index + 1]
# Iterate through the stack call (in reverse) and find the last call from
# a file in Home Assistant. Try to figure out where error happened.
for pathname in reversed(stack):
# Try to match with a file within Home Assistant
if match := paths_re.match(pathname[0]):
return [match.group(1), pathname[1]]
# Ok, we don't know what this is
return (record.pathname, record.lineno)
class LogEntry:
"""Store HA log entries."""
def __init__(self, record, stack, source):
"""Initialize a log entry."""
self.first_occurred = self.timestamp = record.created
self.name = record.name
self.level = record.levelname
self.message = deque([record.getMessage()], maxlen=5)
self.exception = ""
self.root_cause = None
if record.exc_info:
self.exception = "".join(traceback.format_exception(*record.exc_info))
_, _, tb = record.exc_info # pylint: disable=invalid-name
# Last line of traceback contains the root cause of the exception
if traceback.extract_tb(tb):
self.root_cause = str(traceback.extract_tb(tb)[-1])
self.source = source
self.count = 1
self.hash = str([self.name, *self.source, self.root_cause])
def to_dict(self):
"""Convert object into dict to maintain backward compatibility."""
return {
"name": self.name,
"message": list(self.message),
"level": self.level,
"source": self.source,
"timestamp": self.timestamp,
"exception": self.exception,
"count": self.count,
"first_occurred": self.first_occurred,
}
class DedupStore(OrderedDict):
"""Data store to hold max amount of deduped entries."""
def __init__(self, maxlen=50):
"""Initialize a new DedupStore."""
super().__init__()
self.maxlen = maxlen
def add_entry(self, entry):
"""Add a new entry."""
key = entry.hash
if key in self:
# Update stored entry
existing = self[key]
existing.count += 1
existing.timestamp = entry.timestamp
if entry.message[0] not in existing.message:
existing.message.append(entry.message[0])
self.move_to_end(key)
else:
self[key] = entry
if len(self) > self.maxlen:
# Removes the first record which should also be the oldest
self.popitem(last=False)
def to_list(self):
"""Return reversed list of log entries - LIFO."""
return [value.to_dict() for value in reversed(self.values())]
class LogErrorHandler(logging.Handler):
"""Log handler for error messages."""
def __init__(self, hass, maxlen, fire_event, paths_re):
"""Initialize a new LogErrorHandler."""
super().__init__()
self.hass = hass
self.records = DedupStore(maxlen=maxlen)
self.fire_event = fire_event
self.paths_re = paths_re
def emit(self, record):
"""Save error and warning logs.
Everything logged with error or warning is saved in local buffer. A
default upper limit is set to 50 (older entries are discarded) but can
be changed if needed.
"""
stack = []
if not record.exc_info:
stack = [(f[0], f[1]) for f in traceback.extract_stack()]
entry = LogEntry(
record, stack, _figure_out_source(record, stack, self.paths_re)
)
self.records.add_entry(entry)
if self.fire_event:
self.hass.bus.fire(EVENT_SYSTEM_LOG, entry.to_dict())
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the logger component."""
if (conf := config.get(DOMAIN)) is None:
conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN]
hass_path: str = HOMEASSISTANT_PATH[0]
config_dir = hass.config.config_dir
assert config_dir is not None
paths_re = re.compile(
r"(?:{})/(.*)".format("|".join([re.escape(x) for x in (hass_path, config_dir)]))
)
handler = LogErrorHandler(
hass, conf[CONF_MAX_ENTRIES], conf[CONF_FIRE_EVENT], paths_re
)
handler.setLevel(logging.WARN)
hass.data[DOMAIN] = handler
@callback
def _async_stop_handler(_) -> None:
"""Cleanup handler."""
logging.root.removeHandler(handler)
del hass.data[DOMAIN]
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_stop_handler)
logging.root.addHandler(handler)
websocket_api.async_register_command(hass, list_errors)
async def async_service_handler(service: ServiceCall) -> None:
"""Handle logger services."""
if service.service == "clear":
handler.records.clear()
return
if service.service == "write":
logger = logging.getLogger(
service.data.get(CONF_LOGGER, f"{__name__}.external")
)
level = service.data[CONF_LEVEL]
getattr(logger, level)(service.data[CONF_MESSAGE])
hass.services.async_register(
DOMAIN, SERVICE_CLEAR, async_service_handler, schema=SERVICE_CLEAR_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_WRITE, async_service_handler, schema=SERVICE_WRITE_SCHEMA
)
return True
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "system_log/list"})
@callback
def list_errors(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
):
"""List all possible diagnostic handlers."""
connection.send_result(
msg["id"],
hass.data[DOMAIN].records.to_list(),
)