Coverage for src/lilbee/cli/commands/serve_logging.py: 100%
78 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +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 logging.handlers import RotatingFileHandler
9from pathlib import Path
10from types import TracebackType
11from typing import IO
13from lilbee.core.config import cfg
15logger = logging.getLogger(__name__)
17_LOG_DIR_NAME = "logs"
18_SERVER_LOG_FILE_NAME = "server.log"
19_FAULT_LOG_FILE_NAME = "server-fault.log"
20_MAX_BYTES = 2_097_152 # 2 MiB
21_BACKUP_COUNT = 3
22_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
23_UVICORN_ACCESS_LOGGER = "uvicorn.access"
24_HEALTH_ACCESS_PATH = "/api/health"
27def setup_server_log_file() -> Path:
28 """Install a RotatingFileHandler at ``cfg.data_root/logs/server.log``. Idempotent."""
29 log_dir = cfg.data_root / _LOG_DIR_NAME
30 log_dir.mkdir(parents=True, exist_ok=True)
31 log_path = log_dir / _SERVER_LOG_FILE_NAME
33 root = logging.getLogger()
34 for handler in root.handlers:
35 if isinstance(handler, RotatingFileHandler) and Path(handler.baseFilename) == log_path:
36 return log_path
38 file_handler = RotatingFileHandler(log_path, maxBytes=_MAX_BYTES, backupCount=_BACKUP_COUNT)
39 file_handler.setFormatter(logging.Formatter(_LOG_FORMAT))
40 file_handler.setLevel(logging.INFO)
41 root.addHandler(file_handler)
42 if root.level > logging.INFO:
43 # NOTSET stream handlers delegate to root; pin them so stderr verbosity is unchanged.
44 for handler in root.handlers:
45 if handler is not file_handler and handler.level == logging.NOTSET:
46 handler.setLevel(root.level)
47 root.setLevel(logging.INFO)
48 return log_path
51class _FaultLog:
52 """Holds the fault-log handle alive; faulthandler writes to the raw fd."""
54 def __init__(self) -> None:
55 self._handle: IO[str] | None = None
57 def enable(self) -> Path:
58 """Open the fault log if needed and point faulthandler at it."""
59 log_dir = cfg.data_root / _LOG_DIR_NAME
60 log_dir.mkdir(parents=True, exist_ok=True)
61 fault_path = log_dir / _FAULT_LOG_FILE_NAME
62 if self._handle is None or self._handle.closed:
63 self._handle = fault_path.open("a", encoding="utf-8")
64 faulthandler.enable(file=self._handle)
65 return fault_path
68_fault_log = _FaultLog()
71def enable_fault_log() -> Path:
72 """Point ``faulthandler`` at ``cfg.data_root/logs/server-fault.log``. Idempotent."""
73 return _fault_log.enable()
76class _ExceptHook:
77 """Tracks whether the logging excepthook is installed."""
79 def __init__(self) -> None:
80 self.installed = False
82 def install(self) -> None:
83 """Install the logging excepthook once, chaining to the previous hook."""
84 if self.installed:
85 return
86 previous = sys.excepthook
88 def _hook(
89 exc_type: type[BaseException], exc: BaseException, tb: TracebackType | None
90 ) -> None:
91 logger.critical("unhandled exception", exc_info=(exc_type, exc, tb))
92 previous(exc_type, exc, tb)
94 sys.excepthook = _hook
95 self.installed = True
98_except_hook = _ExceptHook()
101def install_excepthook() -> None:
102 """Log unhandled exceptions before the previous hook runs. Idempotent."""
103 _except_hook.install()
106def setup_server_logging() -> None:
107 """File log, fault log, and excepthook for a ``serve`` process."""
108 setup_server_log_file()
109 enable_fault_log()
110 install_excepthook()
113class _HealthAccessFilter(logging.Filter):
114 """Drop uvicorn access records for the health endpoint."""
116 def filter(self, record: logging.LogRecord) -> bool:
117 return _HEALTH_ACCESS_PATH not in record.getMessage()
120def install_health_access_filter() -> None:
121 """Quiet health-poll lines on the uvicorn access logger. Idempotent."""
122 access_logger = logging.getLogger(_UVICORN_ACCESS_LOGGER)
123 for filt in access_logger.filters:
124 if isinstance(filt, _HealthAccessFilter):
125 return
126 access_logger.addFilter(_HealthAccessFilter())