Coverage for src/lilbee/cli/commands/serve_logging.py: 100%
83 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Server log-file routing for ``lilbee serve``: rotating file under the data root."""
3from __future__ import annotations
5import faulthandler
6import logging
7import sys
8from collections.abc import Iterable
9from logging.handlers import RotatingFileHandler
10from pathlib import Path
11from types import TracebackType
12from typing import IO
14from lilbee.core.config import cfg, load_warnings
16logger = logging.getLogger(__name__)
18_LOG_DIR_NAME = "logs"
19_SERVER_LOG_FILE_NAME = "server.log"
20_FAULT_LOG_FILE_NAME = "server-fault.log"
21_MAX_BYTES = 2_097_152 # 2 MiB
22_BACKUP_COUNT = 3
23_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
24_UVICORN_ACCESS_LOGGER = "uvicorn.access"
25_HEALTH_ACCESS_PATH = "/api/health"
28def setup_server_log_file(earlier: Iterable[str] = ()) -> Path:
29 """Install a RotatingFileHandler at ``cfg.data_root/logs/server.log``. Idempotent.
31 A new handler starts with the *earlier* warnings, logged before any handler existed.
32 """
33 log_dir = cfg.data_root / _LOG_DIR_NAME
34 log_dir.mkdir(parents=True, exist_ok=True)
35 log_path = log_dir / _SERVER_LOG_FILE_NAME
37 root = logging.getLogger()
38 for handler in root.handlers:
39 if isinstance(handler, RotatingFileHandler) and Path(handler.baseFilename) == log_path:
40 return log_path
42 file_handler = RotatingFileHandler(log_path, maxBytes=_MAX_BYTES, backupCount=_BACKUP_COUNT)
43 file_handler.setFormatter(logging.Formatter(_LOG_FORMAT))
44 file_handler.setLevel(logging.INFO)
45 root.addHandler(file_handler)
46 for message in earlier:
47 file_handler.handle(
48 logging.makeLogRecord(
49 {
50 "name": logger.name,
51 "levelno": logging.WARNING,
52 "levelname": "WARNING",
53 "msg": message,
54 }
55 )
56 )
57 if root.level > logging.INFO:
58 root.setLevel(logging.INFO)
59 return log_path
62class _FaultLog:
63 """Holds the fault-log handle alive; faulthandler writes to the raw fd."""
65 def __init__(self) -> None:
66 self._handle: IO[str] | None = None
68 def enable(self) -> Path:
69 """Open the fault log if needed and point faulthandler at it."""
70 log_dir = cfg.data_root / _LOG_DIR_NAME
71 log_dir.mkdir(parents=True, exist_ok=True)
72 fault_path = log_dir / _FAULT_LOG_FILE_NAME
73 if self._handle is None or self._handle.closed:
74 self._handle = fault_path.open("a", encoding="utf-8")
75 faulthandler.enable(file=self._handle)
76 return fault_path
79_fault_log = _FaultLog()
82def enable_fault_log() -> Path:
83 """Point ``faulthandler`` at ``cfg.data_root/logs/server-fault.log``. Idempotent."""
84 return _fault_log.enable()
87class _ExceptHook:
88 """Tracks whether the logging excepthook is installed."""
90 def __init__(self) -> None:
91 self.installed = False
93 def install(self) -> None:
94 """Install the logging excepthook once, chaining to the previous hook."""
95 if self.installed:
96 return
97 previous = sys.excepthook
99 def _hook(
100 exc_type: type[BaseException], exc: BaseException, tb: TracebackType | None
101 ) -> None:
102 logger.critical("unhandled exception", exc_info=(exc_type, exc, tb))
103 previous(exc_type, exc, tb)
105 sys.excepthook = _hook
106 self.installed = True
109_except_hook = _ExceptHook()
112def install_excepthook() -> None:
113 """Log unhandled exceptions before the previous hook runs. Idempotent."""
114 _except_hook.install()
117def _show_info_on_console() -> None:
118 """Lower each root console handler to INFO so startup and signal lines show at any CLI level."""
119 for handler in logging.getLogger().handlers:
120 if (
121 isinstance(handler, logging.StreamHandler)
122 and not isinstance(handler, logging.FileHandler)
123 and handler.level > logging.INFO
124 ):
125 handler.setLevel(logging.INFO)
128def setup_server_logging() -> None:
129 """Console, file log, fault log, and excepthook for a ``serve`` process."""
130 setup_server_log_file(load_warnings)
131 _show_info_on_console()
132 enable_fault_log()
133 install_excepthook()
136class _HealthAccessFilter(logging.Filter):
137 """Drop uvicorn access records for the health endpoint."""
139 def filter(self, record: logging.LogRecord) -> bool:
140 return _HEALTH_ACCESS_PATH not in record.getMessage()
143def install_health_access_filter() -> None:
144 """Quiet health-poll lines on the uvicorn access logger. Idempotent."""
145 access_logger = logging.getLogger(_UVICORN_ACCESS_LOGGER)
146 for filt in access_logger.filters:
147 if isinstance(filt, _HealthAccessFilter):
148 return
149 access_logger.addFilter(_HealthAccessFilter())