Skip to content

fix: Don't hang when capturing long stacktrace #4191

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

Merged
merged 4 commits into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
11 changes: 7 additions & 4 deletions sentry_sdk/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,14 @@ def removed_because_raw_data(cls):
)

@classmethod
def removed_because_over_size_limit(cls):
# type: () -> AnnotatedValue
"""The actual value was removed because the size of the field exceeded the configured maximum size (specified with the max_request_body_size sdk option)"""
def removed_because_over_size_limit(cls, value=""):
# type: (Any) -> AnnotatedValue
"""
The actual value was removed because the size of the field exceeded the configured maximum size,
for example specified with the max_request_body_size sdk option.
"""
return AnnotatedValue(
value="",
value=value,
metadata={
"rem": [ # Remark
[
Expand Down
2 changes: 2 additions & 0 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,8 @@ def _update_session_from_event(
if exceptions:
errored = True
for error in exceptions:
if isinstance(error, AnnotatedValue):
error = error.value or {}
mechanism = error.get("mechanism")
if isinstance(mechanism, Mapping) and mechanism.get("handled") is False:
crashed = True
Expand Down
28 changes: 24 additions & 4 deletions sentry_sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@
FALSY_ENV_VALUES = frozenset(("false", "f", "n", "no", "off", "0"))
TRUTHY_ENV_VALUES = frozenset(("true", "t", "y", "yes", "on", "1"))

MAX_STACK_FRAMES = 2000
"""Maximum number of stack frames to send to Sentry.

If we have more than this number of stack frames, we will stop processing
the stacktrace to avoid getting stuck in a long-lasting loop. This value
exceeds the default sys.getrecursionlimit() of 1000, so users will only
be affected by this limit if they have a custom recursion limit.
"""


def env_to_bool(value, *, strict=False):
# type: (Any, Optional[bool]) -> bool | None
Expand Down Expand Up @@ -732,10 +741,15 @@ def single_exception_from_error_tuple(
max_value_length=max_value_length,
custom_repr=custom_repr,
)
for tb in iter_stacks(tb)
for tb, _ in zip(iter_stacks(tb), range(MAX_STACK_FRAMES + 1))
] # type: List[Dict[str, Any]]

if frames:
if len(frames) > MAX_STACK_FRAMES:
exception_value["stacktrace"] = AnnotatedValue.removed_because_over_size_limit(
value=None
)

elif frames:
if not full_stack:
new_frames = frames
else:
Expand Down Expand Up @@ -941,7 +955,7 @@ def to_string(value):


def iter_event_stacktraces(event):
# type: (Event) -> Iterator[Dict[str, Any]]
# type: (Event) -> Iterator[Annotated[Dict[str, Any]]]
if "stacktrace" in event:
yield event["stacktrace"]
if "threads" in event:
Expand All @@ -950,20 +964,26 @@ def iter_event_stacktraces(event):
yield thread["stacktrace"]
if "exception" in event:
for exception in event["exception"].get("values") or ():
if "stacktrace" in exception:
if isinstance(exception, dict) and "stacktrace" in exception:
yield exception["stacktrace"]


def iter_event_frames(event):
# type: (Event) -> Iterator[Dict[str, Any]]
for stacktrace in iter_event_stacktraces(event):
if isinstance(stacktrace, AnnotatedValue):
stacktrace = stacktrace.value or {}

for frame in stacktrace.get("frames") or ():
yield frame


def handle_in_app(event, in_app_exclude=None, in_app_include=None, project_root=None):
# type: (Event, Optional[List[str]], Optional[List[str]], Optional[str]) -> Event
for stacktrace in iter_event_stacktraces(event):
if isinstance(stacktrace, AnnotatedValue):
stacktrace = stacktrace.value or {}

set_in_app_in_frames(
stacktrace.get("frames"),
in_app_exclude=in_app_exclude,
Expand Down
44 changes: 44 additions & 0 deletions tests/test_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -1065,3 +1065,47 @@ def __str__(self):
(event,) = events

assert event["exception"]["values"][0]["value"] == "aha!\nnote 1\nnote 3"


@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="this test appears to cause a segfault on Python < 3.11",
)
def test_stacktrace_big_recursion(sentry_init, capture_events):
"""
Ensure that if the recursion limit is increased, the full stacktrace is not captured,
as it would take too long to process the entire stack trace.
Also, ensure that the capturing does not take too long.
"""
sentry_init()
events = capture_events()

def recurse():
recurse()

old_recursion_limit = sys.getrecursionlimit()

try:
sys.setrecursionlimit(100_000)
recurse()
except RecursionError as e:
capture_start_time = time.perf_counter_ns()
sentry_sdk.capture_exception(e)
capture_end_time = time.perf_counter_ns()
finally:
sys.setrecursionlimit(old_recursion_limit)

(event,) = events

assert event["exception"]["values"][0]["stacktrace"] is None
assert event["_meta"] == {
"exception": {
"values": {"0": {"stacktrace": {"": {"rem": [["!config", "x"]]}}}}
}
}

# On my machine, it takes about 100-200ms to capture the exception,
# so this limit should be generous enough.
assert (
capture_end_time - capture_start_time < 10**9
), "stacktrace capture took too long, check that frame limit is set correctly"
Loading