diff --git a/src/sentry/utils/sdk_crashes/sdk_crash_detection_config.py b/src/sentry/utils/sdk_crashes/sdk_crash_detection_config.py index 797447a05f61ec..810e40d8a69afe 100644 --- a/src/sentry/utils/sdk_crashes/sdk_crash_detection_config.py +++ b/src/sentry/utils/sdk_crashes/sdk_crash_detection_config.py @@ -83,6 +83,14 @@ class SDKCrashDetectionConfig: sdk_frame_config: SDKFrameConfig """The function and module patterns to ignore when detecting SDK crashes. For example, FunctionAndModulePattern("*", "**SentrySDK crash**") for any module with that function""" sdk_crash_ignore_matchers: set[FunctionAndModulePattern] + """The function patterns to ignore when they are the only SDK frames in the stacktrace. + These frames are typically SDK instrumentation frames that intercept calls, such as swizzling or monkey patching, + but don't cause crashes themselves. If there are other SDK frames anywhere in the stacktrace, the crash is still + reported as an SDK crash. For example, SentrySwizzleWrapper is used for method swizzling and shouldn't be reported + as an SDK crash when it's the only SDK frame, since it's highly unlikely the crash stems from that code.""" + sdk_crash_ignore_when_only_sdk_frame_matchers: set[FunctionAndModulePattern] = field( + default_factory=set + ) class SDKCrashDetectionOptions(TypedDict): @@ -152,6 +160,15 @@ def build_sdk_crash_detection_configs() -> Sequence[SDKCrashDetectionConfig]: function_pattern="**SentryCrashExceptionApplicationHelper _crashOnException**", ), }, + sdk_crash_ignore_when_only_sdk_frame_matchers={ + # SentrySwizzleWrapper is used for method swizzling to intercept UI events. + # When it's the only SDK frame, it's highly unlikely the crash stems from the SDK. + # Only report as SDK crash if there are other SDK frames anywhere in the stacktrace. + FunctionAndModulePattern( + module_pattern="*", + function_pattern="**SentrySwizzleWrapper**", + ), + }, ) configs.append(cocoa_config) diff --git a/src/sentry/utils/sdk_crashes/sdk_crash_detector.py b/src/sentry/utils/sdk_crashes/sdk_crash_detector.py index 07c495b149a03b..a28efd470fb9f5 100644 --- a/src/sentry/utils/sdk_crashes/sdk_crash_detector.py +++ b/src/sentry/utils/sdk_crashes/sdk_crash_detector.py @@ -6,7 +6,10 @@ from sentry.db.models import NodeData from sentry.utils.glob import glob_match from sentry.utils.safe import get_path -from sentry.utils.sdk_crashes.sdk_crash_detection_config import SDKCrashDetectionConfig +from sentry.utils.sdk_crashes.sdk_crash_detection_config import ( + FunctionAndModulePattern, + SDKCrashDetectionConfig, +) class SDKCrashDetector: @@ -87,26 +90,79 @@ def is_sdk_crash(self, frames: Sequence[Mapping[str, Any]]) -> bool: # Cocoa SDK frames can be marked as in_app. Therefore, the algorithm only checks if frames # are SDK frames or from system libraries. iter_frames = [f for f in reversed(frames) if f is not None] - for frame in iter_frames: - function = frame.get("function") - module = frame.get("module") - - if function: - for matcher in self.config.sdk_crash_ignore_matchers: - function_matches = glob_match( - function, matcher.function_pattern, ignorecase=True - ) - module_matches = glob_match(module, matcher.module_pattern, ignorecase=True) - if function_matches and module_matches: - return False + # For efficiency, we first check if an SDK frame appears before any non-system frame + # (Loop 1). Most crashes are not SDK crashes, so we avoid the overhead of the ignore + # checks in the common case. Only if we detect a potential SDK crash do we run the + # additional validation loops (Loop 2 and Loop 3). + # Loop 1: Check if the first non-system frame (closest to crash origin) is an SDK frame. + potential_sdk_crash = False + for frame in iter_frames: if self.is_sdk_frame(frame): - return True + potential_sdk_crash = True + break if not self.is_system_library_frame(frame): + # A non-SDK, non-system frame (e.g., app code) appeared first. return False + if not potential_sdk_crash: + return False + + # Loop 2: Check if any frame (up to the first SDK frame) matches sdk_crash_ignore_matchers. + # These are SDK methods used for testing (e.g., +[SentrySDK crash]) that intentionally + # trigger crashes and should not be reported as SDK crashes. We only check frames up to + # the first SDK frame to match the original single-loop algorithm behavior. + for frame in iter_frames: + if self._matches_sdk_crash_ignore(frame): + return False + if self.is_sdk_frame(frame): + # Stop at the first SDK frame; don't check older frames in the call stack. + break + + # Loop 3: Check if the only SDK frame is a "conditional" one (e.g., SentrySwizzleWrapper). + # These are SDK instrumentation frames that intercept calls but are unlikely to cause + # crashes themselves. A single conditional frame is not reported, but multiple SDK frames + # (even if all conditional) are reported to prefer over-reporting over under-reporting. + conditional_sdk_frame_count = 0 + has_non_conditional_sdk_frame = False + for frame in iter_frames: + if self.is_sdk_frame(frame): + if self._matches_ignore_when_only_sdk_frame(frame): + conditional_sdk_frame_count += 1 + else: + has_non_conditional_sdk_frame = True + break + + if conditional_sdk_frame_count == 1 and not has_non_conditional_sdk_frame: + return False + + # Passed all ignore checks: this is an SDK crash. + return True + + def _matches_ignore_when_only_sdk_frame(self, frame: Mapping[str, Any]) -> bool: + return self._matches_frame_pattern( + frame, self.config.sdk_crash_ignore_when_only_sdk_frame_matchers + ) + + def _matches_sdk_crash_ignore(self, frame: Mapping[str, Any]) -> bool: + return self._matches_frame_pattern(frame, self.config.sdk_crash_ignore_matchers) + + def _matches_frame_pattern( + self, frame: Mapping[str, Any], matchers: set[FunctionAndModulePattern] + ) -> bool: + function = frame.get("function") + if not function: + return False + + module = frame.get("module") + for matcher in matchers: + function_matches = glob_match(function, matcher.function_pattern, ignorecase=True) + module_matches = glob_match(module, matcher.module_pattern, ignorecase=True) + if function_matches and module_matches: + return True + return False def is_sdk_frame(self, frame: Mapping[str, Any]) -> bool: diff --git a/tests/sentry/utils/sdk_crashes/test_sdk_crash_detection_cocoa.py b/tests/sentry/utils/sdk_crashes/test_sdk_crash_detection_cocoa.py index 887c0b3b62c484..a3054e641919bb 100644 --- a/tests/sentry/utils/sdk_crashes/test_sdk_crash_detection_cocoa.py +++ b/tests/sentry/utils/sdk_crashes/test_sdk_crash_detection_cocoa.py @@ -653,8 +653,264 @@ def test_sentrycrash_exception_application_helper_not_reported( ) +@patch("sentry.utils.sdk_crashes.sdk_crash_detection.sdk_crash_detection.sdk_crash_reporter") +class CocoaSDKSwizzleWrapperTestMixin(BaseSDKCrashDetectionMixin): + """Tests for SentrySwizzleWrapper conditional SDK crash detection. + + SentrySwizzleWrapper is used for method swizzling to intercept UI events. + When it's the only SDK frame, it's highly unlikely the crash stems from the SDK. + Only report as SDK crash if there are other SDK frames anywhere in the stacktrace. + We prefer to overreport rather than underreport SDK crashes. + + Note: Frames are ordered from oldest (caller) to youngest (exception). + """ + + def test_swizzle_wrapper_only_sdk_frame_not_reported( + self, mock_sdk_crash_reporter: MagicMock + ) -> None: + """ + SentrySwizzleWrapper is the only SDK frame in the stack. + It's highly unlikely the crash stems from SentrySwizzleWrapper. + """ + # Frames ordered from oldest (caller) to youngest (exception) + frames = [ + { + "function": "-[UIApplication sendEvent:]", + "package": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "in_app": False, + }, + { + "function": "-[UIGestureRecognizer _updateGestureForActiveEvents]", + "package": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "in_app": False, + }, + { + "function": "-[UITextMultiTapRecognizer onStateUpdate:]", + "package": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "in_app": False, + }, + { + "function": "__49-[SentrySwizzleWrapper swizzleSendAction:forKey:]_block_invoke_2", + "package": "/private/var/containers/Bundle/Application/59E988EF-46DB-4C75-8E08-10C27DC3E90E/iOS-Swift.app/Frameworks/Sentry.framework/Sentry", + "in_app": False, + }, + { + "function": "-[UITextInputController insertDictationResult:withCorrectionIdentifier:]", + "package": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "in_app": False, + }, + { + "function": "-[NSString substringWithRange:]", + "package": "/System/Library/Frameworks/Foundation.framework/Foundation", + "in_app": False, + }, + { + "function": "objc_exception_throw", + "package": "/usr/lib/libobjc.A.dylib", + "in_app": False, + }, + { + "function": "__exceptionPreprocess", + "package": "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", + "in_app": False, + }, + ] + + self.execute_test( + get_crash_event_with_frames(frames), + False, # Should NOT be reported + mock_sdk_crash_reporter, + ) + + def test_swizzle_wrapper_with_other_sdk_frames_above_reported( + self, mock_sdk_crash_reporter: MagicMock + ) -> None: + """ + SentrySwizzleWrapper is in the stack, but there are other SDK frames above it + (closer to the crash origin). This IS an SDK crash. + """ + # Frames ordered from oldest (caller) to youngest (exception) + frames = [ + { + "function": "-[UIApplication sendEvent:]", + "package": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "in_app": False, + }, + { + "function": "__49-[SentrySwizzleWrapper swizzleSendAction:forKey:]_block_invoke_2", + "package": "/private/var/containers/Bundle/Application/59E988EF-46DB-4C75-8E08-10C27DC3E90E/iOS-Swift.app/Frameworks/Sentry.framework/Sentry", + "in_app": False, + }, + { + "function": "-[UITextInputController insertDictationResult:withCorrectionIdentifier:]", + "package": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "in_app": False, + }, + { + # This is another SDK frame ABOVE SentrySwizzleWrapper (closer to crash) + "function": "-[SentryHub captureEvent:]", + "package": "/private/var/containers/Bundle/Application/59E988EF-46DB-4C75-8E08-10C27DC3E90E/iOS-Swift.app/Frameworks/Sentry.framework/Sentry", + "in_app": False, + }, + { + "function": "objc_exception_throw", + "package": "/usr/lib/libobjc.A.dylib", + "in_app": False, + }, + { + "function": "__exceptionPreprocess", + "package": "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", + "in_app": False, + }, + ] + + self.execute_test( + get_crash_event_with_frames(frames), + True, # Should be reported + mock_sdk_crash_reporter, + ) + + def test_swizzle_wrapper_with_sdk_frame_below_reported( + self, mock_sdk_crash_reporter: MagicMock + ) -> None: + """ + SentrySwizzleWrapper is in the stack, with another SDK frame below it (further from crash). + Since there's another SDK frame anywhere in the stacktrace, this IS an SDK crash. + We prefer to overreport rather than underreport SDK crashes. + """ + # Frames ordered from oldest (caller) to youngest (exception) + frames = [ + { + # This SDK frame is BELOW SentrySwizzleWrapper (further from crash origin) + "function": "-[SentryHub captureEvent:]", + "package": "/private/var/containers/Bundle/Application/59E988EF-46DB-4C75-8E08-10C27DC3E90E/iOS-Swift.app/Frameworks/Sentry.framework/Sentry", + "in_app": False, + }, + { + "function": "-[UIApplication sendEvent:]", + "package": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "in_app": False, + }, + { + "function": "__49-[SentrySwizzleWrapper swizzleSendAction:forKey:]_block_invoke_2", + "package": "/private/var/containers/Bundle/Application/59E988EF-46DB-4C75-8E08-10C27DC3E90E/iOS-Swift.app/Frameworks/Sentry.framework/Sentry", + "in_app": False, + }, + { + "function": "-[NSString substringWithRange:]", + "package": "/System/Library/Frameworks/Foundation.framework/Foundation", + "in_app": False, + }, + { + "function": "__exceptionPreprocess", + "package": "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", + "in_app": False, + }, + ] + + self.execute_test( + get_crash_event_with_frames(frames), + True, # Should be reported - there's another SDK frame in the stack + mock_sdk_crash_reporter, + ) + + def test_swizzle_wrapper_with_always_ignored_sdk_frame_not_reported( + self, mock_sdk_crash_reporter: MagicMock + ) -> None: + """ + SentrySwizzleWrapper with an always-ignored SDK frame ([SentrySDK crash]). + The always-ignored frame should not count as another SDK frame. + """ + frames = [ + { + "function": "+[SentrySDK crash]", + "package": "/private/var/containers/Bundle/Application/59E988EF-46DB-4C75-8E08-10C27DC3E90E/iOS-Swift.app/Frameworks/Sentry.framework/Sentry", + "in_app": False, + }, + { + "function": "-[UIApplication sendEvent:]", + "package": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "in_app": False, + }, + { + "function": "__49-[SentrySwizzleWrapper swizzleSendAction:forKey:]_block_invoke_2", + "package": "/private/var/containers/Bundle/Application/59E988EF-46DB-4C75-8E08-10C27DC3E90E/iOS-Swift.app/Frameworks/Sentry.framework/Sentry", + "in_app": False, + }, + { + "function": "-[NSString substringWithRange:]", + "package": "/System/Library/Frameworks/Foundation.framework/Foundation", + "in_app": False, + }, + { + "function": "__exceptionPreprocess", + "package": "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", + "in_app": False, + }, + ] + + self.execute_test( + get_crash_event_with_frames(frames), + False, + mock_sdk_crash_reporter, + ) + + def test_multiple_swizzle_wrapper_frames_reported( + self, mock_sdk_crash_reporter: MagicMock + ) -> None: + """ + Multiple SentrySwizzleWrapper frames in the stack. + Even though each individual frame would be ignored when it's the only SDK frame, + having multiple SDK frames (even if all conditional) should be reported. + """ + # Frames ordered from oldest (caller) to youngest (exception) + frames = [ + { + "function": "-[UIApplication sendEvent:]", + "package": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "in_app": False, + }, + { + "function": "__49-[SentrySwizzleWrapper swizzleSendAction:forKey:]_block_invoke_2", + "package": "/private/var/containers/Bundle/Application/59E988EF-46DB-4C75-8E08-10C27DC3E90E/iOS-Swift.app/Frameworks/Sentry.framework/Sentry", + "in_app": False, + }, + { + "function": "-[UIGestureRecognizer _updateGestureForActiveEvents]", + "package": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "in_app": False, + }, + { + # Second SentrySwizzleWrapper frame + "function": "__49-[SentrySwizzleWrapper swizzleSendAction:forKey:]_block_invoke_3", + "package": "/private/var/containers/Bundle/Application/59E988EF-46DB-4C75-8E08-10C27DC3E90E/iOS-Swift.app/Frameworks/Sentry.framework/Sentry", + "in_app": False, + }, + { + "function": "-[NSString substringWithRange:]", + "package": "/System/Library/Frameworks/Foundation.framework/Foundation", + "in_app": False, + }, + { + "function": "__exceptionPreprocess", + "package": "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", + "in_app": False, + }, + ] + + self.execute_test( + get_crash_event_with_frames(frames), + True, # Should be reported - multiple SDK frames even if all conditional + mock_sdk_crash_reporter, + ) + + class SDKCrashDetectionCocoaTest( - TestCase, CococaSDKFilenameTestMixin, CococaSDKFramesTestMixin, CococaSDKFunctionTestMixin + TestCase, + CococaSDKFilenameTestMixin, + CococaSDKFramesTestMixin, + CococaSDKFunctionTestMixin, + CocoaSDKSwizzleWrapperTestMixin, ): def create_event(self, data, project_id, assert_no_errors=True): return self.store_event(data=data, project_id=project_id, assert_no_errors=assert_no_errors)