v0.146.0
 1from __future__ import annotations
 2
 3import json
 4import logging
 5from typing import Any
 6
 7# Standard LogRecord attributes that are NOT user context.
 8# Everything else on the record is treated as structured context data.
 9_BASE_RECORD = logging.LogRecord("", 0, "", 0, "", (), None)
10_BASE_RECORD_ATTR_COUNT = len(_BASE_RECORD.__dict__)
11_STANDARD_RECORD_ATTRS = frozenset(_BASE_RECORD.__dict__.keys()) | {
12    "message",
13    "asctime",
14    "keyvalue",
15    "json",
16}
17
18
19def _get_context(record: logging.LogRecord) -> dict[str, Any]:
20    """Extract user context from a LogRecord (everything not a standard attribute)."""
21    if len(record.__dict__) <= _BASE_RECORD_ATTR_COUNT:
22        return {}
23    return {
24        key: value
25        for key, value in record.__dict__.items()
26        if key not in _STANDARD_RECORD_ATTRS
27    }
28
29
30class KeyValueFormatter(logging.Formatter):
31    """Formatter that outputs key-value pairs from structured context."""
32
33    def format(self, record: logging.LogRecord) -> str:
34        kv_pairs = []
35
36        for key, value in _get_context(record).items():
37            formatted_value = self._format_value(value)
38            kv_pairs.append(f"{key}={formatted_value}")
39
40        # %(keyvalue)s substitution requires the value on the record, but
41        # other handlers (notably the OTel LoggingHandler) read vars(record)
42        # and would ship `keyvalue` as a redundant log attribute. Set it,
43        # format, then delete so the mutation doesn't outlive this call.
44        record.keyvalue = " ".join(kv_pairs)
45        try:
46            return super().format(record)
47        finally:
48            record.__dict__.pop("keyvalue", None)
49
50    @staticmethod
51    def _format_value(value: Any) -> str:
52        """Format a value for key-value output."""
53        if isinstance(value, str):
54            s = value
55        else:
56            s = str(value)
57
58        if '"' in s:
59            # Escape quotes and surround it
60            s = s.replace('"', '\\"')
61            s = f'"{s}"'
62        elif s == "":
63            # Quote empty strings instead of printing nothing
64            s = '""'
65        elif any(char in s for char in [" ", "/", "'", ":", "=", "."]):
66            # Surround these with quotes for parsers
67            s = f'"{s}"'
68
69        return s
70
71
72class JSONFormatter(logging.Formatter):
73    """Formatter that outputs JSON with structured context."""
74
75    def format(self, record: logging.LogRecord) -> str:
76        log_obj = {
77            "timestamp": self.formatTime(record),
78            "level": record.levelname,
79            "message": record.getMessage(),
80            "logger": record.name,
81        }
82
83        # Add structured context data
84        log_obj.update(_get_context(record))
85
86        # Handle exceptions
87        if record.exc_info:
88            log_obj["exception"] = self.formatException(record.exc_info)
89
90        # Same shape as KeyValueFormatter — set, format, delete so this
91        # mutation doesn't bleed into other handlers reading vars(record).
92        record.json = json.dumps(log_obj, default=str, ensure_ascii=False)
93        try:
94            return super().format(record)
95        finally:
96            record.__dict__.pop("json", None)