Skip to content

Commit

Permalink
Add support for ExceptionGroups (#2025)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
antonpirker authored May 23, 2023
1 parent 1d9effe commit 5564011
Show file tree
Hide file tree
Showing 9 changed files with 497 additions and 42 deletions.
191 changes: 170 additions & 21 deletions sentry_sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
from urlparse import urlsplit # type: ignore
from urlparse import urlunsplit # type: ignore

try:
# Python 3.11
from builtins import BaseExceptionGroup
except ImportError:
# Python 3.10 and below
BaseExceptionGroup = None # type: ignore

from datetime import datetime
from functools import partial
Expand Down Expand Up @@ -666,19 +672,54 @@ def single_exception_from_error_tuple(
tb, # type: Optional[TracebackType]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
exception_id=None, # type: Optional[int]
parent_id=None, # type: Optional[int]
source=None, # type: Optional[str]
):
# type: (...) -> Dict[str, Any]
mechanism = mechanism or {"type": "generic", "handled": True}
"""
Creates a dict that goes into the events `exception.values` list and is ingestible by Sentry.
See the Exception Interface documentation for more details:
https://develop.sentry.dev/sdk/event-payloads/exception/
"""
exception_value = {} # type: Dict[str, Any]
exception_value["mechanism"] = (
mechanism.copy() if mechanism else {"type": "generic", "handled": True}
)
if exception_id is not None:
exception_value["mechanism"]["exception_id"] = exception_id

if exc_value is not None:
errno = get_errno(exc_value)
else:
errno = None

if errno is not None:
mechanism.setdefault("meta", {}).setdefault("errno", {}).setdefault(
"number", errno
)
exception_value["mechanism"].setdefault("meta", {}).setdefault(
"errno", {}
).setdefault("number", errno)

if source is not None:
exception_value["mechanism"]["source"] = source

is_root_exception = exception_id == 0
if not is_root_exception and parent_id is not None:
exception_value["mechanism"]["parent_id"] = parent_id
exception_value["mechanism"]["type"] = "chained"

if is_root_exception and "type" not in exception_value["mechanism"]:
exception_value["mechanism"]["type"] = "generic"

is_exception_group = BaseExceptionGroup is not None and isinstance(
exc_value, BaseExceptionGroup
)
if is_exception_group:
exception_value["mechanism"]["is_exception_group"] = True

exception_value["module"] = get_type_module(exc_type)
exception_value["type"] = get_type_name(exc_type)
exception_value["value"] = getattr(exc_value, "message", safe_str(exc_value))

if client_options is None:
include_local_variables = True
Expand All @@ -697,17 +738,10 @@ def single_exception_from_error_tuple(
for tb in iter_stacks(tb)
]

rv = {
"module": get_type_module(exc_type),
"type": get_type_name(exc_type),
"value": safe_str(exc_value),
"mechanism": mechanism,
}

if frames:
rv["stacktrace"] = {"frames": frames}
exception_value["stacktrace"] = {"frames": frames}

return rv
return exception_value


HAS_CHAINED_EXCEPTIONS = hasattr(Exception, "__suppress_context__")
Expand Down Expand Up @@ -751,24 +785,139 @@ def walk_exception_chain(exc_info):
yield exc_info


def exceptions_from_error(
exc_type, # type: Optional[type]
exc_value, # type: Optional[BaseException]
tb, # type: Optional[TracebackType]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
exception_id=0, # type: int
parent_id=0, # type: int
source=None, # type: Optional[str]
):
# type: (...) -> Tuple[int, List[Dict[str, Any]]]
"""
Creates the list of exceptions.
This can include chained exceptions and exceptions from an ExceptionGroup.
See the Exception Interface documentation for more details:
https://develop.sentry.dev/sdk/event-payloads/exception/
"""

parent = single_exception_from_error_tuple(
exc_type=exc_type,
exc_value=exc_value,
tb=tb,
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
parent_id=parent_id,
source=source,
)
exceptions = [parent]

parent_id = exception_id
exception_id += 1

should_supress_context = (
hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
)
if should_supress_context:
# Add direct cause.
# The field `__cause__` is set when raised with the exception (using the `from` keyword).
exception_has_cause = (
exc_value
and hasattr(exc_value, "__cause__")
and exc_value.__cause__ is not None
)
if exception_has_cause:
cause = exc_value.__cause__ # type: ignore
(exception_id, child_exceptions) = exceptions_from_error(
exc_type=type(cause),
exc_value=cause,
tb=getattr(cause, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
source="__cause__",
)
exceptions.extend(child_exceptions)

else:
# Add indirect cause.
# The field `__context__` is assigned if another exception occurs while handling the exception.
exception_has_content = (
exc_value
and hasattr(exc_value, "__context__")
and exc_value.__context__ is not None
)
if exception_has_content:
context = exc_value.__context__ # type: ignore
(exception_id, child_exceptions) = exceptions_from_error(
exc_type=type(context),
exc_value=context,
tb=getattr(context, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
source="__context__",
)
exceptions.extend(child_exceptions)

# Add exceptions from an ExceptionGroup.
is_exception_group = exc_value and hasattr(exc_value, "exceptions")
if is_exception_group:
for idx, e in enumerate(exc_value.exceptions): # type: ignore
(exception_id, child_exceptions) = exceptions_from_error(
exc_type=type(e),
exc_value=e,
tb=getattr(e, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
parent_id=parent_id,
source="exceptions[%s]" % idx,
)
exceptions.extend(child_exceptions)

return (exception_id, exceptions)


def exceptions_from_error_tuple(
exc_info, # type: ExcInfo
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
):
# type: (...) -> List[Dict[str, Any]]
exc_type, exc_value, tb = exc_info
rv = []
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
rv.append(
single_exception_from_error_tuple(
exc_type, exc_value, tb, client_options, mechanism
)

is_exception_group = BaseExceptionGroup is not None and isinstance(
exc_value, BaseExceptionGroup
)

if is_exception_group:
(_, exceptions) = exceptions_from_error(
exc_type=exc_type,
exc_value=exc_value,
tb=tb,
client_options=client_options,
mechanism=mechanism,
exception_id=0,
parent_id=0,
)

rv.reverse()
else:
exceptions = []
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
exceptions.append(
single_exception_from_error_tuple(
exc_type, exc_value, tb, client_options, mechanism
)
)

exceptions.reverse()

return rv
return exceptions


def to_string(value):
Expand Down
6 changes: 4 additions & 2 deletions tests/integrations/aws_lambda/test_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ def test_handler(event, context):

assert frame1["in_app"] is True

assert exception["mechanism"] == {"type": "aws_lambda", "handled": False}
assert exception["mechanism"]["type"] == "aws_lambda"
assert not exception["mechanism"]["handled"]

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

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

assert exception["mechanism"] == {"type": "threading", "handled": False}
assert exception["mechanism"]["type"] == "threading"
assert not exception["mechanism"]["handled"]

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

Expand Down
6 changes: 2 additions & 4 deletions tests/integrations/bottle/test_bottle.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,8 @@ def crashing_app(environ, start_response):
assert error is exc.value

(event,) = events
assert event["exception"]["values"][0]["mechanism"] == {
"type": "bottle",
"handled": False,
}
assert event["exception"]["values"][0]["mechanism"]["type"] == "bottle"
assert event["exception"]["values"][0]["mechanism"]["handled"] is False


def test_500(sentry_init, capture_events, app, get_client):
Expand Down
12 changes: 8 additions & 4 deletions tests/integrations/gcp/test_gcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ def cloud_function(functionhandler, event):

assert exception["type"] == "Exception"
assert exception["value"] == "something went wrong"
assert exception["mechanism"] == {"type": "gcp", "handled": False}
assert exception["mechanism"]["type"] == "gcp"
assert not exception["mechanism"]["handled"]


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

assert exception["type"] == "ZeroDivisionError"
assert exception["value"] == "division by zero"
assert exception["mechanism"] == {"type": "gcp", "handled": False}
assert exception["mechanism"]["type"] == "gcp"
assert not exception["mechanism"]["handled"]


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


def test_performance_no_error(run_cloud_function):
Expand Down Expand Up @@ -283,7 +286,8 @@ def cloud_function(functionhandler, event):

assert exception["type"] == "Exception"
assert exception["value"] == "something went wrong"
assert exception["mechanism"] == {"type": "gcp", "handled": False}
assert exception["mechanism"]["type"] == "gcp"
assert not exception["mechanism"]["handled"]

assert envelopes[1]["type"] == "transaction"
assert envelopes[1]["contexts"]["trace"]["op"] == "function.gcp"
Expand Down
5 changes: 4 additions & 1 deletion tests/integrations/pyramid/test_pyramid.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ def errors(request):
(event,) = events
(breadcrumb,) = event["breadcrumbs"]["values"]
assert breadcrumb["message"] == "hi2"
assert event["exception"]["values"][0]["mechanism"]["type"] == "pyramid"
# Checking only the last value in the exceptions list,
# because Pyramid >= 1.9 returns a chained exception and before just a single exception
assert event["exception"]["values"][-1]["mechanism"]["type"] == "pyramid"
assert event["exception"]["values"][-1]["type"] == "ZeroDivisionError"


def test_has_context(route, get_client, sentry_init, capture_events):
Expand Down
6 changes: 4 additions & 2 deletions tests/integrations/threading/test_threading.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ def crash():

(exception,) = event["exception"]["values"]
assert exception["type"] == "ZeroDivisionError"
assert exception["mechanism"] == {"type": "threading", "handled": False}
assert exception["mechanism"]["type"] == "threading"
assert not exception["mechanism"]["handled"]
else:
assert not events

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

assert exception["type"] == "ZeroDivisionError"
assert exception["mechanism"] == {"type": "threading", "handled": False}
assert exception["mechanism"]["type"] == "threading"
assert not exception["mechanism"]["handled"]

if propagate_hub:
assert event["tags"]["stage1"] == "true"
Expand Down
6 changes: 2 additions & 4 deletions tests/integrations/wsgi/test_wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,8 @@ def dogpark(environ, start_response):
assert error_event["transaction"] == "generic WSGI request"
assert error_event["contexts"]["trace"]["op"] == "http.server"
assert error_event["exception"]["values"][0]["type"] == "Exception"
assert error_event["exception"]["values"][0]["mechanism"] == {
"type": "wsgi",
"handled": False,
}
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "wsgi"
assert error_event["exception"]["values"][0]["mechanism"]["handled"] is False
assert (
error_event["exception"]["values"][0]["value"]
== "Fetch aborted. The ball was not returned."
Expand Down
6 changes: 2 additions & 4 deletions tests/test_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,8 @@ def test_generic_mechanism(sentry_init, capture_events):
capture_exception()

(event,) = events
assert event["exception"]["values"][0]["mechanism"] == {
"type": "generic",
"handled": True,
}
assert event["exception"]["values"][0]["mechanism"]["type"] == "generic"
assert event["exception"]["values"