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
22 changes: 20 additions & 2 deletions src/sentry/seer/explorer/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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(),
Expand Down
27 changes: 6 additions & 21 deletions src/sentry/seer/explorer/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 []

Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you mean to include these changes? Whats it for

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah just decided that we shouldn't be sending system-code profiles in any case (reverting recent change)

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]]
Expand Down Expand Up @@ -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)
Expand Down
89 changes: 0 additions & 89 deletions tests/sentry/seer/explorer/test_explorer_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading