Skip to content

Commit cde3aca

Browse files
sentrivanaclaude
andauthored
fix: Fix exception chains (#7552)
Port of #4164 See #3913 for an example of an exception chain that is currently not working properly. Closes https://linear.app/getsentry/issue/PY-1948/better-handling-of-exception-groups --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 43b3bcd commit cde3aca

5 files changed

Lines changed: 169 additions & 100 deletions

File tree

‎MIGRATION_GUIDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Looking to upgrade from Sentry SDK 2.x to 3.x? Here's a comprehensive list of wh
3131
- The option `attach_stacktrace` is now `True` by default, meaning the SDK will attach stack traces to messages.
3232
- The Django integration now creates spans for cache operations by default. Pass `DjangoIntegration(cache_spans=False)` to `sentry_sdk.init()` to turn them off.
3333
- The Django integration no longer force-enables cache spans when Spotlight is active and `settings.DEBUG` is `True`. The `cache_spans` option is now always respected as given.
34+
- Exception groups in exception chains are now properly unfurled.
3435

3536
### Logging
3637

‎sentry_sdk/utils.py‎

Lines changed: 59 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -866,27 +866,16 @@ def exceptions_from_error(
866866
seen_exception_ids: "Optional[Set[int]]" = None,
867867
) -> "Tuple[int, List[Dict[str, Any]]]":
868868
"""
869-
Creates the list of exceptions.
870-
This can include chained exceptions and exceptions from an ExceptionGroup.
869+
Convert the given exception information into the Sentry "exception" format.
871870
872-
See the Exception Interface documentation for more details:
873-
https://develop.sentry.dev/sdk/event-payloads/exception/
874-
875-
Args:
876-
exception_id (int):
877-
878-
Sequential counter for assigning ``mechanism.exception_id``
879-
to each processed exception. Is NOT the result of calling `id()` on the exception itself.
880-
881-
parent_id (int):
882-
883-
The ``mechanism.exception_id`` of the parent exception.
884-
885-
Written into ``mechanism.parent_id`` in the event payload so Sentry can
886-
reconstruct the exception tree.
871+
This will return a list of exceptions (a flattened tree of exceptions) in the
872+
format of the Exception Interface documentation:
873+
https://develop.sentry.dev/sdk/telemetry/errors/#exception-interface
887874
888-
Not to be confused with ``seen_exception_ids``, which tracks Python ``id()``
889-
values for cycle detection.
875+
This function can handle:
876+
- simple exceptions
877+
- chained exceptions (raise .. from ..)
878+
- exception groups
890879
"""
891880

892881
if seen_exception_ids is None:
@@ -902,7 +891,7 @@ def exceptions_from_error(
902891
seen_exceptions.append(exc_value)
903892
seen_exception_ids.add(id(exc_value))
904893

905-
parent = single_exception_from_error_tuple(
894+
base_exception = single_exception_from_error_tuple(
906895
exc_type=exc_type,
907896
exc_value=exc_value,
908897
tb=tb,
@@ -913,70 +902,60 @@ def exceptions_from_error(
913902
source=source,
914903
full_stack=full_stack,
915904
)
916-
exceptions = [parent]
905+
exceptions = [base_exception]
917906

918907
parent_id = exception_id
919908
exception_id += 1
920909

921-
should_supress_context = (
910+
causing_exception = None
911+
exception_source = None
912+
913+
should_suppress_context = (
922914
hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
923915
)
924-
if should_supress_context:
925-
# Add direct cause.
926-
# The field `__cause__` is set when raised with the exception (using the `from` keyword).
927-
exception_has_cause = (
916+
if should_suppress_context:
917+
has_explicit_causing_exception = (
928918
exc_value
929919
and hasattr(exc_value, "__cause__")
930920
and exc_value.__cause__ is not None
931921
)
932-
if exception_has_cause:
933-
cause = exc_value.__cause__ # type: ignore
934-
(exception_id, child_exceptions) = exceptions_from_error(
935-
exc_type=type(cause),
936-
exc_value=cause,
937-
tb=getattr(cause, "__traceback__", None),
938-
client_options=client_options,
939-
mechanism=mechanism,
940-
exception_id=exception_id,
941-
source="__cause__",
942-
full_stack=full_stack,
943-
seen_exceptions=seen_exceptions,
944-
seen_exception_ids=seen_exception_ids,
945-
)
946-
exceptions.extend(child_exceptions)
947-
922+
if has_explicit_causing_exception:
923+
exception_source = "__cause__"
924+
causing_exception = exc_value.__cause__ # type: ignore
948925
else:
949-
# Add indirect cause.
950-
# The field `__context__` is assigned if another exception occurs while handling the exception.
951-
exception_has_content = (
926+
has_implicit_causing_exception = (
952927
exc_value
953928
and hasattr(exc_value, "__context__")
954929
and exc_value.__context__ is not None
955930
)
956-
if exception_has_content:
957-
context = exc_value.__context__ # type: ignore
958-
(exception_id, child_exceptions) = exceptions_from_error(
959-
exc_type=type(context),
960-
exc_value=context,
961-
tb=getattr(context, "__traceback__", None),
962-
client_options=client_options,
963-
mechanism=mechanism,
964-
exception_id=exception_id,
965-
source="__context__",
966-
full_stack=full_stack,
967-
seen_exceptions=seen_exceptions,
968-
seen_exception_ids=seen_exception_ids,
969-
)
970-
exceptions.extend(child_exceptions)
931+
if has_implicit_causing_exception:
932+
exception_source = "__context__"
933+
causing_exception = exc_value.__context__ # type: ignore
934+
935+
if causing_exception:
936+
(exception_id, child_exceptions) = exceptions_from_error(
937+
exc_type=type(causing_exception),
938+
exc_value=causing_exception,
939+
tb=getattr(causing_exception, "__traceback__", None),
940+
client_options=client_options,
941+
mechanism=mechanism,
942+
exception_id=exception_id,
943+
parent_id=parent_id,
944+
source=exception_source,
945+
full_stack=full_stack,
946+
seen_exceptions=seen_exceptions,
947+
seen_exception_ids=seen_exception_ids,
948+
)
949+
exceptions.extend(child_exceptions)
971950

972-
# Add exceptions from an ExceptionGroup.
951+
# Add child exceptions from an ExceptionGroup.
973952
is_exception_group = exc_value and hasattr(exc_value, "exceptions")
974953
if is_exception_group:
975-
for idx, e in enumerate(exc_value.exceptions): # type: ignore
954+
for idx, causing_exception in enumerate(exc_value.exceptions): # type: ignore
976955
(exception_id, child_exceptions) = exceptions_from_error(
977-
exc_type=type(e),
978-
exc_value=e,
979-
tb=getattr(e, "__traceback__", None),
956+
exc_type=type(causing_exception),
957+
exc_value=causing_exception,
958+
tb=getattr(causing_exception, "__traceback__", None),
980959
client_options=client_options,
981960
mechanism=mechanism,
982961
exception_id=exception_id,
@@ -997,38 +976,25 @@ def exceptions_from_error_tuple(
997976
mechanism: "Optional[Dict[str, Any]]" = None,
998977
full_stack: "Optional[list[dict[str, Any]]]" = None,
999978
) -> "List[Dict[str, Any]]":
979+
"""
980+
Convert an exception into Sentry's structured "exception" format.
981+
982+
See https://develop.sentry.dev/sdk/telemetry/errors/#exception-interface
983+
This is the entry point for exception handling.
984+
"""
1000985
exc_type, exc_value, tb = exc_info
1001986

1002-
is_exception_group = BaseExceptionGroup is not None and isinstance(
1003-
exc_value, BaseExceptionGroup
987+
_, exceptions = exceptions_from_error(
988+
exc_type=exc_type,
989+
exc_value=exc_value,
990+
tb=tb,
991+
client_options=client_options,
992+
mechanism=mechanism,
993+
exception_id=0,
994+
parent_id=0,
995+
full_stack=full_stack,
1004996
)
1005997

1006-
if is_exception_group:
1007-
(_, exceptions) = exceptions_from_error(
1008-
exc_type=exc_type,
1009-
exc_value=exc_value,
1010-
tb=tb,
1011-
client_options=client_options,
1012-
mechanism=mechanism,
1013-
exception_id=0,
1014-
parent_id=0,
1015-
full_stack=full_stack,
1016-
)
1017-
1018-
else:
1019-
exceptions = []
1020-
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
1021-
exceptions.append(
1022-
single_exception_from_error_tuple(
1023-
exc_type=exc_type,
1024-
exc_value=exc_value,
1025-
tb=tb,
1026-
client_options=client_options,
1027-
mechanism=mechanism,
1028-
full_stack=full_stack,
1029-
)
1030-
)
1031-
1032998
exceptions.reverse()
1033999

10341000
return exceptions

‎tests/integrations/huggingface_hub/test_huggingface_hub.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -791,8 +791,10 @@ def test_chat_completion_api_error(
791791

792792
(error,) = (item.payload for item in items if item.type == "event")
793793

794-
assert error["exception"]["values"][0]["mechanism"]["type"] == "huggingface_hub"
795-
assert not error["exception"]["values"][0]["mechanism"]["handled"]
794+
assert len(error["exception"]["values"]) == 2
795+
assert error["exception"]["values"][0]["mechanism"]["type"] == "chained"
796+
assert error["exception"]["values"][-1]["mechanism"]["type"] == "huggingface_hub"
797+
assert not error["exception"]["values"][-1]["mechanism"]["handled"]
796798

797799
sentry_sdk.flush()
798800
spans = [item.payload for item in items if item.type == "span"]

‎tests/integrations/strawberry/test_strawberry.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,9 @@ def test_capture_request_if_available_and_send_pii_is_on(
201201

202202
(error_event,) = events
203203

204-
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "strawberry"
204+
assert len(error_event["exception"]["values"]) == 2
205+
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "chained"
206+
assert error_event["exception"]["values"][-1]["mechanism"]["type"] == "strawberry"
205207
assert error_event["request"]["api_target"] == "graphql"
206208
assert error_event["request"]["data"] == {
207209
"query": query,
@@ -255,7 +257,10 @@ def test_do_not_capture_request_if_send_pii_is_off(
255257
assert len(events) == 1
256258

257259
(error_event,) = events
258-
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "strawberry"
260+
261+
assert len(error_event["exception"]["values"]) == 2
262+
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "chained"
263+
assert error_event["exception"]["values"][-1]["mechanism"]["type"] == "strawberry"
259264
assert "data" not in error_event["request"]
260265
assert "response" not in error_event["contexts"]
261266

@@ -336,7 +341,9 @@ def test_event_processor_data_collection(
336341
assert len(events) == 1
337342

338343
(error_event,) = events
339-
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "strawberry"
344+
assert len(error_event["exception"]["values"]) == 2
345+
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "chained"
346+
assert error_event["exception"]["values"][-1]["mechanism"]["type"] == "strawberry"
340347

341348
# request.data comes from the framework integration and must not be
342349
# overwritten by the strawberry integration

‎tests/test_exceptiongroup.py‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,10 @@ def test_exception_chain_cause():
217217
{
218218
"mechanism": {
219219
"handled": False,
220-
"type": "test_suite",
220+
"type": "chained",
221+
"exception_id": 1,
222+
"parent_id": 0,
223+
"source": "__cause__",
221224
},
222225
"module": None,
223226
"type": "TypeError",
@@ -227,6 +230,7 @@ def test_exception_chain_cause():
227230
"mechanism": {
228231
"handled": False,
229232
"type": "test_suite",
233+
"exception_id": 0,
230234
},
231235
"module": None,
232236
"type": "ValueError",
@@ -257,7 +261,10 @@ def test_exception_chain_context():
257261
{
258262
"mechanism": {
259263
"handled": False,
260-
"type": "test_suite",
264+
"type": "chained",
265+
"exception_id": 1,
266+
"parent_id": 0,
267+
"source": "__context__",
261268
},
262269
"module": None,
263270
"type": "TypeError",
@@ -267,6 +274,7 @@ def test_exception_chain_context():
267274
"mechanism": {
268275
"handled": False,
269276
"type": "test_suite",
277+
"exception_id": 0,
270278
},
271279
"module": None,
272280
"type": "ValueError",
@@ -297,6 +305,7 @@ def test_simple_exception():
297305
"mechanism": {
298306
"handled": False,
299307
"type": "test_suite",
308+
"exception_id": 0,
300309
},
301310
"module": None,
302311
"type": "ValueError",
@@ -308,6 +317,90 @@ def test_simple_exception():
308317
assert exception_values == expected_exception_values
309318

310319

320+
@minimum_python_311
321+
def test_exception_group_chained_with_context():
322+
try:
323+
try:
324+
raise ExceptionGroup(
325+
"group",
326+
[
327+
ValueError("child1"),
328+
ExceptionGroup(
329+
"child2",
330+
[
331+
RuntimeError("grandchild1"),
332+
RuntimeError("grandchild2"),
333+
],
334+
),
335+
],
336+
)
337+
finally:
338+
raise TypeError("bar")
339+
except BaseException as e:
340+
exc = e
341+
342+
(event, _) = event_from_exception(
343+
exc,
344+
client_options={
345+
"include_local_variables": True,
346+
"include_source_context": True,
347+
"max_value_length": 1024,
348+
},
349+
mechanism={"type": "test_suite", "handled": False},
350+
)
351+
352+
exception_values = event["exception"]["values"]
353+
354+
# innermost (oldest) to outermost (newest)
355+
assert [(e["type"], e["value"]) for e in exception_values] == [
356+
("RuntimeError", "grandchild2"),
357+
("RuntimeError", "grandchild1"),
358+
("ExceptionGroup", "child2"),
359+
("ValueError", "child1"),
360+
("ExceptionGroup", "group"),
361+
("TypeError", "bar"),
362+
]
363+
364+
# TypeError("bar") is the outermost exception (exception_id=0)
365+
type_error = exception_values[-1]
366+
assert type_error["mechanism"]["type"] == "test_suite"
367+
assert type_error["mechanism"]["exception_id"] == 0
368+
369+
# ExceptionGroup("group") is the __context__ of TypeError
370+
group = exception_values[-2]
371+
assert group["mechanism"]["type"] == "chained"
372+
assert group["mechanism"]["source"] == "__context__"
373+
assert group["mechanism"]["parent_id"] == 0
374+
assert group["mechanism"]["is_exception_group"] is True
375+
376+
group_id = group["mechanism"]["exception_id"]
377+
378+
# ValueError("child1") and ExceptionGroup("child2") are children of "group"
379+
child1 = exception_values[-3]
380+
assert child1["type"] == "ValueError"
381+
assert child1["mechanism"]["source"] == "exceptions[0]"
382+
assert child1["mechanism"]["parent_id"] == group_id
383+
384+
child2 = exception_values[-4]
385+
assert child2["type"] == "ExceptionGroup"
386+
assert child2["mechanism"]["source"] == "exceptions[1]"
387+
assert child2["mechanism"]["parent_id"] == group_id
388+
assert child2["mechanism"]["is_exception_group"] is True
389+
390+
child2_id = child2["mechanism"]["exception_id"]
391+
392+
# RuntimeError("grandchild1") and RuntimeError("grandchild2") are children of "child2"
393+
rt1 = exception_values[-5]
394+
assert rt1["value"] == "grandchild1"
395+
assert rt1["mechanism"]["source"] == "exceptions[0]"
396+
assert rt1["mechanism"]["parent_id"] == child2_id
397+
398+
rt2 = exception_values[-6]
399+
assert rt2["value"] == "grandchild2"
400+
assert rt2["mechanism"]["source"] == "exceptions[1]"
401+
assert rt2["mechanism"]["parent_id"] == child2_id
402+
403+
311404
@minimum_python_311
312405
def test_exceptiongroup_starlette_collapse():
313406
"""

0 commit comments

Comments
 (0)