Files

91 lines
3.0 KiB
Python
Raw Permalink Normal View History

"""
Structured logging primitives.
Every log record is emitted as a single JSON object so Logstash can ingest it
without grok patterns, and every record carries the request id of the HTTP
request that produced it, which makes a single user action traceable end to end
in Kibana.
"""
from __future__ import annotations
import datetime as dt
import json
import logging
import traceback
from contextvars import ContextVar
from typing import Any
# Propagates the current request id to any code running in the same context,
# including background threads started per request.
_request_id: ContextVar[str] = ContextVar("request_id", default="-")
# Attributes that ``logging`` puts on every record; anything else the caller
# passed through ``extra=`` is treated as structured context and serialised.
_STANDARD_ATTRS = frozenset(
{
"args", "asctime", "created", "exc_info", "exc_text", "filename",
"funcName", "levelname", "levelno", "lineno", "module", "msecs",
"message", "msg", "name", "pathname", "process", "processName",
"relativeCreated", "stack_info", "thread", "threadName", "taskName",
"request_id",
}
) # fmt: skip
def set_request_id(value: str) -> None:
_request_id.set(value)
def get_request_id() -> str:
return _request_id.get()
class RequestIDFilter(logging.Filter):
"""Attaches the ambient request id to every record."""
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = getattr(record, "request_id", None) or get_request_id()
return True
class JSONFormatter(logging.Formatter):
"""Renders a ``LogRecord`` as one line of JSON."""
def __init__(self, service: str = "app", environment: str = "development") -> None:
super().__init__()
self.service = service
self.environment = environment
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"@timestamp": dt.datetime.fromtimestamp(record.created, tz=dt.UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"service": self.service,
"environment": self.environment,
"request_id": getattr(record, "request_id", "-"),
}
if record.exc_info:
exc_type, exc_value, exc_tb = record.exc_info
payload["error"] = {
"type": getattr(exc_type, "__name__", str(exc_type)),
"message": str(exc_value),
"stack": "".join(traceback.format_exception(exc_type, exc_value, exc_tb))[:8000],
}
for key, value in record.__dict__.items():
if key not in _STANDARD_ATTRS and not key.startswith("_"):
payload[key] = _coerce(value)
return json.dumps(payload, ensure_ascii=False, default=str)
def _coerce(value: Any) -> Any:
"""Keep the payload JSON serialisable without blowing up on odd types."""
if isinstance(value, str | int | float | bool | type(None) | list | dict):
return value
return str(value)