Skip to content

Commit 5564011

Browse files
authored
Add support for ExceptionGroups (#2025)
With Python 3.11 ExceptionGroups was introduced. This adds support for catching them and displaying them in a meaningful way. See also the related RFC: https://github.com/getsentry/rfcs/blob/main/text/0079-exception-groups.md
1 parent 1d9effe commit 5564011

File tree

9 files changed

+497
-42
lines changed

9 files changed

+497
-42
lines changed

sentry_sdk/utils.py

Lines changed: 170 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@
3030
from urlparse import urlsplit # type: ignore
3131
from urlparse import urlunsplit # type: ignore
3232

33+
try:
34+
# Python 3.11
35+
from builtins import BaseExceptionGroup
36+
except ImportError:
37+
# Python 3.10 and below
38+
BaseExceptionGroup = None # type: ignore
3339

3440
from datetime import datetime
3541
from functools import partial
@@ -666,19 +672,54 @@ def single_exception_from_error_tuple(
666672
tb, # type: Optional[TracebackType]
667673
client_options=None, # type: Optional[Dict[str, Any]]
668674
mechanism=None, # type: Optional[Dict[str, Any]]
675+
exception_id=None, # type: Optional[int]
676+
parent_id=None, # type: Optional[int]
677+
source=None, # type: Optional[str]
669678
):
670679
# type: (...) -> Dict[str, Any]
671-
mechanism = mechanism or {"type": "generic", "handled": True}
680+
"""
681+
Creates a dict that goes into the events `exception.values` list and is ingestible by Sentry.
682+
683+
See the Exception Interface documentation for more details:
684+
https://develop.sentry.dev/sdk/event-payloads/exception/
685+
"""
686+
exception_value = {} # type: Dict[str, Any]
687+
exception_value["mechanism"] = (
688+
mechanism.copy() if mechanism else {"type": "generic", "handled": True}
689+
)
690+
if exception_id is not None:
691+
exception_value["mechanism"]["exception_id"] = exception_id
672692

673693
if exc_value is not None:
674694
errno = get_errno(exc_value)
675695
else:
676696
errno = None
677697

678698
if errno is not None:
679-
mechanism.setdefault("meta", {}).setdefault("errno", {}).setdefault(
680-
"number", errno
681-
)
699+
exception_value["mechanism"].setdefault("meta", {}).setdefault(
700+
"errno", {}
701+
).setdefault("number", errno)
702+
703+
if source is not None:
704+
exception_value["mechanism"]["source"] = source
705+
706+
is_root_exception = exception_id == 0
707+
if not is_root_exception and parent_id is not None:
708+
exception_value["mechanism"]["parent_id"] = parent_id
709+
exception_value["mechanism"]["type"] = "chained"
710+
711+
if is_root_exception and "type" not in exception_value["mechanism"]:
712+
exception_value["mechanism"]["type"] = "generic"
713+
714+
is_exception_group = BaseExceptionGroup is not None and isinstance(
715+
exc_value, BaseExceptionGroup
716+
)
717+
if is_exception_group:
718+
exception_value["mechanism"]["is_exception_group"] = True
719+
720+
exception_value["module"] = get_type_module(exc_type)
721+
exception_value["type"] = get_type_name(exc_type)
722+
exception_value["value"] = getattr(exc_value, "message", safe_str(exc_value))
682723

683724
if client_options is None:
684725
include_local_variables = True
@@ -697,17 +738,10 @@ def single_exception_from_error_tuple(
697738
for tb in iter_stacks(tb)
698739
]
699740

700-
rv = {
701-
"module": get_type_module(exc_type),
702-
"type": get_type_name(exc_type),
703-
"value": safe_str(exc_value),
704-
"mechanism": mechanism,
705-
}
706-
707741
if frames:
708-
rv["stacktrace"] = {"frames": frames}
742+
exception_value["stacktrace"] = {"frames": frames}
709743

710-
return rv
744+
return exception_value
711745

712746

713747
HAS_CHAINED_EXCEPTIONS = hasattr(Exception, "__suppress_context__")
@@ -751,24 +785,139 @@ def walk_exception_chain(exc_info):
751785
yield exc_info
752786

753787

788+
def exceptions_from_error(
789+
exc_type, # type: Optional[type]
790+
exc_value, # type: Optional[BaseException]
791+
tb, # type: Optional[TracebackType]
792+
client_options=None, # type: Optional[Dict[str, Any]]
793+
mechanism=None, # type: Optional[Dict[str, Any]]
794+
exception_id=0, # type: int
795+
parent_id=0, # type: int
796+
source=None, # type: Optional[str]
797+
):
798+
# type: (...) -> Tuple[int, List[Dict[str, Any]]]
799+
"""
800+
Creates the list of exceptions.
801+
This can include chained exceptions and exceptions from an ExceptionGroup.
802+
803+
See the Exception Interface documentation for more details:
804+
https://develop.sentry.dev/sdk/event-payloads/exception/
805+
"""
806+
807+
parent = single_exception_from_error_tuple(
808+
exc_type=exc_type,
809+
exc_value=exc_value,
810+
tb=tb,
811+
client_options=client_options,
812+
mechanism=mechanism,
813+
exception_id=exception_id,
814+
parent_id=parent_id,
815+
source=source,
816+
)
817+
exceptions = [parent]
818+
819+
parent_id = exception_id
820+
exception_id += 1
821+
822+
should_supress_context = (
823+
hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
824+
)
825+
if should_supress_context:
826+
# Add direct cause.
827+
# The field `__cause__` is set when raised with the exception (using the `from` keyword).
828+
exception_has_cause = (
829+
exc_value
830+
and hasattr(exc_value, "__cause__")
831+
and exc_value.__cause__ is not None
832+
)
833+
if exception_has_cause:
834+
cause = exc_value.__cause__ # type: ignore
835+
(exception_id, child_exceptions) = exceptions_from_error(
836+
exc_type=type(cause),
837+
exc_value=cause,
838+
tb=getattr(cause, "__traceback__", None),
839+
client_options=client_options,
840+
mechanism=mechanism,
841+
exception_id=exception_id,
842+
source="__cause__",
843+
)
844+
exceptions.extend(child_exceptions)
845+
846+
else:
847+
# Add indirect cause.
848+
# The field `__context__` is assigned if another exception occurs while handling the exception.
849+
exception_has_content = (
850+
exc_value
851+
and hasattr(exc_value, "__context__")
852+
and exc_value.__context__ is not None
853+
)
854+
if exception_has_content:
855+
context = exc_value.__context__ # type: ignore
856+
(exception_id, child_exceptions) = exceptions_from_error(
857+
exc_type=type(context),
858+
exc_value=context,
859+
tb=getattr(context, "__traceback__", None),
860+
client_options=client_options,
861+
mechanism=mechanism,
862+
exception_id=exception_id,
863+
source="__context__",
864+
)
865+
exceptions.extend(child_exceptions)
866+
867+
# Add exceptions from an ExceptionGroup.
868+
is_exception_group = exc_value and hasattr(exc_value, "exceptions")
869+
if is_exception_group:
870+
for idx, e in enumerate(exc_value.exceptions): # type: ignore
871+
(exception_id, child_exceptions) = exceptions_from_error(
872+
exc_type=type(e),
873+
exc_value=e,
874+
tb=getattr(e, "__traceback__", None),
875+
client_options=client_options,
876+
mechanism=mechanism,
877+
exception_id=exception_id,
878+
parent_id=parent_id,
879+
source="exceptions[%s]" % idx,
880+
)
881+
exceptions.extend(child_exceptions)
882+
883+
return (exception_id, exceptions)
884+
885+
754886
def exceptions_from_error_tuple(
755887
exc_info, # type: ExcInfo
756888
client_options=None, # type: Optional[Dict[str, Any]]
757889
mechanism=None, # type: Optional[Dict[str, Any]]
758890
):
759891
# type: (...) -> List[Dict[str, Any]]
760892
exc_type, exc_value, tb = exc_info
761-
rv = []
762-
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
763-
rv.append(
764-
single_exception_from_error_tuple(
765-
exc_type, exc_value, tb, client_options, mechanism
766-
)
893+
894+
is_exception_group = BaseExceptionGroup is not None and isinstance(
895+
exc_value, BaseExceptionGroup
896+
)
897+
898+
if is_exception_group:
899+
(_, exceptions) = exceptions_from_error(
900+
exc_type=exc_type,
901+
exc_value=exc_value,
902+
tb=tb,
903+
client_options=client_options,
904+
mechanism=mechanism,
905+
exception_id=0,
906+
parent_id=0,
767907
)
768908

769-
rv.reverse()
909+
else:
910+
exceptions = []
911+
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
912+
exceptions.append(
913+
single_exception_from_error_tuple(
914+
exc_type, exc_value, tb, client_options, mechanism
915+
)
916+
)
917+
918+
exceptions.reverse()
770919

771-
return rv
920+
return exceptions
772921

773922

774923
def to_string(value):

tests/integrations/aws_lambda/test_aws.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@ def test_handler(event, context):
189189

190190
assert frame1["in_app"] is True
191191

192-
assert exception["mechanism"] == {"type": "aws_lambda", "handled": False}
192+
assert exception["mechanism"]["type"] == "aws_lambda"
193+
assert not exception["mechanism"]["handled"]
193194

194195
assert event["extra"]["lambda"]["function_name"].startswith("test_function_")
195196

@@ -327,7 +328,8 @@ def test_handler(event, context):
327328
"WARNING : Function is expected to get timed out. Configured timeout duration = 3 seconds.",
328329
)
329330

330-
assert exception["mechanism"] == {"type": "threading", "handled": False}
331+
assert exception["mechanism"]["type"] == "threading"
332+
assert not exception["mechanism"]["handled"]
331333

332334
assert event["extra"]["lambda"]["function_name"].startswith("test_function_")
333335

tests/integrations/bottle/test_bottle.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -386,10 +386,8 @@ def crashing_app(environ, start_response):
386386
assert error is exc.value
387387

388388
(event,) = events
389-
assert event["exception"]["values"][0]["mechanism"] == {
390-
"type": "bottle",
391-
"handled": False,
392-
}
389+
assert event["exception"]["values"][0]["mechanism"]["type"] == "bottle"
390+
assert event["exception"]["values"][0]["mechanism"]["handled"] is False
393391

394392

395393
def test_500(sentry_init, capture_events, app, get_client):

tests/integrations/gcp/test_gcp.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ def cloud_function(functionhandler, event):
173173

174174
assert exception["type"] == "Exception"
175175
assert exception["value"] == "something went wrong"
176-
assert exception["mechanism"] == {"type": "gcp", "handled": False}
176+
assert exception["mechanism"]["type"] == "gcp"
177+
assert not exception["mechanism"]["handled"]
177178

178179

179180
def test_unhandled_exception(run_cloud_function):
@@ -200,7 +201,8 @@ def cloud_function(functionhandler, event):
200201

201202
assert exception["type"] == "ZeroDivisionError"
202203
assert exception["value"] == "division by zero"
203-
assert exception["mechanism"] == {"type": "gcp", "handled": False}
204+
assert exception["mechanism"]["type"] == "gcp"
205+
assert not exception["mechanism"]["handled"]
204206

205207

206208
def test_timeout_error(run_cloud_function):
@@ -230,7 +232,8 @@ def cloud_function(functionhandler, event):
230232
exception["value"]
231233
== "WARNING : Function is expected to get timed out. Configured timeout duration = 3 seconds."
232234
)
233-
assert exception["mechanism"] == {"type": "threading", "handled": False}
235+
assert exception["mechanism"]["type"] == "threading"
236+
assert not exception["mechanism"]["handled"]
234237

235238

236239
def test_performance_no_error(run_cloud_function):
@@ -283,7 +286,8 @@ def cloud_function(functionhandler, event):
283286

284287
assert exception["type"] == "Exception"
285288
assert exception["value"] == "something went wrong"
286-
assert exception["mechanism"] == {"type": "gcp", "handled": False}
289+
assert exception["mechanism"]["type"] == "gcp"
290+
assert not exception["mechanism"]["handled"]
287291

288292
assert envelopes[1]["type"] == "transaction"
289293
assert envelopes[1]["contexts"]["trace"]["op"] == "function.gcp"

tests/integrations/pyramid/test_pyramid.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ def errors(request):
9797
(event,) = events
9898
(breadcrumb,) = event["breadcrumbs"]["values"]
9999
assert breadcrumb["message"] == "hi2"
100-
assert event["exception"]["values"][0]["mechanism"]["type"] == "pyramid"
100+
# Checking only the last value in the exceptions list,
101+
# because Pyramid >= 1.9 returns a chained exception and before just a single exception
102+
assert event["exception"]["values"][-1]["mechanism"]["type"] == "pyramid"
103+
assert event["exception"]["values"][-1]["type"] == "ZeroDivisionError"
101104

102105

103106
def test_has_context(route, get_client, sentry_init, capture_events):

tests/integrations/threading/test_threading.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ def crash():
2929

3030
(exception,) = event["exception"]["values"]
3131
assert exception["type"] == "ZeroDivisionError"
32-
assert exception["mechanism"] == {"type": "threading", "handled": False}
32+
assert exception["mechanism"]["type"] == "threading"
33+
assert not exception["mechanism"]["handled"]
3334
else:
3435
assert not events
3536

@@ -63,7 +64,8 @@ def stage2():
6364
(exception,) = event["exception"]["values"]
6465

6566
assert exception["type"] == "ZeroDivisionError"
66-
assert exception["mechanism"] == {"type": "threading", "handled": False}
67+
assert exception["mechanism"]["type"] == "threading"
68+
assert not exception["mechanism"]["handled"]
6769

6870
if propagate_hub:
6971
assert event["tags"]["stage1"] == "true"

tests/integrations/wsgi/test_wsgi.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,8 @@ def dogpark(environ, start_response):
140140
assert error_event["transaction"] == "generic WSGI request"
141141
assert error_event["contexts"]["trace"]["op"] == "http.server"
142142
assert error_event["exception"]["values"][0]["type"] == "Exception"
143-
assert error_event["exception"]["values"][0]["mechanism"] == {
144-
"type": "wsgi",
145-
"handled": False,
146-
}
143+
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "wsgi"
144+
assert error_event["exception"]["values"][0]["mechanism"]["handled"] is False
147145
assert (
148146
error_event["exception"]["values"][0]["value"]
149147
== "Fetch aborted. The ball was not returned."

tests/test_basics.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,8 @@ def test_generic_mechanism(sentry_init, capture_events):
102102
capture_exception()
103103

104104
(event,) = events
105-
assert event["exception"]["values"][0]["mechanism"] == {
106-
"type": "generic",
107-
"handled": True,
108-
}
105+
assert event["exception"]["values"][0]["mechanism"]["type"] == "generic"
106+
assert event["exception"]["values"][0]["mechanism"]["handled"]
109107

110108

111109
def test_option_before_send(sentry_init, capture_events):

0 commit comments

Comments
 (0)