Browse Source

feat(sdk-crashes): Add React-Native crash detector (#63009)

Add the SDK crash detector for React-Native with one sample SDK crash.
As React-Native system library paths don't have a reliable prefix but
instead a pattern that system library paths contain, this PR changes the
system library path config from a prefix to a regex. The logic for the
Cocoa SDK stays unchanged because the regex still requires the system
library path to start with the configured patterns.

Co-authored-by: Iker Barriocanal <32816711+iker-barriocanal@users.noreply.github.com>
Philipp Hofmann 1 year ago
parent
commit
7d1b3705c9

+ 170 - 0
fixtures/sdk_crash_detection/crash_event_react_native.py

@@ -0,0 +1,170 @@
+from typing import Dict, Mapping, MutableMapping, Sequence
+
+
+def get_frames(filename: str) -> Sequence[MutableMapping[str, str]]:
+    frames = [
+        {
+            "function": "dispatchEvent",
+            "filename": "/Users/sentry.user/git-repos/sentry-react-native/samples/react-native/node_modules/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js",
+            "abs_path": "/Users/sentry.user/git-repos/sentry-react-native/samples/react-native/node_modules/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js",
+        },
+        {
+            "function": "Button.props.onPress",
+            "filename": "/Users/sentry.user/git-repos/sentry-react-native/samples/react-native/src/Screens/HomeScreen.tsx",
+            "abs_path": "/Users/sentry.user/git-repos/sentry-react-native/samples/react-native/src/Screens/HomeScreen.tsx",
+        },
+        {
+            "function": "community.lib.dosomething",
+            "filename": "/Users/sentry.user/git-repos/sentry-react-native/samples/react-native/node_modules/react-native-community/Renderer/implementations/ReactFabric-dev.js",
+            "abs_path": "/Users/sentry.user/git-repos/sentry-react-native/samples/react-native/node_modules/react-native-community/Renderer/implementations/ReactFabric-dev.js",
+        },
+        {
+            "function": "nativeCrash",
+            "filename": "/Users/sentry.user/git-repos/sentry-react-native/dist/js/sdk.js",
+            "abs_path": "/Users/sentry.user/git-repos/sentry-react-native/dist/js/sdk.js",
+        },
+        {
+            "function": "ReactNativeClient#nativeCrash",
+            "filename": filename,
+            "abs_path": "/Users/sentry.user/git-repos/sentry-react-native/dist/js/client.js",
+        },
+    ]
+    return frames
+
+
+def get_crash_event(
+    filename="/Users/sentry.user/git-repos/sentry-react-native/dist/js/client.js", **kwargs
+) -> Dict[str, object]:
+    return get_crash_event_with_frames(get_frames(filename=filename), **kwargs)
+
+
+def get_crash_event_with_frames(frames: Sequence[Mapping[str, str]], **kwargs) -> Dict[str, object]:
+    result = {
+        "event_id": "150d5b0b4f3a4797a3cd1345374ac484",
+        "release": "com.samplenewarchitecture@1.0+1",
+        "dist": "1",
+        "platform": "javascript",
+        "message": "",
+        "environment": "dev",
+        "exception": {
+            "values": [
+                {
+                    "type": "Error",
+                    "value": "Uncaught Thrown Error",
+                    "stacktrace": {"frames": frames},
+                    "mechanism": {"type": "onerror", "handled": False},
+                }
+            ]
+        },
+        "key_id": "3554525",
+        "level": "fatal",
+        "contexts": {
+            "app": {
+                "app_start_time": "2024-01-11T10:30:29.281Z",
+                "app_identifier": "com.samplenewarchitecture",
+                "app_name": "sampleNewArchitecture",
+                "app_version": "1.0",
+                "app_build": "1",
+                "in_foreground": True,
+                "view_names": ["Home"],
+                "permissions": {
+                    "ACCESS_NETWORK_STATE": "granted",
+                    "ACCESS_WIFI_STATE": "granted",
+                    "DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION": "granted",
+                    "INTERNET": "granted",
+                    "SYSTEM_ALERT_WINDOW": "not_granted",
+                },
+                "type": "app",
+            },
+            "device": {
+                "family": "sdk_gphone64_arm64",
+                "model": "sdk_gphone64_arm64",
+                "model_id": "UPB2.230407.019",
+                "battery_level": 100.0,
+                "orientation": "portrait",
+                "manufacturer": "Google",
+                "brand": "google",
+                "screen_width_pixels": 1080,
+                "screen_height_pixels": 2209,
+                "screen_density": 2.625,
+                "screen_dpi": 420,
+                "online": True,
+                "charging": False,
+                "low_memory": False,
+                "simulator": True,
+                "memory_size": 2074669056,
+                "free_memory": 607039488,
+                "storage_size": 6228115456,
+                "free_storage": 4940427264,
+                "boot_time": "2024-01-11T09:56:37.070Z",
+                "timezone": "Europe/Vienna",
+                "locale": "en_US",
+                "processor_count": 4,
+                "processor_frequency": 0,
+                "archs": ["arm64-v8a"],
+                "battery_temperature": 25,
+                "connection_type": "wifi",
+                "id": "64b13018-2922-4938-92b1-3135861a69c8",
+                "language": "en",
+                "type": "device",
+            },
+            "os": {
+                "name": "Android",
+                "version": "13",
+                "build": "sdk_gphone64_arm64-userdebug UpsideDownCake UPB2.230407.019 10170211 dev-keys",
+                "kernel_version": "6.1.21-android14-3-01811-g9e35a21ec03f-ab9850788",
+                "rooted": False,
+                "type": "os",
+            },
+        },
+        "logger": "",
+        "sdk": {
+            "name": "sentry.javascript.react-native",
+            "version": "5.15.2",
+            "integrations": [
+                "ModulesLoader",
+                "ReactNativeErrorHandlers",
+                "Release",
+                "InboundFilters",
+                "FunctionToString",
+                "Breadcrumbs",
+                "HttpContext",
+                "NativeLinkedErrors",
+                "EventOrigin",
+                "SdkInfo",
+                "ReactNativeInfo",
+                "DebugSymbolicator",
+                "RewriteFrames",
+                "DeviceContext",
+                "HermesProfiling",
+                "ReactNativeTracing",
+                "Screenshot",
+                "ViewHierarchy",
+                "HttpClient",
+                "react-navigation-v5",
+                "ReactNativeUserInteractionTracing",
+                "ReactNativeProfiler",
+                "TouchEventBoundary",
+            ],
+            "packages": [
+                {"name": "sentry.java.android.react-native", "version": "6.34.0"},
+                {"name": "npm:@sentry/react-native", "version": "5.15.2"},
+            ],
+        },
+        "timestamp": 1704969036.875,
+        "type": "error",
+        "user": {
+            "email": "philipp@example.com",
+            "ip_address": "85.193.160.231",
+            "geo": {
+                "country_code": "AT",
+                "city": "Diersbach",
+                "subdivision": "Upper Austria",
+                "region": "Austria",
+            },
+        },
+        "version": "7",
+    }
+
+    result.update(kwargs)
+    return result

+ 5 - 5
src/sentry/utils/sdk_crashes/configs.py

@@ -21,7 +21,7 @@ cocoa_sdk_crash_detector_config = SDKCrashDetectorConfig(
     # the frames contain the full paths required for detecting system frames in is_system_library_frame.
     # Therefore, we require at least sentry-cocoa 8.2.0.
     min_sdk_version="8.2.0",
-    system_library_paths={"/System/Library/", "/usr/lib/"},
+    system_library_path_patterns={r"/System/Library/*", r"/usr/lib/*"},
     sdk_frame_config=SDKFrameConfig(
         function_patterns={
             r"*sentrycrash*",
@@ -44,13 +44,13 @@ react_native_sdk_crash_detector_config = SDKCrashDetectorConfig(
     # 4.0.0 was released in June 2022, see https://github.com/getsentry/sentry-react-native/releases/tag/4.0.0.
     # We require at least sentry-react-native 4.0.0 to only detect SDK crashes for not too old versions.
     min_sdk_version="4.0.0",
-    system_library_paths={
-        "react-native/Libraries/",
-        "react-native-community/",
+    system_library_path_patterns={
+        r"*/react-native/Libraries/*",
+        r"*/react-native-community/*",
     },
     sdk_frame_config=SDKFrameConfig(
         function_patterns=set(),
-        filename_patterns={r"**/sentry-react-native/**"},
+        filename_patterns={r"**/sentry-react-native/dist/**"},
         path_replacer=KeepAfterPatternMatchPathReplacer(
             patterns={r"\/sentry-react-native\/.*", r"\/@sentry.*"},
             fallback_path="sentry-react-native",

+ 14 - 3
src/sentry/utils/sdk_crashes/sdk_crash_detection.py

@@ -8,7 +8,10 @@ import sentry_sdk
 from sentry.eventstore.models import Event, GroupEvent
 from sentry.issues.grouptype import GroupCategory
 from sentry.utils.safe import get_path, set_path
-from sentry.utils.sdk_crashes.configs import cocoa_sdk_crash_detector_config
+from sentry.utils.sdk_crashes.configs import (
+    cocoa_sdk_crash_detector_config,
+    react_native_sdk_crash_detector_config,
+)
 from sentry.utils.sdk_crashes.event_stripper import strip_event_data
 from sentry.utils.sdk_crashes.sdk_crash_detection_config import SDKCrashDetectionConfig, SdkName
 from sentry.utils.sdk_crashes.sdk_crash_detector import SDKCrashDetector
@@ -125,6 +128,14 @@ class SDKCrashDetection:
 
 
 _crash_reporter = SDKCrashReporter()
-_cocoa_sdk_crash_detector = SDKCrashDetector(config=cocoa_sdk_crash_detector_config)
 
-sdk_crash_detection = SDKCrashDetection(_crash_reporter, {SdkName.Cocoa: _cocoa_sdk_crash_detector})
+_cocoa_sdk_crash_detector = SDKCrashDetector(config=cocoa_sdk_crash_detector_config)
+_react_native_sdk_crash_detector = SDKCrashDetector(config=react_native_sdk_crash_detector_config)
+
+sdk_crash_detection = SDKCrashDetection(
+    _crash_reporter,
+    {
+        SdkName.Cocoa: _cocoa_sdk_crash_detector,
+        SdkName.ReactNative: _react_native_sdk_crash_detector,
+    },
+)

+ 3 - 3
src/sentry/utils/sdk_crashes/sdk_crash_detector.py

@@ -24,7 +24,7 @@ class SDKCrashDetectorConfig:
 
     min_sdk_version: str
 
-    system_library_paths: Set[str]
+    system_library_path_patterns: Set[str]
 
     sdk_frame_config: SDKFrameConfig
 
@@ -134,9 +134,9 @@ class SDKCrashDetector:
 
     def is_system_library_frame(self, frame: Mapping[str, Any]) -> bool:
         for field in self.fields_containing_paths:
-            for system_library_path in self.config.system_library_paths:
+            for pattern in self.config.system_library_path_patterns:
                 field_with_path = frame.get(field)
-                if field_with_path and field_with_path.startswith(system_library_path):
+                if field_with_path and glob_match(field_with_path, pattern, ignorecase=True):
                     return True
 
         return False

+ 94 - 0
tests/sentry/utils/sdk_crashes/test_sdk_crash_detection_react_native.py

@@ -0,0 +1,94 @@
+from functools import wraps
+from unittest.mock import patch
+
+import pytest
+
+from fixtures.sdk_crash_detection.crash_event_react_native import get_crash_event
+from sentry.testutils.pytest.fixtures import django_db_all
+from sentry.utils.safe import get_path, set_path
+from sentry.utils.sdk_crashes.sdk_crash_detection import sdk_crash_detection
+from sentry.utils.sdk_crashes.sdk_crash_detection_config import SDKCrashDetectionConfig, SdkName
+
+sdk_configs = [
+    SDKCrashDetectionConfig(sdk_name=SdkName.ReactNative, project_id=1234, sample_rate=1.0)
+]
+
+
+def decorators(func):
+    @wraps(func)
+    @django_db_all
+    @pytest.mark.snuba
+    @patch("random.random", return_value=0.1)
+    @patch("sentry.utils.sdk_crashes.sdk_crash_detection.sdk_crash_detection.sdk_crash_reporter")
+    def wrapper(*args, **kwargs):
+        return func(*args, **kwargs)
+
+    return wrapper
+
+
+@decorators
+def test_sdk_crash_is_reported(mock_sdk_crash_reporter, mock_random, store_event):
+    event = store_event(data=get_crash_event())
+
+    sdk_crash_detection.detect_sdk_crash(event=event, configs=sdk_configs)
+
+    assert mock_sdk_crash_reporter.report.call_count == 1
+    reported_event_data = mock_sdk_crash_reporter.report.call_args.args[0]
+
+    stripped_frames = get_path(
+        reported_event_data, "exception", "values", -1, "stacktrace", "frames"
+    )
+
+    assert len(stripped_frames) == 4
+    assert stripped_frames[0]["function"] == "dispatchEvent"
+    assert stripped_frames[1]["function"] == "community.lib.dosomething"
+    assert stripped_frames[2]["function"] == "nativeCrash"
+    assert stripped_frames[3]["function"] == "ReactNativeClient#nativeCrash"
+
+
+@decorators
+def test_sdk_crash_sample_app_not_reported(mock_sdk_crash_reporter, mock_random, store_event):
+    event = store_event(
+        data=get_crash_event(
+            filename="/Users/sentry.user/git-repos/sentry-react-native/samples/react-native/src/Screens/HomeScreen.tsx"
+        )
+    )
+
+    sdk_crash_detection.detect_sdk_crash(event=event, configs=sdk_configs)
+
+    assert mock_sdk_crash_reporter.report.call_count == 0
+
+
+@decorators
+def test_sdk_crash_react_natives_not_reported(mock_sdk_crash_reporter, mock_random, store_event):
+    event = store_event(
+        data=get_crash_event(
+            filename="/Users/sentry.user/git-repos/sentry-react-natives/dist/js/client.js"
+        )
+    )
+
+    sdk_crash_detection.detect_sdk_crash(event=event, configs=sdk_configs)
+
+    assert mock_sdk_crash_reporter.report.call_count == 0
+
+
+@decorators
+def test_beta_sdk_version_detected(mock_sdk_crash_reporter, mock_random, store_event):
+    event_data = get_crash_event()
+    set_path(event_data, "sdk", "version", value="4.1.0-beta.0")
+    event = store_event(data=event_data)
+
+    sdk_crash_detection.detect_sdk_crash(event=event, configs=sdk_configs)
+
+    assert mock_sdk_crash_reporter.report.call_count == 1
+
+
+@decorators
+def test_too_low_min_sdk_version_not_detected(mock_sdk_crash_reporter, mock_random, store_event):
+    event_data = get_crash_event()
+    set_path(event_data, "sdk", "version", value="3.9.9")
+    event = store_event(data=event_data)
+
+    sdk_crash_detection.detect_sdk_crash(event=event, configs=sdk_configs)
+
+    assert mock_sdk_crash_reporter.report.call_count == 0