From 1c4c194b9a8ef4a16ea604d615902829d93c358c Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:00:49 -0800 Subject: [PATCH 1/4] generic fxs --- src/sentry/seer/explorer/tools.py | 130 +++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 3e803bada9d910..2a070be9f3dd06 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -5,6 +5,7 @@ from sentry import eventstore, features from sentry.api import client +from sentry.api.endpoints.organization_events_timeseries import TOP_EVENTS_DATASETS from sentry.api.serializers.base import serialize from sentry.api.serializers.models.event import EventSerializer, IssueEventSerializerResponse from sentry.api.serializers.models.group import GroupSerializer @@ -18,7 +19,7 @@ from sentry.replays.post_process import process_raw_response from sentry.replays.query import query_replay_id_by_prefix, query_replay_instance from sentry.search.eap.types import SearchResolverConfig -from sentry.search.events.types import SnubaParams +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.sentry_data_models import EAPTrace @@ -26,6 +27,7 @@ from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans from sentry.snuba.trace import query_trace_data +from sentry.snuba.utils import get_dataset from sentry.utils.dates import parse_stats_period logger = logging.getLogger(__name__) @@ -63,7 +65,6 @@ def execute_trace_query_chart( "project": project_ids, "dataset": "spans", "referrer": Referrer.SEER_RPC, - "transformAliasToInputFormat": "1", # Required for RPC datasets } # Add group_by if provided (for top events) @@ -160,7 +161,6 @@ def execute_trace_query_table( "project": project_ids, "dataset": "spans", "referrer": Referrer.SEER_RPC, - "transformAliasToInputFormat": "1", # Required for RPC datasets } # Remove None values @@ -175,6 +175,130 @@ def execute_trace_query_table( return resp.data +def execute_timeseries_query( + *, + org_id: int, + dataset: str, + y_axes: list[str], + group_by: list[str] | None = None, + query: str, + stats_period: str, + interval: str | None = None, # Stats period format, e.g. '3h' + project_ids: list[int] | None = None, + sampling_mode: SAMPLING_MODES = "NORMAL", +) -> dict[str, Any] | None: + """ + Execute a query to get chart/timeseries data by calling the events-stats endpoint. + """ + try: + organization = Organization.objects.get(id=org_id) + except Organization.DoesNotExist: + logger.warning("Organization not found", extra={"org_id": org_id}) + return None + + group_by = group_by or [] + + params: dict[str, Any] = { + "dataset": dataset, + "yAxis": y_axes, + "field": y_axes + group_by, + "query": query, + "statsPeriod": stats_period, + "interval": interval, + "project": project_ids, + "sampling": sampling_mode, + "referrer": Referrer.SEER_RPC, + "excludeOther": "0", # Always include "Other" series + } + + # Add top_events if group_by is provided + if group_by and get_dataset(dataset) in TOP_EVENTS_DATASETS: + params["topEvents"] = 5 + + # Remove None values + params = {k: v for k, v in params.items() if v is not None} + + # Call sentry API client. This will raise API errors for non-2xx / 3xx status. + resp = client.get( + auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]), + user=None, + path=f"/organizations/{organization.slug}/events-stats/", + params=params, + ) + data = resp.data + + # Always normalize to the nested {"metric": {"data": [...]}} format for consistency + metric_is_single = len(y_axes) == 1 + metric_name = y_axes[0] if metric_is_single else None + if metric_name and metric_is_single: + # Handle grouped data with single metric: wrap each group's data in the metric name + if group_by: + return { + group_value: ( + {metric_name: group_data} + if isinstance(group_data, dict) and "data" in group_data + else group_data + ) + for group_value, group_data in data.items() + } + + # Handle non-grouped data with single metric: wrap data in the metric name + if isinstance(data, dict) and "data" in data: + return {metric_name: data} + + return data + + +def execute_table_query( + *, + org_id: int, + dataset: str, + fields: list[str], + query: str, + sort: str, + per_page: int, + stats_period: str, + project_ids: list[int] | None = None, + sampling_mode: SAMPLING_MODES = "NORMAL", +) -> dict[str, Any] | None: + """ + Execute a query to get table data by calling the events endpoint. + """ + try: + organization = Organization.objects.get(id=org_id) + except Organization.DoesNotExist: + logger.warning("Organization not found", extra={"org_id": org_id}) + return None + + if not fields: + # Must pass in at least one field. + return None + + params: dict[str, Any] = { + "dataset": dataset, + "field": fields, + "query": query, + "sort": sort if sort else ("-timestamp" if "timestamp" in fields else None), + "per_page": per_page, + "statsPeriod": stats_period, + "project": project_ids, + "sampling": sampling_mode, + "referrer": Referrer.SEER_RPC, + } + + # Remove None values + params = {k: v for k, v in params.items() if v is not None} + + # Call sentry API client. This will raise API errors for non-2xx / 3xx status. + resp = client.get( + auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]), + user=None, + path=f"/organizations/{organization.slug}/events/", + params=params, + ) + return resp.data + + def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: """ Get the full span waterfall and connected errors for a trace. From 8471621200f884664563455f59a81f2646e0ef4d Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:55:34 -0800 Subject: [PATCH 2/4] call generics from current rpcs --- src/sentry/seer/endpoints/seer_rpc.py | 4 + src/sentry/seer/explorer/tools.py | 243 +++++++++-------------- tests/sentry/seer/explorer/test_tools.py | 1 + 3 files changed, 96 insertions(+), 152 deletions(-) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 536f04230c015a..a96177db2442ad 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -86,6 +86,8 @@ rpc_get_transactions_for_project, ) from sentry.seer.explorer.tools import ( + execute_table_query, + execute_timeseries_query, execute_trace_query_chart, execute_trace_query_table, get_issue_details, @@ -1196,6 +1198,8 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s "get_issue_details": get_issue_details, "execute_trace_query_chart": execute_trace_query_chart, "execute_trace_query_table": execute_trace_query_table, + "execute_table_query": execute_table_query, + "execute_timeseries_query": execute_timeseries_query, "get_repository_definition": get_repository_definition, "call_custom_tool": call_custom_tool, # diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 2a070be9f3dd06..da8d4c5757f07c 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -10,7 +10,7 @@ from sentry.api.serializers.models.event import EventSerializer, IssueEventSerializerResponse from sentry.api.serializers.models.group import GroupSerializer from sentry.api.utils import default_start_end_dates -from sentry.constants import ObjectStatus +from sentry.constants import ALL_ACCESS_PROJECT_ID, ObjectStatus from sentry.models.apikey import ApiKey from sentry.models.group import Group from sentry.models.organization import Organization @@ -45,62 +45,15 @@ def execute_trace_query_chart( """ Execute a trace query to get chart/timeseries data by calling the events-stats endpoint. """ - try: - organization = Organization.objects.get(id=org_id) - except Organization.DoesNotExist: - logger.warning("Organization not found", extra={"org_id": org_id}) - return None - - # Use provided project_ids or get all project IDs for the organization - if project_ids is None: - project_ids = list(organization.project_set.values_list("id", flat=True)) - if not project_ids: - logger.warning("No projects found for organization", extra={"org_id": org_id}) - return None - - params: dict[str, Any] = { - "query": query, - "statsPeriod": stats_period, - "yAxis": y_axes, - "project": project_ids, - "dataset": "spans", - "referrer": Referrer.SEER_RPC, - } - - # Add group_by if provided (for top events) - if group_by and len(group_by) > 0: - params["topEvents"] = 5 - params["field"] = group_by - params["excludeOther"] = "0" # Include "Other" series - - resp = client.get( - auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]), - user=None, - path=f"/organizations/{organization.slug}/events-stats/", - params=params, + return execute_timeseries_query( + org_id=org_id, + dataset="spans", + y_axes=y_axes, + group_by=group_by, + query=query, + stats_period=stats_period, + project_ids=project_ids, ) - data = resp.data - - # Always normalize to the nested {"metric": {"data": [...]}} format for consistency - metric_is_single = len(y_axes) == 1 - metric_name = y_axes[0] if metric_is_single else None - if metric_name and metric_is_single: - # Handle grouped data with single metric: wrap each group's data in the metric name - if group_by: - return { - group_value: ( - {metric_name: group_data} - if isinstance(group_data, dict) and "data" in group_data - else group_data - ) - for group_value, group_data in data.items() - } - - # Handle non-grouped data with single metric: wrap data in the metric name - if isinstance(data, dict) and "data" in data: - return {metric_name: data} - - return data def execute_trace_query_table( @@ -118,19 +71,6 @@ def execute_trace_query_table( """ Execute a trace query to get table data by calling the events endpoint. """ - try: - organization = Organization.objects.get(id=org_id) - except Organization.DoesNotExist: - logger.warning("Organization not found", extra={"org_id": org_id}) - return None - - # Use provided project_ids or get all project IDs for the organization - if project_ids is None: - project_ids = list(organization.project_set.values_list("id", flat=True)) - if not project_ids: - logger.warning("No projects found for organization", extra={"org_id": org_id}) - return None - # Determine fields based on mode if mode == "aggregates": # Aggregates mode: group_by fields + aggregate functions @@ -152,20 +92,70 @@ def execute_trace_query_table( "trace", ] + return execute_table_query( + org_id=org_id, + dataset="spans", + fields=fields, + query=query, + sort=sort, + per_page=per_page, + stats_period=stats_period, + project_ids=project_ids, + ) + + +def execute_table_query( + *, + org_id: int, + dataset: str, + fields: list[str], + query: str, + sort: str, + per_page: int, + stats_period: str, + project_ids: list[int] | None = None, + project_slugs: list[str] | None = None, + sampling_mode: SAMPLING_MODES = "NORMAL", +) -> dict[str, Any] | None: + """ + Execute a query to get table data by calling the events endpoint. + + Arg notes: + project_ids: The IDs of the projects to query. Cannot be provided with project_slugs. + project_slugs: The slugs of the projects to query. Cannot be provided with project_ids. + If neither project_ids nor project_slugs are provided, all active projects will be queried. + """ + try: + organization = Organization.objects.get(id=org_id) + except Organization.DoesNotExist: + logger.warning("Organization not found", extra={"org_id": org_id}) + return None + + if not fields: + # Must pass in at least one field. + return None + + if not project_ids and not project_slugs: + project_ids = [ALL_ACCESS_PROJECT_ID] + # Note if both project_ids and project_slugs are provided, the API request will 400. + params: dict[str, Any] = { - "query": query, - "statsPeriod": stats_period, + "dataset": dataset, "field": fields, - "sort": sort if sort else ("-timestamp" if not group_by else None), + "query": query, + "sort": sort if sort else ("-timestamp" if "timestamp" in fields else None), "per_page": per_page, + "statsPeriod": stats_period, "project": project_ids, - "dataset": "spans", + "projectSlug": project_slugs, + "sampling": sampling_mode, "referrer": Referrer.SEER_RPC, } # Remove None values params = {k: v for k, v in params.items() if v is not None} + # Call sentry API client. This will raise API errors for non-2xx / 3xx status. resp = client.get( auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]), user=None, @@ -183,12 +173,21 @@ def execute_timeseries_query( group_by: list[str] | None = None, query: str, stats_period: str, - interval: str | None = None, # Stats period format, e.g. '3h' + interval: str | None = None, project_ids: list[int] | None = None, + project_slugs: list[str] | None = None, sampling_mode: SAMPLING_MODES = "NORMAL", + partial: Literal["0", "1"] | None = None, ) -> dict[str, Any] | None: """ Execute a query to get chart/timeseries data by calling the events-stats endpoint. + + Arg notes: + interval: The interval of each bucket. Valid stats period format, e.g. '3h'. + partial: Whether to allow partial buckets if the last bucket does not align with rollup. + project_ids: The IDs of the projects to query. Cannot be provided with project_slugs. + project_slugs: The slugs of the projects to query. Cannot be provided with project_ids. + If neither project_ids nor project_slugs are provided, all active projects will be queried. """ try: organization = Organization.objects.get(id=org_id) @@ -197,6 +196,9 @@ def execute_timeseries_query( return None group_by = group_by or [] + if not project_ids and not project_slugs: + project_ids = [ALL_ACCESS_PROJECT_ID] + # Note if both project_ids and project_slugs are provided, the API request will 400. params: dict[str, Any] = { "dataset": dataset, @@ -206,8 +208,10 @@ def execute_timeseries_query( "statsPeriod": stats_period, "interval": interval, "project": project_ids, + "projectSlug": project_slugs, "sampling": sampling_mode, "referrer": Referrer.SEER_RPC, + "partial": partial, "excludeOther": "0", # Always include "Other" series } @@ -249,56 +253,6 @@ def execute_timeseries_query( return data -def execute_table_query( - *, - org_id: int, - dataset: str, - fields: list[str], - query: str, - sort: str, - per_page: int, - stats_period: str, - project_ids: list[int] | None = None, - sampling_mode: SAMPLING_MODES = "NORMAL", -) -> dict[str, Any] | None: - """ - Execute a query to get table data by calling the events endpoint. - """ - try: - organization = Organization.objects.get(id=org_id) - except Organization.DoesNotExist: - logger.warning("Organization not found", extra={"org_id": org_id}) - return None - - if not fields: - # Must pass in at least one field. - return None - - params: dict[str, Any] = { - "dataset": dataset, - "field": fields, - "query": query, - "sort": sort if sort else ("-timestamp" if "timestamp" in fields else None), - "per_page": per_page, - "statsPeriod": stats_period, - "project": project_ids, - "sampling": sampling_mode, - "referrer": Referrer.SEER_RPC, - } - - # Remove None values - params = {k: v for k, v in params.items() if v is not None} - - # Call sentry API client. This will raise API errors for non-2xx / 3xx status. - resp = client.get( - auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]), - user=None, - path=f"/organizations/{organization.slug}/events/", - params=params, - ) - return resp.data - - def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: """ Get the full span waterfall and connected errors for a trace. @@ -476,7 +430,8 @@ def _get_issue_event_timeseries( first_seen_delta: timedelta, ) -> tuple[dict[str, Any], str, str] | None: """ - Get event counts over time for an issue by calling the events-stats endpoint. + Get event counts over time for an issue (no group by) by calling the events-stats endpoint. Dynamically picks + a stats period and interval based on the issue's first seen date and EVENT_TIMESERIES_RESOLUTIONS. """ stats_period, interval = None, None @@ -488,35 +443,19 @@ def _get_issue_event_timeseries( stats_period = stats_period or "90d" interval = interval or "3d" - params: dict[str, Any] = { - "dataset": "issuePlatform", - "query": f"issue:{issue_short_id}", - "yAxis": "count()", - "partial": "1", - "statsPeriod": stats_period, - "interval": interval, - "project": project_id, - "referrer": Referrer.SEER_RPC, - } - - resp = client.get( - auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]), - user=None, - path=f"/organizations/{organization.slug}/events-stats/", - params=params, + data = execute_timeseries_query( + org_id=organization.id, + dataset="issuePlatform", + y_axes=["count()"], + group_by=[], + query=f"issue:{issue_short_id}", + stats_period=stats_period, + interval=interval, + project_ids=[project_id], + partial="1", ) - if resp.status_code != 200 or not (resp.data or {}).get("data"): - logger.warning( - "Failed to get event counts for issue", - extra={ - "organization_slug": organization.slug, - "project_id": project_id, - "issue_id": issue_short_id, - }, - ) - return None - return {"count()": {"data": resp.data["data"]}}, stats_period, interval + return data, stats_period, interval def get_issue_details( diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index a51db5a5cdb82e..01b38398877d81 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -88,6 +88,7 @@ def test_execute_trace_query_chart_count_metric(self): query="", stats_period="1h", y_axes=["count()"], + # project_ids=[self.project.id], ) assert result is not None From 33faefdbd4f7bdd676c66b4b828fe2b51748890a Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:56:46 -0800 Subject: [PATCH 3/4] rm comment --- tests/sentry/seer/explorer/test_tools.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 01b38398877d81..a51db5a5cdb82e 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -88,7 +88,6 @@ def test_execute_trace_query_chart_count_metric(self): query="", stats_period="1h", y_axes=["count()"], - # project_ids=[self.project.id], ) assert result is not None From 20ffb3cbdabe32567cf9b3eb3015bd3b8725a9bf Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 14 Nov 2025 16:39:16 -0800 Subject: [PATCH 4/4] restore old rpcs + review --- src/sentry/seer/explorer/tools.py | 113 ++++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 21 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index da8d4c5757f07c..e80075003531bd 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -45,15 +45,63 @@ def execute_trace_query_chart( """ Execute a trace query to get chart/timeseries data by calling the events-stats endpoint. """ - return execute_timeseries_query( - org_id=org_id, - dataset="spans", - y_axes=y_axes, - group_by=group_by, - query=query, - stats_period=stats_period, - project_ids=project_ids, + try: + organization = Organization.objects.get(id=org_id) + except Organization.DoesNotExist: + logger.warning("Organization not found", extra={"org_id": org_id}) + return None + + # Use provided project_ids or get all project IDs for the organization + if project_ids is None: + project_ids = list(organization.project_set.values_list("id", flat=True)) + if not project_ids: + logger.warning("No projects found for organization", extra={"org_id": org_id}) + return None + + params: dict[str, Any] = { + "query": query, + "statsPeriod": stats_period, + "yAxis": y_axes, + "project": project_ids, + "dataset": "spans", + "referrer": Referrer.SEER_RPC, + "transformAliasToInputFormat": "1", # Required for RPC datasets + } + + # Add group_by if provided (for top events) + if group_by and len(group_by) > 0: + params["topEvents"] = 5 + params["field"] = group_by + params["excludeOther"] = "0" # Include "Other" series + + resp = client.get( + auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]), + user=None, + path=f"/organizations/{organization.slug}/events-stats/", + params=params, ) + data = resp.data + + # Always normalize to the nested {"metric": {"data": [...]}} format for consistency + metric_is_single = len(y_axes) == 1 + metric_name = y_axes[0] if metric_is_single else None + if metric_name and metric_is_single: + # Handle grouped data with single metric: wrap each group's data in the metric name + if group_by: + return { + group_value: ( + {metric_name: group_data} + if isinstance(group_data, dict) and "data" in group_data + else group_data + ) + for group_value, group_data in data.items() + } + + # Handle non-grouped data with single metric: wrap data in the metric name + if isinstance(data, dict) and "data" in data: + return {metric_name: data} + + return data def execute_trace_query_table( @@ -71,6 +119,19 @@ def execute_trace_query_table( """ Execute a trace query to get table data by calling the events endpoint. """ + try: + organization = Organization.objects.get(id=org_id) + except Organization.DoesNotExist: + logger.warning("Organization not found", extra={"org_id": org_id}) + return None + + # Use provided project_ids or get all project IDs for the organization + if project_ids is None: + project_ids = list(organization.project_set.values_list("id", flat=True)) + if not project_ids: + logger.warning("No projects found for organization", extra={"org_id": org_id}) + return None + # Determine fields based on mode if mode == "aggregates": # Aggregates mode: group_by fields + aggregate functions @@ -92,16 +153,28 @@ def execute_trace_query_table( "trace", ] - return execute_table_query( - org_id=org_id, - dataset="spans", - fields=fields, - query=query, - sort=sort, - per_page=per_page, - stats_period=stats_period, - project_ids=project_ids, + params: dict[str, Any] = { + "query": query, + "statsPeriod": stats_period, + "field": fields, + "sort": sort if sort else ("-timestamp" if not group_by else None), + "per_page": per_page, + "project": project_ids, + "dataset": "spans", + "referrer": Referrer.SEER_RPC, + "transformAliasToInputFormat": "1", # Required for RPC datasets + } + + # Remove None values + params = {k: v for k, v in params.items() if v is not None} + + resp = client.get( + auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]), + user=None, + path=f"/organizations/{organization.slug}/events/", + params=params, ) + return resp.data def execute_table_query( @@ -131,10 +204,6 @@ def execute_table_query( logger.warning("Organization not found", extra={"org_id": org_id}) return None - if not fields: - # Must pass in at least one field. - return None - if not project_ids and not project_slugs: project_ids = [ALL_ACCESS_PROJECT_ID] # Note if both project_ids and project_slugs are provided, the API request will 400. @@ -455,6 +524,8 @@ def _get_issue_event_timeseries( partial="1", ) + if data is None: + return None return data, stats_period, interval