Skip to content

Commit 496aaf1

Browse files
authored
feat(explorer): add event timeseries dict to issue details tool (#102119)
1 parent 3a6ebe7 commit 496aaf1

File tree

2 files changed

+164
-6
lines changed

2 files changed

+164
-6
lines changed

src/sentry/seer/explorer/tools.py

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from datetime import datetime, timedelta, timezone
2+
from datetime import UTC, datetime, timedelta, timezone
33
from typing import Any, Literal
44

55
from sentry import eventstore
@@ -23,6 +23,7 @@
2323
from sentry.snuba.referrer import Referrer
2424
from sentry.snuba.spans_rpc import Spans
2525
from sentry.snuba.trace import query_trace_data
26+
from sentry.utils.dates import parse_stats_period
2627

2728
logger = logging.getLogger(__name__)
2829

@@ -322,6 +323,69 @@ def get_repository_definition(*, organization_id: int, repo_full_name: str) -> d
322323
}
323324

324325

326+
# Tuples of (total period, interval) (both in sentry stats period format).
327+
EVENT_TIMESERIES_RESOLUTIONS = (
328+
("6h", "15m"), # 24 buckets
329+
("24h", "1h"), # 24 buckets
330+
("3d", "3h"), # 24 buckets
331+
("7d", "6h"), # 28 buckets
332+
("14d", "12h"), # 28 buckets
333+
("30d", "24h"), # 30 buckets
334+
("90d", "3d"), # 30 buckets
335+
)
336+
337+
338+
def _get_issue_event_timeseries(
339+
*,
340+
organization: Organization,
341+
project_id: int,
342+
issue_short_id: str,
343+
first_seen_delta: timedelta,
344+
) -> tuple[dict[str, Any], str, str] | None:
345+
"""
346+
Get event counts over time for an issue by calling the events-stats endpoint.
347+
"""
348+
349+
stats_period, interval = None, None
350+
for p, i in EVENT_TIMESERIES_RESOLUTIONS:
351+
delta = parse_stats_period(p)
352+
if delta and first_seen_delta <= delta:
353+
stats_period, interval = p, i
354+
break
355+
stats_period = stats_period or "90d"
356+
interval = interval or "3d"
357+
358+
params: dict[str, Any] = {
359+
"dataset": "issuePlatform",
360+
"query": f"issue:{issue_short_id}",
361+
"yAxis": "count()",
362+
"partial": "1",
363+
"statsPeriod": stats_period,
364+
"interval": interval,
365+
"project": project_id,
366+
"referrer": Referrer.SEER_RPC,
367+
}
368+
369+
resp = client.get(
370+
auth=ApiKey(organization_id=organization.id, scope_list=["org:read", "project:read"]),
371+
user=None,
372+
path=f"/organizations/{organization.slug}/events-stats/",
373+
params=params,
374+
)
375+
if resp.status_code != 200 or not (resp.data or {}).get("data"):
376+
logger.warning(
377+
"Failed to get event counts for issue",
378+
extra={
379+
"organization_slug": organization.slug,
380+
"project_id": project_id,
381+
"issue_id": issue_short_id,
382+
},
383+
)
384+
return None
385+
386+
return {"count()": {"data": resp.data["data"]}}, stats_period, interval
387+
388+
325389
def get_issue_details(
326390
*,
327391
issue_id: str,
@@ -354,12 +418,12 @@ def get_issue_details(
354418
)
355419
return None
356420

421+
org_project_ids = Project.objects.filter(
422+
organization=organization, status=ObjectStatus.ACTIVE
423+
).values_list("id", flat=True)
424+
357425
try:
358426
if issue_id.isdigit():
359-
org_project_ids = Project.objects.filter(
360-
organization=organization, status=ObjectStatus.ACTIVE
361-
).values_list("id", flat=True)
362-
363427
group = Group.objects.get(project_id__in=org_project_ids, id=int(issue_id))
364428
else:
365429
group = Group.objects.by_qualified_short_id(organization_id, issue_id)
@@ -415,8 +479,24 @@ def get_issue_details(
415479
)
416480
tags_overview = None
417481

482+
ts_result = _get_issue_event_timeseries(
483+
organization=organization,
484+
project_id=group.project_id,
485+
issue_short_id=group.qualified_short_id,
486+
first_seen_delta=datetime.now(UTC) - group.first_seen,
487+
)
488+
if ts_result:
489+
timeseries, stats_period, interval = ts_result
490+
else:
491+
timeseries = None
492+
stats_period = None
493+
interval = None
494+
418495
return {
419496
"issue": serialized_group,
497+
"event_timeseries": timeseries,
498+
"timeseries_stats_period": stats_period,
499+
"timeseries_interval": interval,
420500
"tags_overview": tags_overview,
421501
"event": serialized_event,
422502
"event_id": event.event_id,

tests/sentry/seer/explorer/test_tools.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import uuid
2-
from datetime import datetime, timedelta
2+
from datetime import UTC, datetime, timedelta
33
from typing import Literal
44
from unittest.mock import patch
55

66
import pytest
77
from pydantic import BaseModel
88

9+
from sentry.api import client
910
from sentry.constants import ObjectStatus
1011
from sentry.models.group import Group
1112
from sentry.models.groupassignee import GroupAssignee
1213
from sentry.models.repository import Repository
1314
from sentry.seer.endpoints.seer_rpc import get_organization_project_ids
1415
from sentry.seer.explorer.tools import (
16+
EVENT_TIMESERIES_RESOLUTIONS,
1517
execute_trace_query_chart,
1618
execute_trace_query_table,
1719
get_issue_details,
@@ -21,6 +23,7 @@
2123
from sentry.seer.sentry_data_models import EAPTrace
2224
from sentry.testutils.cases import APITransactionTestCase, SnubaTestCase, SpanTestCase
2325
from sentry.testutils.helpers.datetime import before_now
26+
from sentry.utils.dates import parse_stats_period
2427
from sentry.utils.samples import load_data
2528
from tests.sentry.issues.test_utils import OccurrenceTestMixin
2629

@@ -631,6 +634,20 @@ class _SentryEventData(BaseModel):
631634

632635
class TestGetIssueDetails(APITransactionTestCase, SnubaTestCase, OccurrenceTestMixin):
633636

637+
def _validate_event_timeseries(self, timeseries: dict):
638+
assert isinstance(timeseries, dict)
639+
assert "count()" in timeseries
640+
assert "data" in timeseries["count()"]
641+
assert isinstance(timeseries["count()"]["data"], list)
642+
for item in timeseries["count()"]["data"]:
643+
assert len(item) == 2
644+
assert isinstance(item[0], int)
645+
assert isinstance(item[1], list)
646+
assert len(item[1]) == 1
647+
assert isinstance(item[1][0], dict)
648+
assert "count" in item[1][0]
649+
assert isinstance(item[1][0]["count"], int)
650+
634651
@patch("sentry.models.group.get_recommended_event")
635652
@patch("sentry.seer.explorer.tools.get_all_tags_overview")
636653
def _test_get_issue_details_success(
@@ -717,6 +734,9 @@ def _test_get_issue_details_success(
717734
else:
718735
assert result["event_trace_id"] is None
719736

737+
# Validate timeseries dict structure.
738+
self._validate_event_timeseries(result["event_timeseries"])
739+
720740
def test_get_issue_details_success_int_id(self):
721741
self._test_get_issue_details_success(use_short_id=False)
722742

@@ -861,6 +881,64 @@ def test_get_issue_details_with_assigned_team(self, mock_get_tags, mock_get_reco
861881
assert md.assignedTo.name == self.team.slug
862882
assert md.assignedTo.email is None
863883

884+
@patch("sentry.seer.explorer.tools.client")
885+
@patch("sentry.models.group.get_recommended_event")
886+
@patch("sentry.seer.explorer.tools.get_all_tags_overview")
887+
def test_get_issue_details_timeseries_resolution(
888+
self,
889+
mock_get_tags,
890+
mock_get_recommended_event,
891+
mock_api_client,
892+
):
893+
"""Test groups with different first_seen dates"""
894+
mock_get_tags.return_value = {"tags_overview": [{"key": "test_tag", "top_values": []}]}
895+
# Passthrough to real client - allows testing call args
896+
mock_api_client.get.side_effect = client.get
897+
898+
for stats_period, interval in EVENT_TIMESERIES_RESOLUTIONS:
899+
delta = parse_stats_period(stats_period)
900+
assert delta is not None
901+
if delta > timedelta(days=30):
902+
# Skip the 90d test as the retention for testutils is 30d.
903+
continue
904+
905+
# Set a first_seen date slightly newer than the stats period we're testing.
906+
first_seen = datetime.now(UTC) - delta + timedelta(minutes=6, seconds=7)
907+
data = load_data("python", timestamp=first_seen)
908+
data["exception"] = {"values": [{"type": "Exception", "value": "Test exception"}]}
909+
event = self.store_event(data=data, project_id=self.project.id)
910+
mock_get_recommended_event.return_value = event
911+
912+
# Second newer event
913+
data = load_data("python", timestamp=first_seen + timedelta(minutes=6, seconds=7))
914+
data["exception"] = {"values": [{"type": "Exception", "value": "Test exception"}]}
915+
self.store_event(data=data, project_id=self.project.id)
916+
917+
group = event.group
918+
assert isinstance(group, Group)
919+
assert group.first_seen == first_seen
920+
921+
result = get_issue_details(
922+
issue_id=str(group.id),
923+
organization_id=self.organization.id,
924+
selected_event="recommended",
925+
)
926+
927+
# Assert expected stats params were passed to the API.
928+
_, kwargs = mock_api_client.get.call_args
929+
assert kwargs["path"] == f"/organizations/{self.organization.slug}/events-stats/"
930+
assert kwargs["params"]["statsPeriod"] == stats_period
931+
assert kwargs["params"]["interval"] == interval
932+
933+
# Validate final results.
934+
assert result is not None
935+
self._validate_event_timeseries(result["event_timeseries"])
936+
assert result["timeseries_stats_period"] == stats_period
937+
assert result["timeseries_interval"] == interval
938+
939+
# Ensure next iteration makes a fresh group.
940+
group.delete()
941+
864942

865943
@pytest.mark.django_db(databases=["default", "control"])
866944
class TestGetRepositoryDefinition(APITransactionTestCase):

0 commit comments

Comments
 (0)