diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 813d76d7bc2999..5116a768217259 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -22,6 +22,7 @@ from sentry.search.events.types import SAMPLING_MODES, SnubaParams from sentry.seer.autofix.autofix import get_all_tags_overview from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS +from sentry.seer.explorer.index_data import UNESCAPED_QUOTE_RE from sentry.seer.explorer.utils import _convert_profile_to_execution_tree, fetch_profile_data from sentry.seer.sentry_data_models import EAPTrace from sentry.services.eventstore.models import Event, GroupEvent @@ -308,7 +309,12 @@ def rpc_get_trace_waterfall(trace_id: str, organization_id: int) -> dict[str, An return trace.dict() if trace else {} -def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[str, Any]: +def rpc_get_profile_flamegraph( + profile_id: str, + organization_id: int, + trace_id: str | None = None, + span_description: str | None = None, +) -> dict[str, Any]: """ Fetch and format a profile flamegraph by profile ID (8-char or full 32-char). @@ -322,6 +328,8 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st Args: profile_id: Profile ID - can be 8 characters (prefix) or full 32 characters organization_id: Organization ID to search within + trace_id: Optional trace ID to filter profile spans more precisely + span_description: Optional span description to filter profile spans more precisely Returns: Dictionary with either: @@ -370,10 +378,17 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st organization=organization, ) + query_string = f"(profile.id:{profile_id}* OR profiler.id:{profile_id}*)" + if trace_id: + query_string += f" trace:{trace_id}" + if span_description: + escaped_description = UNESCAPED_QUOTE_RE.sub('\\"', span_description) + query_string += f' span.description:"*{escaped_description}*"' + # Query with aggregation to get profile metadata result = Spans.run_table_query( params=snuba_params, - query_string=f"(profile.id:{profile_id}* OR profiler.id:{profile_id}*)", + query_string=query_string, selected_columns=[ "profile.id", "profiler.id", @@ -397,6 +412,9 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st extra={ "profile_id": profile_id, "organization_id": organization_id, + "trace_id": trace_id, + "span_description": span_description, + "query_string": query_string, "data": data, "window_start": window_start.isoformat(), "window_end": window_end.isoformat(), diff --git a/src/sentry/seer/explorer/utils.py b/src/sentry/seer/explorer/utils.py index 42bc649cd97d0a..1fc9cbe5e3a5b4 100644 --- a/src/sentry/seer/explorer/utils.py +++ b/src/sentry/seer/explorer/utils.py @@ -49,8 +49,8 @@ def normalize_description(description: str) -> str: def _convert_profile_to_execution_tree(profile_data: dict) -> list[dict]: """ 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). + 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. """ profile = profile_data.get( @@ -66,7 +66,6 @@ def _convert_profile_to_execution_tree(profile_data: dict) -> list[dict]: 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 [] @@ -91,22 +90,8 @@ def _convert_profile_to_execution_tree(profile_data: dict) -> list[dict]: selected_thread_id = max(thread_in_app_counts.items(), key=lambda x: x[1])[0] show_all_frames = False else: - # No in_app frames found, try to find MainThread - main_thread_id_from_metadata = next( - ( - str(thread_id) - for thread_id, metadata in thread_metadata.items() - if metadata.get("name") == "MainThread" - ), - None, - ) - - selected_thread_id = main_thread_id_from_metadata or ( - str(samples[0]["thread_id"]) if samples else None - ) - show_all_frames = ( - True # Show all frames including system frames when no in_app frames exist - ) + # No in_app frames found, return empty tree instead of falling back to system frames + return [] def _get_elapsed_since_start_ns( sample: dict[str, Any], all_samples: list[dict[str, Any]] @@ -355,8 +340,8 @@ def apply_durations(node): def convert_profile_to_execution_tree(profile_data: dict) -> list[ExecutionTreeNode]: """ 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). + 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. """ dict_tree = _convert_profile_to_execution_tree(profile_data) diff --git a/tests/sentry/seer/explorer/test_explorer_utils.py b/tests/sentry/seer/explorer/test_explorer_utils.py index 40c1ae6cea509a..f6ad7e1bffa65f 100644 --- a/tests/sentry/seer/explorer/test_explorer_utils.py +++ b/tests/sentry/seer/explorer/test_explorer_utils.py @@ -619,92 +619,3 @@ def test_convert_profile_selects_thread_with_most_in_app_frames(self) -> None: assert root.children[0].function == "worker_function_2" assert len(root.children[0].children) == 1 assert root.children[0].children[0].function == "worker_function_1" - - def test_convert_profile_fallback_to_mainthread_when_no_in_app_frames(self) -> None: - """Test fallback to MainThread when no threads have in_app frames.""" - profile_data: dict[str, Any] = { - "profile": { - "frames": [ - { - "function": "stdlib_function", - "module": "stdlib", - "filename": "/usr/lib/stdlib.py", - "lineno": 100, - "in_app": False, - }, - { - "function": "another_stdlib_function", - "module": "stdlib", - "filename": "/usr/lib/stdlib.py", - "lineno": 200, - "in_app": False, - }, - ], - "stacks": [[0, 1]], - "samples": [ - { - "elapsed_since_start_ns": 1000000, - "thread_id": "1", # MainThread - "stack_id": 0, - }, - { - "elapsed_since_start_ns": 1000000, - "thread_id": "2", # WorkerThread - "stack_id": 0, - }, - ], - "thread_metadata": { - "1": {"name": "MainThread"}, - "2": {"name": "WorkerThread"}, - }, - } - } - - result = convert_profile_to_execution_tree(profile_data) - assert len(result) == 1 - - # Should contain all frames from MainThread (including system frames) - root = result[0] - assert root.function == "another_stdlib_function" - assert len(root.children) == 1 - assert root.children[0].function == "stdlib_function" - - def test_convert_profile_fallback_to_first_sample_when_no_mainthread_no_in_app(self) -> None: - """Test fallback to first sample's thread when no MainThread and no in_app frames.""" - profile_data: dict[str, Any] = { - "profile": { - "frames": [ - { - "function": "stdlib_function", - "module": "stdlib", - "filename": "/usr/lib/stdlib.py", - "lineno": 100, - "in_app": False, - }, - ], - "stacks": [[0]], - "samples": [ - { - "elapsed_since_start_ns": 1000000, - "thread_id": "worker1", # First sample - should be selected - "stack_id": 0, - }, - { - "elapsed_since_start_ns": 1000000, - "thread_id": "worker2", - "stack_id": 0, - }, - ], - "thread_metadata": { - "worker1": {"name": "WorkerThread1"}, - "worker2": {"name": "WorkerThread2"}, - }, - } - } - - result = convert_profile_to_execution_tree(profile_data) - assert len(result) == 1 - - # Should contain frames from worker1 (first sample's thread) - root = result[0] - assert root.function == "stdlib_function"