Coverage for dataexcept/pino.py: 100%
53 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-27 14:44 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-27 14:44 +0000
1"""Projecting a DataExcept envelope onto the error key of a Pino log record.
3A Node.js service reading these payloads has a logger with opinions. Pino's own
4error serializer emits ``type``, ``message`` and ``stack``, and everything
5downstream -- pino-pretty, transports, error trackers -- keys on those names.
7The envelope already agrees about ``type`` and ``message``, so the projection
8is deliberately thin. It differs in three places, each for a reason:
10* ``exceptions`` becomes ``errors``, which is what JavaScript's
11 ``AggregateError`` calls the same thing;
12* ``stack`` is emitted only when a real stack representation exists, never
13 invented to satisfy a consumer that expects one;
14* ``attributes`` stays nested rather than being spread onto the error, because
15 spreading would let an attribute called ``type`` or ``stack`` overwrite the
16 fields the consumer keys on.
18Everything else -- identity, failure metadata, the whole cause, context and
19member tree, and the redaction already applied to all of it -- is carried
20through. This is a projection of the envelope, not a replacement for it: the
21envelope stays the canonical contract, and Pino's shape does not get to define
22what DataExcept records.
23"""
25from __future__ import annotations
27import traceback
28from collections.abc import Mapping, Sequence
29from typing import Any, Optional
31from .redaction import redact_urls_in_text
32from .serialization import exception_to_dict
34__all__ = ["envelope_to_pino", "exception_to_pino"]
36#: Envelope fields the projection carries through under the same name.
37_CARRIED = ("type", "module", "message", "attributes", "failure", "cycle", "truncated")
39#: Envelope fields holding a single nested node.
40_NESTED = ("cause", "context")
42#: A depth bound of its own. Envelopes are already bounded when DataExcept
43#: builds them, but this function also accepts one that arrived from somewhere
44#: else, and a projection must not be the thing that overflows the stack.
45_MAX_DEPTH = 32
48def _project(node: Mapping[str, Any], *, depth: int, seen: set[int]) -> dict[str, Any]:
49 """Return one envelope node in Pino's shape, without raising."""
50 identity = id(node)
51 if depth > _MAX_DEPTH or identity in seen:
52 return {"truncated": True}
54 seen.add(identity)
55 try:
56 record: dict[str, Any] = {
57 field: node[field] for field in _CARRIED if field in node
58 }
59 for field in _NESTED:
60 child = node.get(field)
61 if isinstance(child, Mapping):
62 record[field] = _project(child, depth=depth + 1, seen=seen)
64 members = node.get("exceptions")
65 if isinstance(members, Sequence) and not isinstance(members, (str, bytes)):
66 record["errors"] = [
67 _project(member, depth=depth + 1, seen=seen)
68 for member in members
69 if isinstance(member, Mapping)
70 ]
71 return record
72 except Exception: # pragma: no cover - hostile mapping implementations
73 # The serializer never replaces a failure with a failure about the
74 # failure, and neither does this.
75 return {"truncated": True}
76 finally:
77 seen.discard(identity)
80def _with_stack(record: dict[str, Any], stack: str) -> dict[str, Any]:
81 """Return *record* with ``stack`` following ``message``, where it reads.
83 A marker is left alone. Neither a cycle record nor a truncation marker is
84 an error to hang a stack on -- they stand in for one -- and the profile
85 lets neither carry anything beyond the fields that identify it as a marker.
86 """
87 if "message" not in record or "cycle" in record or "truncated" in record:
88 return record
90 ordered: dict[str, Any] = {}
91 for field, value in record.items():
92 ordered[field] = value
93 if field == "message":
94 ordered["stack"] = stack
95 return ordered
98def _rendered_stack(exc: BaseException) -> Optional[str]:
99 """Return the exception's real traceback, or None when it has none.
101 An exception that was never raised has no traceback, and a stack is the one
102 field a log consumer will believe without checking. Absence is the honest
103 answer; a fabricated frame is not.
104 """
105 if exc.__traceback__ is None:
106 return None
107 try:
108 rendered = "".join(
109 traceback.format_exception(type(exc), exc, exc.__traceback__)
110 )
111 except Exception: # pragma: no cover - hostile traceback objects
112 return None
113 return redact_urls_in_text(rendered, keep_path=False) or None
116def envelope_to_pino(
117 envelope: Mapping[str, Any],
118 *,
119 stack: Optional[str] = None,
120) -> dict[str, Any]:
121 """Project *envelope* into the value Pino logs under its error key.
123 Pass ``stack`` only when a real stack representation is in hand -- the
124 traceback the failure actually carried, or a stack a caller received across
125 a boundary. It is redacted like every other exported string.
126 """
127 if not isinstance(envelope, Mapping):
128 raise TypeError("envelope must be a mapping")
129 if stack is not None and not isinstance(stack, str):
130 raise TypeError("stack must be a string or None")
132 record = _project(envelope, depth=0, seen=set())
133 if stack is None:
134 return record
135 return _with_stack(record, redact_urls_in_text(stack, keep_path=False))
138def exception_to_pino(
139 exc: BaseException,
140 *,
141 include_attributes: bool = True,
142 max_depth: int = 8,
143 include_stack: bool = False,
144) -> dict[str, Any]:
145 """Return *exc* in Pino's shape, by way of its envelope.
147 ``include_stack`` renders the exception's own traceback when it has one.
148 The field is omitted rather than invented when it does not.
149 """
150 envelope = exception_to_dict(
151 exc,
152 include_attributes=include_attributes,
153 max_depth=max_depth,
154 )
155 return envelope_to_pino(
156 envelope,
157 stack=_rendered_stack(exc) if include_stack else None,
158 )