Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for ExceptionGroups #2025

Merged
merged 44 commits into from
May 23, 2023
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b33e427
Renamed var
antonpirker Apr 3, 2023
d24e1cb
Preparations
antonpirker Apr 3, 2023
732d3b4
Added mechanism source
antonpirker Apr 3, 2023
5ca8030
Merge branch 'master' into antonpirker/exception_groups
antonpirker Apr 4, 2023
7a1df11
Merge branch 'master' into antonpirker/exception_groups
antonpirker Apr 19, 2023
34e82c3
Reverted some stuff to implement without breaking it
antonpirker Apr 20, 2023
8f3be5b
First working version of exception groups
antonpirker Apr 21, 2023
f0396e2
Fixed check for ExceptionGroup type
antonpirker Apr 21, 2023
23afd49
Fixed some tests
antonpirker Apr 21, 2023
105dbd2
Fixed more tests
antonpirker Apr 21, 2023
194ee93
Fixed some typing
antonpirker Apr 21, 2023
1df808a
Upgraded linting tooling and fixed the typing errors
antonpirker Apr 21, 2023
392cdd6
Merge branch 'antonpirker/upgrade_linting' into antonpirker/exception…
antonpirker Apr 21, 2023
6819b7a
Upgraded ci python version for linting
antonpirker Apr 21, 2023
f80f9e0
Merge branch 'antonpirker/upgrade_linting' into antonpirker/exception…
antonpirker Apr 21, 2023
bb7f5ae
Updated tests
antonpirker Apr 24, 2023
57c301e
Updated tests
antonpirker Apr 24, 2023
9272f9f
Updated tests
antonpirker Apr 24, 2023
17a10d8
Merge branch 'master' into antonpirker/exception_groups
antonpirker Apr 25, 2023
3aebd00
Have a handled flag everywhere
antonpirker May 4, 2023
332fc9b
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 4, 2023
e4ec055
Added __supress_context__ check
antonpirker May 4, 2023
1a9a572
Preserve old behaviour for non-group exceptions
antonpirker May 4, 2023
1abf441
Check for BaseExceptionGroup
antonpirker May 4, 2023
d9cd15f
Fixed some linting
antonpirker May 4, 2023
d651f18
Fixed exception group base class import
antonpirker May 4, 2023
db7b484
Fixed check for base exception group
antonpirker May 4, 2023
7cdb8c7
again.
antonpirker May 4, 2023
f414948
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 4, 2023
68743e5
Preserve old behaviour
antonpirker May 4, 2023
3c376e1
Merge branch 'antonpirker/exception_groups' of github.com:getsentry/s…
antonpirker May 4, 2023
48ca339
Updated tests
antonpirker May 4, 2023
b2d94df
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 4, 2023
30d2e57
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 5, 2023
60d9c00
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 8, 2023
89426d5
Polishing
antonpirker May 10, 2023
1119250
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 10, 2023
79f685d
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 15, 2023
627471a
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 15, 2023
0c6b2c2
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 17, 2023
7bc507f
Fixed tests
antonpirker May 17, 2023
ac850ec
Fixed tests
antonpirker May 17, 2023
8521b09
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 17, 2023
1ab2c22
Merge branch 'master' into antonpirker/exception_groups
antonpirker May 23, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 172 additions & 21 deletions sentry_sdk/utils.py
antonpirker marked this conversation as resolved.
Show resolved Hide resolved
sl0thentr0py marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@
from urlparse import urlsplit # type: ignore
from urlparse import urlunsplit # type: ignore

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

from datetime import datetime
from functools import partial
Expand Down Expand Up @@ -658,19 +664,58 @@ def single_exception_from_error_tuple(
tb, # type: Optional[TracebackType]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
exception_id=None, # type: Optional[int]
parent_id=None, # type: Optional[int]
source=None, # type: Optional[str]
):
# type: (...) -> Dict[str, Any]
mechanism = mechanism or {"type": "generic", "handled": True}
"""
Creates a dict that goes into the events `exception.values` list and is ingestible by Sentry.

See the Exception Interface documentation for more details:
https://develop.sentry.dev/sdk/event-payloads/exception/
"""
exception_value = {} # type: Dict[str, Any]
exception_value["mechanism"] = (
mechanism.copy() if mechanism else {"type": "generic", "handled": True}
)
if exception_id is not None:
exception_value["mechanism"]["exception_id"] = exception_id

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

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

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

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

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

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

exception_value["module"] = get_type_module(exc_type)
exception_value["type"] = get_type_name(exc_type)
exception_value["value"] = (
antonpirker marked this conversation as resolved.
Show resolved Hide resolved
safe_str(exc_value)
if not exc_value or not hasattr(exc_value, "message")
else exc_value.message
)

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

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

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

return rv
return exception_value


HAS_CHAINED_EXCEPTIONS = hasattr(Exception, "__suppress_context__")
Expand Down Expand Up @@ -740,24 +778,137 @@ 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/
"""

# TODO: implement also the seen_exception_ids from walk_exception_chain above.
antonpirker marked this conversation as resolved.
Show resolved Hide resolved
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),
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),
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(
antonpirker marked this conversation as resolved.
Show resolved Hide resolved
exc_type=type(e),
exc_value=e,
tb=getattr(e, "__traceback__", None),
mechanism=mechanism,
exception_id=exception_id,
parent_id=parent_id,
source="exceptions[%s]" % idx,
antonpirker marked this conversation as resolved.
Show resolved Hide resolved
)
exceptions.extend(child_exceptions)

return (exception_id, exceptions)


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

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

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

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

exceptions.reverse()

return rv
return exceptions


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

assert frame1["in_app"] is True

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

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

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

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

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

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

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


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

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


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

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


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


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

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

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


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

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

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

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

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

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


def test_option_before_send(sentry_init, capture_events):
Expand Down
Loading