55
66from sentry import eventstore , features
77from sentry .api import client
8+ from sentry .api .endpoints .organization_events_timeseries import TOP_EVENTS_DATASETS
89from sentry .api .serializers .base import serialize
910from sentry .api .serializers .models .event import EventSerializer , IssueEventSerializerResponse
1011from sentry .api .serializers .models .group import GroupSerializer
1112from sentry .api .utils import default_start_end_dates
12- from sentry .constants import ObjectStatus
13+ from sentry .constants import ALL_ACCESS_PROJECT_ID , ObjectStatus
1314from sentry .models .apikey import ApiKey
1415from sentry .models .group import Group
1516from sentry .models .organization import Organization
1819from sentry .replays .post_process import process_raw_response
1920from sentry .replays .query import query_replay_id_by_prefix , query_replay_instance
2021from sentry .search .eap .types import SearchResolverConfig
21- from sentry .search .events .types import SnubaParams
22+ from sentry .search .events .types import SAMPLING_MODES , SnubaParams
2223from sentry .seer .autofix .autofix import get_all_tags_overview
2324from sentry .seer .constants import SEER_SUPPORTED_SCM_PROVIDERS
2425from sentry .seer .sentry_data_models import EAPTrace
2526from sentry .services .eventstore .models import Event , GroupEvent
2627from sentry .snuba .referrer import Referrer
2728from sentry .snuba .spans_rpc import Spans
2829from sentry .snuba .trace import query_trace_data
30+ from sentry .snuba .utils import get_dataset
2931from sentry .utils .dates import parse_stats_period
3032
3133logger = 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+
178325def 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
398532def get_issue_details (
0 commit comments