Coverage for src / basanos / _logging.py: 100%
25 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 05:23 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 05:23 +0000
1"""Structured JSON logging for basanos.
3Provides a :class:`JSONFormatter` that applications can attach to any
4:class:`logging.Handler` to receive log records as JSON objects with a
5consistent schema::
7 {
8 "timestamp": "2024-01-01T00:00:00",
9 "level": "WARNING",
10 "logger": "basanos.math.optimizer",
11 "event": "<formatted log message>",
12 "context": { ... } # present when extra={"context": {...}} is used
13 }
15Usage example::
17 import logging
18 from basanos import JSONFormatter
20 handler = logging.StreamHandler()
21 handler.setFormatter(JSONFormatter())
22 logging.getLogger("basanos").addHandler(handler)
23 logging.getLogger("basanos").setLevel(logging.DEBUG)
24"""
26import json
27import logging
28import math
29from typing import Any
31# Attributes that belong to logging.LogRecord itself and must not be
32# re-emitted as extra context fields in the JSON payload.
33_STDLIB_RECORD_ATTRS: frozenset[str] = frozenset(
34 {
35 "args",
36 "created",
37 "exc_info",
38 "exc_text",
39 "filename",
40 "funcName",
41 "levelname",
42 "levelno",
43 "lineno",
44 "message",
45 "module",
46 "msecs",
47 "msg",
48 "name",
49 "pathname",
50 "process",
51 "processName",
52 "relativeCreated",
53 "stack_info",
54 "taskName",
55 "thread",
56 "threadName",
57 }
58)
61def _to_serialisable(value: Any) -> Any:
62 """Recursively coerce *value* into a JSON-serialisable form.
64 Non-finite :class:`float` values (``nan``, ``inf``, ``-inf``) are
65 converted to their :func:`str` representation so that the resulting JSON
66 is strictly valid (RFC 8259 does not permit ``NaN`` or ``Infinity``).
67 :class:`dict`, :class:`list`, and :class:`tuple` containers are traversed
68 recursively; all other non-serialisable types are handled by the
69 ``default=str`` fallback in :func:`json.dumps`.
71 Args:
72 value: The value to coerce.
74 Returns:
75 A JSON-serialisable representation of *value*.
76 """
77 if isinstance(value, float) and not math.isfinite(value):
78 return str(value)
79 if isinstance(value, dict):
80 return {k: _to_serialisable(v) for k, v in value.items()}
81 if isinstance(value, (list, tuple)):
82 return [_to_serialisable(v) for v in value]
83 return value
86class JSONFormatter(logging.Formatter):
87 """Log formatter that serialises each record as a single-line JSON object.
89 Applications can attach this formatter to any :class:`logging.Handler` to
90 receive machine-readable, structured log output from the *basanos* library.
92 The JSON payload always contains:
94 * ``timestamp`` - wall-clock time of the record formatted with *datefmt*.
95 * ``level`` - upper-case level name (e.g. ``"WARNING"``).
96 * ``logger`` - dotted logger name (e.g. ``"basanos.math.optimizer"``).
97 * ``event`` - the fully-formatted log message.
99 Any extra fields supplied by the caller via the ``extra=`` keyword
100 argument to :meth:`logging.Logger.warning` (or equivalent) are merged
101 into the JSON object at the top level. The conventional field for
102 structured context is ``"context"`` (a plain :class:`dict`), but any
103 JSON-serialisable extra key is accepted.
105 Non-finite :class:`float` values (``nan``, ``inf``) and other
106 non-serialisable types are converted to their :func:`str` representation
107 automatically, so the formatter never raises on unexpected types (e.g.
108 :data:`math.nan`, :class:`numpy.float64`, :class:`datetime.date`).
110 The produced JSON is strictly RFC 8259-compliant (no bare ``NaN`` or
111 ``Infinity`` tokens).
113 Example::
115 handler = logging.StreamHandler()
116 handler.setFormatter(JSONFormatter())
117 logging.getLogger("basanos").addHandler(handler)
119 Args:
120 datefmt: Optional :func:`time.strftime` format string for the
121 ``timestamp`` field. Defaults to ISO-8601
122 (``"%Y-%m-%dT%H:%M:%S"``).
123 """
125 _ISO_FMT = "%Y-%m-%dT%H:%M:%S"
127 def __init__(self, datefmt: str | None = None) -> None:
128 super().__init__(datefmt=datefmt or self._ISO_FMT)
130 def format(self, record: logging.LogRecord) -> str:
131 """Return the log record serialised as a JSON string.
133 Args:
134 record: The :class:`~logging.LogRecord` to format.
136 Returns:
137 A single-line JSON string.
138 """
139 payload: dict[str, Any] = {
140 "timestamp": self.formatTime(record, self.datefmt),
141 "level": record.levelname,
142 "logger": record.name,
143 "event": record.getMessage(),
144 }
146 # Merge any extra fields supplied by the caller (e.g. "context").
147 for key, value in record.__dict__.items():
148 if key not in _STDLIB_RECORD_ATTRS and not key.startswith("_"):
149 payload[key] = _to_serialisable(value)
151 if record.exc_info:
152 payload["exc_info"] = self.formatException(record.exc_info)
154 return json.dumps(payload, default=str)