"""Structured JSON logging for Corvix.
Provides :func:`configure_logging`, which installs a single stdout handler on
the root logger emitting one JSON object per line with a consistent schema:
``timestamp``, ``level``, ``logger``, ``module``, ``event`` and any extra
fields passed via ``logger.*(..., extra={...})``.
A :mod:`contextvars`-based context (see :func:`bind_log_context`) lets request
scoped fields such as ``request_id`` be attached to every log line emitted while
handling a request without threading them through every call.
"""
from __future__ import annotations
import json
import logging
import sys
from contextvars import ContextVar
from datetime import UTC, datetime
from os import environ
# Standard ``logging.LogRecord`` attributes; anything else attached to a record
# (via ``extra={...}``) is treated as a structured field and serialised.
[docs]
_RESERVED_RECORD_KEYS: frozenset[str] = frozenset(
{
"args",
"asctime",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"module",
"msecs",
"message",
"msg",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack_info",
"taskName",
"thread",
"threadName",
}
)
[docs]
_log_context: ContextVar[dict[str, object] | None] = ContextVar("corvix_log_context", default=None)
# The handler installed by :func:`configure_logging`; reused across calls so we
# never stack duplicate handlers (e.g. uvicorn reload, multiple CLI commands).
[docs]
_handler: logging.Handler | None = None
[docs]
def _current_context() -> dict[str, object]:
return _log_context.get() or {}
[docs]
def bind_log_context(**fields: object) -> dict[str, object] | None:
"""Merge *fields* into the current logging context and return the previous one.
The returned value should be passed to :func:`reset_log_context` to restore
the prior state (typically in a ``finally`` block).
"""
previous = _log_context.get()
_log_context.set({**(previous or {}), **fields})
return previous
[docs]
def reset_log_context(previous: dict[str, object] | None) -> None:
"""Restore a logging context previously returned by :func:`bind_log_context`."""
_log_context.set(previous)
[docs]
def _build_handler(log_format: str) -> logging.Handler:
handler = logging.StreamHandler(stream=sys.stdout)
if log_format == _LOG_FORMAT_CONSOLE:
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(name)s %(message)s"))
else:
handler.setFormatter(JsonFormatter())
return handler