Skip to content

Commit 3ae4abb

Browse files
authored
Handle cyclic references in exceptions (#1284)
1 parent e3938ed commit 3ae4abb

File tree

2 files changed

+87
-18
lines changed

2 files changed

+87
-18
lines changed

logfire/_internal/utils.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ def platform_is_emscripten() -> bool:
435435
return platform.system().lower() == 'emscripten'
436436

437437

438-
def canonicalize_exception_traceback(exc: BaseException) -> str:
438+
def canonicalize_exception_traceback(exc: BaseException, seen: set[int] | None = None) -> str:
439439
"""Return a canonical string representation of an exception traceback.
440440
441441
Exceptions with the same representation are considered the same for fingerprinting purposes.
@@ -459,23 +459,28 @@ def canonicalize_exception_traceback(exc: BaseException) -> str:
459459
if frame_summary not in visited: # ignore repeated frames
460460
visited.add(frame_summary)
461461
parts.append(frame_summary)
462-
if isinstance(exc, BaseExceptionGroup):
463-
sub_exceptions: tuple[BaseException] = exc.exceptions # type: ignore
464-
parts += [
465-
'\n<ExceptionGroup>',
466-
*sorted({canonicalize_exception_traceback(nested_exc) for nested_exc in sub_exceptions}),
467-
'\n</ExceptionGroup>\n',
468-
]
469-
if exc.__cause__ is not None:
470-
parts += [
471-
'\n__cause__:',
472-
canonicalize_exception_traceback(exc.__cause__),
473-
]
474-
if exc.__context__ is not None and not exc.__suppress_context__:
475-
parts += [
476-
'\n__context__:',
477-
canonicalize_exception_traceback(exc.__context__),
478-
]
462+
seen = seen or set()
463+
if id(exc) in seen:
464+
parts.append('\n<repeated exception>')
465+
else:
466+
seen.add(id(exc))
467+
if isinstance(exc, BaseExceptionGroup):
468+
sub_exceptions: tuple[BaseException] = exc.exceptions # type: ignore
469+
parts += [
470+
'\n<ExceptionGroup>',
471+
*sorted({canonicalize_exception_traceback(nested_exc, seen) for nested_exc in sub_exceptions}),
472+
'\n</ExceptionGroup>\n',
473+
]
474+
if exc.__cause__ is not None:
475+
parts += [
476+
'\n__cause__:',
477+
canonicalize_exception_traceback(exc.__cause__, seen),
478+
]
479+
if exc.__context__ is not None and not exc.__suppress_context__:
480+
parts += [
481+
'\n__context__:',
482+
canonicalize_exception_traceback(exc.__context__, seen),
483+
]
479484
return '\n'.join(parts)
480485
except Exception: # pragma: no cover
481486
log_internal_error()

tests/test_canonicalize_exception.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,67 @@ def test_fingerprint_attribute(exporter: TestExporter):
178178
assert span.attributes[ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY] == snapshot(
179179
'3ca86c8642e26597ed1f2485859197fd294e17719e31b302b55246dab493ce83'
180180
)
181+
182+
183+
def test_cyclic_exception_cause():
184+
try:
185+
try:
186+
raise ValueError('test')
187+
except Exception as e:
188+
raise e from e
189+
except Exception as e2:
190+
assert canonicalize_exception_traceback(e2) == snapshot("""\
191+
192+
builtins.ValueError
193+
----
194+
tests.test_canonicalize_exception.test_cyclic_exception_cause
195+
raise e from e
196+
tests.test_canonicalize_exception.test_cyclic_exception_cause
197+
raise ValueError('test')
198+
199+
__cause__:
200+
201+
builtins.ValueError
202+
----
203+
tests.test_canonicalize_exception.test_cyclic_exception_cause
204+
raise e from e
205+
tests.test_canonicalize_exception.test_cyclic_exception_cause
206+
raise ValueError('test')
207+
208+
<repeated exception>\
209+
""")
210+
211+
212+
@pytest.mark.skipif(sys.version_info < (3, 11), reason='ExceptionGroup is not available in Python < 3.11')
213+
def test_cyclic_exception_group():
214+
try:
215+
raise ExceptionGroup('group', [ValueError('test')]) # noqa
216+
except ExceptionGroup as group: # noqa
217+
try:
218+
raise group.exceptions[0]
219+
except Exception as e:
220+
assert canonicalize_exception_traceback(e) == snapshot("""\
221+
222+
builtins.ValueError
223+
----
224+
tests.test_canonicalize_exception.test_cyclic_exception_group
225+
raise group.exceptions[0]
226+
227+
__context__:
228+
229+
builtins.ExceptionGroup
230+
----
231+
tests.test_canonicalize_exception.test_cyclic_exception_group
232+
raise ExceptionGroup('group', [ValueError('test')]) # noqa
233+
234+
<ExceptionGroup>
235+
236+
builtins.ValueError
237+
----
238+
tests.test_canonicalize_exception.test_cyclic_exception_group
239+
raise group.exceptions[0]
240+
241+
<repeated exception>
242+
243+
</ExceptionGroup>
244+
""")

0 commit comments

Comments
 (0)