91 lines
3.0 KiB
Python
91 lines
3.0 KiB
Python
|
|
"""
|
||
|
|
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)
|