From cda51274de6b11c59a496d610907e4656fa99fd7 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Thu, 5 Dec 2024 14:29:06 +0100 Subject: [PATCH] Add missing stack frames (#3673) Add a new `init()` option `add_full_stack` (default `False`), when set to `True` it will add all the missing frames from the beginning of the execution to the stack trace sent to Sentry. Also adds another option `max_stack_frames` (default `100`) to limit the number of frames sent. The limitation is only enforced when `add_full_stack=True` to not change behavior for existing users. Fixes #3646 --- sentry_sdk/consts.py | 5 ++ sentry_sdk/utils.py | 82 +++++++++++++++++++++++-- tests/test_full_stack_frames.py | 103 ++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 tests/test_full_stack_frames.py diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 488743b579..6750e85f99 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -6,6 +6,9 @@ # up top to prevent circular import due to integration import DEFAULT_MAX_VALUE_LENGTH = 1024 +DEFAULT_MAX_STACK_FRAMES = 100 +DEFAULT_ADD_FULL_STACK = False + # Also needs to be at the top to prevent circular import class EndpointType(Enum): @@ -551,6 +554,8 @@ def __init__( cert_file=None, # type: Optional[str] key_file=None, # type: Optional[str] custom_repr=None, # type: Optional[Callable[..., Optional[str]]] + add_full_stack=DEFAULT_ADD_FULL_STACK, # type: bool + max_stack_frames=DEFAULT_MAX_STACK_FRAMES, # type: Optional[int] ): # type: (...) -> None pass diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 4d07974809..ae6e7538ac 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -26,7 +26,12 @@ import sentry_sdk from sentry_sdk._compat import PY37 -from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH, EndpointType +from sentry_sdk.consts import ( + DEFAULT_ADD_FULL_STACK, + DEFAULT_MAX_STACK_FRAMES, + DEFAULT_MAX_VALUE_LENGTH, + EndpointType, +) from typing import TYPE_CHECKING @@ -737,6 +742,7 @@ def single_exception_from_error_tuple( exception_id=None, # type: Optional[int] parent_id=None, # type: Optional[int] source=None, # type: Optional[str] + full_stack=None, # type: Optional[list[dict[str, Any]]] ): # type: (...) -> Dict[str, Any] """ @@ -804,10 +810,15 @@ def single_exception_from_error_tuple( custom_repr=custom_repr, ) for tb in iter_stacks(tb) - ] + ] # type: List[Dict[str, Any]] if frames: - exception_value["stacktrace"] = {"frames": frames} + if not full_stack: + new_frames = frames + else: + new_frames = merge_stack_frames(frames, full_stack, client_options) + + exception_value["stacktrace"] = {"frames": new_frames} return exception_value @@ -862,6 +873,7 @@ def exceptions_from_error( exception_id=0, # type: int parent_id=0, # type: int source=None, # type: Optional[str] + full_stack=None, # type: Optional[list[dict[str, Any]]] ): # type: (...) -> Tuple[int, List[Dict[str, Any]]] """ @@ -881,6 +893,7 @@ def exceptions_from_error( exception_id=exception_id, parent_id=parent_id, source=source, + full_stack=full_stack, ) exceptions = [parent] @@ -906,6 +919,7 @@ def exceptions_from_error( mechanism=mechanism, exception_id=exception_id, source="__cause__", + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -927,6 +941,7 @@ def exceptions_from_error( mechanism=mechanism, exception_id=exception_id, source="__context__", + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -943,6 +958,7 @@ def exceptions_from_error( exception_id=exception_id, parent_id=parent_id, source="exceptions[%s]" % idx, + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -953,6 +969,7 @@ def exceptions_from_error_tuple( exc_info, # type: ExcInfo client_options=None, # type: Optional[Dict[str, Any]] mechanism=None, # type: Optional[Dict[str, Any]] + full_stack=None, # type: Optional[list[dict[str, Any]]] ): # type: (...) -> List[Dict[str, Any]] exc_type, exc_value, tb = exc_info @@ -970,6 +987,7 @@ def exceptions_from_error_tuple( mechanism=mechanism, exception_id=0, parent_id=0, + full_stack=full_stack, ) else: @@ -977,7 +995,12 @@ def exceptions_from_error_tuple( 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 + exc_type=exc_type, + exc_value=exc_value, + tb=tb, + client_options=client_options, + mechanism=mechanism, + full_stack=full_stack, ) ) @@ -1096,6 +1119,46 @@ def exc_info_from_error(error): return exc_info +def merge_stack_frames(frames, full_stack, client_options): + # type: (List[Dict[str, Any]], List[Dict[str, Any]], Optional[Dict[str, Any]]) -> List[Dict[str, Any]] + """ + Add the missing frames from full_stack to frames and return the merged list. + """ + frame_ids = { + ( + frame["abs_path"], + frame["context_line"], + frame["lineno"], + frame["function"], + ) + for frame in frames + } + + new_frames = [ + stackframe + for stackframe in full_stack + if ( + stackframe["abs_path"], + stackframe["context_line"], + stackframe["lineno"], + stackframe["function"], + ) + not in frame_ids + ] + new_frames.extend(frames) + + # Limit the number of frames + max_stack_frames = ( + client_options.get("max_stack_frames", DEFAULT_MAX_STACK_FRAMES) + if client_options + else None + ) + if max_stack_frames is not None: + new_frames = new_frames[len(new_frames) - max_stack_frames :] + + return new_frames + + def event_from_exception( exc_info, # type: Union[BaseException, ExcInfo] client_options=None, # type: Optional[Dict[str, Any]] @@ -1104,12 +1167,21 @@ def event_from_exception( # type: (...) -> Tuple[Event, Dict[str, Any]] exc_info = exc_info_from_error(exc_info) hint = event_hint_with_exc_info(exc_info) + + if client_options and client_options.get("add_full_stack", DEFAULT_ADD_FULL_STACK): + full_stack = current_stacktrace( + include_local_variables=client_options["include_local_variables"], + max_value_length=client_options["max_value_length"], + )["frames"] + else: + full_stack = None + return ( { "level": "error", "exception": { "values": exceptions_from_error_tuple( - exc_info, client_options, mechanism + exc_info, client_options, mechanism, full_stack ) }, }, diff --git a/tests/test_full_stack_frames.py b/tests/test_full_stack_frames.py new file mode 100644 index 0000000000..ad0826cd10 --- /dev/null +++ b/tests/test_full_stack_frames.py @@ -0,0 +1,103 @@ +import sentry_sdk + + +def test_full_stack_frames_default(sentry_init, capture_events): + sentry_init() + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) == 2 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" + + +def test_full_stack_frames_enabled(sentry_init, capture_events): + sentry_init( + add_full_stack=True, + ) + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) > 2 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" + assert frames[-3]["function"] == "foo" + assert frames[-4]["function"] == "test_full_stack_frames_enabled" + + +def test_full_stack_frames_enabled_truncated(sentry_init, capture_events): + sentry_init( + add_full_stack=True, + max_stack_frames=3, + ) + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) == 3 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" + assert frames[-3]["function"] == "foo" + + +def test_full_stack_frames_default_no_truncation_happening(sentry_init, capture_events): + sentry_init( + max_stack_frames=1, # this is ignored if add_full_stack=False (which is the default) + ) + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) == 2 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo"