Skip to content

Commit e4534e7

Browse files
feat(explorer): add rpc for profile flamegraph tool (#103293)
Adds an RPC that gets a profile flamegraph given just a profile id (8 char or 32 char). Automatically finds the full ID and distinguishes between transaction and continuous profiles. The caveat is we require tracing for profiling to work, but this is true in 99% of cases already. --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent c479fcd commit e4534e7

File tree

3 files changed

+356
-0
lines changed

3 files changed

+356
-0
lines changed

src/sentry/seer/endpoints/seer_rpc.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
get_issue_details,
9494
get_replay_metadata,
9595
get_repository_definition,
96+
rpc_get_profile_flamegraph,
9697
rpc_get_trace_waterfall,
9798
)
9899
from sentry.seer.fetch_issues import by_error_type, by_function_name, by_text_query, utils
@@ -1199,6 +1200,7 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s
11991200
"get_issues_for_transaction": rpc_get_issues_for_transaction,
12001201
"get_trace_waterfall": rpc_get_trace_waterfall,
12011202
"get_issue_details": get_issue_details,
1203+
"get_profile_flamegraph": rpc_get_profile_flamegraph,
12021204
"execute_trace_query_chart": execute_trace_query_chart,
12031205
"execute_trace_query_table": execute_trace_query_table,
12041206
"execute_table_query": execute_table_query,

src/sentry/seer/explorer/tools.py

Lines changed: 161 additions & 0 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.utils import _convert_profile_to_execution_tree, fetch_profile_data
2526
from sentry.seer.sentry_data_models import EAPTrace
2627
from sentry.services.eventstore.models import Event, GroupEvent
2728
from sentry.snuba.referrer import Referrer
@@ -433,6 +434,166 @@ def rpc_get_trace_waterfall(trace_id: str, organization_id: int) -> dict[str, An
433434
return trace.dict() if trace else {}
434435

435436

437+
def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[str, Any]:
438+
"""
439+
Fetch and format a profile flamegraph by profile ID (8-char or full 32-char).
440+
441+
This function:
442+
1. Queries EAP spans across all projects in the organization
443+
2. Uses 14-day sliding windows to search up to 90 days back
444+
3. Finds spans with matching profile_id/profiler_id and aggregates timestamps
445+
4. Fetches the raw profile data from the profiling service
446+
5. Converts to execution tree and formats as ASCII flamegraph
447+
448+
Args:
449+
profile_id: Profile ID - can be 8 characters (prefix) or full 32 characters
450+
organization_id: Organization ID to search within
451+
452+
Returns:
453+
Dictionary with either:
454+
- Success: {"formatted_profile": str, "metadata": dict}
455+
- Failure: {"error": str}
456+
"""
457+
try:
458+
organization = Organization.objects.get(id=organization_id)
459+
except Organization.DoesNotExist:
460+
logger.warning(
461+
"rpc_get_profile_flamegraph: Organization not found",
462+
extra={"organization_id": organization_id},
463+
)
464+
return {"error": "Organization not found"}
465+
466+
# Get all projects for the organization
467+
projects = list(Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE))
468+
469+
if not projects:
470+
logger.warning(
471+
"rpc_get_profile_flamegraph: No projects found for organization",
472+
extra={"organization_id": organization_id},
473+
)
474+
return {"error": "No projects found for organization"}
475+
476+
# Search up to 90 days back using 14-day sliding windows
477+
now = datetime.now(UTC)
478+
window_days = 14
479+
max_days = 90
480+
481+
full_profile_id: str | None = None
482+
full_profiler_id: str | None = None
483+
project_id: int | None = None
484+
min_start_ts: float | None = None
485+
max_end_ts: float | None = None
486+
487+
# Slide back in time in 14-day windows
488+
for days_back in range(0, max_days, window_days):
489+
window_end = now - timedelta(days=days_back)
490+
window_start = now - timedelta(days=min(days_back + window_days, max_days))
491+
492+
snuba_params = SnubaParams(
493+
start=window_start,
494+
end=window_end,
495+
projects=projects,
496+
organization=organization,
497+
)
498+
499+
# Query with aggregation to get profile metadata
500+
result = Spans.run_table_query(
501+
params=snuba_params,
502+
query_string=f"(profile.id:{profile_id}* OR profiler.id:{profile_id}*)",
503+
selected_columns=[
504+
"profile.id",
505+
"profiler.id",
506+
"project.id",
507+
"min(precise.start_ts)",
508+
"max(precise.finish_ts)",
509+
],
510+
orderby=[],
511+
offset=0,
512+
limit=1,
513+
referrer=Referrer.SEER_RPC,
514+
config=SearchResolverConfig(
515+
auto_fields=True,
516+
),
517+
sampling_mode="NORMAL",
518+
)
519+
520+
data = result.get("data")
521+
if data:
522+
row = data[0]
523+
full_profile_id = row.get("profile.id")
524+
full_profiler_id = row.get("profiler.id")
525+
project_id = row.get("project.id")
526+
min_start_ts = row.get("min(precise.start_ts)")
527+
max_end_ts = row.get("max(precise.finish_ts)")
528+
break
529+
530+
# Determine profile type and actual ID to use
531+
is_continuous = bool(full_profiler_id and not full_profile_id)
532+
actual_profile_id = full_profiler_id or full_profile_id
533+
534+
if not actual_profile_id:
535+
logger.info(
536+
"rpc_get_profile_flamegraph: Profile not found",
537+
extra={"profile_id": profile_id, "organization_id": organization_id},
538+
)
539+
return {"error": "Profile not found in the last 90 days"}
540+
if not project_id:
541+
logger.warning(
542+
"rpc_get_profile_flamegraph: Could not find project id for profile",
543+
extra={"profile_id": profile_id, "organization_id": organization_id},
544+
)
545+
return {"error": "Project not found"}
546+
547+
logger.info(
548+
"rpc_get_profile_flamegraph: Found profile",
549+
extra={
550+
"profile_id": actual_profile_id,
551+
"project_id": project_id,
552+
"is_continuous": is_continuous,
553+
"min_start_ts": min_start_ts,
554+
"max_end_ts": max_end_ts,
555+
},
556+
)
557+
558+
# Fetch the profile data
559+
profile_data = fetch_profile_data(
560+
profile_id=actual_profile_id,
561+
organization_id=organization_id,
562+
project_id=project_id,
563+
start_ts=min_start_ts,
564+
end_ts=max_end_ts,
565+
is_continuous=is_continuous,
566+
)
567+
568+
if not profile_data:
569+
logger.warning(
570+
"rpc_get_profile_flamegraph: Failed to fetch profile data from profiling service",
571+
extra={"profile_id": actual_profile_id, "project_id": project_id},
572+
)
573+
return {"error": "Failed to fetch profile data from profiling service"}
574+
575+
# Convert to execution tree (returns dicts, not Pydantic models)
576+
execution_tree = _convert_profile_to_execution_tree(profile_data)
577+
578+
if not execution_tree:
579+
logger.warning(
580+
"rpc_get_profile_flamegraph: Empty execution tree",
581+
extra={"profile_id": actual_profile_id, "project_id": project_id},
582+
)
583+
return {"error": "Failed to generate execution tree from profile data"}
584+
585+
return {
586+
"execution_tree": execution_tree,
587+
"metadata": {
588+
"profile_id": actual_profile_id,
589+
"project_id": project_id,
590+
"is_continuous": is_continuous,
591+
"start_ts": min_start_ts,
592+
"end_ts": max_end_ts,
593+
},
594+
}
595+
596+
436597
def get_repository_definition(*, organization_id: int, repo_full_name: str) -> dict | None:
437598
"""
438599
Look up a repository by full name (owner/repo-name) that the org has access to.

0 commit comments

Comments
 (0)