Coverage for dataexcept/logging_helpers.py: 99%

81 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-27 14:44 +0000

1"""Helper functions for logging exceptions consistently.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import json 

7import logging 

8import traceback 

9from typing import Any, Iterator, Mapping, Optional 

10 

11from .observability import OperationContext 

12from .redaction import redact_urls_in_text 

13 

14Context = Mapping[str, Any] 

15 

16#: Sentinel: the value could not be coerced into anything JSON will take. 

17_UNCOERCIBLE = object() 

18 

19__all__ = [ 

20 "Context", 

21 "log_and_raise", 

22 "log_exception", 

23 "log_then_raise", 

24] 

25 

26 

27def _is_json_safe(value: Any) -> bool: 

28 """True if a strict JSON encoder will accept *value* as it stands. 

29 

30 ``allow_nan=False`` because ``json.dumps`` otherwise emits bare ``NaN`` and 

31 ``Infinity``, which are not valid JSON and will be rejected downstream. 

32 """ 

33 try: 

34 json.dumps(value, allow_nan=False) 

35 except (TypeError, ValueError): 

36 return False 

37 return True 

38 

39 

40def _coerced(value: Any) -> Any: 

41 """Round-trip *value* through JSON, stringifying whatever will not encode. 

42 

43 Returns ``_UNCOERCIBLE`` rather than raising: this runs while the caller is 

44 already handling a failure. 

45 """ 

46 try: 

47 return json.loads(json.dumps(value, default=str, allow_nan=False)) 

48 except Exception: 

49 return _UNCOERCIBLE 

50 

51 

52def _described(value: Any) -> str: 

53 """Describe *value* without letting it raise. 

54 

55 An object may define a ``__repr__`` that raises. Naming the type is the 

56 most that can be said without invoking anything the object controls. 

57 """ 

58 try: 

59 return repr(value) 

60 except Exception: 

61 try: 

62 return f"<unrepresentable {type(value).__name__}>" 

63 except Exception: # pragma: no cover - a type with a hostile __name__ 

64 return "<unrepresentable>" 

65 

66 

67def _normalize_context_value(value: Any) -> Any: 

68 """Return *value* in a form a strict JSON log encoder will accept. 

69 

70 Nothing here may raise. This runs while the caller is already handling a 

71 failure, and an exception escaping would replace their error with one about 

72 logging it -- so even a hostile ``__repr__`` has to be survivable. 

73 """ 

74 if _is_json_safe(value): 

75 return value 

76 

77 coerced = _coerced(value) 

78 if coerced is not _UNCOERCIBLE: 

79 return coerced 

80 

81 return _described(value) 

82 

83 

84def _build_extra( 

85 context: Context | None, 

86 operation_context: OperationContext | None = None, 

87) -> dict[str, Any] | None: 

88 extra: dict[str, Any] = {} 

89 

90 if context: 

91 extra["dataexcept_context"] = { 

92 key: _normalize_context_value(value) for key, value in context.items() 

93 } 

94 

95 if operation_context is not None: 

96 if not isinstance(operation_context, OperationContext): 

97 raise TypeError("operation_context must be an OperationContext or None") 

98 serialized = operation_context.to_dict() 

99 if serialized: 99 ↛ 102line 99 didn't jump to line 102 because the condition on line 99 was always true

100 extra["dataexcept_operation"] = serialized 

101 

102 return extra or None 

103 

104 

105def _chain_mentions_a_url(exc: BaseException) -> bool: 

106 """True if *exc* or anything it chains to renders a URL. 

107 

108 A cheap pre-check: walking the chain and testing for "://" avoids 

109 formatting a traceback for every exception that is logged. 

110 """ 

111 seen: set[int] = set() 

112 current: BaseException | None = exc 

113 while current is not None and id(current) not in seen: 

114 seen.add(id(current)) 

115 try: 

116 if "://" in str(current): 

117 return True 

118 except Exception: # pragma: no cover - a __str__ that itself raises 

119 return True 

120 current = current.__cause__ or current.__context__ 

121 return False 

122 

123 

124def log_exception( 

125 exc: Exception, 

126 logger: Optional[logging.Logger] = None, 

127 level: int = logging.ERROR, 

128 context: Context | None = None, 

129 operation_context: OperationContext | None = None, 

130) -> None: 

131 """Log *exc* at the given log *level* using *logger*. 

132 

133 If *logger* is ``None`` a module level logger is used. 

134 

135 ``context`` remains the free-form application context. ``operation_context`` 

136 carries the stable request/job/tool-call identifiers shared with tracing and 

137 error-tracker integrations. 

138 

139 DataExcept redacts what it renders, but a wrapped third-party exception 

140 renders itself: an HTTP client's error may quote the credential-bearing URL 

141 it was called with, and ``exc_info`` makes logging print that whole chain. 

142 When the chain contains a URL the traceback is formatted and scrubbed here; 

143 otherwise the structured ``exc_info`` path is used unchanged, so ordinary 

144 exceptions keep the shape log aggregators expect. 

145 

146 Logging is fail-open: context conversion, traceback rendering or the logger 

147 itself may fail, but that failure is swallowed so observability can never 

148 replace the exception the caller was already handling. 

149 """ 

150 try: 

151 if logger is None: 

152 logger = logging.getLogger(__name__) 

153 extra = _build_extra(context, operation_context) 

154 

155 if _chain_mentions_a_url(exc): 

156 formatted = "".join( 

157 traceback.format_exception(type(exc), exc, exc.__traceback__) 

158 ) 

159 keep_path = getattr(type(exc), "_keep_url_path", True) 

160 scrubbed = redact_urls_in_text(formatted, keep_path=keep_path).rstrip() 

161 logger.log(level, "%s\n%s", exc, scrubbed, extra=extra) 

162 return 

163 

164 exc_info = (type(exc), exc, exc.__traceback__) 

165 logger.log(level, "%s", exc, exc_info=exc_info, extra=extra) 

166 except Exception: 

167 return 

168 

169 

170@contextlib.contextmanager 

171def log_and_raise( 

172 logger: Optional[logging.Logger] = None, 

173 level: int = logging.ERROR, 

174 context: Context | None = None, 

175 operation_context: OperationContext | None = None, 

176) -> Iterator[None]: 

177 """Context manager that logs and re-raises exceptions preserving traceback.""" 

178 try: 

179 yield 

180 except Exception as exc: 

181 log_exception( 

182 exc, 

183 logger=logger, 

184 level=level, 

185 context=context, 

186 operation_context=operation_context, 

187 ) 

188 raise 

189 

190 

191def log_then_raise( 

192 exc: Exception, 

193 logger: Optional[logging.Logger] = None, 

194 level: int = logging.ERROR, 

195 context: Context | None = None, 

196 operation_context: OperationContext | None = None, 

197) -> None: 

198 """Log *exc* and immediately raise it. 

199 

200 This helper mirrors the pre-context-manager API for scenarios where adding a 

201 ``with`` block would be too intrusive. Prefer :func:`log_and_raise` whenever 

202 possible so tracebacks remain untouched. 

203 """ 

204 log_exception( 

205 exc, 

206 logger=logger, 

207 level=level, 

208 context=context, 

209 operation_context=operation_context, 

210 ) 

211 raise exc