"""Structured logging via structlog. Emits key=value (dev) or JSON (prod) with a per-request/task ``correlation_id`` bound through a contextvar. Call :func:`configure_logging` once at startup. """ import logging from contextvars import ContextVar from typing import cast import structlog from structlog.typing import EventDict, FilteringBoundLogger, Processor, WrappedLogger # Bound by middleware (HTTP) and worker entrypoints (tasks) so every log line # downstream carries the same id without explicit passing. correlation_id: ContextVar[str | None] = ContextVar("correlation_id", default=None) def _add_correlation_id(_logger: WrappedLogger, _method: str, event_dict: EventDict) -> EventDict: cid = correlation_id.get() if cid is not None: event_dict["correlation_id"] = cid return event_dict def configure_logging(*, level: str = "INFO", json: bool = False) -> None: shared: list[Processor] = [ structlog.contextvars.merge_contextvars, _add_correlation_id, structlog.processors.add_log_level, structlog.processors.TimeStamper(fmt="iso", utc=True), structlog.processors.StackInfoRenderer(), structlog.processors.format_exc_info, ] renderer = ( structlog.processors.JSONRenderer() if json else structlog.dev.ConsoleRenderer(colors=True) ) structlog.configure( processors=[*shared, renderer], wrapper_class=structlog.make_filtering_bound_logger(logging.getLevelName(level.upper())), logger_factory=structlog.PrintLoggerFactory(), cache_logger_on_first_use=True, ) # Route stdlib logging (uvicorn, sqlalchemy) through structlog formatting. logging.basicConfig(level=level.upper(), format="%(message)s") def get_logger(name: str | None = None) -> FilteringBoundLogger: return cast(FilteringBoundLogger, structlog.get_logger(name))