""" Structured logging service — D-01. Single entry-point for structlog + stdlib bridge. Bridges stdlib loggers (uvicorn, sqlalchemy, celery) through the same structured JSON processor chain. Security invariants: - No function raises HTTPException — pure Python, no FastAPI coupling. - merge_contextvars is FIRST in the processor chain — correlation_id and other contextvar-bound fields appear in every log line. - Calling setup_logging twice is idempotent — existing root handlers are cleared before adding the new handler. - uvicorn.access propagate is False — CorrelationIDMiddleware in main.py owns request-level logging; access log lines are not doubled. """ from __future__ import annotations import logging import structlog def setup_logging(json_logs: bool = False, log_level: str = "INFO") -> None: """Configure structlog + stdlib logging bridge. Args: json_logs: If True, render logs as JSON (production). If False, use ConsoleRenderer with colours (development default). log_level: Root logger level string, e.g. "INFO", "DEBUG", "WARNING". Case-insensitive. Idempotent: clears existing root handlers before installing the new one so calling this function multiple times in tests does not duplicate output. """ timestamper = structlog.processors.TimeStamper(fmt="iso") # shared_processors: applied by BOTH structlog native loggers and stdlib # loggers bridged through ProcessorFormatter (foreign_pre_chain). # merge_contextvars MUST be first — ensures correlation_id, user_id, etc. # bound via structlog.contextvars.bind_contextvars() appear in every line. shared_processors: list = [ structlog.contextvars.merge_contextvars, structlog.stdlib.add_log_level, structlog.stdlib.add_logger_name, structlog.stdlib.PositionalArgumentsFormatter(), structlog.stdlib.ExtraAdder(), timestamper, structlog.processors.StackInfoRenderer(), ] if json_logs: # format_exc_info renders exception tracebacks as a JSON string field # rather than a multi-line text block. shared_processors.append(structlog.processors.format_exc_info) # Configure structlog itself — processors for native structlog log calls. structlog.configure( processors=shared_processors + [ structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ], logger_factory=structlog.stdlib.LoggerFactory(), cache_logger_on_first_use=True, ) # Pick renderer based on mode. log_renderer = ( structlog.processors.JSONRenderer() if json_logs else structlog.dev.ConsoleRenderer() ) # ProcessorFormatter bridges stdlib loggers (uvicorn, sqlalchemy, celery) # through the same processor chain. foreign_pre_chain handles records # that arrive from stdlib; processors runs after that. formatter = structlog.stdlib.ProcessorFormatter( foreign_pre_chain=shared_processors, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, log_renderer, ], ) handler = logging.StreamHandler() handler.setFormatter(formatter) # Idempotency: clear existing handlers before adding ours. root_logger = logging.getLogger() root_logger.handlers.clear() root_logger.addHandler(handler) root_logger.setLevel(log_level.upper()) # Route uvicorn logs through structlog. # uvicorn and uvicorn.error: propagate to root so they pass through # the ProcessorFormatter and emit in the same JSON format. for name in ("uvicorn", "uvicorn.error"): _log = logging.getLogger(name) _log.handlers.clear() _log.propagate = True # uvicorn.access: suppress — CorrelationIDMiddleware in main.py owns # request-level logging, so the default uvicorn access lines must NOT # be duplicated. _access = logging.getLogger("uvicorn.access") _access.handlers.clear() _access.propagate = False