"""JSON formatter for stdlib logging that preserves extra fields. A single logging.Formatter subclass that serialises any keyword arguments passed via ``extra=`` into the JSON output alongside the standard record attributes. """ from __future__ import annotations import json import logging from datetime import datetime, timezone from typing import Any # Attributes that belong to the standard LogRecord and should NOT be # treated as user-supplied extra fields. _STD_RECORD_ATTRS: frozenset[str] = frozenset( { "name", "msg", "args", "levelname", "levelno", "pathname", "filename", "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", "created", "msecs", "relativeCreated", "thread", "threadName", "processName", "process", "message", "asctime", "taskName", } ) class JSONFormatter(logging.Formatter): """Format log records as JSON lines, including extra fields. Usage:: handler = logging.StreamHandler() handler.setFormatter(JSONFormatter()) logging.getLogger().addHandler(handler) Output keys: - ``event`` – the log message - ``level`` – lower-cased level name - ``timestamp`` – ISO-8601 UTC timestamp - ``logger`` – logger name - any ``extra`` fields supplied by the caller """ def format(self, record: logging.LogRecord) -> str: """Return a JSON string for *record*.""" log_dict: dict[str, Any] = { "event": record.getMessage(), "level": record.levelname.lower(), "timestamp": ( datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat() ), "logger": record.name, } # Merge any extra fields attached to the record. for key, value in record.__dict__.items(): if key not in _STD_RECORD_ATTRS: log_dict[key] = value # Include exception info when present. if record.exc_info and not record.exc_text: record.exc_text = self.formatException(record.exc_info) if record.exc_text: log_dict["exception"] = record.exc_text return json.dumps(log_dict, default=str)