feat(observability): add dual local logging sinks and static environment context

Why:
- Wanted human-readable console output while developing locally, without
  losing a machine-parseable log for later grepping/parsing. A single
  renderer chosen by a flag can't do both at once.
- ADR-0011 had no way to correlate an issue with a specific deployment
  (build/region/instance) independent of any one request.

Changes:
- configure_logging() now builds two independent handlers: console (always
  on, colored unless LOG_JSON_FORMAT=true) and an optional rotating JSON file
  (LOG_FILE_PATH, unset by default) -- the same structlog event fans out to
  both, so call sites are unaffected.
- A static structlog processor binds env/service_version onto every event.
  Deliberately not a contextvar: RequestIdMiddleware's clear_contextvars()
  would wipe a value bound there before the first request.
- New settings: APP_SERVICE_VERSION, LOG_FILE_PATH/LOG_FILE_MAX_BYTES/
  LOG_FILE_BACKUP_COUNT.
- ADR-0011 amended with both decisions ("console and file are independent
  sinks locally"; "bind process-level environment context once at startup").

Impact:
- configure_logging() signature changed to (logging_settings, app_settings);
  both call sites (lifespan, qdrant_bootstrap CLI) updated.
This commit is contained in:
Ali Zarinkolah
2026-08-20 19:20:27 +03:30
parent e9e83b3a26
commit 9e8987968c
9 changed files with 297 additions and 39 deletions

View File

@@ -1,14 +1,50 @@
"""Logging configuration: structlog + stdlib, dual local sinks (ADR-0011).
Console and an optional file are independent, simultaneous handlers on the
same logger, not a single renderer chosen by a flag -- the same structlog
event fans out to both. The console handler is always human-readable
(`ConsoleRenderer`); the file handler, when enabled via `LOG_FILE_PATH`,
always renders JSON regardless of `LOG_JSON_FORMAT`, so a saved log stays
machine-parseable even when the terminal next to it is not.
`LOG_JSON_FORMAT` controls *stdout's* renderer only: production sets it `true`
so the container log collector gets JSON; local development leaves it `false`
for the colored console. `LOG_FILE_PATH` is expected to be unset in
production -- stdout/stderr collection is preferred there over a log file
inside the container.
"""
import logging
import logging.config
import sys
from collections.abc import Callable
import structlog
from src.config import LoggingSettings
from src.config import AppLimitSettings, LoggingSettings
def configure_logging(settings: LoggingSettings) -> None:
def _bind_environment(settings: AppLimitSettings) -> Callable[..., dict[str, object]]:
"""A static processor, not a contextvar: `env`/`service_version` don't
vary per request, and a contextvar bound before the first request would
be wiped by `RequestIdMiddleware`'s `clear_contextvars()` on that request.
Closing over `settings` at configure time makes every event carry them
instead, regardless of request context (ADR-0011).
"""
def processor(
logger: object, method_name: str, event_dict: dict[str, object]
) -> dict[str, object]:
event_dict["env"] = settings.env
event_dict["service_version"] = settings.service_version
return event_dict
return processor
def configure_logging(settings: LoggingSettings, app_settings: AppLimitSettings) -> None:
shared_processors = [
_bind_environment(app_settings),
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
@@ -27,55 +63,84 @@ def configure_logging(settings: LoggingSettings) -> None:
cache_logger_on_first_use=True,
)
renderer = (
console_renderer = (
structlog.processors.JSONRenderer()
if settings.json_format
else structlog.dev.ConsoleRenderer(colors=True)
)
formatters = {
"console": {
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
console_renderer,
],
"foreign_pre_chain": [
structlog.stdlib.ExtraAdder(),
*shared_processors,
],
},
}
handlers: dict[str, dict[str, object]] = {
"console": {
"class": "logging.StreamHandler",
"level": settings.level,
"formatter": "console",
"stream": sys.stdout,
},
}
root_handlers = ["console"]
if settings.file_path is not None:
# File handler always renders JSON, independent of the console
# renderer chosen above -- a saved log stays machine-parseable even
# when stdout is the colored, human-readable renderer.
formatters["file"] = {
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.JSONRenderer(),
],
"foreign_pre_chain": [
structlog.stdlib.ExtraAdder(),
*shared_processors,
],
}
handlers["file"] = {
"class": "logging.handlers.RotatingFileHandler",
"level": settings.level,
"formatter": "file",
"filename": settings.file_path,
"maxBytes": settings.file_max_bytes,
"backupCount": settings.file_backup_count,
}
root_handlers.append("file")
logging.config.dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
renderer,
],
"foreign_pre_chain": [
structlog.stdlib.ExtraAdder(),
*shared_processors,
],
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": settings.level,
"formatter": "default",
"stream": sys.stdout,
},
},
"formatters": formatters,
"handlers": handlers,
"loggers": {
"": {
"handlers": ["console"],
"handlers": root_handlers,
"level": settings.level,
"propagate": False,
},
"uvicorn": {
"handlers": ["console"],
"handlers": root_handlers,
"level": settings.level,
"propagate": False,
},
"uvicorn.access": {
"handlers": ["console"],
"handlers": root_handlers,
"level": settings.level,
"propagate": False,
},
"sqlalchemy.engine": {
"handlers": ["console"],
"handlers": root_handlers,
"level": "WARNING",
"propagate": False,
},