diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index b4641828a30764..84b98912417204 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -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 diff --git a/src/sentry/seer/autofix/autofix_tools.py b/src/sentry/seer/autofix/autofix_tools.py index b120d6785996be..48150df39ab888 100644 --- a/src/sentry/seer/autofix/autofix_tools.py +++ b/src/sentry/seer/autofix/autofix_tools.py @@ -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 diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 25f6f24cc27d37..ac4c81814ecaf5 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -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( @@ -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": { @@ -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, }, } diff --git a/src/sentry/seer/explorer/utils.py b/src/sentry/seer/explorer/utils.py index 1fc9cbe5e3a5b4..90ad8a6cf9d898 100644 --- a/src/sentry/seer/explorer/utils.py +++ b/src/sentry/seer/explorer/utils.py @@ -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" @@ -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] = {} @@ -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]] @@ -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 = [] @@ -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]] = [] @@ -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]: @@ -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.""" diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 9f57f063fe25af..2968143a0ef380 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -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" @@ -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" @@ -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" @@ -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" @@ -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" diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 67fad12b997976..2e1c8c48d4bb62 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -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) @@ -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) @@ -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) @@ -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) @@ -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)