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:
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user