diff --git a/src/sentry/stacktraces/processing.py b/src/sentry/stacktraces/processing.py index 3e466acbca570d..32497112f321a2 100644 --- a/src/sentry/stacktraces/processing.py +++ b/src/sentry/stacktraces/processing.py @@ -1,12 +1,15 @@ from __future__ import annotations import logging +import os from collections.abc import Callable, Mapping, MutableMapping, Sequence from datetime import datetime, timezone +from pathlib import Path, PureWindowsPath from typing import TYPE_CHECKING, Any, NamedTuple from urllib.parse import urlparse import sentry_sdk +from sentry_ophio.enhancers import Enhancements as RustEnhancements from sentry.models.project import Project from sentry.models.release import Release @@ -347,6 +350,11 @@ def normalize_stacktraces_for_grouping( # If a grouping config is available, run grouping enhancers if grouping_config is not None: + # Some SDKs (so far only Python, but could be extended to others in the future) send the + # running app's working directory as `project_root`, so it can be used when determining + # what's in and out of app + _add_project_root_rule_to_enhancements(data, grouping_config) + with sentry_sdk.start_span(op=op, name="apply_modifications_to_frame"): for frames, stacktrace_container in zip(stacktrace_frames, stacktrace_containers): # This call has a caching mechanism when the same stacktrace and rules are used @@ -371,6 +379,42 @@ def normalize_stacktraces_for_grouping( data["metadata"] = event_metadata +def _add_project_root_rule_to_enhancements( + event_data: MutableMapping[str, Any], grouping_config: StrategyConfiguration +) -> None: + """ + If the event's `debug_meta` entry includes `project_root`, use it to add an in-app rule to the + existing grouping config. + + The new rule, if any, is added before existing rules (which include already-loaded custom + rules). That way, the new rule will apply first and custom rules will still have a chance to + override it. + """ + from sentry.grouping.enhancer import Enhancements # Prevent circular import by importing here + + project_root = get_path(event_data, "debug_meta", "project_root") + if not project_root: + return + + # Normalize the path before using it to create the rule + if "\\" in project_root: + project_root = PureWindowsPath(project_root).as_posix() + else: + project_root = Path(project_root).as_posix() + + existing_rust_enhancements = grouping_config.enhancements.rust_enhancements + new_in_app_rule_enhancements = Enhancements.from_config_string( + f"path:{os.path.join(project_root, "**")} +app" + ) + + # Merge the new rule with the existing enhancements + merged_rust_enhancements = RustEnhancements.empty() + merged_rust_enhancements.extend_from(new_in_app_rule_enhancements.rust_enhancements) + merged_rust_enhancements.extend_from(existing_rust_enhancements) + + grouping_config.enhancements.rust_enhancements = merged_rust_enhancements + + def _update_frame(frame: dict[str, Any], platform: str | None) -> None: """Restore the original in_app value before the first grouping enhancers have been run. This allows to re-apply grouping diff --git a/tests/sentry/stacktraces/test_in_app_normalization.py b/tests/sentry/stacktraces/test_in_app_normalization.py index d7a5725c943868..b76b30a5d00aeb 100644 --- a/tests/sentry/stacktraces/test_in_app_normalization.py +++ b/tests/sentry/stacktraces/test_in_app_normalization.py @@ -2,9 +2,14 @@ from typing import Any -from sentry.grouping.api import get_default_grouping_config_dict, load_grouping_config +from sentry.grouping.api import ( + get_default_grouping_config_dict, + get_grouping_config_dict_for_project, + load_grouping_config, +) from sentry.stacktraces.processing import normalize_stacktraces_for_grouping from sentry.testutils.cases import TestCase +from sentry.utils.safe import get_path def make_stacktrace(frame_0_in_app="not set", frame_1_in_app="not set") -> dict[str, Any]: @@ -132,6 +137,132 @@ def test_detects_frame_mix_correctly_with_multiple_stacktraces(self): ), f"Expected {expected_frame_mix}, got {frame_mix} with stacktrace `in-app` values {stacktrace_0_mix}, {stacktrace_1_mix}" +class DynamicProjectRootRuleTest(TestCase): + def test_simple(self): + stacktrace = { + "frames": [ + { + "abs_path": "/path/to/app/root/some/in_app/path/app_file.py", + "context_line": "do_app_stuff()", + }, + { + "abs_path": "/path/to/virtual/env/some/package/path/library_file.py", + "context_line": "do_third_party_stuff()", + }, + ] + } + + event_with_project_root = make_event([stacktrace]) + # TODO: For now this has to be added separately, since normalization strips it out + event_with_project_root["debug_meta"] = {"project_root": "/path/to/app/root"} + + grouping_config = load_grouping_config(get_grouping_config_dict_for_project(self.project)) + normalize_stacktraces_for_grouping(event_with_project_root, grouping_config) + + # Without the dynamic rule, both frames would be `in_app: False`, as that's the default when + # no value is provided in the stacktrace. + frames = event_with_project_root["exception"]["values"][0]["stacktrace"]["frames"] + assert frames[0]["in_app"] is True + assert frames[1]["in_app"] is False + + def test_applies_rule_in_edge_cases(self): + for project_root, frame_path in [ + # Trailing slash + ("/path/to/app/root/", "/path/to/app/root/some/in_app/path/app_file.py"), + # Windows path + ("C:\\path\\to\\app\\root", "C:\\path\\to\\app\\root\\some\\in_app\\path\\app_file.py"), + # Windows path with a trailing backslash + ( + "C:\\path\\to\\app\\root\\", + "C:\\path\\to\\app\\root\\some\\in_app\\path\\app_file.py", + ), + ]: + stacktrace = { + "frames": [ + { + "abs_path": frame_path, + "context_line": "do_app_stuff()", + }, + ] + } + + event_with_project_root = make_event([stacktrace]) + # TODO: For now this has to be added separately, since normalization strips it out + event_with_project_root["debug_meta"] = {"project_root": project_root} + + grouping_config = load_grouping_config( + get_grouping_config_dict_for_project(self.project) + ) + normalize_stacktraces_for_grouping(event_with_project_root, grouping_config) + + # Without the dynamic rule, the frame would be `in_app: False`, as that's the default + # when no value is provided in the stacktrace. + frames = event_with_project_root["exception"]["values"][0]["stacktrace"]["frames"] + assert frames[0]["in_app"] is True, f"Case with project root {project_root} failed" + + def test_applies_default_if_no_project_root(self): + stacktrace = { + "frames": [ + { + "abs_path": "/path/to/app/root/some/in_app/path/app_file.py", + "context_line": "do_app_stuff()", + }, + { + "abs_path": "/path/to/virtual/env/some/package/path/library_file.py", + "context_line": "do_third_party_stuff()", + }, + ] + } + + event_without_project_root = make_event([stacktrace]) + assert get_path(event_without_project_root, "debug_meta", "project_root") is None + + grouping_config = load_grouping_config(get_grouping_config_dict_for_project(self.project)) + normalize_stacktraces_for_grouping(event_without_project_root, grouping_config) + + # The default `in_app` value if none is provided in the stacktrace is False. + frames = event_without_project_root["exception"]["values"][0]["stacktrace"]["frames"] + assert frames[0]["in_app"] is False + assert frames[1]["in_app"] is False + + def test_custom_rule_takes_precedence(self): + stacktrace = { + "frames": [ + { + "abs_path": "/path/to/app/root/some/in_app/path/app_file.py", + "context_line": "do_app_stuff()", + }, + { + "abs_path": "/path/to/app/root/some/other/in_app/path/some_other_file.py", + "context_line": "do_other_app_stuff()", + }, + ] + } + + event_with_project_root = make_event([stacktrace]) + # TODO: For now this has to be added separately, since normalization strips it out + event_with_project_root["debug_meta"] = {"project_root": "/path/to/app/root"} + + # First, normalize with no custom rules - both frames should come out as in-app because of + # the dynamically-created rule + grouping_config = load_grouping_config(get_grouping_config_dict_for_project(self.project)) + normalize_stacktraces_for_grouping(event_with_project_root, grouping_config) + + frames = event_with_project_root["exception"]["values"][0]["stacktrace"]["frames"] + assert frames[0]["in_app"] is True + assert frames[1]["in_app"] is True + + # Now, add a custom rule and normalize again + self.project.update_option("sentry:grouping_enhancements", "path:**/app_file.py -app") + grouping_config = load_grouping_config(get_grouping_config_dict_for_project(self.project)) + normalize_stacktraces_for_grouping(event_with_project_root, grouping_config) + + # The first frame is now `in_app: False`, showing the custom rule won out + frames = event_with_project_root["exception"]["values"][0]["stacktrace"]["frames"] + assert frames[0]["in_app"] is False + assert frames[1]["in_app"] is True + + class MacOSInAppDetectionTest(TestCase): def test_macos_package_in_app_detection(self): data: dict[str, Any] = {