From 619ca2e054cedab4d2fdc349a38c52c7f3277fac Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 10 Apr 2026 11:48:08 +0200 Subject: [PATCH 1/4] feat(code-mappings): handle Java monorepo source roots --- .../auto_source_code_config/code_mapping.py | 95 ++++++--- .../auto_source_code_config/utils/java.py | 89 ++++++++ .../test_organization_derive_code_mappings.py | 43 ++++ .../test_code_mapping.py | 201 ++++++++++++++++++ .../test_process_event.py | 28 +++ 5 files changed, 421 insertions(+), 35 deletions(-) create mode 100644 src/sentry/issues/auto_source_code_config/utils/java.py diff --git a/src/sentry/issues/auto_source_code_config/code_mapping.py b/src/sentry/issues/auto_source_code_config/code_mapping.py index 4918b88728bd5a..6f28f1b2416ff6 100644 --- a/src/sentry/issues/auto_source_code_config/code_mapping.py +++ b/src/sentry/issues/auto_source_code_config/code_mapping.py @@ -27,6 +27,7 @@ ) from .frame_info import FrameInfo, create_frame_info from .integration_utils import InstallationNotFoundError, get_installation +from .utils.java import find_java_source_roots from .utils.misc import get_straight_path_prefix_end_index logger = logging.getLogger(__name__) @@ -41,6 +42,8 @@ class CodeMapping(NamedTuple): SLASH = "/" BACKSLASH = "\\" # This is the Python representation of a single backslash +CodeMappingKey = tuple[str, str] + def derive_code_mappings( organization: Organization, @@ -67,7 +70,8 @@ class CodeMappingTreesHelper: def __init__(self, trees: Mapping[str, RepoTree]): self.trees = trees - self.code_mappings: dict[str, CodeMapping] = {} + # Multiple source roots may legitimately share the same stack root in one monorepo. + self.code_mappings: dict[CodeMappingKey, CodeMapping] = {} def generate_code_mappings( self, frames: Sequence[Mapping[str, Any]], platform: str | None = None @@ -111,7 +115,9 @@ def get_file_and_repo_matches(self, frame_filename: FrameInfo) -> list[dict[str, extra = {"stack_path": stack_path, "source_path": source_path} try: - stack_root, source_root = find_roots(frame_filename, source_path) + stack_root, source_root = find_roots( + frame_filename, source_path, repo_tree.files + ) except UnexpectedPathException: logger.warning("Unexpected format for stack_path or source_path", extra=extra) continue @@ -160,19 +166,19 @@ def _stacktrace_buckets( def _process_stackframes(self, buckets: Mapping[str, Sequence[FrameInfo]]) -> bool: """This processes all stackframes and returns if a new code mapping has been generated""" reprocess = False - for stackframe_root, stackframes in buckets.items(): - if not self.code_mappings.get(stackframe_root): - for frame_filename in stackframes: - code_mapping = self._find_code_mapping(frame_filename) - if code_mapping: + for stackframes in buckets.values(): + for frame_filename in stackframes: + for code_mapping in self._find_code_mappings(frame_filename): + mapping_key = (code_mapping.stacktrace_root, code_mapping.source_path) + if mapping_key not in self.code_mappings: # This allows processing some stack frames that # were matching more than one file reprocess = True - self.code_mappings[stackframe_root] = code_mapping + self.code_mappings[mapping_key] = code_mapping return reprocess - def _find_code_mapping(self, frame_filename: FrameInfo) -> CodeMapping | None: - """Look for the file path through all the trees and a generate code mapping for it if a match is found""" + def _find_code_mappings(self, frame_filename: FrameInfo) -> list[CodeMapping]: + """Look for the file path through all the trees and generate code mappings for it.""" code_mappings: list[CodeMapping] = [] # XXX: This will need optimization by changing the data structure of the trees for repo_full_name in self.trees.keys(): @@ -191,13 +197,17 @@ def _find_code_mapping(self, frame_filename: FrameInfo) -> CodeMapping | None: if len(code_mappings) == 0: logger.warning("No files matched for %s", frame_filename.raw_path) - return None - # This means that the file has been found in more than one repo - elif len(code_mappings) > 1: + return [] + + unique_code_mappings = { + (code_mapping.stacktrace_root, code_mapping.source_path): code_mapping + for code_mapping in code_mappings + } + if len({code_mapping.repo.name for code_mapping in unique_code_mappings.values()}) > 1: logger.warning("More than one repo matched %s", frame_filename.raw_path) - return None + return [] - return code_mappings[0] + return list(unique_code_mappings.values()) def _generate_code_mapping_from_tree( self, @@ -214,34 +224,43 @@ def _generate_code_mapping_from_tree( if self._is_potential_match(src_path, frame_filename) ] - if len(matched_files) != 1: + if len(matched_files) == 0: return [] - stack_path = frame_filename.raw_path - source_path = matched_files[0] - - extra = {"stack_path": stack_path, "source_path": source_path} - try: - stack_root, source_root = find_roots(frame_filename, source_path) - except UnexpectedPathException: - logger.warning("Unexpected format for stack_path or source_path", extra=extra) + if len(matched_files) > 1 and not all( + find_java_source_roots(source_path, repo_tree.files) for source_path in matched_files + ): return [] - extra.update({"stack_root": stack_root, "source_root": source_root}) - if stack_path.replace(stack_root, source_root, 1).replace("\\", "/") != source_path: - logger.warning( - "Unexpected stack_path/source_path found. A code mapping was not generated.", - extra=extra, - ) - return [] + code_mappings: dict[tuple[str, str], CodeMapping] = {} + for source_path in matched_files: + stack_path = frame_filename.raw_path + extra = {"stack_path": stack_path, "source_path": source_path} + try: + stack_root, source_root = find_roots(frame_filename, source_path, repo_tree.files) + except UnexpectedPathException: + logger.warning("Unexpected format for stack_path or source_path", extra=extra) + continue + + extra.update({"stack_root": stack_root, "source_root": source_root}) + if stack_path.replace(stack_root, source_root, 1).replace("\\", "/") != source_path: + logger.warning( + "Unexpected stack_path/source_path found. A code mapping was not generated.", + extra=extra, + ) + continue - return [ - CodeMapping( + code_mapping = CodeMapping( repo=repo_tree.repo, stacktrace_root=stack_root, source_path=source_root, ) - ] + code_mappings[(code_mapping.stacktrace_root, code_mapping.source_path)] = code_mapping + + if len(matched_files) > 1 and len(code_mappings) != len(matched_files): + return [] + + return list(code_mappings.values()) def _is_potential_match(self, src_file: str, frame_filename: FrameInfo) -> bool: """ @@ -419,7 +438,9 @@ def get_sorted_code_mapping_configs(project: Project) -> list[RepositoryProjectP return sorted_configs -def find_roots(frame_filename: FrameInfo, source_path: str) -> tuple[str, str]: +def find_roots( + frame_filename: FrameInfo, source_path: str, repo_files: Sequence[str] | None = None +) -> tuple[str, str]: """ Returns a tuple containing the stack_root, and the source_root. If there is no overlap, raise an exception since this should not happen @@ -444,6 +465,10 @@ def find_roots(frame_filename: FrameInfo, source_path: str) -> tuple[str, str]: # "Packaged" logic # e.g. stack_path: some_package/src/foo.py -> source_path: src/foo.py source_prefix = source_path.rpartition(stack_path)[0] + + if java_source_roots := find_java_source_roots(source_path, repo_files): + return java_source_roots + return ( f"{stack_root}{frame_filename.stack_root}/".replace("//", "/"), f"{source_prefix}{frame_filename.stack_root}/".replace("//", "/"), diff --git a/src/sentry/issues/auto_source_code_config/utils/java.py b/src/sentry/issues/auto_source_code_config/utils/java.py new file mode 100644 index 00000000000000..36c903a757968d --- /dev/null +++ b/src/sentry/issues/auto_source_code_config/utils/java.py @@ -0,0 +1,89 @@ +from collections.abc import Sequence + +SLASH = "/" +JAVA_SOURCE_ROOT_MARKERS = ("src/main/java/", "src/main/kotlin/") + + +def get_java_source_set_root(source_path: str) -> str | None: + """Return the repo path through the Java/Kotlin source-set marker. + + Example: + `module/src/main/java/io/sentry/Foo.java` -> `module/src/main/java/` + """ + for marker in JAVA_SOURCE_ROOT_MARKERS: + prefix, separator, _ = source_path.partition(marker) + if separator: + return f"{prefix}{separator}" + + return None + + +def find_package_root_relative_to_source_set( + source_root: str, repo_files: Sequence[str] +) -> str | None: + """Walk a source set until the directory tree stops being a single-child chain. + + Examples: + `["module/src/main/java/io/sentry/graphql/Foo.java"]` with + `source_root="module/src/main/java/"` returns `io/sentry/graphql/`. + + `["module/src/main/java/io/sentry/asyncprofiler/jfr/JfrParser.java", + "module/src/main/java/io/sentry/asyncprofiler/metrics/ProfileMetric.java"]` + with `source_root="module/src/main/java/"` returns `io/sentry/asyncprofiler/`. + """ + relative_paths = [ + file.removeprefix(source_root) for file in repo_files if file.startswith(source_root) + ] + if not relative_paths: + return None + + package_root = "" + while True: + has_file = False + subdirs: set[str] = set() + + for relative_path in relative_paths: + if package_root: + if not relative_path.startswith(package_root): + continue + remainder = relative_path[len(package_root) :] + else: + remainder = relative_path + + if not remainder: + continue + + if SLASH not in remainder: + has_file = True + break + + subdirs.add(remainder.split(SLASH, 1)[0]) + if len(subdirs) > 1: + break + + if has_file or len(subdirs) != 1: + return package_root + + package_root = f"{package_root}{subdirs.pop()}{SLASH}" + + +def find_java_source_roots( + source_path: str, repo_files: Sequence[str] | None +) -> tuple[str, str] | None: + """Return `(stack_root, source_root)` from a Java/Kotlin repo path. + + Example: + `sentry-graphql-core/src/main/java/io/sentry/graphql/GraphQLFetcher.java` + becomes + `("io/sentry/graphql/", "sentry-graphql-core/src/main/java/io/sentry/graphql/")`. + """ + if not repo_files: + return None + + if not (source_root := get_java_source_set_root(source_path)): + return None + + if (package_root := find_package_root_relative_to_source_set(source_root, repo_files)) is None: + return None + + return package_root, f"{source_root}{package_root}" diff --git a/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py b/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py index 84ba5ddfc3323d..84e2dff8f5cd7a 100644 --- a/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py +++ b/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py @@ -97,6 +97,49 @@ def test_get_frame_with_module(self, mock_get_trees_for_org: Any) -> None: assert response.status_code == 200, response.content assert response.data == expected_matches + @patch("sentry.integrations.github.integration.GitHubIntegration.get_trees_for_org") + def test_get_frame_with_module_multiple_same_repo_matches( + self, mock_get_trees_for_org: Any + ) -> None: + config_data = { + "absPath": "GraphQLFetcher.java", + "module": "io.sentry.graphql.GraphQLFetcher", + "platform": "java", + "stacktraceFilename": "GraphQLFetcher.java", + } + expected_matches = [ + { + "filename": "sentry-graphql/src/main/java/io/sentry/graphql/GraphQLFetcher.java", + "repo_name": "getsentry/codemap", + "repo_branch": "master", + "stacktrace_root": "io/sentry/graphql/", + "source_path": "sentry-graphql/src/main/java/io/sentry/graphql/", + }, + { + "filename": "sentry-graphql-core/src/main/java/io/sentry/graphql/GraphQLFetcher.java", + "repo_name": "getsentry/codemap", + "repo_branch": "master", + "stacktrace_root": "io/sentry/graphql/", + "source_path": "sentry-graphql-core/src/main/java/io/sentry/graphql/", + }, + ] + + mock_get_trees_for_org.return_value = { + "getsentry/codemap": RepoTree( + RepoAndBranch( + name="getsentry/codemap", + branch="master", + ), + files=[ + "sentry-graphql/src/main/java/io/sentry/graphql/GraphQLFetcher.java", + "sentry-graphql-core/src/main/java/io/sentry/graphql/GraphQLFetcher.java", + ], + ) + } + response = self.client.get(self.url, data=config_data, format="json") + assert response.status_code == 200, response.content + assert response.data == expected_matches + @patch("sentry.integrations.github.integration.GitHubIntegration.get_trees_for_org") def test_get_start_with_backslash(self, mock_get_trees_for_org: Any) -> None: file = "stack/root/file.py" diff --git a/tests/sentry/issues/auto_source_code_config/test_code_mapping.py b/tests/sentry/issues/auto_source_code_config/test_code_mapping.py index 3bf6736ceac7b5..bc8856eaa2b90e 100644 --- a/tests/sentry/issues/auto_source_code_config/test_code_mapping.py +++ b/tests/sentry/issues/auto_source_code_config/test_code_mapping.py @@ -239,6 +239,116 @@ def test_get_file_and_repo_matches_multiple(self) -> None: ] assert matches == expected_matches + def test_generate_code_mappings_same_repo_multiple_java_source_roots(self) -> None: + repo_tree = RepoTree( + self.foo_repo, + files=[ + "sentry-graphql/src/main/java/io/sentry/graphql/GraphQLFetcher.java", + "sentry-graphql-core/src/main/java/io/sentry/graphql/GraphQLFetcher.java", + ], + ) + helper = CodeMappingTreesHelper({self.foo_repo.name: repo_tree}) + + code_mappings = helper.generate_code_mappings( + [{"module": "io.sentry.graphql.GraphQLFetcher", "abs_path": "GraphQLFetcher.java"}], + platform="java", + ) + + assert sorted(code_mappings) == sorted( + [ + CodeMapping( + repo=self.foo_repo, + stacktrace_root="io/sentry/graphql/", + source_path="sentry-graphql/src/main/java/io/sentry/graphql/", + ), + CodeMapping( + repo=self.foo_repo, + stacktrace_root="io/sentry/graphql/", + source_path="sentry-graphql-core/src/main/java/io/sentry/graphql/", + ), + ] + ) + + def test_generate_code_mappings_realistic_java_monorepo_layout(self) -> None: + repo_tree = RepoTree( + self.foo_repo, + files=[ + "sentry-opentelemetry/sentry-opentelemetry-agent/src/main/java/io/sentry/opentelemetry/agent/AgentMain.java", + "sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/AgentCustomizer.java", + "sentry-opentelemetry/sentry-opentelemetry-agentless/src/main/java/io/sentry/opentelemetry/agent/AgentlessMain.java", + "sentry-opentelemetry/sentry-opentelemetry-agentless-spring/src/main/java/io/sentry/opentelemetry/agent/AgentlessSpringMain.java", + "sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/BootstrapMain.java", + "sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/CoreMain.java", + "sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OtlpMain.java", + ], + ) + helper = CodeMappingTreesHelper({self.foo_repo.name: repo_tree}) + + code_mappings = helper.generate_code_mappings( + [ + {"module": "io.sentry.opentelemetry.agent.AgentMain", "abs_path": "AgentMain.java"}, + { + "module": "io.sentry.opentelemetry.AgentCustomizer", + "abs_path": "AgentCustomizer.java", + }, + { + "module": "io.sentry.opentelemetry.agent.AgentlessMain", + "abs_path": "AgentlessMain.java", + }, + { + "module": "io.sentry.opentelemetry.agent.AgentlessSpringMain", + "abs_path": "AgentlessSpringMain.java", + }, + { + "module": "io.sentry.opentelemetry.BootstrapMain", + "abs_path": "BootstrapMain.java", + }, + {"module": "io.sentry.opentelemetry.CoreMain", "abs_path": "CoreMain.java"}, + {"module": "io.sentry.opentelemetry.otlp.OtlpMain", "abs_path": "OtlpMain.java"}, + ], + platform="java", + ) + + assert sorted(code_mappings) == sorted( + [ + CodeMapping( + repo=self.foo_repo, + stacktrace_root="io/sentry/opentelemetry/agent/", + source_path="sentry-opentelemetry/sentry-opentelemetry-agent/src/main/java/io/sentry/opentelemetry/agent/", + ), + CodeMapping( + repo=self.foo_repo, + stacktrace_root="io/sentry/opentelemetry/", + source_path="sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/", + ), + CodeMapping( + repo=self.foo_repo, + stacktrace_root="io/sentry/opentelemetry/agent/", + source_path="sentry-opentelemetry/sentry-opentelemetry-agentless/src/main/java/io/sentry/opentelemetry/agent/", + ), + CodeMapping( + repo=self.foo_repo, + stacktrace_root="io/sentry/opentelemetry/agent/", + source_path="sentry-opentelemetry/sentry-opentelemetry-agentless-spring/src/main/java/io/sentry/opentelemetry/agent/", + ), + CodeMapping( + repo=self.foo_repo, + stacktrace_root="io/sentry/opentelemetry/", + source_path="sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/", + ), + CodeMapping( + repo=self.foo_repo, + stacktrace_root="io/sentry/opentelemetry/", + source_path="sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/", + ), + CodeMapping( + repo=self.foo_repo, + stacktrace_root="io/sentry/opentelemetry/otlp/", + source_path="sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/", + ), + ] + ) + def test_find_roots_starts_with_period_slash(self) -> None: stacktrace_root, source_path = find_roots( create_frame_info({"filename": "./app/foo.tsx"}), "static/app/foo.tsx" @@ -341,6 +451,97 @@ def test_find_roots_windows_path_with_spaces_source_match(self) -> None: assert source_path == "frontend/" +class TestFindRootsJavaSourceRootMarkers(TestCase): + """Tests for find_roots detecting Gradle-style Java/Kotlin source roots.""" + + def test_find_roots_java_source_root(self) -> None: + frame = create_frame_info( + {"module": "io.sentry.android.core.SentryAndroid", "abs_path": "SentryAndroid.java"}, + "java", + ) + stack_root, source_root = find_roots( + frame, + "sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java", + [ + "sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java", + "sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java", + ], + ) + assert stack_root == "io/sentry/android/core/" + assert source_root == "sentry-android-core/src/main/java/io/sentry/android/core/" + + def test_find_roots_java_deep_module(self) -> None: + frame = create_frame_info( + { + "module": "io.sentry.spring.boot.jakarta.SentryAutoConfiguration", + "abs_path": "SentryAutoConfiguration.java", + }, + "java", + ) + stack_root, source_root = find_roots( + frame, + "sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java", + [ + "sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java", + "sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryWebFilter.java", + ], + ) + assert stack_root == "io/sentry/spring/boot/jakarta/" + assert ( + source_root == "sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/" + ) + + def test_find_roots_kotlin_source_root(self) -> None: + frame = create_frame_info( + {"module": "io.sentry.test.TestHelper", "abs_path": "TestHelper.kt"}, + "java", + ) + stack_root, source_root = find_roots( + frame, + "sentry-test-support/src/main/kotlin/io/sentry/test/TestHelper.kt", + [ + "sentry-test-support/src/main/kotlin/io/sentry/TestLogger.kt", + "sentry-test-support/src/main/kotlin/io/sentry/test/TestHelper.kt", + ], + ) + assert stack_root == "io/sentry/" + assert source_root == "sentry-test-support/src/main/kotlin/io/sentry/" + + def test_find_roots_no_marker_falls_back(self) -> None: + frame = create_frame_info( + {"module": "io.sentry.android.core.SentryAndroid", "abs_path": "SentryAndroid.java"}, + "java", + ) + stack_root, source_root = find_roots( + frame, + "src/io/sentry/android/core/SentryAndroid.java", + ) + # Falls back to frame_filename.stack_root which is "io/sentry/android/core/" + assert stack_root == "io/sentry/android/core/" + assert source_root == "src/io/sentry/android/core/" + + def test_find_roots_java_generates_correct_code_mapping(self) -> None: + frame = create_frame_info( + { + "module": "io.sentry.spring.boot.jakarta.SentryAutoConfiguration", + "abs_path": "SentryAutoConfiguration.java", + }, + "java", + ) + stack_root, source_root = find_roots( + frame, + "sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java", + [ + "sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java", + "sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryWebFilter.java", + ], + ) + assert ( + frame.raw_path.replace(stack_root, source_root, 1) + == "sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java" + ) + + class TestConvertStacktraceFramePathToSourcePath(TestCase): def setUp(self) -> None: super() diff --git a/tests/sentry/issues/auto_source_code_config/test_process_event.py b/tests/sentry/issues/auto_source_code_config/test_process_event.py index f6d2531b440484..a1f614072c91b2 100644 --- a/tests/sentry/issues/auto_source_code_config/test_process_event.py +++ b/tests/sentry/issues/auto_source_code_config/test_process_event.py @@ -1067,3 +1067,31 @@ def test_multi_module(self) -> None: ], expected_new_in_app_stack_trace_rules=[f"stack.module:{java_module_prefix}.** +app"], ) + + def test_same_package_in_multiple_gradle_subprojects(self) -> None: + with patch(f"{CODE_ROOT}.stacktraces._check_not_categorized", return_value=True): + self._process_and_assert_configuration_changes( + repo_trees={ + REPO1: [ + "sentry-graphql/src/main/java/io/sentry/graphql/GraphQLFetcher.java", + "sentry-graphql-core/src/main/java/io/sentry/graphql/GraphQLFetcher.java", + ] + }, + frames=[ + self.frame_from_module( + "io.sentry.graphql.GraphQLFetcher", "GraphQLFetcher.java" + ) + ], + platform=self.platform, + expected_new_code_mappings=[ + self.code_mapping( + "io/sentry/graphql/", + "sentry-graphql/src/main/java/io/sentry/graphql/", + ), + self.code_mapping( + "io/sentry/graphql/", + "sentry-graphql-core/src/main/java/io/sentry/graphql/", + ), + ], + expected_new_in_app_stack_trace_rules=["stack.module:io.sentry.** +app"], + ) From d131905461fd59f2d420e58785209653da080896 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 10 Apr 2026 13:17:22 +0200 Subject: [PATCH 2/4] refactor(code-mappings): hide source root overrides behind frame info --- .../auto_source_code_config/code_mapping.py | 16 +++---- .../auto_source_code_config/constants.py | 3 ++ .../auto_source_code_config/frame_info.py | 43 +++++++++++++++++-- .../auto_source_code_config/utils/platform.py | 12 ++++++ .../test_code_mapping.py | 26 +++++++++++ .../test_frame_info.py | 43 +++++++++++++++++++ 6 files changed, 130 insertions(+), 13 deletions(-) diff --git a/src/sentry/issues/auto_source_code_config/code_mapping.py b/src/sentry/issues/auto_source_code_config/code_mapping.py index 6f28f1b2416ff6..dd71f186133767 100644 --- a/src/sentry/issues/auto_source_code_config/code_mapping.py +++ b/src/sentry/issues/auto_source_code_config/code_mapping.py @@ -27,7 +27,6 @@ ) from .frame_info import FrameInfo, create_frame_info from .integration_utils import InstallationNotFoundError, get_installation -from .utils.java import find_java_source_roots from .utils.misc import get_straight_path_prefix_end_index logger = logging.getLogger(__name__) @@ -228,7 +227,8 @@ def _generate_code_mapping_from_tree( return [] if len(matched_files) > 1 and not all( - find_java_source_roots(source_path, repo_tree.files) for source_path in matched_files + frame_filename.has_source_roots_override(source_path, repo_tree.files) + for source_path in matched_files ): return [] @@ -465,13 +465,11 @@ def find_roots( # "Packaged" logic # e.g. stack_path: some_package/src/foo.py -> source_path: src/foo.py source_prefix = source_path.rpartition(stack_path)[0] - - if java_source_roots := find_java_source_roots(source_path, repo_files): - return java_source_roots - - return ( - f"{stack_root}{frame_filename.stack_root}/".replace("//", "/"), - f"{source_prefix}{frame_filename.stack_root}/".replace("//", "/"), + return frame_filename.resolve_source_roots( + source_path=source_path, + source_prefix=source_prefix, + stack_root_prefix=stack_root, + repo_files=repo_files, ) elif stack_path.endswith(source_path): stack_prefix = stack_path.rpartition(source_path)[0] diff --git a/src/sentry/issues/auto_source_code_config/constants.py b/src/sentry/issues/auto_source_code_config/constants.py index 90b627a3be3b46..ffeeb2b2ab2b00 100644 --- a/src/sentry/issues/auto_source_code_config/constants.py +++ b/src/sentry/issues/auto_source_code_config/constants.py @@ -5,6 +5,8 @@ from sentry.integrations.types import IntegrationProviderSlug +from .utils.java import find_java_source_roots + METRIC_PREFIX = "auto_source_code_config" DERIVED_ENHANCEMENTS_OPTION_KEY = "sentry:derived_grouping_enhancements" SUPPORTED_INTEGRATIONS = [IntegrationProviderSlug.GITHUB.value] @@ -19,6 +21,7 @@ # e.g. com.foo.bar.Baz$handle$1, Baz.kt -> com/foo/bar/Baz.kt "extract_filename_from_module": True, "create_in_app_stack_trace_rules": True, + "source_roots_resolver": find_java_source_roots, "extensions": ["kt", "kts", "java", "jsp", "scala", "sc"], }, "javascript": {"extensions": ["js", "jsx", "mjs", "tsx", "ts"]}, diff --git a/src/sentry/issues/auto_source_code_config/frame_info.py b/src/sentry/issues/auto_source_code_config/frame_info.py index bfbd0f44ae8b45..a84ba703ad26a6 100644 --- a/src/sentry/issues/auto_source_code_config/frame_info.py +++ b/src/sentry/issues/auto_source_code_config/frame_info.py @@ -14,7 +14,12 @@ NeedsExtension, UnsupportedFrameInfo, ) -from .utils.platform import PlatformConfig, supported_platform +from .utils.platform import ( + PlatformConfig, + SourceRootsResolver, + noop_source_roots_resolver, + supported_platform, +) NOT_FOUND = -1 @@ -24,12 +29,14 @@ def create_frame_info(frame: Mapping[str, Any], platform: str | None = None) -> FrameInfo: """Factory function to create the appropriate FrameInfo instance.""" + source_roots_resolver = noop_source_roots_resolver if platform and supported_platform(platform): platform_config = PlatformConfig(platform) + source_roots_resolver = platform_config.get_source_roots_resolver() if platform_config.extracts_filename_from_module(): - return ModuleBasedFrameInfo(frame) + return ModuleBasedFrameInfo(frame, source_roots_resolver) - return PathBasedFrameInfo(frame) + return PathBasedFrameInfo(frame, source_roots_resolver) class FrameInfo(ABC): @@ -37,7 +44,12 @@ class FrameInfo(ABC): normalized_path: str stack_root: str - def __init__(self, frame: Mapping[str, Any]) -> None: + def __init__( + self, + frame: Mapping[str, Any], + source_roots_resolver: SourceRootsResolver = noop_source_roots_resolver, + ) -> None: + self._source_roots_resolver = source_roots_resolver self.process_frame(frame) def __repr__(self) -> str: @@ -53,6 +65,29 @@ def process_frame(self, frame: Mapping[str, Any]) -> None: """Process the frame and set the necessary attributes.""" raise NotImplementedError("Subclasses must implement process_frame") + def _find_source_roots_override( + self, source_path: str, repo_files: Sequence[str] | None + ) -> tuple[str, str] | None: + return self._source_roots_resolver(source_path, repo_files) + + def has_source_roots_override(self, source_path: str, repo_files: Sequence[str] | None) -> bool: + return self._find_source_roots_override(source_path, repo_files) is not None + + def resolve_source_roots( + self, + source_path: str, + source_prefix: str, + stack_root_prefix: str = "", + repo_files: Sequence[str] | None = None, + ) -> tuple[str, str]: + if source_roots_override := self._find_source_roots_override(source_path, repo_files): + return source_roots_override + + return ( + f"{stack_root_prefix}{self.stack_root}/".replace("//", "/"), + f"{source_prefix}{self.stack_root}/".replace("//", "/"), + ) + class ModuleBasedFrameInfo(FrameInfo): def process_frame(self, frame: Mapping[str, Any]) -> None: diff --git a/src/sentry/issues/auto_source_code_config/utils/platform.py b/src/sentry/issues/auto_source_code_config/utils/platform.py index 82936827206301..6a367045e374f8 100644 --- a/src/sentry/issues/auto_source_code_config/utils/platform.py +++ b/src/sentry/issues/auto_source_code_config/utils/platform.py @@ -1,9 +1,18 @@ +from collections.abc import Callable, Sequence from typing import Any from sentry.models.organization import Organization from ..constants import PLATFORMS_CONFIG +SourceRootsResolver = Callable[[str, Sequence[str] | None], tuple[str, str] | None] + + +def noop_source_roots_resolver( + source_path: str, repo_files: Sequence[str] | None +) -> tuple[str, str] | None: + return None + def supported_platform(platform: str) -> bool: """Return True if the platform is supported""" @@ -45,3 +54,6 @@ def extracts_filename_from_module(self) -> bool: def creates_in_app_stack_trace_rules(self) -> bool: return self.config.get("create_in_app_stack_trace_rules", False) + + def get_source_roots_resolver(self) -> SourceRootsResolver: + return self.config.get("source_roots_resolver", noop_source_roots_resolver) diff --git a/tests/sentry/issues/auto_source_code_config/test_code_mapping.py b/tests/sentry/issues/auto_source_code_config/test_code_mapping.py index bc8856eaa2b90e..75ddad887b1e2a 100644 --- a/tests/sentry/issues/auto_source_code_config/test_code_mapping.py +++ b/tests/sentry/issues/auto_source_code_config/test_code_mapping.py @@ -349,6 +349,22 @@ def test_generate_code_mappings_realistic_java_monorepo_layout(self) -> None: ] ) + def test_multiple_matches_with_java_source_markers_do_not_enable_path_based_frames( + self, + ) -> None: + repo_tree = RepoTree( + self.foo_repo, + files=[ + "module-a/src/main/java/foo/bar.py", + "module-b/src/main/java/foo/bar.py", + ], + ) + helper = CodeMappingTreesHelper({self.foo_repo.name: repo_tree}) + + code_mappings = helper.generate_code_mappings([{"filename": "foo/bar.py"}]) + + assert code_mappings == [] + def test_find_roots_starts_with_period_slash(self) -> None: stacktrace_root, source_path = find_roots( create_frame_info({"filename": "./app/foo.tsx"}), "static/app/foo.tsx" @@ -520,6 +536,16 @@ def test_find_roots_no_marker_falls_back(self) -> None: assert stack_root == "io/sentry/android/core/" assert source_root == "src/io/sentry/android/core/" + def test_find_roots_non_java_source_root_marker_uses_generic_fallback(self) -> None: + frame = create_frame_info({"filename": "foo/bar.py"}) + stack_root, source_root = find_roots( + frame, + "pkg/src/main/java/foo/bar.py", + ["pkg/src/main/java/foo/bar.py"], + ) + assert stack_root == "foo/" + assert source_root == "pkg/src/main/java/foo/" + def test_find_roots_java_generates_correct_code_mapping(self) -> None: frame = create_frame_info( { diff --git a/tests/sentry/issues/auto_source_code_config/test_frame_info.py b/tests/sentry/issues/auto_source_code_config/test_frame_info.py index dc5f35e5a72603..efb20de706267b 100644 --- a/tests/sentry/issues/auto_source_code_config/test_frame_info.py +++ b/tests/sentry/issues/auto_source_code_config/test_frame_info.py @@ -126,6 +126,49 @@ def test_java_valid_frames( assert frame_info.stack_root == expected_stack_root assert frame_info.normalized_path == expected_normalized_path + def test_java_platform_resolves_source_roots(self) -> None: + frame_info = create_frame_info( + {"module": "io.sentry.android.core.SentryAndroid", "abs_path": "SentryAndroid.java"}, + "java", + ) + + assert frame_info.has_source_roots_override( + "sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java", + [ + "sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java", + "sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java", + ], + ) + + assert frame_info.resolve_source_roots( + source_path="sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java", + source_prefix="sentry-android-core/src/main/java/", + repo_files=[ + "sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java", + "sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java", + ], + ) == ( + "io/sentry/android/core/", + "sentry-android-core/src/main/java/io/sentry/android/core/", + ) + + def test_non_java_platform_resolves_source_roots_with_generic_fallback(self) -> None: + frame_info = create_frame_info({"filename": "foo/bar.py"}, "python") + + assert not frame_info.has_source_roots_override( + "pkg/src/main/java/foo/bar.py", + ["pkg/src/main/java/foo/bar.py"], + ) + + assert frame_info.resolve_source_roots( + source_path="pkg/src/main/java/foo/bar.py", + source_prefix="pkg/src/main/java/", + repo_files=["pkg/src/main/java/foo/bar.py"], + ) == ( + "foo/", + "pkg/src/main/java/foo/", + ) + @pytest.mark.parametrize( "frame_filename, stack_root, normalized_path", [ From bbb1549e2c480a2eaf89b3d992a378dfc8b3744d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 10 Apr 2026 14:18:59 +0200 Subject: [PATCH 3/4] fix(code-mappings): satisfy mypy source root resolver typing --- src/sentry/issues/auto_source_code_config/frame_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/issues/auto_source_code_config/frame_info.py b/src/sentry/issues/auto_source_code_config/frame_info.py index a84ba703ad26a6..93ba6c5efde8a4 100644 --- a/src/sentry/issues/auto_source_code_config/frame_info.py +++ b/src/sentry/issues/auto_source_code_config/frame_info.py @@ -29,7 +29,7 @@ def create_frame_info(frame: Mapping[str, Any], platform: str | None = None) -> FrameInfo: """Factory function to create the appropriate FrameInfo instance.""" - source_roots_resolver = noop_source_roots_resolver + source_roots_resolver: SourceRootsResolver = noop_source_roots_resolver if platform and supported_platform(platform): platform_config = PlatformConfig(platform) source_roots_resolver = platform_config.get_source_roots_resolver() From b43aead0db29abc55d209f33d393f39b88866e15 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 13 Apr 2026 09:52:15 +0200 Subject: [PATCH 4/4] refactor(code-mappings): inline source root override resolver --- src/sentry/issues/auto_source_code_config/frame_info.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/sentry/issues/auto_source_code_config/frame_info.py b/src/sentry/issues/auto_source_code_config/frame_info.py index 93ba6c5efde8a4..8b3e86bff0463c 100644 --- a/src/sentry/issues/auto_source_code_config/frame_info.py +++ b/src/sentry/issues/auto_source_code_config/frame_info.py @@ -65,13 +65,8 @@ def process_frame(self, frame: Mapping[str, Any]) -> None: """Process the frame and set the necessary attributes.""" raise NotImplementedError("Subclasses must implement process_frame") - def _find_source_roots_override( - self, source_path: str, repo_files: Sequence[str] | None - ) -> tuple[str, str] | None: - return self._source_roots_resolver(source_path, repo_files) - def has_source_roots_override(self, source_path: str, repo_files: Sequence[str] | None) -> bool: - return self._find_source_roots_override(source_path, repo_files) is not None + return self._source_roots_resolver(source_path, repo_files) is not None def resolve_source_roots( self, @@ -80,7 +75,7 @@ def resolve_source_roots( stack_root_prefix: str = "", repo_files: Sequence[str] | None = None, ) -> tuple[str, str]: - if source_roots_override := self._find_source_roots_override(source_path, repo_files): + if source_roots_override := self._source_roots_resolver(source_path, repo_files): return source_roots_override return (