From 806c030b05852c7efd9d29f0966d3407cf54fb4d Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Thu, 13 Nov 2025 07:41:43 -0800 Subject: [PATCH 1/5] feat(explorer): add rpc for profile flamegraph tool --- src/sentry/seer/endpoints/seer_rpc.py | 2 + src/sentry/seer/explorer/tools.py | 155 ++++++++++++++++++ tests/sentry/seer/explorer/test_tools.py | 193 +++++++++++++++++++++++ 3 files changed, 350 insertions(+) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 536f04230c015a..379fc083c24c67 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -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 @@ -1194,6 +1195,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, diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 2ea0f2a4971324..d0262a2e4f7065 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -20,6 +20,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 @@ -282,6 +283,160 @@ 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)) + + 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 = None + full_profiler_id = None + project_id = None + min_start_ts = None + max_end_ts = 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 + + if not full_profile_id and not full_profiler_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"} + + # 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 + + 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. diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 54a171393674a2..5f40b4a8d279f7 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -19,6 +19,7 @@ get_issue_details, get_repository_definition, get_trace_waterfall, + rpc_get_profile_flamegraph, ) from sentry.seer.sentry_data_models import EAPTrace from sentry.testutils.cases import APITransactionTestCase, SnubaTestCase, SpanTestCase @@ -1144,3 +1145,195 @@ def test_get_repository_definition_filters_unsupported_with_supported(self): assert result is not None assert result["provider"] == "integrations:github" assert result["external_id"] == "12345678" + + +@pytest.mark.django_db(databases=["default", "control"]) +class TestRpcGetProfileFlamegraph(APITransactionTestCase, SpanTestCase, SnubaTestCase): + def setUp(self): + super().setUp() + self.ten_mins_ago = before_now(minutes=10) + + @patch("sentry.seer.explorer.tools._convert_profile_to_execution_tree") + @patch("sentry.seer.explorer.tools.fetch_profile_data") + def test_rpc_get_profile_flamegraph_finds_transaction_profile( + self, mock_fetch_profile, mock_convert_tree + ): + """Test finding transaction profile via profile.id with wildcard query""" + profile_id_8char = "a1b2c3d4" + full_profile_id = profile_id_8char + "e5f6789012345678901234567" + + # Create span with profile_id (top-level field) + span = self.create_span( + { + "description": "test span", + "profile_id": full_profile_id, + }, + start_ts=self.ten_mins_ago, + duration=100, + ) + self.store_spans([span], is_eap=True) + + # Mock the profile data fetch and conversion + mock_fetch_profile.return_value = {"profile": {"frames": [], "stacks": [], "samples": []}} + mock_convert_tree.return_value = [{"function": "main", "module": "app"}] + + result = rpc_get_profile_flamegraph(profile_id_8char, self.organization.id) + + # Should find the profile via wildcard query + assert "execution_tree" in result + assert result["metadata"]["profile_id"] == full_profile_id + assert result["metadata"]["is_continuous"] is False + + @patch("sentry.seer.explorer.tools._convert_profile_to_execution_tree") + @patch("sentry.seer.explorer.tools.fetch_profile_data") + def test_rpc_get_profile_flamegraph_finds_continuous_profile( + self, mock_fetch_profile, mock_convert_tree + ): + """Test finding continuous profile via profiler.id with wildcard query""" + profiler_id_8char = "b1c2d3e4" + full_profiler_id = profiler_id_8char + "f5a6b7c8d9e0f1a2b3c4d5e6" + + # Create span with profiler_id in sentry_tags (continuous profile) + # Set profile_id to None since continuous profiles use profiler_id instead + span = self.create_span( + { + "description": "continuous span", + "profile_id": None, + "sentry_tags": { + "profiler_id": full_profiler_id, + }, + }, + start_ts=self.ten_mins_ago, + duration=200, + ) + self.store_spans([span], is_eap=True) + + # Mock the profile data + mock_fetch_profile.return_value = { + "chunk": {"profile": {"frames": [], "stacks": [], "samples": []}} + } + mock_convert_tree.return_value = [{"function": "worker", "module": "tasks"}] + + result = rpc_get_profile_flamegraph(profiler_id_8char, self.organization.id) + + # Should find via profiler.id and identify as continuous + assert "execution_tree" in result + assert result["metadata"]["profile_id"] == full_profiler_id + assert result["metadata"]["is_continuous"] is True + + @patch("sentry.seer.explorer.tools._convert_profile_to_execution_tree") + @patch("sentry.seer.explorer.tools.fetch_profile_data") + def test_rpc_get_profile_flamegraph_aggregates_timestamps_across_spans( + self, mock_fetch_profile, mock_convert_tree + ): + """Test that min/max timestamps are aggregated across multiple spans with same profile""" + profile_id_8char = "c1d2e3f4" + full_profile_id = profile_id_8char + "a5b6c7d8e9f0a1b2c3d4e5f6" + + # Create multiple spans with the same profile at different times + span1_time = self.ten_mins_ago + span2_time = self.ten_mins_ago + timedelta(minutes=2) + span3_time = self.ten_mins_ago + timedelta(minutes=5) + + spans = [ + self.create_span( + { + "description": f"span-{i}", + "profile_id": full_profile_id, + }, + start_ts=start_time, + duration=100, + ) + for i, start_time in enumerate([span1_time, span2_time, span3_time]) + ] + self.store_spans(spans, is_eap=True) + + mock_fetch_profile.return_value = {"profile": {"frames": [], "stacks": [], "samples": []}} + mock_convert_tree.return_value = [{"function": "test", "module": "test"}] + + result = rpc_get_profile_flamegraph(profile_id_8char, self.organization.id) + + # Verify the aggregate query worked and got min/max timestamps + assert "execution_tree" in result + metadata = result["metadata"] + assert metadata["profile_id"] == full_profile_id + + # Should have aggregated start_ts and end_ts from all spans + assert metadata["start_ts"] is not None + assert metadata["end_ts"] is not None + # The min should be from span1, max from span3 + assert metadata["start_ts"] <= metadata["end_ts"] + + @patch("sentry.seer.explorer.tools._convert_profile_to_execution_tree") + @patch("sentry.seer.explorer.tools.fetch_profile_data") + def test_rpc_get_profile_flamegraph_sliding_window_finds_old_profile( + self, mock_fetch_profile, mock_convert_tree + ): + """Test that sliding 14-day windows can find profiles from 20 days ago""" + profile_id_8char = "d1e2f3a4" + full_profile_id = profile_id_8char + "b5c6d7e8f9a0b1c2d3e4f5a6" + twenty_days_ago = before_now(days=20) + + # Create span 20 days ago + span = self.create_span( + { + "description": "old span", + "profile_id": full_profile_id, + }, + start_ts=twenty_days_ago, + duration=150, + ) + self.store_spans([span], is_eap=True) + + mock_fetch_profile.return_value = {"profile": {"frames": [], "stacks": [], "samples": []}} + mock_convert_tree.return_value = [{"function": "old_function", "module": "old"}] + + result = rpc_get_profile_flamegraph(profile_id_8char, self.organization.id) + + # Should find it via sliding window (second 14-day window) + assert "execution_tree" in result + assert result["metadata"]["profile_id"] == full_profile_id + + @patch("sentry.seer.explorer.tools._convert_profile_to_execution_tree") + @patch("sentry.seer.explorer.tools.fetch_profile_data") + def test_rpc_get_profile_flamegraph_full_32char_id(self, mock_fetch_profile, mock_convert_tree): + """Test with full 32-character profile ID (no wildcard needed)""" + full_profile_id = "e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6" + + span = self.create_span( + { + "description": "test span", + "profile_id": full_profile_id, + }, + start_ts=self.ten_mins_ago, + duration=100, + ) + self.store_spans([span], is_eap=True) + + mock_fetch_profile.return_value = {"profile": {"frames": [], "stacks": [], "samples": []}} + mock_convert_tree.return_value = [{"function": "handler", "module": "server"}] + + result = rpc_get_profile_flamegraph(full_profile_id, self.organization.id) + + # Should work with full ID + assert "execution_tree" in result + assert result["metadata"]["profile_id"] == full_profile_id + + def test_rpc_get_profile_flamegraph_not_found_in_90_days(self): + """Test when profile ID doesn't match any spans in 90-day window""" + # Create a span without the profile we're looking for + span = self.create_span( + { + "description": "unrelated span", + "profile_id": "different12345678901234567890123", + }, + start_ts=self.ten_mins_ago, + duration=100, + ) + self.store_spans([span], is_eap=True) + + result = rpc_get_profile_flamegraph("notfound", self.organization.id) + + # Should return error indicating not found + assert "error" in result + assert "not found in the last 90 days" in result["error"] From 93101cce3c8bd89576f893ddbe5118f3af675c7d Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Thu, 13 Nov 2025 08:15:19 -0800 Subject: [PATCH 2/5] fix typing --- src/sentry/seer/explorer/tools.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index d0262a2e4f7065..58c307a2f4cb69 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -327,11 +327,11 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st window_days = 14 max_days = 90 - full_profile_id = None - full_profiler_id = None - project_id = None - min_start_ts = None - max_end_ts = None + 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): @@ -376,16 +376,22 @@ def rpc_get_profile_flamegraph(profile_id: str, organization_id: int) -> dict[st max_end_ts = row.get("max(precise.finish_ts)") break - if not full_profile_id and not full_profiler_id: + # 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"} - - # 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 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", From d397e7dc76a5e0c376ea878b18a04c975fa186ae Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Fri, 14 Nov 2025 09:43:56 -0800 Subject: [PATCH 3/5] test cleanup --- tests/sentry/seer/explorer/test_tools.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 5f40b4a8d279f7..10ab235a723788 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -22,7 +22,7 @@ rpc_get_profile_flamegraph, ) from sentry.seer.sentry_data_models import EAPTrace -from sentry.testutils.cases import APITransactionTestCase, SnubaTestCase, SpanTestCase +from sentry.testutils.cases import APITestCase, APITransactionTestCase, SnubaTestCase, SpanTestCase from sentry.testutils.helpers.datetime import before_now from sentry.utils.dates import parse_stats_period from sentry.utils.samples import load_data @@ -1147,8 +1147,7 @@ def test_get_repository_definition_filters_unsupported_with_supported(self): assert result["external_id"] == "12345678" -@pytest.mark.django_db(databases=["default", "control"]) -class TestRpcGetProfileFlamegraph(APITransactionTestCase, SpanTestCase, SnubaTestCase): +class TestRpcGetProfileFlamegraph(APITestCase, SpanTestCase, SnubaTestCase): def setUp(self): super().setUp() self.ten_mins_ago = before_now(minutes=10) From e6baabea9cfed8748557d5b0cb04ef0b52166897 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Sat, 15 Nov 2025 00:59:26 +0000 Subject: [PATCH 4/5] :hammer_and_wrench: apply pre-commit fixes --- tests/sentry/seer/explorer/test_tools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index fe71cae38f1710..102b747d3a143e 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1343,6 +1343,7 @@ def test_rpc_get_profile_flamegraph_not_found_in_90_days(self): assert "error" in result assert "not found in the last 90 days" in result["error"] + class TestGetReplayMetadata(ReplaysSnubaTestCase): def setUp(self) -> None: super().setUp() @@ -1533,4 +1534,4 @@ def test_get_replay_metadata_short_id(self) -> None: organization_id=self.organization.id, ) is None - ) \ No newline at end of file + ) From f7f1cdf3398735c3e1ad8f6b47e611a2a9b3dd37 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Fri, 14 Nov 2025 17:01:57 -0800 Subject: [PATCH 5/5] lint --- tests/sentry/seer/explorer/test_tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 102b747d3a143e..7f0c27a841a451 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -25,6 +25,7 @@ ) from sentry.seer.sentry_data_models import EAPTrace from sentry.testutils.cases import ( + APITestCase, APITransactionTestCase, ReplaysSnubaTestCase, SnubaTestCase,