mirror of
https://github.com/home-assistant/core
synced 2024-08-02 23:40:32 +02:00
250 lines
7.8 KiB
Python
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(),
|
|
)
|