Emit JSON logs through structlog, including uvicorn and stdlib loggers

configure_logging now builds one processor chain shared by structlog
calls and foreign stdlib records (uvicorn, the consumers' getLogger),
rendered as one JSON object per line on stdout. Tracebacks stay inside
their line instead of splitting into one Docker log entry per frame.

uvicorn.access is deliberately left untouched: --no-access-log works by
disabling its propagation, and re-enabling it would bring access logs
back (and inflate the healthz/lookup line counts the tests rely on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Movses Aslanyan
2026-10-13 20:27:34 +03:30
co-authored by Claude Opus 5.5
parent 7ddb614ded
commit 7be54a6ff9
2 changed files with 61 additions and 8 deletions
+60 -8
View File
@@ -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)