From 556401156c2872d2afed5ff2c9966e7ddf27fdbf Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Tue, 23 May 2023 09:30:41 +0200 Subject: [PATCH] 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 --- sentry_sdk/utils.py | 191 +++++++++-- tests/integrations/aws_lambda/test_aws.py | 6 +- tests/integrations/bottle/test_bottle.py | 6 +- tests/integrations/gcp/test_gcp.py | 12 +- tests/integrations/pyramid/test_pyramid.py | 5 +- .../integrations/threading/test_threading.py | 6 +- tests/integrations/wsgi/test_wsgi.py | 6 +- tests/test_basics.py | 6 +- tests/test_exceptiongroup.py | 301 ++++++++++++++++++ 9 files changed, 497 insertions(+), 42 deletions(-) create mode 100644 tests/test_exceptiongroup.py diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index fa4346ecdb..58f46e2955 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -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 @@ -666,9 +672,23 @@ 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) @@ -676,9 +696,30 @@ def single_exception_from_error_tuple( 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 @@ -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__") @@ -751,6 +785,104 @@ 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]] @@ -758,17 +890,34 @@ def exceptions_from_error_tuple( ): # 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): diff --git a/tests/integrations/aws_lambda/test_aws.py b/tests/integrations/aws_lambda/test_aws.py index 78c9770317..9c792be678 100644 --- a/tests/integrations/aws_lambda/test_aws.py +++ b/tests/integrations/aws_lambda/test_aws.py @@ -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_") @@ -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_") diff --git a/tests/integrations/bottle/test_bottle.py b/tests/integrations/bottle/test_bottle.py index 206ba1cefd..eed5e990b9 100644 --- a/tests/integrations/bottle/test_bottle.py +++ b/tests/integrations/bottle/test_bottle.py @@ -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): diff --git a/tests/integrations/gcp/test_gcp.py b/tests/integrations/gcp/test_gcp.py index 478196cb52..938749ccf4 100644 --- a/tests/integrations/gcp/test_gcp.py +++ b/tests/integrations/gcp/test_gcp.py @@ -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): @@ -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): @@ -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): @@ -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" diff --git a/tests/integrations/pyramid/test_pyramid.py b/tests/integrations/pyramid/test_pyramid.py index 9fc15c052f..dc1567e3eb 100644 --- a/tests/integrations/pyramid/test_pyramid.py +++ b/tests/integrations/pyramid/test_pyramid.py @@ -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): diff --git a/tests/integrations/threading/test_threading.py b/tests/integrations/threading/test_threading.py index 683a6c74dd..56f7a36ea3 100644 --- a/tests/integrations/threading/test_threading.py +++ b/tests/integrations/threading/test_threading.py @@ -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 @@ -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" diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index 03b86f87ef..a2b29eb9cf 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -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." diff --git a/tests/test_basics.py b/tests/test_basics.py index e509fc6600..751b0a617b 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -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"][0]["mechanism"]["handled"] def test_option_before_send(sentry_init, capture_events): diff --git a/tests/test_exceptiongroup.py b/tests/test_exceptiongroup.py new file mode 100644 index 0000000000..47b3344dc6 --- /dev/null +++ b/tests/test_exceptiongroup.py @@ -0,0 +1,301 @@ +import sys +import pytest + +from sentry_sdk.utils import event_from_exception + + +try: + # Python 3.11 + from builtins import ExceptionGroup # type: ignore +except ImportError: + # Python 3.10 and below + ExceptionGroup = None + + +minimum_python_311 = pytest.mark.skipif( + sys.version_info < (3, 11), reason="ExceptionGroup tests need Python >= 3.11" +) + + +@minimum_python_311 +def test_exceptiongroup(): + exception_group = None + + try: + try: + raise RuntimeError("something") + except RuntimeError: + raise ExceptionGroup( + "nested", + [ + ValueError(654), + ExceptionGroup( + "imports", + [ + ImportError("no_such_module"), + ModuleNotFoundError("another_module"), + ], + ), + TypeError("int"), + ], + ) + except ExceptionGroup as e: + exception_group = e + + (event, _) = event_from_exception( + exception_group, + client_options={ + "include_local_variables": True, + "include_source_context": True, + }, + mechanism={"type": "test_suite", "handled": False}, + ) + + values = event["exception"]["values"] + + # For this test the stacktrace and the module is not important + for x in values: + if "stacktrace" in x: + del x["stacktrace"] + if "module" in x: + del x["module"] + + expected_values = [ + { + "mechanism": { + "exception_id": 6, + "handled": False, + "parent_id": 0, + "source": "exceptions[2]", + "type": "chained", + }, + "type": "TypeError", + "value": "int", + }, + { + "mechanism": { + "exception_id": 5, + "handled": False, + "parent_id": 3, + "source": "exceptions[1]", + "type": "chained", + }, + "type": "ModuleNotFoundError", + "value": "another_module", + }, + { + "mechanism": { + "exception_id": 4, + "handled": False, + "parent_id": 3, + "source": "exceptions[0]", + "type": "chained", + }, + "type": "ImportError", + "value": "no_such_module", + }, + { + "mechanism": { + "exception_id": 3, + "handled": False, + "is_exception_group": True, + "parent_id": 0, + "source": "exceptions[1]", + "type": "chained", + }, + "type": "ExceptionGroup", + "value": "imports", + }, + { + "mechanism": { + "exception_id": 2, + "handled": False, + "parent_id": 0, + "source": "exceptions[0]", + "type": "chained", + }, + "type": "ValueError", + "value": "654", + }, + { + "mechanism": { + "exception_id": 1, + "handled": False, + "parent_id": 0, + "source": "__context__", + "type": "chained", + }, + "type": "RuntimeError", + "value": "something", + }, + { + "mechanism": { + "exception_id": 0, + "handled": False, + "is_exception_group": True, + "type": "test_suite", + }, + "type": "ExceptionGroup", + "value": "nested", + }, + ] + + assert values == expected_values + + +@minimum_python_311 +def test_exceptiongroup_simple(): + exception_group = None + + try: + raise ExceptionGroup( + "simple", + [ + RuntimeError("something strange's going on"), + ], + ) + except ExceptionGroup as e: + exception_group = e + + (event, _) = event_from_exception( + exception_group, + client_options={ + "include_local_variables": True, + "include_source_context": True, + }, + mechanism={"type": "test_suite", "handled": False}, + ) + + exception_values = event["exception"]["values"] + + assert len(exception_values) == 2 + + assert exception_values[0]["type"] == "RuntimeError" + assert exception_values[0]["value"] == "something strange's going on" + assert exception_values[0]["mechanism"] == { + "type": "chained", + "handled": False, + "exception_id": 1, + "source": "exceptions[0]", + "parent_id": 0, + } + + assert exception_values[1]["type"] == "ExceptionGroup" + assert exception_values[1]["value"] == "simple" + assert exception_values[1]["mechanism"] == { + "type": "test_suite", + "handled": False, + "exception_id": 0, + "is_exception_group": True, + } + frame = exception_values[1]["stacktrace"]["frames"][0] + assert frame["module"] == "tests.test_exceptiongroup" + assert frame["lineno"] == 151 + assert frame["context_line"] == " raise ExceptionGroup(" + + +def test_exception_chain_cause(): + exception_chain_cause = ValueError("Exception with cause") + exception_chain_cause.__context__ = TypeError("Exception in __context__") + exception_chain_cause.__cause__ = TypeError( + "Exception in __cause__" + ) # this implicitly sets exception_chain_cause.__suppress_context__=True + + (event, _) = event_from_exception( + exception_chain_cause, + client_options={ + "include_local_variables": True, + "include_source_context": True, + }, + mechanism={"type": "test_suite", "handled": False}, + ) + + expected_exception_values = [ + { + "mechanism": { + "handled": False, + "type": "test_suite", + }, + "module": None, + "type": "TypeError", + "value": "Exception in __cause__", + }, + { + "mechanism": { + "handled": False, + "type": "test_suite", + }, + "module": None, + "type": "ValueError", + "value": "Exception with cause", + }, + ] + + exception_values = event["exception"]["values"] + assert exception_values == expected_exception_values + + +def test_exception_chain_context(): + exception_chain_context = ValueError("Exception with context") + exception_chain_context.__context__ = TypeError("Exception in __context__") + + (event, _) = event_from_exception( + exception_chain_context, + client_options={ + "include_local_variables": True, + "include_source_context": True, + }, + mechanism={"type": "test_suite", "handled": False}, + ) + + expected_exception_values = [ + { + "mechanism": { + "handled": False, + "type": "test_suite", + }, + "module": None, + "type": "TypeError", + "value": "Exception in __context__", + }, + { + "mechanism": { + "handled": False, + "type": "test_suite", + }, + "module": None, + "type": "ValueError", + "value": "Exception with context", + }, + ] + + exception_values = event["exception"]["values"] + assert exception_values == expected_exception_values + + +def test_simple_exception(): + simple_excpetion = ValueError("A simple exception") + + (event, _) = event_from_exception( + simple_excpetion, + client_options={ + "include_local_variables": True, + "include_source_context": True, + }, + mechanism={"type": "test_suite", "handled": False}, + ) + + expected_exception_values = [ + { + "mechanism": { + "handled": False, + "type": "test_suite", + }, + "module": None, + "type": "ValueError", + "value": "A simple exception", + }, + ] + + exception_values = event["exception"]["values"] + assert exception_values == expected_exception_values