2026-09-05 08:12:30 +03:30
|
|
|
import logging
|
2026-10-13 20:27:34 +03:30
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import structlog
|
2026-10-13 20:31:44 +03:30
|
|
|
from opentelemetry import trace
|
2026-10-13 20:27:34 +03:30
|
|
|
|
|
|
|
|
# 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")
|
2026-09-05 08:12:30 +03:30
|
|
|
|
|
|
|
|
|
2026-10-13 20:27:34 +03:30
|
|
|
def _add_service(service_name: str):
|
|
|
|
|
def processor(logger, method_name, event_dict):
|
|
|
|
|
event_dict["service"] = service_name
|
|
|
|
|
return event_dict
|
|
|
|
|
|
|
|
|
|
return processor
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 20:31:44 +03:30
|
|
|
def _add_trace_context(logger, method_name, event_dict):
|
|
|
|
|
"""Stamp the active span's IDs on the record, as lowercase hex.
|
|
|
|
|
|
|
|
|
|
The name `trace_id` is load-bearing: Grafana's Loki derived field matches
|
|
|
|
|
the case-sensitive regex trace[_-]?id["=: ]+([a-fA-F0-9]+), so OTel's own
|
|
|
|
|
`otelTraceID` would never become a link. Outside a span (startup, the
|
|
|
|
|
consumers' idle loop) the context is invalid and nothing is added, rather
|
|
|
|
|
than an all-zeros ID that points at no trace.
|
|
|
|
|
"""
|
|
|
|
|
ctx = trace.get_current_span().get_span_context()
|
|
|
|
|
if ctx.is_valid:
|
|
|
|
|
event_dict["trace_id"] = trace.format_trace_id(ctx.trace_id)
|
|
|
|
|
event_dict["span_id"] = trace.format_span_id(ctx.span_id)
|
|
|
|
|
return event_dict
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 20:27:34 +03:30
|
|
|
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),
|
2026-10-13 20:31:44 +03:30
|
|
|
_add_trace_context,
|
2026-10-13 20:27:34 +03:30
|
|
|
]
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-05 08:12:30 +03:30
|
|
|
)
|
2026-10-13 20:27:34 +03:30
|
|
|
|
|
|
|
|
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)
|