diff --git a/fixtures/sdk_crash_detection/__init__.py b/fixtures/sdk_crash_detection/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/fixtures/sdk_crash_detection/crash_event.py b/fixtures/sdk_crash_detection/crash_event.py new file mode 100644 index 00000000000000..89ddebeb3f06eb --- /dev/null +++ b/fixtures/sdk_crash_detection/crash_event.py @@ -0,0 +1,269 @@ +from typing import Any, Collection, Dict, Mapping, Sequence + +IN_APP_FRAME = { + "function": "LoginViewController.viewDidAppear", + "raw_function": "LoginViewController.viewDidAppear(Bool)", + "symbol": "$s8Sentry9LoginViewControllerC13viewDidAppearyySbF", + "package": "SentryApp", + "filename": "LoginViewController.swift", + "abs_path": "/Users/sentry/git/iOS/Sentry/LoggedOut/LoginViewController.swift", + "lineno": 196, + "in_app": True, + "image_addr": "0x100260000", + "instruction_addr": "0x102b16630", + "symbol_addr": "0x100260000", +} + + +def get_sentry_frame(function: str, in_app: bool = False) -> Mapping[str, Any]: + return { + "function": function, + "package": "Sentry", + "in_app": in_app, + "image_addr": "0x100304000", + } + + +def get_frames(function: str, sentry_frame_in_app: bool = False) -> Sequence[Mapping[str, Any]]: + frames = [ + get_sentry_frame(function, sentry_frame_in_app), + { + "function": "LoginViewController.viewDidAppear", + "symbol": "$s8Sentry9LoginViewControllerC13viewDidAppearyySbF", + "package": "SentryApp", + "in_app": True, + "filename": "LoginViewController.swift", + "image_addr": "0x100260000", + }, + IN_APP_FRAME, + { + "function": "-[UIViewController _setViewAppearState:isAnimating:]", + "symbol": "-[UIViewController _setViewAppearState:isAnimating:]", + "package": "UIKitCore", + "in_app": False, + "image_addr": "0x1a4e8f000", + }, + { + "function": "-[UIViewController __viewDidAppear:]", + "symbol": "-[UIViewController __viewDidAppear:]", + "package": "UIKitCore", + "in_app": False, + "image_addr": "0x1a4e8f000", + }, + { + "function": "-[UIViewController _endAppearanceTransition:]", + "symbol": "-[UIViewController _endAppearanceTransition:]", + "package": "UIKitCore", + "in_app": False, + "image_addr": "0x1a4e8f000", + }, + { + "function": "-[UINavigationController navigationTransitionView:didEndTransition:fromView:toView:]", + "symbol": "-[UINavigationController navigationTransitionView:didEndTransition:fromView:toView:]", + "package": "UIKitCore", + "in_app": False, + "image_addr": "0x1a4e8f000", + }, + { + "function": "__49-[UINavigationController _startCustomTransition:]_block_invoke", + "symbol": "__49-[UINavigationController _startCustomTransition:]_block_invoke", + "package": "UIKitCore", + "in_app": False, + "image_addr": "0x1a4e8f000", + }, + ] + + # The frames have to be ordered from caller to callee, or oldest to youngest. + # The last frame is the one creating the exception. + # As we usually look at stacktraces from youngest to oldest, we reverse the order. + return frames[::-1] + + +def get_crash_event(handled=False, function="-[Sentry]", **kwargs) -> Dict[str, Collection[str]]: + return get_crash_event_with_frames(get_frames(function), handled=handled, **kwargs) + + +def get_crash_event_with_frames( + frames: Sequence[Mapping[str, Any]], handled=False, **kwargs +) -> Dict[str, Collection[str]]: + result = { + "event_id": "80e3496eff734ab0ac993167aaa0d1cd", + "release": "5.222.5", + "type": "error", + "level": "fatal", + "platform": "cocoa", + "tags": {"level": "fatal"}, + "exception": { + "values": [ + { + "stacktrace": { + "frames": frames, + }, + "type": "SIGABRT", + "mechanism": {"handled": handled}, + } + ] + }, + "breadcrumbs": { + "values": [ + { + "timestamp": 1675900265.0, + "type": "debug", + "category": "started", + "level": "info", + "message": "Breadcrumb Tracking", + }, + ] + }, + "contexts": { + "app": { + "app_start_time": "2023-02-08T23:51:05Z", + "device_app_hash": "8854fe9e3d4e4a66493baee798bfae0228efabf1", + "build_type": "app store", + "app_identifier": "com.some.company.io", + "app_name": "SomeCompany", + "app_version": "5.222.5", + "app_build": "21036", + "app_id": "397D4F75-6C01-32D1-BF46-62098979E470", + "type": "app", + }, + "device": { + "family": "iOS", + "model": "iPhone14,8", + "model_id": "D28AP", + "arch": "arm64e", + "memory_size": 5944508416, + "free_memory": 102154240, + "usable_memory": 4125687808, + "storage_size": 127854202880, + "boot_time": "2023-02-01T05:21:23Z", + "timezone": "PST", + "type": "device", + }, + "os": { + "name": "iOS", + "version": "16.3", + "build": "20D47", + "kernel_version": "Darwin Kernel Version 22.3.0: Wed Jan 4 21:25:19 PST 2023; root:xnu-8792.82.2~1/RELEASE_ARM64_T8110", + "rooted": False, + "type": "os", + }, + }, + "debug_meta": { + "images": [ + { + "code_file": "/private/var/containers/Bundle/Application/895DA2DE-5FE3-44A0-8C0F-900519EA5516/iOS-Swift.app/iOS-Swift", + "debug_id": "aa8a3697-c88a-36f9-a687-3d3596568c8d", + "arch": "arm64", + "image_addr": "0x100260000", + "image_size": 180224, + "image_vmaddr": "0x100000000", + "type": "macho", + }, + { + "code_file": "/private/var/containers/Bundle/Application/9EB557CD-D653-4F51-BFCE-AECE691D4347/iOS-Swift.app/Frameworks/Sentry.framework/Sentry", + "debug_id": "e2623c4d-79c5-3cdf-90ab-2cf44e026bdd", + "arch": "arm64", + "image_addr": "0x100304000", + "image_size": 802816, + "type": "macho", + }, + { + "code_file": "/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore", + "debug_id": "b0858d8e-7220-37bf-873f-ecc2b0a358c3", + "arch": "arm64e", + "image_addr": "0x1a4e8f000", + "image_size": 25309184, + "image_vmaddr": "0x188ff7000", + "type": "macho", + }, + { + "code_file": "/System/Library/Frameworks/CFNetwork.framework/CFNetwork", + "debug_id": "b2273be9-538a-3f56-b9c7-801f39550f58", + "arch": "arm64e", + "image_addr": "0x1a3e32000", + "image_size": 3977216, + "image_vmaddr": "0x187f9a000", + "in_app": False, + "type": "macho", + }, + ] + }, + "environment": "test-app", + "sdk": { + "name": "sentry.cocoa", + "version": "8.1.0", + "integrations": [ + "Crash", + "PerformanceTracking", + "MetricKit", + "WatchdogTerminationTracking", + "ViewHierarchy", + "NetworkTracking", + "ANRTracking", + "AutoBreadcrumbTracking", + "FramesTracking", + "AppStartTracking", + "Screenshot", + "FileIOTracking", + "UIEventTracking", + "AutoSessionTracking", + "CoreDataTracking", + "PreWarmedAppStartTracing", + ], + }, + "threads": { + "values": [ + { + "id": 0, + "stacktrace": { + "frames": [ + { + "function": "", + "in_app": False, + "data": {"symbolicator_status": "unknown_image"}, + "image_addr": "0x0", + "instruction_addr": "0x1129be52e", + "symbol_addr": "0x0", + }, + { + "function": "", + "in_app": False, + "data": {"symbolicator_status": "unknown_image"}, + "image_addr": "0x0", + "instruction_addr": "0x104405f21", + "symbol_addr": "0x0", + }, + ], + }, + "raw_stacktrace": { + "frames": [ + { + "function": "", + "in_app": False, + "image_addr": "0x0", + "instruction_addr": "0x1129be52e", + "symbol_addr": "0x0", + }, + { + "function": "", + "in_app": False, + "image_addr": "0x0", + "instruction_addr": "0x104405f21", + "symbol_addr": "0x0", + }, + ], + }, + "crashed": True, + } + ] + }, + "user": { + "id": "803F5C87-0F8B-41C7-8499-27BD71A92738", + "ip_address": "192.168.0.1", + "geo": {"country_code": "US", "region": "United States"}, + }, + "logger": "my.logger.name", + } + result.update(kwargs) + return result diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 9faf0317e577dc..3a4758f1d9ebae 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1073,10 +1073,11 @@ def sdk_crash_monitoring(job: PostProcessJob): ) return None - with metrics.timer("post_process.sdk_crash_monitoring.duration"), sentry_sdk.start_span( - op="tasks.post_process_group.sdk_crash_monitoring" - ): - sdk_crash_detection.detect_sdk_crash() + with metrics.timer("post_process.sdk_crash_monitoring.duration"): + with sentry_sdk.start_span(op="tasks.post_process_group.sdk_crash_monitoring"): + sdk_crash_detection.detect_sdk_crash( + event=event, event_project_id=settings.SDK_CRASH_DETECTION_PROJECT_ID + ) def plugin_post_process_group(plugin_slug, event, **kwargs): diff --git a/src/sentry/utils/sdk_crashes/cocoa_sdk_crash_detector.py b/src/sentry/utils/sdk_crashes/cocoa_sdk_crash_detector.py new file mode 100644 index 00000000000000..e03b397b2392b3 --- /dev/null +++ b/src/sentry/utils/sdk_crashes/cocoa_sdk_crash_detector.py @@ -0,0 +1,9 @@ +from sentry.utils.sdk_crashes.sdk_crash_detector import SDKCrashDetector + + +class CocoaSDKCrashDetector(SDKCrashDetector): + def is_sdk_crash(self) -> bool: + return True + + def is_sdk_frame(self) -> bool: + return True diff --git a/src/sentry/utils/sdk_crashes/sdk_crash_detection.py b/src/sentry/utils/sdk_crashes/sdk_crash_detection.py index 454fd63248ba2e..59b7b3068a256d 100644 --- a/src/sentry/utils/sdk_crashes/sdk_crash_detection.py +++ b/src/sentry/utils/sdk_crashes/sdk_crash_detection.py @@ -1,13 +1,73 @@ from __future__ import annotations +from typing import Any, Mapping, Optional + +from sentry.eventstore.models import Event +from sentry.issues.grouptype import GroupCategory +from sentry.utils.safe import get_path, set_path +from sentry.utils.sdk_crashes.cocoa_sdk_crash_detector import CocoaSDKCrashDetector +from sentry.utils.sdk_crashes.sdk_crash_detector import SDKCrashDetector + + +class SDKCrashReporter: + def report(self, event_data: Mapping[str, Any], event_project_id: int) -> Event: + from sentry.event_manager import EventManager + + manager = EventManager(dict(event_data)) + manager.normalize() + return manager.save(project_id=event_project_id) + class SDKCrashDetection: - """ - Placeholder class for SDK crash detection. - """ + def __init__( + self, + sdk_crash_reporter: SDKCrashReporter, + sdk_crash_detector: SDKCrashDetector, + ): + self.sdk_crash_reporter = sdk_crash_reporter + self.cocoa_sdk_crash_detector = sdk_crash_detector + + def detect_sdk_crash(self, event: Event, event_project_id: int) -> Optional[Event]: + should_detect_sdk_crash = ( + event.group + and event.group.issue_category == GroupCategory.ERROR + and event.group.platform == "cocoa" + ) + if not should_detect_sdk_crash: + return None + + context = get_path(event.data, "contexts", "sdk_crash_detection") + if context is not None and context.get("detected", False): + return None + + # Getting the frames and checking if the event is unhandled might different per platform. + # We will change this once we implement this for more platforms. + is_unhandled = ( + get_path(event.data, "exception", "values", -1, "mechanism", "data", "handled") is False + ) + if is_unhandled is False: + return None + + frames = get_path(event.data, "exception", "values", -1, "stacktrace", "frames") + if not frames: + return None + + # We still need to pass in the frames to validate it's an unhandled event coming from the Cocoa SDK. + # We will do this in a separate PR. + if self.cocoa_sdk_crash_detector.is_sdk_crash(): + # We still need to strip event data for to avoid collecting PII. We will do this in a separate PR. + sdk_crash_event_data = event.data + + set_path( + sdk_crash_event_data, "contexts", "sdk_crash_detection", value={"detected": True} + ) + + return self.sdk_crash_reporter.report(sdk_crash_event_data, event_project_id) - def detect_sdk_crash(self): return None -sdk_crash_detection = SDKCrashDetection() +_crash_reporter = SDKCrashReporter() +_cocoa_sdk_crash_detector = CocoaSDKCrashDetector() + +sdk_crash_detection = SDKCrashDetection(_crash_reporter, _cocoa_sdk_crash_detector) diff --git a/src/sentry/utils/sdk_crashes/sdk_crash_detector.py b/src/sentry/utils/sdk_crashes/sdk_crash_detector.py new file mode 100644 index 00000000000000..db66d6910f9a06 --- /dev/null +++ b/src/sentry/utils/sdk_crashes/sdk_crash_detector.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod + + +class SDKCrashDetector(ABC): + @abstractmethod + def is_sdk_crash(self) -> bool: + """ + Returns true if the stacktrace stems from an SDK crash. + + :param frames: The stacktrace frames ordered from newest to oldest. + """ + raise NotImplementedError + + @abstractmethod + def is_sdk_frame(self) -> bool: + raise NotImplementedError diff --git a/tests/sentry/utils/sdk_crashes/test_sdk_crash_detection.py b/tests/sentry/utils/sdk_crashes/test_sdk_crash_detection.py new file mode 100644 index 00000000000000..791d2c04651f86 --- /dev/null +++ b/tests/sentry/utils/sdk_crashes/test_sdk_crash_detection.py @@ -0,0 +1,119 @@ +import abc +from unittest.mock import patch + +import pytest + +from fixtures.sdk_crash_detection.crash_event import get_crash_event +from sentry.eventstore.snuba.backend import SnubaEventStorage +from sentry.issues.grouptype import PerformanceNPlusOneGroupType +from sentry.testutils import TestCase +from sentry.testutils.cases import BaseTestCase, SnubaTestCase +from sentry.testutils.performance_issues.store_transaction import PerfIssueTransactionTestMixin +from sentry.testutils.silo import region_silo_test +from sentry.utils.safe import set_path +from sentry.utils.sdk_crashes.sdk_crash_detection import sdk_crash_detection + + +class BaseSDKCrashDetectionMixin(BaseTestCase, metaclass=abc.ABCMeta): + @abc.abstractmethod + def create_event(self, data, project_id, assert_no_errors=True): + pass + + def execute_test(self, event_data, should_be_reported, mock_sdk_crash_reporter): + + event = self.create_event( + data=event_data, + project_id=self.project.id, + ) + + sdk_crash_detection.detect_sdk_crash(event=event, event_project_id=1234) + + if should_be_reported: + mock_sdk_crash_reporter.report.assert_called_once() + + reported_event_data = mock_sdk_crash_reporter.report.call_args.args[0] + assert reported_event_data["contexts"]["sdk_crash_detection"]["detected"] is True + else: + mock_sdk_crash_reporter.report.assert_not_called() + + +@patch("sentry.utils.sdk_crashes.sdk_crash_detection.sdk_crash_detection.sdk_crash_reporter") +class PerformanceEventTestMixin( + BaseSDKCrashDetectionMixin, SnubaTestCase, PerfIssueTransactionTestMixin +): + def test_performance_event_not_detected(self, mock_sdk_crash_reporter): + fingerprint = "some_group" + fingerprint = f"{PerformanceNPlusOneGroupType.type_id}-{fingerprint}" + event = self.store_transaction( + project_id=self.project.id, + user_id="hi", + fingerprint=[fingerprint], + ) + + sdk_crash_detection.detect_sdk_crash(event=event, event_project_id=1234) + + mock_sdk_crash_reporter.report.assert_not_called() + + +@patch("sentry.utils.sdk_crashes.sdk_crash_detection.sdk_crash_detection.sdk_crash_reporter") +class CococaSDKTestMixin(BaseSDKCrashDetectionMixin): + def test_unhandled_is_detected(self, mock_sdk_crash_reporter): + self.execute_test(get_crash_event(), True, mock_sdk_crash_reporter) + + def test_handled_is_not_detected(self, mock_sdk_crash_reporter): + self.execute_test(get_crash_event(handled=True), False, mock_sdk_crash_reporter) + + def test_wrong_platform_not_detected(self, mock_sdk_crash_reporter): + self.execute_test(get_crash_event(platform="coco"), False, mock_sdk_crash_reporter) + + def test_no_exception_not_detected(self, mock_sdk_crash_reporter): + self.execute_test(get_crash_event(exception=[]), False, mock_sdk_crash_reporter) + + def test_sdk_crash_detected_event_is_not_reported(self, mock_sdk_crash_reporter): + event = get_crash_event() + + set_path(event, "contexts", "sdk_crash_detection", value={"detected": True}) + + self.execute_test(event, False, mock_sdk_crash_reporter) + + +class SDKCrashReportTestMixin(BaseSDKCrashDetectionMixin, SnubaTestCase): + @pytest.mark.django_db + def test_sdk_crash_event_stored_to_sdk_crash_project(self): + + cocoa_sdk_crashes_project = self.create_project( + name="Cocoa SDK Crashes", + slug="cocoa-sdk-crashes", + teams=[self.team], + fire_project_created=True, + ) + + event = self.create_event( + data=get_crash_event(), + project_id=self.project.id, + ) + + sdk_crash_event = sdk_crash_detection.detect_sdk_crash( + event=event, event_project_id=cocoa_sdk_crashes_project.id + ) + + assert sdk_crash_event is not None + + event_store = SnubaEventStorage() + fetched_sdk_crash_event = event_store.get_event_by_id( + cocoa_sdk_crashes_project.id, sdk_crash_event.event_id + ) + + assert cocoa_sdk_crashes_project.id == fetched_sdk_crash_event.project_id + assert sdk_crash_event.event_id == fetched_sdk_crash_event.event_id + + +@region_silo_test +class SDKCrashDetectionTest( + TestCase, + CococaSDKTestMixin, + PerformanceEventTestMixin, + SDKCrashReportTestMixin, +): + 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)