Repository: getsentry/sentry-python
Pull request #4164: Better handling of exception groups
Author: login: antonpirker
Created: 2025-03-19T10:29:37Z | Merged: 2025-03-20T14:55:29Z

Properly handle grouped and chained exceptions. The test case in the linked issue illustrates that some ExceptionGroups have been handled in a wrong way. 

Updated some tests, because now that those are handled correctly all the mechanism types except for the root exception are set to "chained" like described in the RFC: https://github.com/getsentry/rfcs/blob/main/text/0079-exception-groups.md#interpretation

Because this will change the grouping of exiting Sentry Issues containing ExceptionGroups, it is safer to release this fix in the next major and make sure that we describe the change in behavior in the changelog. (Note: The grouping in the Ariadne issues will not change because those are not ExceptionGroups and only updating the `mechanism.type` does not change the grouping)

The main change is the `exceptions_from_error` function: 
https://github.com/getsentry/sentry-python/blob/72f3f849f698f3d234415ccf3e175de2e5e69b5e/sentry_sdk/utils.py#L790-L890

Fixes #3913

Linked issue #3913: If Exception.__context__ is an ExceptionGroup, Sentry doesn't capture the ExceptionGroup's children
### How do you use Sentry?

Sentry Saas (sentry.io)

### Version

1.29.2

### Steps to Reproduce

Here's a toy example that demonstrates the problem:
```python
try:
    try:
        raise ExceptionGroup("group", [ValueError("child1"), ValueError("child2")])
    finally:
        raise TypeError("bar")
except BaseException:
    sentry_sdk.capture_exception()
```

`sentry_sdk.capture_exception()` receives the `TypeError`. `TypeError.__context__` points to the `ExceptionGroup`; and then `ExceptionGroup.exceptions` points to `[ValueError("child1"), ValueError("child2")]`.

### Expected Result

The Sentry event should contain all four exceptions: the `TypeError`, the `ExceptionGroup`, and the sub-exceptions `ValueError("child1")` and `ValueError("child2")`.

Additionally, the event should record that the `ExceptionGroup` is `TypeError.__context__` as opposed to `TypeError.__cause__`.

Note that the `sentry_sdk.utils.exceptions_from_error()` function actually does this correctly, but it isn't getting invoked in this case. Here's a screenshot of how it looks if I patch the Sentry SDK to always use `sentry_sdk.utils.exceptions_from_error()`:

<img width="745" alt="Image" src="https://github.com/user-attachments/assets/1c4bfbff-e300-408f-8b7c-9c82a7d3a192" />


### Actual Result

When the `TypeError` is passed into `sentry_sdk.utils.exceptions_from_error_tuple()`, it checks whether the `TypeError` is an `ExceptionGroup` or not: https://github.com/getsentry/sentry-python/blob/2.19.2/sentry_sdk/utils.py#L968-L1009 Because the `TypeError` is not an `ExceptionGroup`, it calls `walk_exception_chain()`, which examines `TypeError.__context__` to find the `ExceptionGroup`; but it doesn't recurse into the sub-exceptions `ValueError("child1")` and `ValueError("child2")`. So the Sentry event only contains the `TypeError` and the `ExceptionGroup`:

<img width="745" alt="Image" src="https://github.com/user-attachments/assets/e5fb6d10-7b2b-4a59-9a9e-d9c173d2b06a" />

Also, the "Related Exceptions" tree in the above screenshot appears to be nonsensical.

I tested this with SDK version 1.29.2, but the bug appears to still be present in the latest version of the code.
Linked issue comments: user: login: tmaxwell-anthropic
created_at: 2025-01-10T05:06:22Z
body: (Minor related issue: I noticed that `sentry_sdk.utils.exceptions_from_error()` doesn't pass `parent_id` when recursing [here](https://github.com/getsentry/sentry-python/blob/2.19.2/sentry_sdk/utils.py#L914-L923) and [here](https://github.com/getsentry/sentry-python/blob/2.19.2/sentry_sdk/utils.py#L936-L945). That seems like it might have been a bug?)
author_association: NONE
user: login: antonpirker
created_at: 2025-01-13T10:47:42Z
body: Hey @tmaxwell-anthropic thanks for this great bug report! 

I have put this on our list. 
author_association: CONTRIBUTOR
user: login: antonpirker
created_at: 2025-03-20T14:56:53Z
body: This has been fixed in the linked PR and will be released with Sentry SDK version 3.0.
author_association: CONTRIBUTOR

Diff:
diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md
index 7a1275b852..263e27e111 100644
--- a/MIGRATION_GUIDE.md
+++ b/MIGRATION_GUIDE.md
@@ -22,6 +22,7 @@ Looking to upgrade from Sentry SDK 2.x to 3.x? Here's a comprehensive list of wh
 - clickhouse-driver integration: The query is now available under the `db.query.text` span attribute (only if `send_default_pii` is `True`).
 - `sentry_sdk.init` now returns `None` instead of a context manager.
 - The `sampling_context` argument of `traces_sampler` and `profiles_sampler` now additionally contains all span attributes known at span start.
+- We updated how we handle `ExceptionGroup`s. You will now get more data if ExceptionGroups are appearing in chained exceptions. It could happen that after updating the SDK the grouping of issues change because of this. So eventually you will see the same exception in two Sentry issues (one from before the update, one from after the update)
 - The integration-specific content of the `sampling_context` argument of `traces_sampler` and `profiles_sampler` now looks different.
   - The Celery integration doesn't add the `celery_job` dictionary anymore. Instead, the individual keys are now available as:
 
diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py
index 22bb09c242..2e6d82d0ae 100644
--- a/sentry_sdk/utils.py
+++ b/sentry_sdk/utils.py
@@ -775,14 +775,17 @@ def exceptions_from_error(
 ):
     # 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/
+    Converts the given exception information into the Sentry structured "exception" format.
+    This will return a list of exceptions (a flattened tree of exceptions) in the
+    format of the Exception Interface documentation:
+    https://develop.sentry.dev/sdk/data-model/event-payloads/exception/
+
+    This function can handle:
+    - simple exceptions
+    - chained exceptions (raise .. from ..)
+    - exception groups
     """
-
-    parent = single_exception_from_error_tuple(
+    base_exception = single_exception_from_error_tuple(
         exc_type=exc_type,
         exc_value=exc_value,
         tb=tb,
@@ -793,64 +796,63 @@ def exceptions_from_error(
         source=source,
         full_stack=full_stack,
     )
-    exceptions = [parent]
+    exceptions = [base_exception]
 
     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 = (
+    causing_exception = None
+    exception_source = None
+
+    # Add any causing exceptions, if present.
+    should_suppress_context = hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__  # type: ignore
+    # Note: __suppress_context__ is True if the exception is raised with the `from` keyword.
+    if should_suppress_context:
+        # Explicitly chained exceptions (Like: raise NewException() from OriginalException())
+        # The field `__cause__` is set to OriginalException
+        has_explicit_causing_exception = (
             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__",
-                full_stack=full_stack,
-            )
-            exceptions.extend(child_exceptions)
-
+        if has_explicit_causing_exception:
+            exception_source = "__cause__"
+            causing_exception = exc_value.__cause__  # type: ignore
     else:
-        # Add indirect cause.
-        # The field `__context__` is assigned if another exception occurs while handling the exception.
-        exception_has_content = (
+        # Implicitly chained exceptions (when an exception occurs while handling another exception)
+        # The field `__context__` is set in the exception that occurs while handling another exception,
+        # to the other exception.
+        has_implicit_causing_exception = (
             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__",
-                full_stack=full_stack,
-            )
-            exceptions.extend(child_exceptions)
+        if has_implicit_causing_exception:
+            exception_source = "__context__"
+            causing_exception = exc_value.__context__  # type: ignore
+
+    if causing_exception:
+        (exception_id, child_exceptions) = exceptions_from_error(
+            exc_type=type(causing_exception),
+            exc_value=causing_exception,
+            tb=getattr(causing_exception, "__traceback__", None),
+            client_options=client_options,
+            mechanism=mechanism,
+            exception_id=exception_id,
+            parent_id=parent_id,
+            source=exception_source,
+            full_stack=full_stack,
+        )
+        exceptions.extend(child_exceptions)
 
-    # Add exceptions from an ExceptionGroup.
+    # Add child 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
+        for idx, causing_exception 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),
+                exc_type=type(causing_exception),
+                exc_value=causing_exception,
+                tb=getattr(causing_exception, "__traceback__", None),
                 client_options=client_options,
                 mechanism=mechanism,
                 exception_id=exception_id,
@@ -870,38 +872,29 @@ def exceptions_from_error_tuple(
     full_stack=None,  # type: Optional[list[dict[str, Any]]]
 ):
     # type: (...) -> List[Dict[str, Any]]
+    """
+    Convert Python's exception information into Sentry's structured "exception" format in the event.
+    See https://develop.sentry.dev/sdk/data-model/event-payloads/exception/
+    This is the entry point for the exception handling.
+    """
+    # unpack the exception info tuple
     exc_type, exc_value, tb = exc_info
 
-    is_exception_group = BaseExceptionGroup is not None and isinstance(
-        exc_value, BaseExceptionGroup
+    # let exceptions_from_error do the actual work
+    _, 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,
+        full_stack=full_stack,
     )
 
-    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,
-            full_stack=full_stack,
-        )
-
-    else:
-        exceptions = []
-        for exc_type, exc_value, tb in walk_exception_chain(exc_info):
-            exceptions.append(
-                single_exception_from_error_tuple(
-                    exc_type=exc_type,
-                    exc_value=exc_value,
-                    tb=tb,
-                    client_options=client_options,
-                    mechanism=mechanism,
-                    full_stack=full_stack,
-                )
-            )
-
+    # make sure the exceptions are sorted
+    # from the innermost (oldest)
+    # to the outermost (newest) exception
     exceptions.reverse()
 
     return exceptions
diff --git a/tests/integrations/ariadne/test_ariadne.py b/tests/integrations/ariadne/test_ariadne.py
index 2c3b086aa5..6637a88451 100644
--- a/tests/integrations/ariadne/test_ariadne.py
+++ b/tests/integrations/ariadne/test_ariadne.py
@@ -68,7 +68,9 @@ def test_capture_request_and_response_if_send_pii_is_on_async(
     assert len(events) == 1
 
     (event,) = events
-    assert event["exception"]["values"][0]["mechanism"]["type"] == "ariadne"
+    assert len(event["exception"]["values"]) == 2
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "chained"
+    assert event["exception"]["values"][-1]["mechanism"]["type"] == "ariadne"
     assert event["contexts"]["response"] == {
         "data": {
             "data": {"error": None},
@@ -111,7 +113,10 @@ def graphql_server():
     assert len(events) == 1
 
     (event,) = events
-    assert event["exception"]["values"][0]["mechanism"]["type"] == "ariadne"
+    assert len(event["exception"]["values"]) == 2
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "chained"
+    assert event["exception"]["values"][-1]["mechanism"]["type"] == "ariadne"
+
     assert event["contexts"]["response"] == {
         "data": {
             "data": {"error": None},
@@ -152,7 +157,10 @@ def test_do_not_capture_request_and_response_if_send_pii_is_off_async(
     assert len(events) == 1
 
     (event,) = events
-    assert event["exception"]["values"][0]["mechanism"]["type"] == "ariadne"
+    assert len(event["exception"]["values"]) == 2
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "chained"
+    assert event["exception"]["values"][-1]["mechanism"]["type"] == "ariadne"
+
     assert "data" not in event["request"]
     assert "response" not in event["contexts"]
 
@@ -182,7 +190,9 @@ def graphql_server():
     assert len(events) == 1
 
     (event,) = events
-    assert event["exception"]["values"][0]["mechanism"]["type"] == "ariadne"
+    assert len(event["exception"]["values"]) == 2
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "chained"
+    assert event["exception"]["values"][-1]["mechanism"]["type"] == "ariadne"
     assert "data" not in event["request"]
     assert "response" not in event["contexts"]
 
diff --git a/tests/integrations/strawberry/test_strawberry.py b/tests/integrations/strawberry/test_strawberry.py
index fdf7ff71bb..d1774aeca5 100644
--- a/tests/integrations/strawberry/test_strawberry.py
+++ b/tests/integrations/strawberry/test_strawberry.py
@@ -204,7 +204,9 @@ def test_capture_request_if_available_and_send_pii_is_on(
 
     (error_event,) = events
 
-    assert error_event["exception"]["values"][0]["mechanism"]["type"] == "strawberry"
+    assert len(error_event["exception"]["values"]) == 2
+    assert error_event["exception"]["values"][0]["mechanism"]["type"] == "chained"
+    assert error_event["exception"]["values"][-1]["mechanism"]["type"] == "strawberry"
     assert error_event["request"]["api_target"] == "graphql"
     assert error_event["request"]["data"] == {
         "query": query,
@@ -258,7 +260,10 @@ def test_do_not_capture_request_if_send_pii_is_off(
     assert len(events) == 1
 
     (error_event,) = events
-    assert error_event["exception"]["values"][0]["mechanism"]["type"] == "strawberry"
+
+    assert len(error_event["exception"]["values"]) == 2
+    assert error_event["exception"]["values"][0]["mechanism"]["type"] == "chained"
+    assert error_event["exception"]["values"][-1]["mechanism"]["type"] == "strawberry"
     assert "data" not in error_event["request"]
     assert "response" not in error_event["contexts"]
 
diff --git a/tests/test_exceptiongroup.py b/tests/test_exceptiongroup.py
index 4c7afc58eb..01ec0a78d4 100644
--- a/tests/test_exceptiongroup.py
+++ b/tests/test_exceptiongroup.py
@@ -217,7 +217,10 @@ def test_exception_chain_cause():
         {
             "mechanism": {
                 "handled": False,
-                "type": "test_suite",
+                "type": "chained",
+                "exception_id": 1,
+                "parent_id": 0,
+                "source": "__cause__",
             },
             "module": None,
             "type": "TypeError",
@@ -227,6 +230,7 @@ def test_exception_chain_cause():
             "mechanism": {
                 "handled": False,
                 "type": "test_suite",
+                "exception_id": 0,
             },
             "module": None,
             "type": "ValueError",
@@ -257,7 +261,10 @@ def test_exception_chain_context():
         {
             "mechanism": {
                 "handled": False,
-                "type": "test_suite",
+                "type": "chained",
+                "exception_id": 1,
+                "parent_id": 0,
+                "source": "__context__",
             },
             "module": None,
             "type": "TypeError",
@@ -267,6 +274,7 @@ def test_exception_chain_context():
             "mechanism": {
                 "handled": False,
                 "type": "test_suite",
+                "exception_id": 0,
             },
             "module": None,
             "type": "ValueError",
@@ -297,6 +305,7 @@ def test_simple_exception():
             "mechanism": {
                 "handled": False,
                 "type": "test_suite",
+                "exception_id": 0,
             },
             "module": None,
             "type": "ValueError",


Reviews:
user: login: szokeasaurusrex
body: @antonpirker I am still struggling a bit to see how the behavior is supposed to change here. Can you walk me through this on Thursday?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T11:29:31Z
user: login: sentrivana
body: special thanks for the comments, very helpful in understanding what's going on
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T13:21:53Z
user: login: szokeasaurusrex
body: Please review my comments before merging
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-20T12:54:39Z
user: login: antonpirker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T14:18:05Z
user: login: antonpirker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T14:54:42Z

Review comments:
diff_hunk: @@ -870,38 +872,29 @@ def exceptions_from_error_tuple(
     full_stack=None,  # type: Optional[list[dict[str, Any]]]
 ):
     # type: (...) -> List[Dict[str, Any]]
+    """
+    Convert Python's exception information into Sentry's structured "exception" format in the event.
+    See https://develop.sentry.dev/sdk/data-model/event-payloads/exception/
+    This is the entry point for the exception handling.
+    """
+    # unpack the exception info tuple
     exc_type, exc_value, tb = exc_info
 
-    is_exception_group = BaseExceptionGroup is not None and isinstance(
-        exc_value, BaseExceptionGroup
+    # let exceptions_from_error do the actual work
path: sentry_sdk/utils.py
user: login: szokeasaurusrex
body: ```suggestion
```

I don't think this comment is needed here
created_at: 2025-03-20T12:46:56Z
author_association: MEMBER
diff_hunk: @@ -870,38 +872,29 @@ def exceptions_from_error_tuple(
     full_stack=None,  # type: Optional[list[dict[str, Any]]]
 ):
     # type: (...) -> List[Dict[str, Any]]
+    """
+    Convert Python's exception information into Sentry's structured "exception" format in the event.
+    See https://develop.sentry.dev/sdk/data-model/event-payloads/exception/
+    This is the entry point for the exception handling.
+    """
+    # unpack the exception info tuple
     exc_type, exc_value, tb = exc_info
 
-    is_exception_group = BaseExceptionGroup is not None and isinstance(
-        exc_value, BaseExceptionGroup
+    # let exceptions_from_error do the actual work
+    _, 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,
+        full_stack=full_stack,
     )
 
-    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,
-            full_stack=full_stack,
-        )
-
-    else:
-        exceptions = []
-        for exc_type, exc_value, tb in walk_exception_chain(exc_info):
-            exceptions.append(
-                single_exception_from_error_tuple(
-                    exc_type=exc_type,
-                    exc_value=exc_value,
-                    tb=tb,
-                    client_options=client_options,
-                    mechanism=mechanism,
-                    full_stack=full_stack,
-                )
-            )
-
+    # make sure the exceptions are sorted
+    # from the innermost (oldest)
+    # to the outermost (newest) exception
     exceptions.reverse()
path: sentry_sdk/utils.py
user: login: szokeasaurusrex
body: [optional – can also address in a later PR or wait and see if it is actually a problem in the real world] 

Per our offline discussion, please check whether it would be possible to construct the exceptions list (within `exceptions_from_error`) in the correct order, so that reversing here is unnecessary.

Reason it would be better to construct in the opposite order is that `exceptions_from_error` on each recursive call is appending all of the exceptions to a list containing the base exception (an O(n) operation). Since we call `exceptions_from_error` O(n) times, this makes the function potentially O(n^2). If instead, we simply take the array from the recursive call, and then append to that array, I believe we solve this potential problem.

If it is too much effort to address here, let's just merge the PR for now, assuming exception chains are usually small, this won't matter much anyways.
created_at: 2025-03-20T12:51:05Z
author_association: MEMBER
diff_hunk: @@ -68,7 +68,8 @@ def test_capture_request_and_response_if_send_pii_is_on_async(
     assert len(events) == 1
 
     (event,) = events
-    assert event["exception"]["values"][0]["mechanism"]["type"] == "ariadne"
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "chained"
+    assert event["exception"]["values"][-1]["mechanism"]["type"] == "ariadne"
path: tests/integrations/ariadne/test_ariadne.py
user: login: szokeasaurusrex
body: [optional] maybe also assert the values between these two (or assert that these are the only two values)
created_at: 2025-03-20T12:53:31Z
author_association: MEMBER
diff_hunk: @@ -68,7 +68,8 @@ def test_capture_request_and_response_if_send_pii_is_on_async(
     assert len(events) == 1
 
     (event,) = events
-    assert event["exception"]["values"][0]["mechanism"]["type"] == "ariadne"
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "chained"
+    assert event["exception"]["values"][-1]["mechanism"]["type"] == "ariadne"
path: tests/integrations/ariadne/test_ariadne.py
user: login: szokeasaurusrex
body: same goes for the other tests
created_at: 2025-03-20T12:53:41Z
author_association: MEMBER
diff_hunk: @@ -68,7 +68,8 @@ def test_capture_request_and_response_if_send_pii_is_on_async(
     assert len(events) == 1
 
     (event,) = events
-    assert event["exception"]["values"][0]["mechanism"]["type"] == "ariadne"
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "chained"
+    assert event["exception"]["values"][-1]["mechanism"]["type"] == "ariadne"
path: tests/integrations/ariadne/test_ariadne.py
user: login: antonpirker
body: ok
created_at: 2025-03-20T14:18:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -870,38 +872,29 @@ def exceptions_from_error_tuple(
     full_stack=None,  # type: Optional[list[dict[str, Any]]]
 ):
     # type: (...) -> List[Dict[str, Any]]
+    """
+    Convert Python's exception information into Sentry's structured "exception" format in the event.
+    See https://develop.sentry.dev/sdk/data-model/event-payloads/exception/
+    This is the entry point for the exception handling.
+    """
+    # unpack the exception info tuple
     exc_type, exc_value, tb = exc_info
 
-    is_exception_group = BaseExceptionGroup is not None and isinstance(
-        exc_value, BaseExceptionGroup
+    # let exceptions_from_error do the actual work
+    _, 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,
+        full_stack=full_stack,
     )
 
-    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,
-            full_stack=full_stack,
-        )
-
-    else:
-        exceptions = []
-        for exc_type, exc_value, tb in walk_exception_chain(exc_info):
-            exceptions.append(
-                single_exception_from_error_tuple(
-                    exc_type=exc_type,
-                    exc_value=exc_value,
-                    tb=tb,
-                    client_options=client_options,
-                    mechanism=mechanism,
-                    full_stack=full_stack,
-                )
-            )
-
+    # make sure the exceptions are sorted
+    # from the innermost (oldest)
+    # to the outermost (newest) exception
     exceptions.reverse()
path: sentry_sdk/utils.py
user: login: antonpirker
body: Because exception chains are not that long on average in my opinion it is not worth the effort and also the decline in readability of the code to make this change now.
created_at: 2025-03-20T14:54:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: antonpirker
created_at: 2025-03-19T10:31:17Z
body: (The diff is very hard to read, just check out the whole file to make your live easier)

The function `exceptions_from_error` handles all the cases, single exceptions, chained exceptions, and exception groups. It walks through the complete tree of exceptions.

(We can in another PR also think about removing `walk_exception_chain` everywhere and just use `exceptions_from_error` for everything. But this is outside of the scope of this PR)
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-03-19T10:31:23Z
body: ### :x: 872 Tests Failed:
| Tests completed | Failed | Passed | Skipped |
|---|---|---|---|
| 21278 | 872 | 20406 | 4507 |
<details><summary>View the top 3 failed test(s) by shortest run time</summary>

> 
> ```python
>  tests.integrations.celery.integration_tests.test_celery_beat_cron_monitoring
> ```
> 
> <details><summary>Stack Traces | 0s run time</summary>
> 
> > 
> > ```python
> > .../hostedtoolcache/Python/3.7.17.../x64/lib/python3.7/importlib/__init__.py:127: in import_module
> >     return _bootstrap._gcd_import(name[level:], package, level)
> > <frozen importlib._bootstrap>:1006: in _gcd_import
> >     ???
> > <frozen importlib._bootstrap>:983: in _find_and_load
> >     ???
> > <frozen importlib._bootstrap>:953: in _find_and_load_unlocked
> >     ???
> > <frozen importlib._bootstrap>:219: in _call_with_frames_removed
> >     ???
> > <frozen importlib._bootstrap>:1006: in _gcd_import
> >     ???
> > <frozen importlib._bootstrap>:983: in _find_and_load
> >     ???
> > <frozen importlib._bootstrap>:967: in _find_and_load_unlocked
> >     ???
> > <frozen importlib._bootstrap>:677: in _load_unlocked
> >     ???
> > <frozen importlib._bootstrap_external>:728: in exec_module
> >     ???
> > <frozen importlib._bootstrap>:219: in _call_with_frames_removed
> >     ???
> > .../celery/integration_tests/__init__.py:7: in <module>
> >     from celery.beat import Scheduler
> > .tox/py3.7-celery-v5.0.5/lib/python3.7.../site-packages/celery/beat.py:22: in <module>
> >     from . import __version__, platforms, signals
> > .tox/py3.7-celery-v5.0.5/lib/python3.7.../site-packages/celery/signals.py:14: in <module>
> >     from .utils.dispatch import Signal
> > .tox/py3.7-celery-v5.0.5/lib/python3.7.../celery/utils/__init__.py:16: in <module>
> >     from .nodenames import nodename, nodesplit, worker_direct
> > .tox/py3.7-celery-v5.0.5/lib/python3.7.../celery/utils/nodenames.py:6: in <module>
> >     from kombu.entity import Exchange, Queue
> > .tox/py3.7-celery-v5.0.5/lib/python3.7.../site-packages/kombu/entity.py:7: in <module>
> >     from .serialization import prepare_accept_content
> > .tox/py3.7-celery-v5.0.5/lib/python3.7.../site-packages/kombu/serialization.py:440: in <module>
> >     for ep, args in entrypoints('kombu.serializers'):  # pragma: no cover
> > .tox/py3.7-celery-v5.0.5/lib/python3.7.../kombu/utils/compat.py:82: in entrypoints
> >     for ep in importlib_metadata.entry_points().get(namespace, [])
> > E   AttributeError: 'EntryPoints' object has no attribute 'get'
> > ```
> 
> </details>


> 
> ```python
>  tests.integrations.celery.test_celery
> ```
> 
> <details><summary>Stack Traces | 0s run time</summary>
> 
> > 
> > ```python
> > ImportError while importing test module '.../integrations/celery/test_celery.py'.
> > Hint: make sure your test modules/packages have valid Python names.
> > Traceback:
> > .tox/py3.7-celery-v5.0.5/lib/python3.7.../site-packages/_pytest/python.py:617: in _importtestmodule
> >     mod = import_path(self.path, mode=importmode, root=self.config.rootpath)
> > .tox/py3.7-celery-v5.0.5/lib/python3.7.../site-packages/_pytest/pathlib.py:567: in import_path
> >     importlib.import_module(module_name)
> > .../hostedtoolcache/Python/3.7.17.../x64/lib/python3.7/importlib/__init__.py:127: in import_module
> >     return _bootstrap._gcd_import(name[level:], package, level)
> > <frozen importlib._bootstrap>:1006: in _gcd_import
> >     ???
> > <frozen importlib._bootstrap>:983: in _find_and_load
> >     ???
> > <frozen importlib._bootstrap>:967: in _find_and_load_unlocked
> >     ???
> > <frozen importlib._bootstrap>:677: in _load_unlocked
> >     ???
> > .tox/py3.7-celery-v5.0.5/lib/python3.7.../_pytest/assertion/rewrite.py:186: in exec_module
> >     exec(co, module.__dict__)
> > .../integrations/celery/test_celery.py:6: in <module>
> >     from celery import Celery, VERSION
> > E   ImportError: cannot import name 'Celery' from 'celery' (.../sentry-python/sentry-python/.tox/py3.7-celery-v5.0.5/lib/python3.7.../site-packages/celery/__init__.py)
> > ```
> 
> </details>


> 
> ```python
>  tests.integrations.celery.test_update_celery_task_headers
> ```
> 
> <details><summary>Stack Traces | 0s run time</summary>
> 
> > 
> > ```python
> > .../integrations/celery/beat.py:28: in <module>
> >     from celery import Task, Celery  # type: ignore
> > E   ImportError: cannot import name 'Task' from 'celery' (.../sentry-python/sentry-python/.tox/py3.7-celery-v5.0.5/lib/python3.7.../site-packages/celery/__init__.py)
> > 
> > During handling of the above exception, another exception occurred:
> > .../integrations/celery/test_update_celery_task_headers.py:7: in <module>
> >     from sentry_sdk.integrations.celery import _update_celery_task_headers
> > .../integrations/celery/__init__.py:9: in <module>
> >     from sentry_sdk.integrations.celery.beat import (
> > .../integrations/celery/beat.py:37: in <module>
> >     raise DidNotEnable("Celery not installed")
> > E   sentry_sdk.integrations.DidNotEnable: Celery not installed
> > ```
> 
> </details>

</details>

To view more test analytics, go to the [Test Analytics Dashboard](https://app.codecov.io/gh/getsentry/sentry-python/tests/antonpirker%2Ffix%2Fexception-groups-on-potel)
<sub>📋 Got 3 mins? [Take this short survey](https://forms.gle/BpocVj23nhr2Y45G7) to help us improve Test Analytics.</sub>
author_association: NONE