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
2 changes: 2 additions & 0 deletions src/sentry/seer/endpoints/seer_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
get_issue_details,
get_replay_metadata,
get_repository_definition,
rpc_get_profile_flamegraph,
rpc_get_trace_waterfall,
)
from sentry.seer.fetch_issues import by_error_type, by_function_name, by_text_query, utils
Expand Down Expand Up @@ -1197,6 +1198,7 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s
"get_issues_for_transaction": rpc_get_issues_for_transaction,
"get_trace_waterfall": rpc_get_trace_waterfall,
"get_issue_details": get_issue_details,
"get_profile_flamegraph": rpc_get_profile_flamegraph,
"execute_trace_query_chart": execute_trace_query_chart,
"execute_trace_query_table": execute_trace_query_table,
"get_repository_definition": get_repository_definition,
Expand Down
161 changes: 161 additions & 0 deletions src/sentry/seer/explorer/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from sentry.search.events.types import SnubaParams
from sentry.seer.autofix.autofix import get_all_tags_overview
from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS
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
from sentry.snuba.referrer import Referrer
Expand Down Expand Up @@ -286,6 +287,166 @@ 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]:
"""
Fetch and format a profile flamegraph by profile ID (8-char or full 32-char).

This function:
1. Queries EAP spans across all projects in the organization
2. Uses 14-day sliding windows to search up to 90 days back
3. Finds spans with matching profile_id/profiler_id and aggregates timestamps
4. Fetches the raw profile data from the profiling service
5. Converts to execution tree and formats as ASCII flamegraph

Args:
profile_id: Profile ID - can be 8 characters (prefix) or full 32 characters
organization_id: Organization ID to search within

Returns:
Dictionary with either:
- Success: {"formatted_profile": str, "metadata": dict}
- Failure: {"error": str}
"""
try:
organization = Organization.objects.get(id=organization_id)
except Organization.DoesNotExist:
logger.warning(
"rpc_get_profile_flamegraph: Organization not found",
extra={"organization_id": organization_id},
)
return {"error": "Organization not found"}

# Get all projects for the organization
projects = list(Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE))
Copy link

@semgrep-code-getsentry semgrep-code-getsentry bot Nov 14, 2025

Choose a reason for hiding this comment

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

Risk: Affected versions of django are vulnerable to Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'). The ORM methods QuerySet.filter(), QuerySet.exclude(), QuerySet.get() and the Q() class can be tricked into SQL injection when you pass a specially crafted dictionary via **kwargs that includes a malicious _connector entry. This bypasses the normal query parameterization and lets an attacker inject arbitrary SQL into the WHERE clause.

Fix: Upgrade this library to at least version 5.2.8 at sentry/uv.lock:305.

Reference(s): GHSA-frmv-pr5f-9mcr, CVE-2025-64459

🎈 Fixed in commit a12ee26 🎈


if not projects:
logger.warning(
"rpc_get_profile_flamegraph: No projects found for organization",
extra={"organization_id": organization_id},
)
return {"error": "No projects found for organization"}

# Search up to 90 days back using 14-day sliding windows
now = datetime.now(UTC)
window_days = 14
max_days = 90

full_profile_id: str | None = None
full_profiler_id: str | None = None
project_id: int | None = None
min_start_ts: float | None = None
max_end_ts: float | None = None

# Slide back in time in 14-day windows
for days_back in range(0, max_days, window_days):
window_end = now - timedelta(days=days_back)
window_start = now - timedelta(days=min(days_back + window_days, max_days))

snuba_params = SnubaParams(
start=window_start,
end=window_end,
projects=projects,
organization=organization,
)

# 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}*)",
selected_columns=[
"profile.id",
"profiler.id",
"project.id",
"min(precise.start_ts)",
"max(precise.finish_ts)",
],
orderby=[],
offset=0,
limit=1,
referrer=Referrer.SEER_RPC,
config=SearchResolverConfig(
auto_fields=True,
),
sampling_mode="NORMAL",
)

data = result.get("data")
if data:
row = data[0]
full_profile_id = row.get("profile.id")
full_profiler_id = row.get("profiler.id")
project_id = row.get("project.id")
min_start_ts = row.get("min(precise.start_ts)")
max_end_ts = row.get("max(precise.finish_ts)")
break
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Aggregate Query Mixes Profile Data

The aggregate query at lines 349-367 doesn't use GROUP BY when selecting non-aggregated columns (profile.id, profiler.id, project.id) alongside aggregation functions (min(precise.start_ts), max(precise.finish_ts)). When the wildcard query matches spans with different full profile IDs sharing the same prefix, the aggregates compute min/max timestamps across all profiles while returning an arbitrary profile ID. This causes fetch_profile_data to receive timestamps from different profiles than the one being fetched, potentially leading to incorrect or missing profile data.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

pretty sure it automatically applies the group by


# Determine profile type and actual ID to use
is_continuous = bool(full_profiler_id and not full_profile_id)
actual_profile_id = full_profiler_id or full_profile_id

if not actual_profile_id:
logger.info(
"rpc_get_profile_flamegraph: Profile not found",
extra={"profile_id": profile_id, "organization_id": organization_id},
)
return {"error": "Profile not found in the last 90 days"}
if not project_id:
logger.warning(
"rpc_get_profile_flamegraph: Could not find project id for profile",
extra={"profile_id": profile_id, "organization_id": organization_id},
)
return {"error": "Project not found"}

logger.info(
"rpc_get_profile_flamegraph: Found profile",
extra={
"profile_id": actual_profile_id,
"project_id": project_id,
"is_continuous": is_continuous,
"min_start_ts": min_start_ts,
"max_end_ts": max_end_ts,
},
)

# Fetch the profile data
profile_data = fetch_profile_data(
profile_id=actual_profile_id,
organization_id=organization_id,
project_id=project_id,
start_ts=min_start_ts,
end_ts=max_end_ts,
is_continuous=is_continuous,
)

if not profile_data:
logger.warning(
"rpc_get_profile_flamegraph: Failed to fetch profile data from profiling service",
extra={"profile_id": actual_profile_id, "project_id": project_id},
)
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)

if not execution_tree:
logger.warning(
"rpc_get_profile_flamegraph: Empty execution tree",
extra={"profile_id": actual_profile_id, "project_id": project_id},
)
return {"error": "Failed to generate execution tree from profile data"}

return {
"execution_tree": execution_tree,
"metadata": {
"profile_id": actual_profile_id,
"project_id": project_id,
"is_continuous": is_continuous,
"start_ts": min_start_ts,
"end_ts": max_end_ts,
},
}


def get_repository_definition(*, organization_id: int, repo_full_name: str) -> dict | None:
"""
Look up a repository by full name (owner/repo-name) that the org has access to.
Expand Down
Loading
Loading