Skip to content
145 changes: 69 additions & 76 deletions sentry_sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -800,14 +800,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,
Expand All @@ -818,64 +821,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,
Expand All @@ -895,38 +897,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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] does the exceptions_from_error function already internally handle the non-exception group case?

From naively looking at the diff, it seems that the code for handling exceptions which are not exception groups has been deleted, so I am just wondering where this logic is handled now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have now updated the code to make it easier to grasp what is happening. (The diff is very hard to read, just checkout the file)

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)

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
Expand Down
14 changes: 10 additions & 4 deletions tests/integrations/ariadne/test_ariadne.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
assert event["contexts"]["response"] == {
"data": {
"data": {"error": None},
Expand Down Expand Up @@ -111,7 +112,9 @@ def graphql_server():
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"

assert event["contexts"]["response"] == {
"data": {
"data": {"error": None},
Expand Down Expand Up @@ -152,7 +155,9 @@ 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 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"]

Expand Down Expand Up @@ -182,7 +187,8 @@ def graphql_server():
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"
assert "data" not in event["request"]
assert "response" not in event["contexts"]

Expand Down
6 changes: 4 additions & 2 deletions tests/integrations/strawberry/test_strawberry.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ def test_capture_request_if_available_and_send_pii_is_on(

(error_event,) = events

assert error_event["exception"]["values"][0]["mechanism"]["type"] == "strawberry"
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,
Expand Down Expand Up @@ -258,7 +259,8 @@ 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 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"]

Expand Down
6 changes: 5 additions & 1 deletion tests/new_scopes_compat/test_new_scopes_compat_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ def create_expected_error_event(trx, span):
"exception": {
"values": [
{
"mechanism": {"type": "generic", "handled": True},
"mechanism": {
"exception_id": 0,
"type": "generic",
"handled": True,
},
"module": None,
"type": "ValueError",
"value": "This is a test exception",
Expand Down
13 changes: 11 additions & 2 deletions tests/test_exceptiongroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -227,6 +230,7 @@ def test_exception_chain_cause():
"mechanism": {
"handled": False,
"type": "test_suite",
"exception_id": 0,
},
"module": None,
"type": "ValueError",
Expand Down Expand Up @@ -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",
Expand All @@ -267,6 +274,7 @@ def test_exception_chain_context():
"mechanism": {
"handled": False,
"type": "test_suite",
"exception_id": 0,
},
"module": None,
"type": "ValueError",
Expand Down Expand Up @@ -297,6 +305,7 @@ def test_simple_exception():
"mechanism": {
"handled": False,
"type": "test_suite",
"exception_id": 0,
},
"module": None,
"type": "ValueError",
Expand Down
Loading