Skip to content
Merged
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
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
24 changes: 16 additions & 8 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 All @@ -91,7 +97,7 @@ def _convert_profile_to_execution_tree(profile_data: dict) -> list[dict]:
show_all_frames = False
else:
# No in_app frames found, return empty tree instead of falling back to system frames
return []
return [], None

def _get_elapsed_since_start_ns(
sample: dict[str, Any], all_samples: list[dict[str, Any]]
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