Skip to content

Commit 4ed24e4

Browse files
authored
feat(code-mappings): Add support for stacktrace frames with backslashes for automatic code mappings (#68845)
This PR updates the existing code mapping logic to support stack traces whose filepaths contained backslashes instead of forward slashes. This effectively adds automatic code mapping support for issues derived from Windows machines (for the existing supported languages). I've made the choice to save stack roots in the code mappings using whichever slash delimiter that the original stack path used. For example, if we have the following stack path and source: * Stack Path = `"D:\Users\code\sentry\models\release.py"` * Source path = `"src/sentry/models/release.py"` Then the roots generated will be: * Stack Root = `"D:\Users\code\"` (note that this source root still uses backslashes instead of forward slashes) * Source Root = `"src/"` This should be less confusing to the end user since we explicitly say we are going to replace the `stack_root` in the `stack_path` with the `source_root` to get the `source_path`. It might be confusing if the `stack_root`'s slash-delimiter was not the same as that in the `stack_path`. This also means that the previous logic to apply code mappings to a given frame to generate a stacktrace link needed to be updated so that the source path generated would only include slashes (since this path is used in a github URL, which only uses forward slashes).
1 parent 380b086 commit 4ed24e4

File tree

3 files changed

+106
-10
lines changed

3 files changed

+106
-10
lines changed

src/sentry/integrations/utils/code_mapping.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,19 @@ class UnsupportedFrameFilename(Exception):
6161
class FrameFilename:
6262
def __init__(self, frame_file_path: str) -> None:
6363
self.raw_path = frame_file_path
64-
if frame_file_path[0] == "/":
64+
is_windows_path = False
65+
if "\\" in frame_file_path:
66+
is_windows_path = True
67+
frame_file_path = frame_file_path.replace("\\", "/")
68+
69+
if frame_file_path[0] == "/" or frame_file_path[0] == "\\":
6570
frame_file_path = frame_file_path[1:]
6671

6772
# Using regexes would be better but this is easier to understand
6873
if (
6974
not frame_file_path
7075
or frame_file_path[0] in ["[", "<"]
7176
or frame_file_path.find(" ") > -1
72-
or frame_file_path.find("\\") > -1 # Windows support
7377
or frame_file_path.find("/") == -1
7478
):
7579
raise UnsupportedFrameFilename("This path is not supported.")
@@ -79,8 +83,20 @@ def __init__(self, frame_file_path: str) -> None:
7983
if not self.extension:
8084
raise UnsupportedFrameFilename("It needs an extension.")
8185

86+
# Remove drive letter if it exists
87+
if is_windows_path and frame_file_path[1] == ":":
88+
frame_file_path = frame_file_path[2:]
89+
# windows drive letters can be like C:\ or C:
90+
# so we need to remove the slash if it exists
91+
if frame_file_path[0] == "/":
92+
frame_file_path = frame_file_path[1:]
93+
8294
start_at_index = get_straight_path_prefix_end_index(frame_file_path)
8395
self.straight_path_prefix = frame_file_path[:start_at_index]
96+
97+
# We normalize the path to be as close to what the path would
98+
# look like in the source code repository, hence why we remove
99+
# the straight path prefix and drive letter
84100
self.normalized_path = frame_file_path[start_at_index:]
85101
if start_at_index == 0:
86102
self.root = frame_file_path.split("/")[0]
@@ -145,7 +161,7 @@ def list_file_matches(self, frame_filename: FrameFilename) -> list[dict[str, str
145161
)
146162
continue
147163

148-
if stack_path.replace(stack_root, source_root, 1) != source_path:
164+
if stack_path.replace(stack_root, source_root, 1).replace("\\", "/") != source_path:
149165
logger.info(
150166
"Unexpected stack_path/source_path found. A code mapping was not generated.",
151167
extra={
@@ -259,7 +275,7 @@ def _generate_code_mapping_from_tree(
259275
)
260276
return []
261277

262-
if stack_path.replace(stack_root, source_root, 1) != source_path:
278+
if stack_path.replace(stack_root, source_root, 1).replace("\\", "/") != source_path:
263279
logger.info(
264280
"Unexpected stack_path/source_path found. A code mapping was not generated.",
265281
extra={
@@ -370,6 +386,10 @@ def convert_stacktrace_frame_path_to_source_path(
370386
If the code mapping does not apply to the frame, returns None.
371387
"""
372388

389+
stack_root = code_mapping.stack_root
390+
if "\\" in code_mapping.stack_root:
391+
stack_root = code_mapping.stack_root.replace("\\", "/")
392+
373393
# In most cases, code mappings get applied to frame.filename, but some platforms such as Java
374394
# contain folder info in other parts of the frame (e.g. frame.module="com.example.app.MainActivity"
375395
# gets transformed to "com/example/app/MainActivity.java"), so in those cases we use the
@@ -379,13 +399,13 @@ def convert_stacktrace_frame_path_to_source_path(
379399
)
380400

381401
if stacktrace_path and stacktrace_path.startswith(code_mapping.stack_root):
382-
return stacktrace_path.replace(code_mapping.stack_root, code_mapping.source_root, 1)
402+
return stacktrace_path.replace(stack_root, code_mapping.source_root, 1)
383403

384404
# Some platforms only provide the file's name without folder paths, so we
385405
# need to use the absolute path instead. If the code mapping has a non-empty
386406
# stack_root value and it matches the absolute path, we do the mapping on it.
387407
if frame.abs_path and frame.abs_path.startswith(code_mapping.stack_root):
388-
return frame.abs_path.replace(code_mapping.stack_root, code_mapping.source_root, 1)
408+
return frame.abs_path.replace(stack_root, code_mapping.source_root, 1)
389409

390410
return None
391411

@@ -505,8 +525,8 @@ def find_roots(stack_path: str, source_path: str) -> tuple[str, str]:
505525
If there is no overlap, raise an exception since this should not happen
506526
"""
507527
stack_root = ""
508-
if stack_path[0] == "/":
509-
stack_root += "/"
528+
if stack_path[0] == "/" or stack_path[0] == "\\":
529+
stack_root += stack_path[0]
510530
stack_path = stack_path[1:]
511531

512532
if stack_path == source_path:
@@ -534,7 +554,7 @@ def find_roots(stack_path: str, source_path: str) -> tuple[str, str]:
534554
source_root = source_path.rpartition(overlap)[0]
535555
stack_root += stack_path_delim.join(stack_root_items)
536556

537-
if stack_root and stack_root[-1] != SLASH: # append trailing slash
557+
if stack_root and stack_root[-1] != stack_path_delim: # append trailing slash
538558
stack_root = f"{stack_root}{stack_path_delim}"
539559
if source_root and source_root[-1] != SLASH:
540560
source_root = f"{source_root}{SLASH}"

tests/sentry/integrations/utils/test_code_mapping.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
"README", # no extension
4949
"ssl.py",
5050
# XXX: The following will need to be supported
51-
"C:\\Users\\Donia\\AppData\\Roaming\\Adobe\\UXP\\Plugins\\External\\452f92d2_0.13.0\\main.js",
5251
"initialization.dart",
5352
"backburner.js",
5453
]

tests/sentry/tasks/test_derive_code_mappings.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,83 @@ def test_raises_generic_errors(self, mock_logger):
9797
)
9898

9999

100+
class TestBackSlashDeriveCodeMappings(BaseDeriveCodeMappings):
101+
def setUp(self):
102+
super().setUp()
103+
self.platform = "python"
104+
# The lack of a \ after the drive letter in the third frame signals that
105+
# this is a relative path. This may be unlikely to occur in practice,
106+
# but worth testing nonetheless.
107+
self.event_data = self.generate_data(
108+
[
109+
{"in_app": True, "filename": "\\sentry\\mouse.py"},
110+
{"in_app": True, "filename": "\\sentry\\dog\\cat\\parrot.py"},
111+
{"in_app": True, "filename": "C:sentry\\tasks.py"},
112+
{"in_app": True, "filename": "D:\\Users\\code\\sentry\\models\\release.py"},
113+
]
114+
)
115+
116+
@responses.activate
117+
def test_backslash_filename_simple(self):
118+
repo_name = "foo/bar"
119+
with patch(
120+
"sentry.integrations.github.client.GitHubClientMixin.get_trees_for_org"
121+
) as mock_get_trees_for_org:
122+
mock_get_trees_for_org.return_value = {
123+
repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/mouse.py"])
124+
}
125+
derive_code_mappings(self.project.id, self.event_data)
126+
code_mapping = RepositoryProjectPathConfig.objects.all()[0]
127+
assert code_mapping.stack_root == "\\"
128+
assert code_mapping.source_root == ""
129+
assert code_mapping.repository.name == repo_name
130+
131+
@responses.activate
132+
def test_backslash_drive_letter_filename_simple(self):
133+
repo_name = "foo/bar"
134+
with patch(
135+
"sentry.integrations.github.client.GitHubClientMixin.get_trees_for_org"
136+
) as mock_get_trees_for_org:
137+
mock_get_trees_for_org.return_value = {
138+
repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/tasks.py"])
139+
}
140+
derive_code_mappings(self.project.id, self.event_data)
141+
code_mapping = RepositoryProjectPathConfig.objects.all()[0]
142+
assert code_mapping.stack_root == "C:sentry\\"
143+
assert code_mapping.source_root == "sentry/"
144+
assert code_mapping.repository.name == repo_name
145+
146+
@responses.activate
147+
def test_backslash_drive_letter_filename_monorepo(self):
148+
repo_name = "foo/bar"
149+
with patch(
150+
"sentry.integrations.github.client.GitHubClientMixin.get_trees_for_org"
151+
) as mock_get_trees_for_org:
152+
mock_get_trees_for_org.return_value = {
153+
repo_name: RepoTree(Repo(repo_name, "master"), ["src/sentry/tasks.py"])
154+
}
155+
derive_code_mappings(self.project.id, self.event_data)
156+
code_mapping = RepositoryProjectPathConfig.objects.all()[0]
157+
assert code_mapping.stack_root == "C:sentry\\"
158+
assert code_mapping.source_root == "src/sentry/"
159+
assert code_mapping.repository.name == repo_name
160+
161+
@responses.activate
162+
def test_backslash_drive_letter_filename_abs_path(self):
163+
repo_name = "foo/bar"
164+
with patch(
165+
"sentry.integrations.github.client.GitHubClientMixin.get_trees_for_org"
166+
) as mock_get_trees_for_org:
167+
mock_get_trees_for_org.return_value = {
168+
repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/models/release.py"])
169+
}
170+
derive_code_mappings(self.project.id, self.event_data)
171+
code_mapping = RepositoryProjectPathConfig.objects.all()[0]
172+
assert code_mapping.stack_root == "D:\\Users\\code\\"
173+
assert code_mapping.source_root == ""
174+
assert code_mapping.repository.name == repo_name
175+
176+
100177
class TestJavascriptDeriveCodeMappings(BaseDeriveCodeMappings):
101178
def setUp(self):
102179
super().setUp()

0 commit comments

Comments
 (0)