""" Centralised logging configuration using structlog. Goals (Story 6.4: Structlog Integration): - JSON logs in production with fields: timestamp, level, event, request_id, user_id - Pretty console logs in development - Single configuration entry-point called from main.py - Context helpers to bind request_id / user_id per request """ from __future__ import annotations import logging import os import sys from typing import Optional import structlog from structlog.contextvars import bind_contextvars, clear_contextvars, merge_contextvars def configure_logging( *, json_logs: bool = True, log_level: str = "INFO", ) -> None: """ Configure structlog + stdlib logging. - json_logs=True → JSONRenderer (production, Docker-friendly) - json_logs=False → ConsoleRenderer (development) - log_level driven by LOG_LEVEL env (INFO/DEBUG/WARNING/ERROR/CRITICAL) """ level = getattr(logging, log_level.upper(), logging.INFO) timestamper = structlog.processors.TimeStamper(fmt="iso") # Common processor chain (used both for structlog and stdlib records) pre_chain = [ merge_contextvars, # adds request_id / user_id when bound structlog.stdlib.add_log_level, timestamper, structlog.processors.format_exc_info, ] if json_logs: renderer = structlog.processors.JSONRenderer() else: # Developer-friendly console output renderer = structlog.dev.ConsoleRenderer(colors=True) processor_formatter = structlog.stdlib.ProcessorFormatter( processor=renderer, foreign_pre_chain=pre_chain, ) handler = logging.StreamHandler(sys.stdout) handler.setFormatter(processor_formatter) # Reset root logger handlers and attach our handler root_logger = logging.getLogger() root_logger.handlers.clear() root_logger.addHandler(handler) root_logger.setLevel(level) structlog.configure( processors=pre_chain + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter], logger_factory=structlog.stdlib.LoggerFactory(), wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=True, ) def get_logger(name: Optional[str] = None): """ Return a structlog logger. Fallbacks to stdlib logger if structlog misconfigured for any reason. """ try: return structlog.get_logger(name) if name is not None else structlog.get_logger() except Exception: # pragma: no cover - defensive fallback return logging.getLogger(name) def bind_request_context( *, request_id: Optional[str] = None, user_id: Optional[str] = None, ) -> None: """ Bind per-request context so every log line includes these fields. Safe to call multiple times; only provided keys are updated. """ kv = {} if request_id: kv["request_id"] = request_id if user_id: kv["user_id"] = user_id if kv: bind_contextvars(**kv) def clear_request_context() -> None: """Clear contextvars after a request has been processed.""" clear_contextvars()