diff --git a/common/logging_config.py b/common/logging_config.py index abc639c..95b3f1d 100644 --- a/common/logging_config.py +++ b/common/logging_config.py @@ -1,12 +1,64 @@ import logging +import sys + +import structlog + +# Loggers that install their own handlers (uvicorn's dictConfig runs before the +# app is imported). Their handlers are stripped so records propagate to the +# root handler below and come out as JSON like everything else. +# uvicorn.access is left alone on purpose: --no-access-log works by setting its +# propagate=False, and re-enabling propagation would turn access logs back on. +_FOREIGN_LOGGERS = ("uvicorn", "uvicorn.error") -def configure_logging(service_name: str) -> logging.Logger: - """Plain-text, human-prose logging -- the deliberately weak Phase 0 - baseline. Per phase-0-base-project-spec.md ยง9: no JSON, no key-value, no - structured fields. Do not "improve" this; later phases replace it.""" - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s %(message)s", +def _add_service(service_name: str): + def processor(logger, method_name, event_dict): + event_dict["service"] = service_name + return event_dict + + return processor + + +def configure_logging(service_name: str) -> structlog.stdlib.BoundLogger: + """One JSON object per line on stdout, for structlog and stdlib loggers alike. + + `shared` runs for every record, whichever API emitted it: structlog calls + pass through it via structlog.configure(), stdlib records (uvicorn, the + consumers' logging.getLogger(...)) via the formatter's foreign_pre_chain. + """ + shared = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso", utc=True), + _add_service(service_name), + ] + + structlog.configure( + processors=[*shared, structlog.stdlib.ProcessorFormatter.wrap_for_formatter], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, ) - return logging.getLogger(service_name) + + formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=shared, + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + structlog.processors.format_exc_info, + structlog.processors.JSONRenderer(), + ], + ) + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root = logging.getLogger() + root.handlers = [handler] + root.setLevel(logging.INFO) + + for name in _FOREIGN_LOGGERS: + foreign = logging.getLogger(name) + foreign.handlers = [] + foreign.propagate = True + + return structlog.get_logger(service_name) diff --git a/requirements.txt b/requirements.txt index 9856341..55bdaff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,5 +8,6 @@ redis==5.2.1 pydantic==2.10.4 pydantic-settings==2.7.1 python-multipart==0.0.20 +structlog==25.5.0 opentelemetry-distro==0.66b1 opentelemetry-exporter-otlp==1.45.1