Coverage for src/lumen/logging.py: 100%
22 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
1"""
2This file sets up the logging configuration using structlog.
3It integrates JSON formatting and request correlation IDs for structured backend logs.
4"""
6import logging
8import structlog
9from asgi_correlation_id import correlation_id
10from structlog.typing import EventDict
12from .observability.log_context import add_otel_trace_context
15def add_correlation_id(
16 logger: logging.Logger, method_name: str, event_dict: EventDict
17) -> EventDict:
18 if req_id := correlation_id.get():
19 event_dict["request_id"] = req_id
20 return event_dict
23def configure_logging(level: str = "info", fmt: str = "console") -> None:
24 shared_processors: list[structlog.types.Processor] = [
25 structlog.stdlib.filter_by_level,
26 structlog.contextvars.merge_contextvars,
27 add_correlation_id,
28 add_otel_trace_context,
29 structlog.stdlib.add_logger_name,
30 structlog.stdlib.add_log_level,
31 structlog.processors.TimeStamper(fmt="iso"),
32 structlog.processors.StackInfoRenderer(),
33 ]
35 renderer: structlog.types.Processor
36 if fmt == "json":
37 renderer = structlog.processors.JSONRenderer()
38 else:
39 renderer = structlog.dev.ConsoleRenderer()
41 structlog.configure(
42 processors=[*shared_processors, renderer],
43 wrapper_class=structlog.stdlib.BoundLogger,
44 context_class=dict,
45 logger_factory=structlog.stdlib.LoggerFactory(),
46 cache_logger_on_first_use=True,
47 )
48 # `logging.basicConfig()` is a no-op once the root logger already has a handler — which
49 # happens whenever `setup_telemetry()` ran first and attached the OTel log-export bridge
50 # handler (LUMEN_OTEL_LOG_EXPORT=true; see observability/otel.py::_setup_log_bridge). Left
51 # to basicConfig alone, the root logger silently stays at its Python default (WARNING),
52 # dropping every `.info()` call — including the entire per-request access log — with no
53 # error of any kind. Set the level explicitly, and always ensure our own stdout handler
54 # exists (checked by marker attribute, not "any handler") so stdout-JSON (Weg A) stays the
55 # default even when the OTel bridge handler was already attached first.
56 root_logger = logging.getLogger()
57 root_logger.setLevel(getattr(logging, level.upper()))
58 if not any(getattr(h, "_lumen_stdout_handler", False) for h in root_logger.handlers):
59 stream_handler = logging.StreamHandler()
60 stream_handler.setFormatter(logging.Formatter("%(message)s"))
61 stream_handler._lumen_stdout_handler = True # type: ignore[attr-defined]
62 root_logger.addHandler(stream_handler)