Coverage for dataexcept/pino.py: 100%

53 statements  

« 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. 

2 

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. 

6 

7The envelope already agrees about ``type`` and ``message``, so the projection 

8is deliberately thin. It differs in three places, each for a reason: 

9 

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. 

17 

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""" 

24 

25from __future__ import annotations 

26 

27import traceback 

28from collections.abc import Mapping, Sequence 

29from typing import Any, Optional 

30 

31from .redaction import redact_urls_in_text 

32from .serialization import exception_to_dict 

33 

34__all__ = ["envelope_to_pino", "exception_to_pino"] 

35 

36#: Envelope fields the projection carries through under the same name. 

37_CARRIED = ("type", "module", "message", "attributes", "failure", "cycle", "truncated") 

38 

39#: Envelope fields holding a single nested node. 

40_NESTED = ("cause", "context") 

41 

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 

46 

47 

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} 

53 

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) 

63 

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) 

78 

79 

80def _with_stack(record: dict[str, Any], stack: str) -> dict[str, Any]: 

81 """Return *record* with ``stack`` following ``message``, where it reads. 

82 

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 

89 

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 

96 

97 

98def _rendered_stack(exc: BaseException) -> Optional[str]: 

99 """Return the exception's real traceback, or None when it has none. 

100 

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 

114 

115 

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. 

122 

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") 

131 

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)) 

136 

137 

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. 

146 

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 )