From b1d7adb764d4e2532ea9952172c40baafbf06f01 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Sun, 23 Nov 2025 13:20:56 -0500 Subject: [PATCH 1/5] fix(explorer): set correct thread id in metadata --- src/sentry/seer/autofix/autofix.py | 2 +- src/sentry/seer/autofix/autofix_tools.py | 2 +- src/sentry/seer/explorer/tools.py | 9 ++------- src/sentry/seer/explorer/utils.py | 14 +++++++++----- tests/sentry/seer/autofix/test_autofix.py | 15 ++++++++++----- tests/sentry/seer/explorer/test_tools.py | 10 +++++----- 6 files changed, 28 insertions(+), 24 deletions(-) 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 b08e1a93a41fd9..c16ac6ddce2f08 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -513,7 +513,7 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st 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( @@ -526,11 +526,6 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st ) 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": { @@ -539,7 +534,7 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st "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 42bc649cd97d0a..20f6f20235fd23 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, or falls back to MainThread if no in_app frames exist (showing all frames including system frames). 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,14 +65,14 @@ 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 [] + return [], None frames = profile.get("frames") stacks = profile.get("stacks") samples = profile.get("samples") thread_metadata = profile.get("thread_metadata", {}) if not all([frames, stacks, samples]): - return [] + return [], None # Count in_app frames per thread thread_in_app_counts: dict[str, int] = {} @@ -349,7 +353,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]: @@ -359,7 +363,7 @@ def convert_profile_to_execution_tree(profile_data: dict) -> list[ExecutionTreeN in_app frames exist (showing all frames including system frames). 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) From c1dfb25ec1d927ed735c47622034870f0703b8bf Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Sun, 23 Nov 2025 13:30:46 -0500 Subject: [PATCH 2/5] typing --- src/sentry/seer/explorer/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/explorer/utils.py b/src/sentry/seer/explorer/utils.py index 20f6f20235fd23..a402ae10a297c8 100644 --- a/src/sentry/seer/explorer/utils.py +++ b/src/sentry/seer/explorer/utils.py @@ -65,14 +65,14 @@ def _convert_profile_to_execution_tree(profile_data: dict) -> tuple[list[dict], "profile" ) # continuous profiles are wrapped as {"chunk": {"profile": {"frames": [], "samples": [], "stacks": []}}} if not profile: - return [], None + return list[dict[Any, Any]](), None frames = profile.get("frames") stacks = profile.get("stacks") samples = profile.get("samples") thread_metadata = profile.get("thread_metadata", {}) if not all([frames, stacks, samples]): - return [], None + return list[dict[Any, Any]](), None # Count in_app frames per thread thread_in_app_counts: dict[str, int] = {} @@ -207,7 +207,7 @@ def process_stack(stack_index: int, include_all_frames: bool = False) -> list[di return nodes if not selected_thread_id: - return [] + return list[dict[Any, Any]](), None # Build the execution tree and track call stacks execution_tree: list[dict[str, Any]] = [] From 35fe77212d1ce07fc3414aafb5287a7780f78d68 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Mon, 24 Nov 2025 07:55:09 -0500 Subject: [PATCH 3/5] attempt fix typing --- src/sentry/seer/explorer/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/explorer/utils.py b/src/sentry/seer/explorer/utils.py index a402ae10a297c8..9f44f271802c75 100644 --- a/src/sentry/seer/explorer/utils.py +++ b/src/sentry/seer/explorer/utils.py @@ -194,7 +194,7 @@ def process_stack(stack_index: int, include_all_frames: bool = False) -> list[di """ frame_indices = stacks[stack_index] if not frame_indices: - return [] + return list[dict[str, Any]]() # Create nodes for frames, maintaining order (bottom to top) nodes = [] From d9fed53ef5e68eb7df414745e54ccb95ee92bae4 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Mon, 24 Nov 2025 08:05:53 -0500 Subject: [PATCH 4/5] attempt fix typing --- src/sentry/seer/explorer/utils.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/sentry/seer/explorer/utils.py b/src/sentry/seer/explorer/utils.py index 9f44f271802c75..287a8f85bda6c6 100644 --- a/src/sentry/seer/explorer/utils.py +++ b/src/sentry/seer/explorer/utils.py @@ -65,14 +65,16 @@ def _convert_profile_to_execution_tree(profile_data: dict) -> tuple[list[dict], "profile" ) # continuous profiles are wrapped as {"chunk": {"profile": {"frames": [], "samples": [], "stacks": []}}} if not profile: - return list[dict[Any, Any]](), None + empty_tree: list[dict[Any, Any]] = [] + return empty_tree, None frames = profile.get("frames") stacks = profile.get("stacks") samples = profile.get("samples") thread_metadata = profile.get("thread_metadata", {}) if not all([frames, stacks, samples]): - return list[dict[Any, Any]](), None + 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] = {} @@ -194,7 +196,8 @@ def process_stack(stack_index: int, include_all_frames: bool = False) -> list[di """ frame_indices = stacks[stack_index] if not frame_indices: - return list[dict[str, Any]]() + empty_stack: list[dict[str, Any]] = [] + return empty_stack # Create nodes for frames, maintaining order (bottom to top) nodes = [] @@ -207,7 +210,8 @@ def process_stack(stack_index: int, include_all_frames: bool = False) -> list[di return nodes if not selected_thread_id: - return list[dict[Any, Any]](), None + 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]] = [] From 09cbebaeb4c7f033a4a8a7af1ea642a998a761ec Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Mon, 24 Nov 2025 08:36:35 -0500 Subject: [PATCH 5/5] bug fix --- src/sentry/seer/explorer/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/explorer/utils.py b/src/sentry/seer/explorer/utils.py index 15e55f529cbcf1..90ad8a6cf9d898 100644 --- a/src/sentry/seer/explorer/utils.py +++ b/src/sentry/seer/explorer/utils.py @@ -97,7 +97,7 @@ def _convert_profile_to_execution_tree(profile_data: dict) -> tuple[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]]