diff --git a/fixtures/sdk_crash_detection/crash_event_dotnet.py b/fixtures/sdk_crash_detection/crash_event_dotnet.py new file mode 100644 index 00000000000000..8e4c7ae2e77222 --- /dev/null +++ b/fixtures/sdk_crash_detection/crash_event_dotnet.py @@ -0,0 +1,160 @@ +import time +from collections.abc import Mapping, MutableMapping, Sequence + + +def get_frames( + sdk_frame_module: str, system_frame_module: str +) -> Sequence[MutableMapping[str, str]]: + frames = [ + { + "function": "Main", + "module": "System.Threading.ThreadPoolWorkQueue", + "filename": "ThreadPoolWorkQueue.cs", + "abs_path": "ThreadPoolWorkQueue.cs", + }, + { + "function": "RunInternal", + "module": "System.Threading.ExecutionContext", + "filename": "ExecutionContext.cs", + "abs_path": "ExecutionContext.cs", + }, + { + "function": "MoveNext", + "module": "Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker", + "filename": "ControllerActionInvoker.cs", + "abs_path": "ControllerActionInvoker.cs", + }, + { + "function": "PostIndex", + "module": "Samples.AspNetCore.Mvc.Controllers.HomeController", + "filename": "HomeController.cs", + "abs_path": "HomeController.cs", + }, + { + "function": "CaptureException", + "module": sdk_frame_module, + "filename": "SentryClient.cs", + "abs_path": "SentryClient.cs", + }, + { + "function": "InvokeAsync", + "module": system_frame_module, + "filename": "SentryMiddleware.cs", + }, + ] + return frames + + +def get_crash_event( + sdk_frame_module="Sentry.SentryClient", + system_frame_module="System.Runtime.CompilerServices.AsyncTaskMethodBuilder", + **kwargs, +) -> dict[str, object]: + return get_crash_event_with_frames( + get_frames(sdk_frame_module, system_frame_module), + **kwargs, + ) + + +def get_unity_frames( + sdk_frame_module: str, unity_frame_module: str +) -> Sequence[MutableMapping[str, str]]: + frames = [ + { + "function": "Update", + "module": "UnityEngine.EventSystems.EventSystem", + "filename": "", + }, + { + "function": "OnPointerClick", + "module": "UnityEngine.UI.Button", + "filename": "", + }, + { + "function": "Invoke", + "module": "UnityEngine.Events.UnityEvent", + "filename": "", + }, + { + "function": "SendMessage", + "module": "SentryTest", + "filename": "", + }, + { + "function": "CaptureException", + "module": sdk_frame_module, + "filename": "", + }, + { + "function": "Invoke", + "module": unity_frame_module, + "filename": "", + }, + ] + return frames + + +def get_unity_crash_event( + sdk_frame_module="Sentry.SentryClient", + unity_frame_module="UnityEngine.Events.InvokableCall", + **kwargs, +) -> dict[str, object]: + return get_crash_event_with_frames( + get_unity_frames(sdk_frame_module, unity_frame_module), + **kwargs, + ) + + +def get_exception( + frames: Sequence[Mapping[str, str]], + mechanism=None, +) -> dict[str, object]: + if mechanism is None: + # linter complains about mutable arguments otherwise + mechanism = {"type": "onerror", "handled": False} + return { + "type": "System.DivideByZeroException", + "value": "Attempted to divide by zero.", + "module": "System", + "stacktrace": {"frames": frames}, + "mechanism": mechanism, + } + + +def get_crash_event_with_frames(frames: Sequence[Mapping[str, str]], **kwargs) -> dict[str, object]: + result = { + "event_id": "0a52a8331d3b45089ebd74f8118d4fa1", + "release": "sentry.dotnet@3.22.0", + "dist": "1", + "platform": "csharp", + "environment": "production", + "exception": {"values": [get_exception(frames)]}, + "key_id": "1336851", + "level": "error", + "contexts": { + "device": { + "name": "DESKTOP-ABC123", + "family": "Desktop", + "model": "PC", + "simulator": False, + }, + "os": { + "name": "Windows", + "version": "10.0.19041", + "build": "19041.1348", + "kernel_version": "10.0.19041.1348", + "type": "os", + }, + "runtime": { + "name": ".NET Core", + "version": "6.0.5", + "type": "runtime", + }, + }, + "sdk": {"name": "sentry.dotnet", "version": "3.22.0"}, + "timestamp": time.time(), + "type": "error", + } + + result.update(kwargs) + return result diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index a50c52608c5880..2ccb8b6df47294 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2548,6 +2548,26 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) +register( + "issues.sdk_crash_detection.dotnet.project_id", + default=0, + type=Int, + flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, +) + +register( + "issues.sdk_crash_detection.dotnet.organization_allowlist", + type=Sequence, + default=[], + flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, +) + +register( + "issues.sdk_crash_detection.dotnet.sample_rate", + default=0.0, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) + # END: SDK Crash Detection register( 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 a7b696ecb30018..ed5c853cd81406 100644 --- a/src/sentry/utils/sdk_crashes/sdk_crash_detection_config.py +++ b/src/sentry/utils/sdk_crashes/sdk_crash_detection_config.py @@ -53,6 +53,7 @@ class SdkName(Enum): Java = "java" Native = "native" Dart = "dart" + Dotnet = "dotnet" @dataclass @@ -430,6 +431,72 @@ def build_sdk_crash_detection_configs() -> Sequence[SDKCrashDetectionConfig]: ) configs.append(dart_config) + dotnet_options = _get_options(sdk_name=SdkName.Dotnet, has_organization_allowlist=True) + + if dotnet_options: + # Unity SDK contains .NET SDK, so the versions must match. 0.24.0 Unity release was + # based on 3.22.0 .NET release. From that point on SDK names and frames should be consistent. + dotnet_min_sdk_version = "3.22.0" + unity_min_sdk_version = "0.24.0" + + dotnet_config = SDKCrashDetectionConfig( + sdk_name=SdkName.Dotnet, + project_id=dotnet_options["project_id"], + sample_rate=dotnet_options["sample_rate"], + organization_allowlist=dotnet_options["organization_allowlist"], + sdk_names={ + "sentry.dotnet": dotnet_min_sdk_version, + "sentry.dotnet.android": dotnet_min_sdk_version, + "sentry.dotnet.aspnet": dotnet_min_sdk_version, + "sentry.dotnet.aspnetcore": dotnet_min_sdk_version, + "sentry.dotnet.aspnetcore.grpc": dotnet_min_sdk_version, + "sentry.dotnet.cocoa": dotnet_min_sdk_version, + "sentry.dotnet.ef": dotnet_min_sdk_version, + "sentry.dotnet.extensions.logging": dotnet_min_sdk_version, + "sentry.dotnet.google-cloud-function": dotnet_min_sdk_version, + "sentry.dotnet.log4net": dotnet_min_sdk_version, + "sentry.dotnet.maui": dotnet_min_sdk_version, + "sentry.dotnet.nlog": dotnet_min_sdk_version, + "sentry.dotnet.serilog": dotnet_min_sdk_version, + "sentry.dotnet.xamarin": dotnet_min_sdk_version, + "sentry.dotnet.xamarin-forms": dotnet_min_sdk_version, + "sentry.dotnet.unity": unity_min_sdk_version, + "sentry.unity": unity_min_sdk_version, + "sentry.unity.lite": unity_min_sdk_version, + }, + # Report fatal errors, since there are no crashes in Unity + report_fatal_errors=True, + ignore_mechanism_type=set(), + allow_mechanism_type=set(), + system_library_path_patterns={ + # .NET System libraries + r"System.**", + r"Microsoft.**", + r"mscorlib**", + r"netstandard**", + # Unity engine libraries + r"UnityEngine.**", + r"UnityEditor.**", + # Common .NET Core/Framework paths + r"**.NETCoreApp**", + r"**.NETFramework**", + r"**.NETStandard**", + }, + sdk_frame_config=SDKFrameConfig( + function_patterns=set(), + path_patterns={ + # Main Sentry .NET SDK modules + r"Sentry.**", + # Unity-specific Sentry paths (for cases where abs_path is available) + r"**/sentry-unity/**", + r"**/sentry-dotnet/**", + }, + path_replacer=KeepFieldPathReplacer(fields={"module", "package", "filename"}), + ), + sdk_crash_ignore_matchers=set(), + ) + configs.append(dotnet_config) + return configs diff --git a/tests/sentry/utils/sdk_crashes/test_build_sdk_crash_detection_configs.py b/tests/sentry/utils/sdk_crashes/test_build_sdk_crash_detection_configs.py index f0ec8e7039f2e1..334f0c4071a3eb 100644 --- a/tests/sentry/utils/sdk_crashes/test_build_sdk_crash_detection_configs.py +++ b/tests/sentry/utils/sdk_crashes/test_build_sdk_crash_detection_configs.py @@ -21,12 +21,15 @@ "issues.sdk_crash_detection.dart.project_id": 5, "issues.sdk_crash_detection.dart.sample_rate": 0.5, "issues.sdk_crash_detection.dart.organization_allowlist": [4], + "issues.sdk_crash_detection.dotnet.project_id": 6, + "issues.sdk_crash_detection.dotnet.sample_rate": 0.6, + "issues.sdk_crash_detection.dotnet.organization_allowlist": [5], } ) def test_build_sdk_crash_detection_configs() -> None: configs = build_sdk_crash_detection_configs() - assert len(configs) == 5 + assert len(configs) == 6 cocoa_config = configs[0] assert cocoa_config.sdk_name == SdkName.Cocoa @@ -58,6 +61,12 @@ def test_build_sdk_crash_detection_configs() -> None: assert dart_config.sample_rate == 0.5 assert dart_config.organization_allowlist == [4] + dotnet_config = configs[5] + assert dotnet_config.sdk_name == SdkName.Dotnet + assert dotnet_config.project_id == 6 + assert dotnet_config.sample_rate == 0.6 + assert dotnet_config.organization_allowlist == [5] + @override_options( { @@ -75,6 +84,9 @@ def test_build_sdk_crash_detection_configs() -> None: "issues.sdk_crash_detection.dart.project_id": 0, "issues.sdk_crash_detection.dart.sample_rate": 0.0, "issues.sdk_crash_detection.dart.organization_allowlist": [], + "issues.sdk_crash_detection.dotnet.project_id": 0, + "issues.sdk_crash_detection.dotnet.sample_rate": 0.0, + "issues.sdk_crash_detection.dotnet.organization_allowlist": [], } ) def test_build_sdk_crash_detection_configs_only_react_native() -> None: @@ -104,6 +116,9 @@ def test_build_sdk_crash_detection_configs_only_react_native() -> None: "issues.sdk_crash_detection.dart.project_id": 5, "issues.sdk_crash_detection.dart.sample_rate": 0.0, "issues.sdk_crash_detection.dart.organization_allowlist": [4], + "issues.sdk_crash_detection.dotnet.project_id": 6, + "issues.sdk_crash_detection.dotnet.sample_rate": 0.0, + "issues.sdk_crash_detection.dotnet.organization_allowlist": [5], } ) def test_build_sdk_crash_detection_configs_no_sample_rate() -> None: @@ -133,6 +148,9 @@ def test_build_sdk_crash_detection_configs_no_sample_rate() -> None: "issues.sdk_crash_detection.dart.project_id": 0, "issues.sdk_crash_detection.dart.sample_rate": 0.0, "issues.sdk_crash_detection.dart.organization_allowlist": [], + "issues.sdk_crash_detection.dotnet.project_id": 0, + "issues.sdk_crash_detection.dotnet.sample_rate": 0.0, + "issues.sdk_crash_detection.dotnet.organization_allowlist": [], } ) def test_build_sdk_crash_detection_default_configs() -> None: diff --git a/tests/sentry/utils/sdk_crashes/test_sdk_crash_detection_dotnet.py b/tests/sentry/utils/sdk_crashes/test_sdk_crash_detection_dotnet.py new file mode 100644 index 00000000000000..c760c8fa1a66d0 --- /dev/null +++ b/tests/sentry/utils/sdk_crashes/test_sdk_crash_detection_dotnet.py @@ -0,0 +1,226 @@ +from collections.abc import Sequence +from functools import wraps +from unittest.mock import patch + +import pytest + +from fixtures.sdk_crash_detection.crash_event_dotnet import get_crash_event, get_unity_crash_event +from sentry.testutils.helpers.options import override_options +from sentry.testutils.pytest.fixtures import django_db_all +from sentry.utils.safe import get_path +from sentry.utils.sdk_crashes.sdk_crash_detection import sdk_crash_detection +from sentry.utils.sdk_crashes.sdk_crash_detection_config import ( + SDKCrashDetectionConfig, + build_sdk_crash_detection_configs, +) + + +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 + + +@pytest.fixture +def configs() -> Sequence[SDKCrashDetectionConfig]: + with override_options( + { + "issues.sdk_crash_detection.dotnet.project_id": 6, + "issues.sdk_crash_detection.dotnet.sample_rate": 0.6, + "issues.sdk_crash_detection.dotnet.organization_allowlist": [5], + } + ): + return build_sdk_crash_detection_configs() + + +@pytest.mark.parametrize( + ["sdk_frame_module", "system_frame_module", "detected"], + [ + # Standard .NET SDK frames + ( + "Sentry.SentryClient", + "System.Threading.Tasks.Task", + True, + ), + ( + "Sentry.AspNetCore.SentryMiddleware", + "Microsoft.AspNetCore.Http.HttpContext", + True, + ), + ( + "Sentry.Extensions.Logging.SentryLogger", + "Microsoft.Extensions.Logging.ILogger", + True, + ), + # Unity SDK frames + ( + "Sentry.Unity.SentryUnity", + "UnityEngine.MonoBehaviour", + True, + ), + # System frames only - should not be detected + ( + "System.Threading.Tasks.Task", + "Microsoft.AspNetCore.Http.HttpContext", + False, + ), + # Non-Sentry SDK frames - should not be detected + ( + "MyApp.SomeClass", + "System.Threading.Tasks.Task", + False, + ), + # Misspelled Sentry - should not be detected + ( + "Sentri.SentryClient", + "System.Threading.Tasks.Task", + False, + ), + ], +) +@decorators +def test_sdk_crash_is_reported_with_dotnet_paths( + mock_sdk_crash_reporter, + mock_random, + store_event, + configs, + sdk_frame_module: str, + system_frame_module: str, + detected: bool, +): + event = store_event( + data=get_crash_event( + sdk_frame_module=sdk_frame_module, system_frame_module=system_frame_module + ) + ) + + # Find the dotnet config (should be the last one added) + dotnet_config = None + for config in configs: + if config.sdk_name.value == "dotnet": + dotnet_config = config + break + + assert dotnet_config is not None, "Dotnet config should be present" + dotnet_config.organization_allowlist = [event.project.organization_id] + + sdk_crash_detection.detect_sdk_crash(event=event, configs=configs) + + if detected: + 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) == 5 + + system_frame1 = stripped_frames[0] + assert system_frame1["function"] == "Main" + assert system_frame1["module"] == "System.Threading.ThreadPoolWorkQueue" + assert system_frame1["filename"] == "ThreadPoolWorkQueue.cs" + assert system_frame1["abs_path"] == "ThreadPoolWorkQueue.cs" + assert system_frame1["in_app"] is False + + sdk_frame = stripped_frames[3] + assert sdk_frame["function"] == "CaptureException" + assert sdk_frame["module"] == sdk_frame_module + assert sdk_frame["filename"] == "SentryClient.cs" + assert "abs_path" not in sdk_frame + assert sdk_frame["in_app"] is True + + system_frame2 = stripped_frames[4] + assert system_frame2["function"] == "InvokeAsync" + assert system_frame2["module"] == system_frame_module + assert system_frame2["filename"] == "SentryMiddleware.cs" + assert system_frame2["in_app"] is False + + else: + assert mock_sdk_crash_reporter.report.call_count == 0 + + +@pytest.mark.parametrize( + ["sdk_frame_module", "unity_frame_module", "detected"], + [ + # Unity SDK frames + ( + "Sentry.Unity.SentryUnity", + "UnityEngine.Events.InvokableCall", + True, + ), + ( + "Sentry.SentryClient", + "UnityEngine.MonoBehaviour", + True, + ), + # Unity system frames only - should not be detected + ( + "UnityEngine.MonoBehaviour", + "UnityEngine.Events.InvokableCall", + False, + ), + # Non-Unity, Non-Sentry - should not be detected + ( + "MyGame.GameManager", + "UnityEngine.Events.InvokableCall", + False, + ), + ], +) +@decorators +def test_sdk_crash_is_reported_with_unity_paths( + mock_sdk_crash_reporter, + mock_random, + store_event, + configs, + sdk_frame_module: str, + unity_frame_module: str, + detected: bool, +): + event = store_event( + data=get_unity_crash_event( + sdk_frame_module=sdk_frame_module, unity_frame_module=unity_frame_module + ) + ) + + # Find the dotnet config + dotnet_config = None + for config in configs: + if config.sdk_name.value == "dotnet": + dotnet_config = config + break + + assert dotnet_config is not None, "Dotnet config should be present" + dotnet_config.organization_allowlist = [event.project.organization_id] + + sdk_crash_detection.detect_sdk_crash(event=event, configs=configs) + + if detected: + 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) == 5 + + unity_frame = stripped_frames[0] + assert unity_frame["function"] == "Update" + assert unity_frame["module"] == "UnityEngine.EventSystems.EventSystem" + assert unity_frame["in_app"] is False + + sdk_frame = stripped_frames[3] + assert sdk_frame["function"] == "CaptureException" + assert sdk_frame["module"] == sdk_frame_module + assert sdk_frame["in_app"] is True + + else: + assert mock_sdk_crash_reporter.report.call_count == 0