Coverage for dataexcept/logging_helpers.py: 98%
71 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-03 20:46 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-03 20:46 +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 .redaction import redact_urls_in_text
13Context = Mapping[str, Any]
15#: Sentinel: the value could not be coerced into anything JSON will take.
16_UNCOERCIBLE = object()
18__all__ = [
19 "Context",
20 "log_and_raise",
21 "log_exception",
22 "log_then_raise",
23]
26def _is_json_safe(value: Any) -> bool:
27 """True if a strict JSON encoder will accept *value* as it stands.
29 ``allow_nan=False`` because ``json.dumps`` otherwise emits bare ``NaN`` and
30 ``Infinity``, which are not valid JSON and will be rejected downstream.
31 """
32 try:
33 json.dumps(value, allow_nan=False)
34 except (TypeError, ValueError):
35 return False
36 return True
39def _coerced(value: Any) -> Any:
40 """Round-trip *value* through JSON, stringifying whatever will not encode.
42 Returns ``_UNCOERCIBLE`` rather than raising: this runs while the caller is
43 already handling a failure.
44 """
45 try:
46 return json.loads(json.dumps(value, default=str, allow_nan=False))
47 except Exception:
48 return _UNCOERCIBLE
51def _described(value: Any) -> str:
52 """Describe *value* without letting it raise.
54 An object may define a ``__repr__`` that raises. Naming the type is the
55 most that can be said without invoking anything the object controls.
56 """
57 try:
58 return repr(value)
59 except Exception:
60 try:
61 return f"<unrepresentable {type(value).__name__}>"
62 except Exception: # pragma: no cover - a type with a hostile __name__
63 return "<unrepresentable>"
66def _normalize_context_value(value: Any) -> Any:
67 """Return *value* in a form a strict JSON log encoder will accept.
69 Nothing here may raise. This runs while the caller is already handling a
70 failure, and an exception escaping would replace their error with one about
71 logging it -- so even a hostile ``__repr__`` has to be survivable.
72 """
73 if _is_json_safe(value):
74 return value
76 coerced = _coerced(value)
77 if coerced is not _UNCOERCIBLE:
78 return coerced
80 return _described(value)
83def _build_extra(context: Context | None) -> dict[str, Any] | None:
84 if not context:
85 return None
86 serialized = {
87 key: _normalize_context_value(value) for key, value in context.items()
88 }
89 return {"dataexcept_context": serialized}
92def _chain_mentions_a_url(exc: BaseException) -> bool:
93 """True if *exc* or anything it chains to renders a URL.
95 A cheap pre-check: walking the chain and testing for "://" avoids
96 formatting a traceback for every exception that is logged.
97 """
98 seen: set[int] = set()
99 current: BaseException | None = exc
100 while current is not None and id(current) not in seen:
101 seen.add(id(current))
102 try:
103 if "://" in str(current):
104 return True
105 except Exception: # pragma: no cover - a __str__ that itself raises
106 return True
107 current = current.__cause__ or current.__context__
108 return False
111def log_exception(
112 exc: Exception,
113 logger: Optional[logging.Logger] = None,
114 level: int = logging.ERROR,
115 context: Context | None = None,
116) -> None:
117 """Log *exc* at the given log *level* using *logger*.
119 If *logger* is ``None`` a module level logger is used.
121 DataExcept redacts what it renders, but a wrapped third-party exception
122 renders itself: an HTTP client's error may quote the credential-bearing URL
123 it was called with, and ``exc_info`` makes logging print that whole chain.
124 When the chain contains a URL the traceback is formatted and scrubbed here;
125 otherwise the structured ``exc_info`` path is used unchanged, so ordinary
126 exceptions keep the shape log aggregators expect.
127 """
128 if logger is None: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 logger = logging.getLogger(__name__)
130 extra = _build_extra(context)
132 if _chain_mentions_a_url(exc):
133 formatted = "".join(
134 traceback.format_exception(type(exc), exc, exc.__traceback__)
135 )
136 keep_path = getattr(type(exc), "_keep_url_path", True)
137 scrubbed = redact_urls_in_text(formatted, keep_path=keep_path).rstrip()
138 logger.log(level, "%s\n%s", exc, scrubbed, extra=extra)
139 return
141 exc_info = (type(exc), exc, exc.__traceback__)
142 logger.log(level, "%s", exc, exc_info=exc_info, extra=extra)
145@contextlib.contextmanager
146def log_and_raise(
147 logger: Optional[logging.Logger] = None,
148 level: int = logging.ERROR,
149 context: Context | None = None,
150) -> Iterator[None]:
151 """Context manager that logs and re-raises exceptions preserving traceback."""
152 try:
153 yield
154 except Exception as exc:
155 log_exception(exc, logger=logger, level=level, context=context)
156 raise
159def log_then_raise(
160 exc: Exception,
161 logger: Optional[logging.Logger] = None,
162 level: int = logging.ERROR,
163 context: Context | None = None,
164) -> None:
165 """Log *exc* and immediately raise it.
167 This helper mirrors the pre-context-manager API for scenarios where adding a
168 ``with`` block would be too intrusive. Prefer :func:`log_and_raise` whenever
169 possible so tracebacks remain untouched.
170 """
171 log_exception(exc, logger=logger, level=level, context=context)
172 raise exc