Skip to content

Commit c93fde5

Browse files
authored
fix(explorer): accept trace id for flamegraph tool (#103818)
Adds trace id as an extra optional filter to narrow down results, since profiler ids can span multiple traces.
1 parent 2f0cb3a commit c93fde5

File tree

3 files changed

+26
-112
lines changed

3 files changed

+26
-112
lines changed

src/sentry/seer/explorer/tools.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from sentry.search.events.types import SAMPLING_MODES, SnubaParams
2323
from sentry.seer.autofix.autofix import get_all_tags_overview
2424
from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS
25+
from sentry.seer.explorer.index_data import UNESCAPED_QUOTE_RE
2526
from sentry.seer.explorer.utils import _convert_profile_to_execution_tree, fetch_profile_data
2627
from sentry.seer.sentry_data_models import EAPTrace
2728
from sentry.services.eventstore.models import Event, GroupEvent
@@ -348,7 +349,12 @@ def rpc_get_trace_waterfall(trace_id: str, organization_id: int) -> dict[str, An
348349
return trace.dict() if trace else {}
349350

350351

351-
def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[str, Any]:
352+
def rpc_get_profile_flamegraph(
353+
profile_id: str,
354+
organization_id: int,
355+
trace_id: str | None = None,
356+
span_description: str | None = None,
357+
) -> dict[str, Any]:
352358
"""
353359
Fetch and format a profile flamegraph by profile ID (8-char or full 32-char).
354360
@@ -362,6 +368,8 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st
362368
Args:
363369
profile_id: Profile ID - can be 8 characters (prefix) or full 32 characters
364370
organization_id: Organization ID to search within
371+
trace_id: Optional trace ID to filter profile spans more precisely
372+
span_description: Optional span description to filter profile spans more precisely
365373
366374
Returns:
367375
Dictionary with either:
@@ -410,10 +418,17 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st
410418
organization=organization,
411419
)
412420

421+
query_string = f"(profile.id:{profile_id}* OR profiler.id:{profile_id}*)"
422+
if trace_id:
423+
query_string += f" trace:{trace_id}"
424+
if span_description:
425+
escaped_description = UNESCAPED_QUOTE_RE.sub('\\"', span_description)
426+
query_string += f' span.description:"*{escaped_description}*"'
427+
413428
# Query with aggregation to get profile metadata
414429
result = Spans.run_table_query(
415430
params=snuba_params,
416-
query_string=f"(profile.id:{profile_id}* OR profiler.id:{profile_id}*)",
431+
query_string=query_string,
417432
selected_columns=[
418433
"profile.id",
419434
"profiler.id",
@@ -437,6 +452,9 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st
437452
extra={
438453
"profile_id": profile_id,
439454
"organization_id": organization_id,
455+
"trace_id": trace_id,
456+
"span_description": span_description,
457+
"query_string": query_string,
440458
"data": data,
441459
"window_start": window_start.isoformat(),
442460
"window_end": window_end.isoformat(),

src/sentry/seer/explorer/utils.py

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ def normalize_description(description: str) -> str:
4949
def _convert_profile_to_execution_tree(profile_data: dict) -> list[dict]:
5050
"""
5151
Converts profile data into a hierarchical representation of code execution.
52-
Selects the thread with the most in_app frames, or falls back to MainThread if no
53-
in_app frames exist (showing all frames including system frames).
52+
Selects the thread with the most in_app frames. Returns empty list if no
53+
in_app frames exist.
5454
Calculates accurate durations for all nodes based on call stack transitions.
5555
"""
5656
profile = profile_data.get(
@@ -66,7 +66,6 @@ def _convert_profile_to_execution_tree(profile_data: dict) -> list[dict]:
6666
frames = profile.get("frames")
6767
stacks = profile.get("stacks")
6868
samples = profile.get("samples")
69-
thread_metadata = profile.get("thread_metadata", {})
7069
if not all([frames, stacks, samples]):
7170
return []
7271

@@ -91,22 +90,8 @@ def _convert_profile_to_execution_tree(profile_data: dict) -> list[dict]:
9190
selected_thread_id = max(thread_in_app_counts.items(), key=lambda x: x[1])[0]
9291
show_all_frames = False
9392
else:
94-
# No in_app frames found, try to find MainThread
95-
main_thread_id_from_metadata = next(
96-
(
97-
str(thread_id)
98-
for thread_id, metadata in thread_metadata.items()
99-
if metadata.get("name") == "MainThread"
100-
),
101-
None,
102-
)
103-
104-
selected_thread_id = main_thread_id_from_metadata or (
105-
str(samples[0]["thread_id"]) if samples else None
106-
)
107-
show_all_frames = (
108-
True # Show all frames including system frames when no in_app frames exist
109-
)
93+
# No in_app frames found, return empty tree instead of falling back to system frames
94+
return []
11095

11196
def _get_elapsed_since_start_ns(
11297
sample: dict[str, Any], all_samples: list[dict[str, Any]]
@@ -355,8 +340,8 @@ def apply_durations(node):
355340
def convert_profile_to_execution_tree(profile_data: dict) -> list[ExecutionTreeNode]:
356341
"""
357342
Converts profile data into a hierarchical representation of code execution.
358-
Selects the thread with the most in_app frames, or falls back to MainThread if no
359-
in_app frames exist (showing all frames including system frames).
343+
Selects the thread with the most in_app frames. Returns empty list if no
344+
in_app frames exist.
360345
Calculates accurate durations for all nodes based on call stack transitions.
361346
"""
362347
dict_tree = _convert_profile_to_execution_tree(profile_data)

tests/sentry/seer/explorer/test_explorer_utils.py

Lines changed: 0 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -619,92 +619,3 @@ def test_convert_profile_selects_thread_with_most_in_app_frames(self) -> None:
619619
assert root.children[0].function == "worker_function_2"
620620
assert len(root.children[0].children) == 1
621621
assert root.children[0].children[0].function == "worker_function_1"
622-
623-
def test_convert_profile_fallback_to_mainthread_when_no_in_app_frames(self) -> None:
624-
"""Test fallback to MainThread when no threads have in_app frames."""
625-
profile_data: dict[str, Any] = {
626-
"profile": {
627-
"frames": [
628-
{
629-
"function": "stdlib_function",
630-
"module": "stdlib",
631-
"filename": "/usr/lib/stdlib.py",
632-
"lineno": 100,
633-
"in_app": False,
634-
},
635-
{
636-
"function": "another_stdlib_function",
637-
"module": "stdlib",
638-
"filename": "/usr/lib/stdlib.py",
639-
"lineno": 200,
640-
"in_app": False,
641-
},
642-
],
643-
"stacks": [[0, 1]],
644-
"samples": [
645-
{
646-
"elapsed_since_start_ns": 1000000,
647-
"thread_id": "1", # MainThread
648-
"stack_id": 0,
649-
},
650-
{
651-
"elapsed_since_start_ns": 1000000,
652-
"thread_id": "2", # WorkerThread
653-
"stack_id": 0,
654-
},
655-
],
656-
"thread_metadata": {
657-
"1": {"name": "MainThread"},
658-
"2": {"name": "WorkerThread"},
659-
},
660-
}
661-
}
662-
663-
result = convert_profile_to_execution_tree(profile_data)
664-
assert len(result) == 1
665-
666-
# Should contain all frames from MainThread (including system frames)
667-
root = result[0]
668-
assert root.function == "another_stdlib_function"
669-
assert len(root.children) == 1
670-
assert root.children[0].function == "stdlib_function"
671-
672-
def test_convert_profile_fallback_to_first_sample_when_no_mainthread_no_in_app(self) -> None:
673-
"""Test fallback to first sample's thread when no MainThread and no in_app frames."""
674-
profile_data: dict[str, Any] = {
675-
"profile": {
676-
"frames": [
677-
{
678-
"function": "stdlib_function",
679-
"module": "stdlib",
680-
"filename": "/usr/lib/stdlib.py",
681-
"lineno": 100,
682-
"in_app": False,
683-
},
684-
],
685-
"stacks": [[0]],
686-
"samples": [
687-
{
688-
"elapsed_since_start_ns": 1000000,
689-
"thread_id": "worker1", # First sample - should be selected
690-
"stack_id": 0,
691-
},
692-
{
693-
"elapsed_since_start_ns": 1000000,
694-
"thread_id": "worker2",
695-
"stack_id": 0,
696-
},
697-
],
698-
"thread_metadata": {
699-
"worker1": {"name": "WorkerThread1"},
700-
"worker2": {"name": "WorkerThread2"},
701-
},
702-
}
703-
}
704-
705-
result = convert_profile_to_execution_tree(profile_data)
706-
assert len(result) == 1
707-
708-
# Should contain frames from worker1 (first sample's thread)
709-
root = result[0]
710-
assert root.function == "stdlib_function"

0 commit comments

Comments
 (0)