diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index bf1330b7a293ee..f2a9613eae773e 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 @@ -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, diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 3e803bada9d910..d0cf222a7e1bec 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -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 @@ -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)) + + 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 + + # 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. diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index a51db5a5cdb82e..7f0c27a841a451 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -21,9 +21,11 @@ get_replay_metadata, get_repository_definition, get_trace_waterfall, + rpc_get_profile_flamegraph, ) from sentry.seer.sentry_data_models import EAPTrace from sentry.testutils.cases import ( + APITestCase, APITransactionTestCase, ReplaysSnubaTestCase, SnubaTestCase, @@ -1152,6 +1154,197 @@ def test_get_repository_definition_filters_unsupported_with_supported(self): assert result["external_id"] == "12345678" +class TestRpcGetProfileFlamegraph(APITestCase, 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"] + + class TestGetReplayMetadata(ReplaysSnubaTestCase): def setUp(self) -> None: super().setUp()