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:
@@ -59,7 +59,7 @@ def create_lifespan(
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
resolved_settings = settings or Settings()
|
||||
configure_logging(resolved_settings.logging)
|
||||
configure_logging(resolved_settings.logging, resolved_settings.app)
|
||||
|
||||
# tiktoken fetches its vocabulary over the network on first use, so warm
|
||||
# it here: a missing vocabulary should fail the process at boot, not the
|
||||
|
||||
@@ -30,7 +30,7 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
async def bootstrap(settings: Settings | None = None) -> int:
|
||||
resolved = settings or Settings()
|
||||
configure_logging(resolved.logging)
|
||||
configure_logging(resolved.logging, resolved.app)
|
||||
client = create_client(resolved.qdrant)
|
||||
try:
|
||||
created = await ensure_chunks_collection(client, collection=resolved.qdrant.collection)
|
||||
|
||||
@@ -226,15 +226,34 @@ class AppLimitSettings(BaseSettings):
|
||||
|
||||
env: str = "local"
|
||||
readiness_check_timeout_seconds: float = 2.0
|
||||
# The deployed commit SHA or release tag (ADR-0011, "Bind process-level
|
||||
# environment context"). Set by CI/CD at build/deploy time -- never
|
||||
# computed at runtime by shelling out to git, which would fail in a
|
||||
# container image with no .git directory.
|
||||
service_version: str = "dev"
|
||||
|
||||
|
||||
class LoggingSettings(BaseSettings):
|
||||
"""Logging sinks (ADR-0011).
|
||||
|
||||
`json_format` controls stdout's renderer only. Production sets it `true`
|
||||
so stdout is JSON for the container log collector; local development
|
||||
leaves it `false` for a colored console renderer. `file_path`, when set,
|
||||
is a second, independent handler that always renders JSON regardless of
|
||||
`json_format` -- a developer can read a human console while still keeping
|
||||
a machine-parseable file. Unset in production: stdout/stderr collection is
|
||||
preferred there over a log file inside the container.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="LOG_", extra="ignore", env_file=".env", env_ignore_empty=True
|
||||
)
|
||||
|
||||
level: str = "INFO"
|
||||
json_format: bool = False
|
||||
file_path: str | None = None
|
||||
file_max_bytes: int = 10 * 1024 * 1024
|
||||
file_backup_count: int = 5
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
@@ -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