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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-27 14:44 +0000
1"""Helper functions for logging exceptions consistently."""
3from __future__ import annotations
5import contextlib
6import json
7import logging
8import traceback
9from typing import Any, Iterator, Mapping, Optional
11from .observability import OperationContext
12from .redaction import redact_urls_in_text
14Context = Mapping[str, Any]
16#: Sentinel: the value could not be coerced into anything JSON will take.
17_UNCOERCIBLE = object()
19__all__ = [
20 "Context",
21 "log_and_raise",
22 "log_exception",
23 "log_then_raise",
24]
27def _is_json_safe(value: Any) -> bool:
28 """True if a strict JSON encoder will accept *value* as it stands.
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
40def _coerced(value: Any) -> Any:
41 """Round-trip *value* through JSON, stringifying whatever will not encode.
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
52def _described(value: Any) -> str:
53 """Describe *value* without letting it raise.
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>"
67def _normalize_context_value(value: Any) -> Any:
68 """Return *value* in a form a strict JSON log encoder will accept.
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
77 coerced = _coerced(value)
78 if coerced is not _UNCOERCIBLE:
79 return coerced
81 return _described(value)
84def _build_extra(
85 context: Context | None,
86 operation_context: OperationContext | None = None,
87) -> dict[str, Any] | None:
88 extra: dict[str, Any] = {}
90 if context:
91 extra["dataexcept_context"] = {
92 key: _normalize_context_value(value) for key, value in context.items()
93 }
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
102 return extra or None
105def _chain_mentions_a_url(exc: BaseException) -> bool:
106 """True if *exc* or anything it chains to renders a URL.
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
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*.
133 If *logger* is ``None`` a module level logger is used.
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.
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.
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)
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
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
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
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.
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