Skip to content

feat(grouping): Use project_root for in-app logic #84156

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/sentry/stacktraces/processing.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
133 changes: 132 additions & 1 deletion tests/sentry/stacktraces/test_in_app_normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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] = {
Expand Down
Loading