Skip to content

Commit 641ea9f

Browse files
authoredMar 18, 2025··
fix(autofix): Add more fields in trace payload (#87260)
Include project and org info in trace tree so that Seer can specify them later. Also include a span tree in each transaction event payload
1 parent d4dd9b1 commit 641ea9f

File tree

2 files changed

+290
-3
lines changed

2 files changed

+290
-3
lines changed
 

‎src/sentry/seer/autofix.py

+94-3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,77 @@
3131
TIMEOUT_SECONDS = 60 * 30 # 30 minutes
3232

3333

34+
def build_spans_tree(spans_data: list[dict]) -> list[dict]:
35+
"""
36+
Builds a hierarchical tree structure from a flat list of spans.
37+
38+
Handles multiple potential roots and preserves parent-child relationships.
39+
A span is considered a root if:
40+
1. It has no parent_span_id, or
41+
2. Its parent_span_id doesn't match any span_id in the provided data
42+
43+
Each node in the tree contains the span data and a list of children.
44+
The tree is sorted by duration (longest spans first) at each level.
45+
"""
46+
# Maps for quick lookup
47+
spans_by_id: dict[str, dict] = {}
48+
children_by_parent_id: dict[str, list[dict]] = {}
49+
root_spans: list[dict] = []
50+
51+
# First pass: organize spans by ID and parent_id
52+
for span in spans_data:
53+
span_id = span.get("span_id")
54+
if not span_id:
55+
continue
56+
57+
# Deep copy the span to avoid modifying the original
58+
span_with_children = span.copy()
59+
span_with_children["children"] = []
60+
spans_by_id[span_id] = span_with_children
61+
62+
parent_id = span.get("parent_span_id")
63+
if parent_id:
64+
if parent_id not in children_by_parent_id:
65+
children_by_parent_id[parent_id] = []
66+
children_by_parent_id[parent_id].append(span_with_children)
67+
68+
# Second pass: identify root spans
69+
# A root span is either:
70+
# 1. A span without a parent_span_id
71+
# 2. A span whose parent_span_id doesn't match any span_id in our data
72+
for span_id, span in spans_by_id.items():
73+
parent_id = span.get("parent_span_id")
74+
if not parent_id or parent_id not in spans_by_id:
75+
root_spans.append(span)
76+
77+
# Third pass: build the tree by connecting children to parents
78+
for parent_id, children in children_by_parent_id.items():
79+
if parent_id in spans_by_id:
80+
parent = spans_by_id[parent_id]
81+
for child in children:
82+
# Only add if not already a child
83+
if child not in parent["children"]:
84+
parent["children"].append(child)
85+
86+
# Function to sort children in each node by duration
87+
def sort_span_tree(node):
88+
if node["children"]:
89+
# Sort children by duration (in descending order to show longest spans first)
90+
node["children"].sort(
91+
key=lambda x: float(x.get("duration", "0").split("s")[0]), reverse=True
92+
)
93+
# Recursively sort each child's children
94+
for child in node["children"]:
95+
sort_span_tree(child)
96+
del node["parent_span_id"]
97+
return node
98+
99+
# Sort the root spans by duration
100+
root_spans.sort(key=lambda x: float(x.get("duration", "0").split("s")[0]), reverse=True)
101+
# Apply sorting to the whole tree
102+
return [sort_span_tree(root) for root in root_spans]
103+
104+
34105
def _get_serialized_event(
35106
event_id: str, group: Group, user: User | RpcUser | AnonymousUser
36107
) -> tuple[dict[str, Any] | None, Event | GroupEvent | None]:
@@ -65,6 +136,7 @@ def _get_trace_tree_for_event(event: Event | GroupEvent, project: Project) -> di
65136
conditions=[
66137
["trace_id", "=", trace_id],
67138
],
139+
organization_id=project.organization_id,
68140
start=start,
69141
end=end,
70142
)
@@ -79,6 +151,7 @@ def _get_trace_tree_for_event(event: Event | GroupEvent, project: Project) -> di
79151
conditions=[
80152
["trace", "=", trace_id],
81153
],
154+
organization_id=project.organization_id,
82155
start=start,
83156
end=end,
84157
)
@@ -111,6 +184,9 @@ def _get_trace_tree_for_event(event: Event | GroupEvent, project: Project) -> di
111184
"parent_span_id": event_data.get("contexts", {}).get("trace", {}).get("parent_span_id"),
112185
"is_transaction": is_transaction,
113186
"is_error": is_error,
187+
"is_current_project": event.project_id == project.id,
188+
"project_slug": event.project.slug,
189+
"project_id": event.project_id,
114190
"children": [],
115191
}
116192

@@ -132,14 +208,26 @@ def _get_trace_tree_for_event(event: Event | GroupEvent, project: Project) -> di
132208
spans = event_data.get("spans", [])
133209
span_ids = [span.get("span_id") for span in spans if span.get("span_id")]
134210

211+
spans_selected_data = [
212+
{
213+
"span_id": span.get("span_id"),
214+
"parent_span_id": span.get("parent_span_id"),
215+
"title": f"{span.get('op', '')} - {span.get('description', '')}",
216+
"data": span.get("data"),
217+
"duration": f"{span.get('timestamp', 0) - span.get('start_timestamp', 0)}s",
218+
}
219+
for span in spans
220+
]
221+
selected_spans_tree = build_spans_tree(spans_selected_data)
222+
135223
event_node.update(
136224
{
137225
"title": f"{op} - {transaction_title}" if op else transaction_title,
138226
"platform": event.platform,
139-
"is_current_project": event.project_id == project.id,
140227
"duration": duration_str,
141228
"profile_id": profile_id,
142229
"span_ids": span_ids, # Store for later use
230+
"spans": selected_spans_tree,
143231
}
144232
)
145233

@@ -162,7 +250,6 @@ def _get_trace_tree_for_event(event: Event | GroupEvent, project: Project) -> di
162250
{
163251
"title": error_title,
164252
"platform": event.platform,
165-
"is_current_project": event.project_id == project.id,
166253
}
167254
)
168255

@@ -269,7 +356,11 @@ def cleanup_node(node):
269356

270357
cleaned_tree = [cleanup_node(root) for root in sorted_tree]
271358

272-
return {"trace_id": event.trace_id, "events": cleaned_tree}
359+
return {
360+
"trace_id": event.trace_id,
361+
"org_id": project.organization_id,
362+
"events": cleaned_tree,
363+
}
273364

274365

275366
def _get_profile_from_trace_tree(

‎tests/sentry/seer/test_autofix.py

+196
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
_get_profile_from_trace_tree,
1313
_get_trace_tree_for_event,
1414
_respond_with_error,
15+
build_spans_tree,
1516
trigger_autofix,
1617
)
1718
from sentry.snuba.dataset import Dataset
@@ -1147,3 +1148,198 @@ def test_respond_with_error(self):
11471148

11481149
assert response.status_code == 400
11491150
assert response.data["detail"] == "Test error message"
1151+
1152+
1153+
class TestBuildSpansTree(TestCase):
1154+
def test_build_spans_tree_basic(self):
1155+
"""Test that a simple list of spans is correctly converted to a tree."""
1156+
spans_data: list[dict] = [
1157+
{
1158+
"span_id": "root-span",
1159+
"parent_span_id": None,
1160+
"title": "Root Span",
1161+
"duration": "10.0s",
1162+
},
1163+
{
1164+
"span_id": "child1",
1165+
"parent_span_id": "root-span",
1166+
"title": "Child 1",
1167+
"duration": "5.0s",
1168+
},
1169+
{
1170+
"span_id": "child2",
1171+
"parent_span_id": "root-span",
1172+
"title": "Child 2",
1173+
"duration": "3.0s",
1174+
},
1175+
{
1176+
"span_id": "grandchild",
1177+
"parent_span_id": "child1",
1178+
"title": "Grandchild",
1179+
"duration": "2.0s",
1180+
},
1181+
]
1182+
1183+
tree = build_spans_tree(spans_data)
1184+
1185+
# Should have one root
1186+
assert len(tree) == 1
1187+
root = tree[0]
1188+
assert root["span_id"] == "root-span"
1189+
assert root["title"] == "Root Span"
1190+
1191+
# Root should have two children, sorted by duration (child1 first)
1192+
assert len(root["children"]) == 2
1193+
assert root["children"][0]["span_id"] == "child1"
1194+
assert root["children"][1]["span_id"] == "child2"
1195+
1196+
# Child1 should have one child
1197+
assert len(root["children"][0]["children"]) == 1
1198+
grandchild = root["children"][0]["children"][0]
1199+
assert grandchild["span_id"] == "grandchild"
1200+
1201+
def test_build_spans_tree_multiple_roots(self):
1202+
"""Test that spans with multiple roots are correctly handled."""
1203+
spans_data: list[dict] = [
1204+
{
1205+
"span_id": "root1",
1206+
"parent_span_id": None,
1207+
"title": "Root 1",
1208+
"duration": "10.0s",
1209+
},
1210+
{
1211+
"span_id": "root2",
1212+
"parent_span_id": None,
1213+
"title": "Root 2",
1214+
"duration": "15.0s",
1215+
},
1216+
{
1217+
"span_id": "child1",
1218+
"parent_span_id": "root1",
1219+
"title": "Child of Root 1",
1220+
"duration": "5.0s",
1221+
},
1222+
{
1223+
"span_id": "child2",
1224+
"parent_span_id": "root2",
1225+
"title": "Child of Root 2",
1226+
"duration": "7.0s",
1227+
},
1228+
]
1229+
1230+
tree = build_spans_tree(spans_data)
1231+
1232+
# Should have two roots, sorted by duration (root2 first)
1233+
assert len(tree) == 2
1234+
assert tree[0]["span_id"] == "root2"
1235+
assert tree[1]["span_id"] == "root1"
1236+
1237+
# Each root should have one child
1238+
assert len(tree[0]["children"]) == 1
1239+
assert tree[0]["children"][0]["span_id"] == "child2"
1240+
1241+
assert len(tree[1]["children"]) == 1
1242+
assert tree[1]["children"][0]["span_id"] == "child1"
1243+
1244+
def test_build_spans_tree_orphaned_parent(self):
1245+
"""Test that spans with parent_span_id not in the data are treated as roots."""
1246+
spans_data: list[dict] = [
1247+
{
1248+
"span_id": "span1",
1249+
"parent_span_id": "non-existent-parent",
1250+
"title": "Orphaned Span",
1251+
"duration": "10.0s",
1252+
},
1253+
{
1254+
"span_id": "span2",
1255+
"parent_span_id": "span1",
1256+
"title": "Child of Orphaned Span",
1257+
"duration": "5.0s",
1258+
},
1259+
]
1260+
1261+
tree = build_spans_tree(spans_data)
1262+
1263+
# span1 should be treated as a root even though it has a parent_span_id
1264+
assert len(tree) == 1
1265+
assert tree[0]["span_id"] == "span1"
1266+
1267+
# span2 should be a child of span1
1268+
assert len(tree[0]["children"]) == 1
1269+
assert tree[0]["children"][0]["span_id"] == "span2"
1270+
1271+
def test_build_spans_tree_empty_input(self):
1272+
"""Test handling of empty input."""
1273+
assert build_spans_tree([]) == []
1274+
1275+
def test_build_spans_tree_missing_span_ids(self):
1276+
"""Test that spans without span_ids are ignored."""
1277+
spans_data: list[dict] = [
1278+
{
1279+
"span_id": "valid-span",
1280+
"parent_span_id": None,
1281+
"title": "Valid Span",
1282+
"duration": "10.0s",
1283+
},
1284+
{
1285+
"span_id": None, # Missing span_id
1286+
"parent_span_id": "valid-span",
1287+
"title": "Invalid Span",
1288+
"duration": "5.0s",
1289+
},
1290+
{
1291+
# No span_id key
1292+
"parent_span_id": "valid-span",
1293+
"title": "Another Invalid Span",
1294+
"duration": "3.0s",
1295+
},
1296+
]
1297+
1298+
tree = build_spans_tree(spans_data)
1299+
1300+
# Only the valid span should be in the tree
1301+
assert len(tree) == 1
1302+
assert tree[0]["span_id"] == "valid-span"
1303+
# No children since the other spans had invalid/missing span_ids
1304+
assert len(tree[0]["children"]) == 0
1305+
1306+
def test_build_spans_tree_duration_sorting(self):
1307+
"""Test that spans are correctly sorted by duration."""
1308+
spans_data: list[dict] = [
1309+
{
1310+
"span_id": "root",
1311+
"parent_span_id": None,
1312+
"title": "Root Span",
1313+
"duration": "10.0s",
1314+
},
1315+
{
1316+
"span_id": "fast-child",
1317+
"parent_span_id": "root",
1318+
"title": "Fast Child",
1319+
"duration": "1.0s",
1320+
},
1321+
{
1322+
"span_id": "medium-child",
1323+
"parent_span_id": "root",
1324+
"title": "Medium Child",
1325+
"duration": "5.0s",
1326+
},
1327+
{
1328+
"span_id": "slow-child",
1329+
"parent_span_id": "root",
1330+
"title": "Slow Child",
1331+
"duration": "9.0s",
1332+
},
1333+
]
1334+
1335+
tree = build_spans_tree(spans_data)
1336+
1337+
# Should have one root
1338+
assert len(tree) == 1
1339+
root = tree[0]
1340+
1341+
# Root should have three children, sorted by duration (slow-child first)
1342+
assert len(root["children"]) == 3
1343+
assert root["children"][0]["span_id"] == "slow-child"
1344+
assert root["children"][1]["span_id"] == "medium-child"
1345+
assert root["children"][2]["span_id"] == "fast-child"

0 commit comments

Comments
 (0)
Please sign in to comment.