Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion src/sentry/seer/autofix/autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ def _collect_all_events(node):
)

if profile:
execution_tree = _convert_profile_to_execution_tree(profile)
execution_tree, _ = _convert_profile_to_execution_tree(profile)
return (
None
if not execution_tree
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/seer/autofix/autofix_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def get_profile_details(
)

if profile:
execution_tree = _convert_profile_to_execution_tree(profile)
execution_tree, _ = _convert_profile_to_execution_tree(profile)
return (
None
if not execution_tree
Expand Down
9 changes: 2 additions & 7 deletions src/sentry/seer/explorer/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ def rpc_get_profile_flamegraph(
return {"error": "Failed to fetch profile data from profiling service"}

# Convert to execution tree (returns dicts, not Pydantic models)
execution_tree = _convert_profile_to_execution_tree(profile_data)
execution_tree, selected_thread_id = _convert_profile_to_execution_tree(profile_data)

if not execution_tree:
logger.warning(
Expand All @@ -544,11 +544,6 @@ def rpc_get_profile_flamegraph(
)
return {"error": "Failed to generate execution tree from profile data"}

# Extract thread_id from profile data
profile = profile_data.get("profile") or profile_data.get("chunk", {}).get("profile")
samples = profile.get("samples", []) if profile else []
thread_id = str(samples[0]["thread_id"]) if samples else None

return {
"execution_tree": execution_tree,
"metadata": {
Expand All @@ -557,7 +552,7 @@ def rpc_get_profile_flamegraph(
"is_continuous": is_continuous,
"start_ts": min_start_ts,
"end_ts": max_end_ts,
"thread_id": thread_id,
"thread_id": selected_thread_id,
},
}

Expand Down
22 changes: 15 additions & 7 deletions src/sentry/seer/explorer/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,16 @@ def normalize_description(description: str) -> str:
return description


def _convert_profile_to_execution_tree(profile_data: dict) -> list[dict]:
def _convert_profile_to_execution_tree(profile_data: dict) -> tuple[list[dict], str | None]:
"""
Converts profile data into a hierarchical representation of code execution.
Selects the thread with the most in_app frames. Returns empty list if no
in_app frames exist.
Calculates accurate durations for all nodes based on call stack transitions.

Returns:
Tuple of (execution_tree, selected_thread_id) where selected_thread_id is the
thread that was used to build the execution tree.
"""
profile = profile_data.get(
"profile"
Comment on lines 51 to 61

This comment was marked as outdated.

Expand All @@ -61,13 +65,15 @@ def _convert_profile_to_execution_tree(profile_data: dict) -> list[dict]:
"profile"
) # continuous profiles are wrapped as {"chunk": {"profile": {"frames": [], "samples": [], "stacks": []}}}
if not profile:
return []
empty_tree: list[dict[Any, Any]] = []
return empty_tree, None

frames = profile.get("frames")
stacks = profile.get("stacks")
samples = profile.get("samples")
if not all([frames, stacks, samples]):
return []
empty_tree_2: list[dict[Any, Any]] = []
return empty_tree_2, None

# Count in_app frames per thread
thread_in_app_counts: dict[str, int] = {}
Expand Down Expand Up @@ -175,7 +181,8 @@ def process_stack(stack_index: int, include_all_frames: bool = False) -> list[di
"""
frame_indices = stacks[stack_index]
if not frame_indices:
return []
empty_stack: list[dict[str, Any]] = []
return empty_stack

# Create nodes for frames, maintaining order (bottom to top)
nodes = []
Expand All @@ -188,7 +195,8 @@ def process_stack(stack_index: int, include_all_frames: bool = False) -> list[di
return nodes

if not selected_thread_id:
return []
empty_tree_3: list[dict[Any, Any]] = []
return empty_tree_3, None

# Build the execution tree and track call stacks
execution_tree: list[dict[str, Any]] = []
Expand Down Expand Up @@ -334,7 +342,7 @@ def apply_durations(node):
for node in execution_tree:
apply_durations(node)

return execution_tree
return execution_tree, selected_thread_id


def convert_profile_to_execution_tree(profile_data: dict) -> list[ExecutionTreeNode]:
Expand All @@ -344,7 +352,7 @@ def convert_profile_to_execution_tree(profile_data: dict) -> list[ExecutionTreeN
in_app frames exist.
Calculates accurate durations for all nodes based on call stack transitions.
"""
dict_tree = _convert_profile_to_execution_tree(profile_data)
dict_tree, _ = _convert_profile_to_execution_tree(profile_data)

def dict_to_execution_tree_node(node_dict: dict) -> ExecutionTreeNode:
"""Convert a dict node to an ExecutionTreeNode Pydantic object."""
Expand Down
15 changes: 10 additions & 5 deletions tests/sentry/seer/autofix/test_autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ def test_convert_profile_to_execution_tree(self) -> None:
}
}

execution_tree = _convert_profile_to_execution_tree(profile_data)
execution_tree, selected_thread_id = _convert_profile_to_execution_tree(profile_data)

# Should only include in_app frames from the selected thread (MainThread in this case)
assert selected_thread_id == "1"
assert len(execution_tree) == 1 # One root node
root = execution_tree[0]
assert root["function"] == "main"
Expand Down Expand Up @@ -99,9 +100,10 @@ def test_convert_profile_to_execution_tree_non_main_thread(self) -> None:
}
}

execution_tree = _convert_profile_to_execution_tree(profile_data)
execution_tree, selected_thread_id = _convert_profile_to_execution_tree(profile_data)

# Should include the worker thread since it has in_app frames
assert selected_thread_id == "2"
assert len(execution_tree) == 1
assert execution_tree[0]["function"] == "worker"
assert execution_tree[0]["filename"] == "worker.py"
Expand All @@ -128,9 +130,10 @@ def test_convert_profile_to_execution_tree_merges_duplicate_frames(self) -> None
}
}

execution_tree = _convert_profile_to_execution_tree(profile_data)
execution_tree, selected_thread_id = _convert_profile_to_execution_tree(profile_data)

# Should only have one node even though frame appears in multiple samples
assert selected_thread_id == "1"
assert len(execution_tree) == 1
assert execution_tree[0]["function"] == "main"

Expand Down Expand Up @@ -201,9 +204,10 @@ def test_convert_profile_to_execution_tree_calculates_durations(self) -> None:
}
}

execution_tree = _convert_profile_to_execution_tree(profile_data)
execution_tree, selected_thread_id = _convert_profile_to_execution_tree(profile_data)

# Should have one root node (main)
assert selected_thread_id == "1"
assert len(execution_tree) == 1
root = execution_tree[0]
assert root["function"] == "main"
Expand Down Expand Up @@ -269,9 +273,10 @@ def test_convert_profile_to_execution_tree_with_timestamp(self) -> None:
}
}

execution_tree = _convert_profile_to_execution_tree(profile_data)
execution_tree, selected_thread_id = _convert_profile_to_execution_tree(profile_data)

# Should have one root node (main)
assert selected_thread_id == "1"
assert len(execution_tree) == 1
root = execution_tree[0]
assert root["function"] == "main"
Expand Down
10 changes: 5 additions & 5 deletions tests/sentry/seer/explorer/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1365,7 +1365,7 @@ def test_rpc_get_profile_flamegraph_finds_transaction_profile(

# Mock the profile data fetch and conversion
mock_fetch_profile.return_value = {"profile": {"frames": [], "stacks": [], "samples": []}}
mock_convert_tree.return_value = [{"function": "main", "module": "app"}]
mock_convert_tree.return_value = ([{"function": "main", "module": "app"}], "1")

result = rpc_get_profile_flamegraph(profile_id_8char, self.organization.id)

Expand Down Expand Up @@ -1402,7 +1402,7 @@ def test_rpc_get_profile_flamegraph_finds_continuous_profile(
mock_fetch_profile.return_value = {
"chunk": {"profile": {"frames": [], "stacks": [], "samples": []}}
}
mock_convert_tree.return_value = [{"function": "worker", "module": "tasks"}]
mock_convert_tree.return_value = ([{"function": "worker", "module": "tasks"}], "2")

result = rpc_get_profile_flamegraph(profiler_id_8char, self.organization.id)

Expand Down Expand Up @@ -1439,7 +1439,7 @@ def test_rpc_get_profile_flamegraph_aggregates_timestamps_across_spans(
self.store_spans(spans, is_eap=True)

mock_fetch_profile.return_value = {"profile": {"frames": [], "stacks": [], "samples": []}}
mock_convert_tree.return_value = [{"function": "test", "module": "test"}]
mock_convert_tree.return_value = ([{"function": "test", "module": "test"}], "3")

result = rpc_get_profile_flamegraph(profile_id_8char, self.organization.id)

Expand Down Expand Up @@ -1476,7 +1476,7 @@ def test_rpc_get_profile_flamegraph_sliding_window_finds_old_profile(
self.store_spans([span], is_eap=True)

mock_fetch_profile.return_value = {"profile": {"frames": [], "stacks": [], "samples": []}}
mock_convert_tree.return_value = [{"function": "old_function", "module": "old"}]
mock_convert_tree.return_value = ([{"function": "old_function", "module": "old"}], "4")

result = rpc_get_profile_flamegraph(profile_id_8char, self.organization.id)

Expand All @@ -1501,7 +1501,7 @@ def test_rpc_get_profile_flamegraph_full_32char_id(self, mock_fetch_profile, moc
self.store_spans([span], is_eap=True)

mock_fetch_profile.return_value = {"profile": {"frames": [], "stacks": [], "samples": []}}
mock_convert_tree.return_value = [{"function": "handler", "module": "server"}]
mock_convert_tree.return_value = ([{"function": "handler", "module": "server"}], "5")

result = rpc_get_profile_flamegraph(full_profile_id, self.organization.id)

Expand Down
Loading