Repository: getsentry/sentry-python

Issue:
Add support for ExceptionGroup
### Problem Statement

ExceptionGroup was introduced in Python 3.11:
https://peps.python.org/pep-0664/#features-for-3-11
https://peps.python.org/pep-0654/

Our current SDK v1.12.0 does not support capturing all exceptions separately with its own stack traces so they can be shown in Sentry UI.

This issue was originally raised here in a general manner to expand the feature to other SDKs:  https://github.com/getsentry/sentry/issues/37716


Discussion:
This issue has gone three weeks without activity. In another week, I will close it.

But! If you comment or otherwise update it, I will reset the clock, and if you label it `Status: Backlog` or `Status: In Progress`, I will leave it alone ... forever!

----

"A weed is but an unloved flower." ― _Ella Wheeler Wilcox_ 🥀
In Sentry we can visualize chained exceptions in the Issues UI. ExceptionGroups could be visualized similarly for the start.

Did some poking around in the Python SDK code:

We have walk_exception_chain() but this is only used:
- in `executing` and `pure_eval` integrations (not enabled by default)
- and `django` and `spark` integrations.

So chained exceptions are not handled by default for the fast majority of users.
## The goal of a first step to support ExceptionGroups:
- Chained exceptions should probably work out of the box without integrations.
- If exception group is captured: just pick first exceptions to be showing up as issue in Sentry. 
- if this picking is happening, add the full group visualization from Python somewhere to the event sent to Sentry (probably best in a context)
- Add new option `keep_exception_groups` by default `False`.
- If `keep_exception_groups` is set to `True` do not pick first exception, but send group as-is.
- make sure that chaining of this exception still works inside the group.

This makes sense, and aligns mostly with my thoughts on https://github.com/getsentry/sentry/issues/37716#issuecomment-1483238814.  Thanks!
We do still need to decide what we mean by "just pick first exception".

Currently (without exception groups) the order of precedence is:

1. `__cause__` unless it is `None`
2. `__context__` unless it is `None`
3. (nothing to chain)

My gut instinct says that we should insert this in the middle, as in:

1. `__cause__` unless it is `None`
2. `exceptions[0]` unless it's empty (or not an exception group)
3. `__context__` unless it is `None`
4. (nothing to chain)

Working on the final spec, but just FYI, I think we will put the full exception group on `extras` instead of `contexts` due to [size constraints](https://develop.sentry.dev/sdk/data-handling/#variable-size).

Resolving patch:
diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py
--- 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):


Test patch:
diff --git a/tests/integrations/aws_lambda/test_aws.py b/tests/integrations/aws_lambda/test_aws.py
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- /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