Skip to content

Commit c479fcd

Browse files
authored
feat(explorer): support generic table and timeseries rpcs and migrate existing tools to them (#103413)
Allows us to make generic (dataset agnostic) EAP table and timeseries queries through Seer RPC. These 2 RPCs are mainly wrappers around the /events/ and /events-stats/ sentry endpoints. Bonus of supporting project_slug filters, which is what the agent works with For testing purposes, the trace_query RPCs are still supported but call the generic ones under the hood. We plan to eventually deprecate these and migrate the agent tools to the generics
1 parent e61a46b commit c479fcd

File tree

2 files changed

+168
-30
lines changed

2 files changed

+168
-30
lines changed

src/sentry/seer/endpoints/seer_rpc.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@
8686
rpc_get_transactions_for_project,
8787
)
8888
from sentry.seer.explorer.tools import (
89+
execute_table_query,
90+
execute_timeseries_query,
8991
execute_trace_query_chart,
9092
execute_trace_query_table,
9193
get_issue_details,
@@ -1199,6 +1201,8 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s
11991201
"get_issue_details": get_issue_details,
12001202
"execute_trace_query_chart": execute_trace_query_chart,
12011203
"execute_trace_query_table": execute_trace_query_table,
1204+
"execute_table_query": execute_table_query,
1205+
"execute_timeseries_query": execute_timeseries_query,
12021206
"get_repository_definition": get_repository_definition,
12031207
"call_custom_tool": call_custom_tool,
12041208
#

src/sentry/seer/explorer/tools.py

Lines changed: 164 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55

66
from sentry import eventstore, features
77
from sentry.api import client
8+
from sentry.api.endpoints.organization_events_timeseries import TOP_EVENTS_DATASETS
89
from sentry.api.serializers.base import serialize
910
from sentry.api.serializers.models.event import EventSerializer, IssueEventSerializerResponse
1011
from sentry.api.serializers.models.group import GroupSerializer
1112
from sentry.api.utils import default_start_end_dates
12-
from sentry.constants import ObjectStatus
13+
from sentry.constants import ALL_ACCESS_PROJECT_ID, ObjectStatus
1314
from sentry.models.apikey import ApiKey
1415
from sentry.models.group import Group
1516
from sentry.models.organization import Organization
@@ -18,14 +19,15 @@
1819
from sentry.replays.post_process import process_raw_response
1920
from sentry.replays.query import query_replay_id_by_prefix, query_replay_instance
2021
from sentry.search.eap.types import SearchResolverConfig
21-
from sentry.search.events.types import SnubaParams
22+
from sentry.search.events.types import SAMPLING_MODES, SnubaParams
2223
from sentry.seer.autofix.autofix import get_all_tags_overview
2324
from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS
2425
from sentry.seer.sentry_data_models import EAPTrace
2526
from sentry.services.eventstore.models import Event, GroupEvent
2627
from sentry.snuba.referrer import Referrer
2728
from sentry.snuba.spans_rpc import Spans
2829
from sentry.snuba.trace import query_trace_data
30+
from sentry.snuba.utils import get_dataset
2931
from sentry.utils.dates import parse_stats_period
3032

3133
logger = logging.getLogger(__name__)
@@ -175,6 +177,151 @@ def execute_trace_query_table(
175177
return resp.data
176178

177179

180+
def execute_table_query(
181+
*,
182+
org_id: int,
183+
dataset: str,
184+
fields: list[str],
185+
query: str,
186+
sort: str,
187+
per_page: int,
188+
stats_period: str,
189+
project_ids: list[int] | None = None,
190+
project_slugs: list[str] | None = None,
191+
sampling_mode: SAMPLING_MODES = "NORMAL",
192+
) -> dict[str, Any] | None:
193+
"""
194+
Execute a query to get table data by calling the events endpoint.
195+
196+
Arg notes:
197+
project_ids: The IDs of the projects to query. Cannot be provided with project_slugs.
198+
project_slugs: The slugs of the projects to query. Cannot be provided with project_ids.
199+
If neither project_ids nor project_slugs are provided, all active projects will be queried.
200+
"""
201+
try:
202+
organization = Organization.objects.get(id=org_id)
203+
except Organization.DoesNotExist:
204+
logger.warning("Organization not found", extra={"org_id": org_id})
205+
return None
206+
207+
if not project_ids and not project_slugs:
208+
project_ids = [ALL_ACCESS_PROJECT_ID]
209+
# Note if both project_ids and project_slugs are provided, the API request will 400.
210+
211+
params: dict[str, Any] = {
212+
"dataset": dataset,
213+
"field": fields,
214+
"query": query,
215+
"sort": sort if sort else ("-timestamp" if "timestamp" in fields else None),
216+
"per_page": per_page,
217+
"statsPeriod": stats_period,
218+
"project": project_ids,
219+
"projectSlug": project_slugs,
220+
"sampling": sampling_mode,
221+
"referrer": Referrer.SEER_RPC,
222+
}
223+
224+
# Remove None values
225+
params = {k: v for k, v in params.items() if v is not None}
226+
227+
# Call sentry API client. This will raise API errors for non-2xx / 3xx status.
228+
resp = client.get(
229+
auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]),
230+
user=None,
231+
path=f"/organizations/{organization.slug}/events/",
232+
params=params,
233+
)
234+
return resp.data
235+
236+
237+
def execute_timeseries_query(
238+
*,
239+
org_id: int,
240+
dataset: str,
241+
y_axes: list[str],
242+
group_by: list[str] | None = None,
243+
query: str,
244+
stats_period: str,
245+
interval: str | None = None,
246+
project_ids: list[int] | None = None,
247+
project_slugs: list[str] | None = None,
248+
sampling_mode: SAMPLING_MODES = "NORMAL",
249+
partial: Literal["0", "1"] | None = None,
250+
) -> dict[str, Any] | None:
251+
"""
252+
Execute a query to get chart/timeseries data by calling the events-stats endpoint.
253+
254+
Arg notes:
255+
interval: The interval of each bucket. Valid stats period format, e.g. '3h'.
256+
partial: Whether to allow partial buckets if the last bucket does not align with rollup.
257+
project_ids: The IDs of the projects to query. Cannot be provided with project_slugs.
258+
project_slugs: The slugs of the projects to query. Cannot be provided with project_ids.
259+
If neither project_ids nor project_slugs are provided, all active projects will be queried.
260+
"""
261+
try:
262+
organization = Organization.objects.get(id=org_id)
263+
except Organization.DoesNotExist:
264+
logger.warning("Organization not found", extra={"org_id": org_id})
265+
return None
266+
267+
group_by = group_by or []
268+
if not project_ids and not project_slugs:
269+
project_ids = [ALL_ACCESS_PROJECT_ID]
270+
# Note if both project_ids and project_slugs are provided, the API request will 400.
271+
272+
params: dict[str, Any] = {
273+
"dataset": dataset,
274+
"yAxis": y_axes,
275+
"field": y_axes + group_by,
276+
"query": query,
277+
"statsPeriod": stats_period,
278+
"interval": interval,
279+
"project": project_ids,
280+
"projectSlug": project_slugs,
281+
"sampling": sampling_mode,
282+
"referrer": Referrer.SEER_RPC,
283+
"partial": partial,
284+
"excludeOther": "0", # Always include "Other" series
285+
}
286+
287+
# Add top_events if group_by is provided
288+
if group_by and get_dataset(dataset) in TOP_EVENTS_DATASETS:
289+
params["topEvents"] = 5
290+
291+
# Remove None values
292+
params = {k: v for k, v in params.items() if v is not None}
293+
294+
# Call sentry API client. This will raise API errors for non-2xx / 3xx status.
295+
resp = client.get(
296+
auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]),
297+
user=None,
298+
path=f"/organizations/{organization.slug}/events-stats/",
299+
params=params,
300+
)
301+
data = resp.data
302+
303+
# Always normalize to the nested {"metric": {"data": [...]}} format for consistency
304+
metric_is_single = len(y_axes) == 1
305+
metric_name = y_axes[0] if metric_is_single else None
306+
if metric_name and metric_is_single:
307+
# Handle grouped data with single metric: wrap each group's data in the metric name
308+
if group_by:
309+
return {
310+
group_value: (
311+
{metric_name: group_data}
312+
if isinstance(group_data, dict) and "data" in group_data
313+
else group_data
314+
)
315+
for group_value, group_data in data.items()
316+
}
317+
318+
# Handle non-grouped data with single metric: wrap data in the metric name
319+
if isinstance(data, dict) and "data" in data:
320+
return {metric_name: data}
321+
322+
return data
323+
324+
178325
def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None:
179326
"""
180327
Get the full span waterfall and connected errors for a trace.
@@ -352,7 +499,8 @@ def _get_issue_event_timeseries(
352499
first_seen_delta: timedelta,
353500
) -> tuple[dict[str, Any], str, str] | None:
354501
"""
355-
Get event counts over time for an issue by calling the events-stats endpoint.
502+
Get event counts over time for an issue (no group by) by calling the events-stats endpoint. Dynamically picks
503+
a stats period and interval based on the issue's first seen date and EVENT_TIMESERIES_RESOLUTIONS.
356504
"""
357505

358506
stats_period, interval = None, None
@@ -364,35 +512,21 @@ def _get_issue_event_timeseries(
364512
stats_period = stats_period or "90d"
365513
interval = interval or "3d"
366514

367-
params: dict[str, Any] = {
368-
"dataset": "issuePlatform",
369-
"query": f"issue:{issue_short_id}",
370-
"yAxis": "count()",
371-
"partial": "1",
372-
"statsPeriod": stats_period,
373-
"interval": interval,
374-
"project": project_id,
375-
"referrer": Referrer.SEER_RPC,
376-
}
377-
378-
resp = client.get(
379-
auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]),
380-
user=None,
381-
path=f"/organizations/{organization.slug}/events-stats/",
382-
params=params,
515+
data = execute_timeseries_query(
516+
org_id=organization.id,
517+
dataset="issuePlatform",
518+
y_axes=["count()"],
519+
group_by=[],
520+
query=f"issue:{issue_short_id}",
521+
stats_period=stats_period,
522+
interval=interval,
523+
project_ids=[project_id],
524+
partial="1",
383525
)
384-
if resp.status_code != 200 or not (resp.data or {}).get("data"):
385-
logger.warning(
386-
"Failed to get event counts for issue",
387-
extra={
388-
"organization_slug": organization.slug,
389-
"project_id": project_id,
390-
"issue_id": issue_short_id,
391-
},
392-
)
393-
return None
394526

395-
return {"count()": {"data": resp.data["data"]}}, stats_period, interval
527+
if data is None:
528+
return None
529+
return data, stats_period, interval
396530

397531

398532
def get_issue_details(

0 commit comments

Comments
 (0)