Skip to content

Commit 3d849f5

Browse files
roagapriscilawebdev
authored andcommitted
feat(explorer): Add RPC for issue stats (#102693)
Adds RPC to get issue stats, needed for getsentry/seer#3891
1 parent 53dfa89 commit 3d849f5

File tree

3 files changed

+287
-1
lines changed

3 files changed

+287
-1
lines changed

src/sentry/seer/assisted_query/issues_tools.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ def execute_issues_query(
492492
limit: Number of results to return (default 25)
493493
494494
Returns:
495-
Issue data from the API response, or None if organization doesn't exist
495+
List of issues, or None if organization doesn't exist
496496
"""
497497
try:
498498
organization = Organization.objects.get(id=org_id)
@@ -523,3 +523,57 @@ def execute_issues_query(
523523
)
524524

525525
return resp.data
526+
527+
528+
def get_issues_stats(
529+
*,
530+
org_id: int,
531+
issue_ids: list[str],
532+
project_ids: list[int],
533+
query: str,
534+
stats_period: str = "7d",
535+
) -> list[dict[str, Any]] | None:
536+
"""
537+
Get stats for specific issues by calling the issues-stats endpoint.
538+
539+
This endpoint provides count, userCount, firstSeen, lastSeen, and
540+
timeseries data for each issue.
541+
542+
Args:
543+
org_id: Organization ID
544+
issue_ids: List of issue IDs to get stats for
545+
project_ids: List of project IDs
546+
query: Search query string (e.g., "is:unresolved")
547+
stats_period: Time period for the query (e.g., "24h", "7d", "14d"). Defaults to "7d".
548+
549+
Returns:
550+
List of issue stats, or None if organization doesn't exist.
551+
Each item contains: id, count, userCount, firstSeen, lastSeen, stats, lifetime
552+
"""
553+
try:
554+
organization = Organization.objects.get(id=org_id)
555+
except Organization.DoesNotExist:
556+
logger.warning("Organization not found", extra={"org_id": org_id})
557+
return None
558+
559+
if not issue_ids:
560+
return []
561+
562+
api_key = ApiKey(organization_id=organization.id, scope_list=API_KEY_SCOPES)
563+
564+
params: dict[str, Any] = {
565+
"project": project_ids,
566+
"groups": issue_ids,
567+
"query": query,
568+
"statsPeriod": stats_period,
569+
"referrer": Referrer.SEER_RPC,
570+
}
571+
572+
resp = client.get(
573+
auth=api_key,
574+
user=None,
575+
path=f"/organizations/{organization.slug}/issues-stats/",
576+
params=params,
577+
)
578+
579+
return resp.data

src/sentry/seer/endpoints/seer_rpc.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
execute_issues_query,
7272
get_filter_key_values,
7373
get_issue_filter_keys,
74+
get_issues_stats,
7475
)
7576
from sentry.seer.autofix.autofix_tools import get_error_event_details, get_profile_details
7677
from sentry.seer.autofix.coding_agent import launch_coding_agents_for_run
@@ -1134,6 +1135,7 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s
11341135
"get_issue_filter_keys": get_issue_filter_keys,
11351136
"get_filter_key_values": get_filter_key_values,
11361137
"execute_issues_query": execute_issues_query,
1138+
"get_issues_stats": get_issues_stats,
11371139
#
11381140
# Explorer
11391141
"get_transactions_for_project": rpc_get_transactions_for_project,

tests/sentry/seer/assisted_query/test_issues_tools.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
from datetime import datetime
2+
13
import pytest
24

35
from sentry.seer.assisted_query.issues_tools import (
46
execute_issues_query,
57
get_filter_key_values,
68
get_issue_filter_keys,
9+
get_issues_stats,
710
)
811
from sentry.testutils.cases import APITestCase, SnubaTestCase
912
from sentry.testutils.helpers.datetime import before_now
@@ -662,3 +665,230 @@ def test_execute_issues_query_multiple_projects(self):
662665
project_ids = {issue["project"]["id"] for issue in result}
663666
assert str(self.project.id) in project_ids
664667
assert str(project2.id) in project_ids
668+
669+
670+
@pytest.mark.django_db(databases=["default", "control"])
671+
class TestGetIssuesStats(APITestCase, SnubaTestCase):
672+
databases = {"default", "control"}
673+
674+
def setUp(self):
675+
super().setUp()
676+
self.min_ago = before_now(minutes=1)
677+
678+
def test_get_issues_stats_success(self):
679+
"""Test that get_issues_stats returns stats for issues"""
680+
# Store two events to create issues
681+
event1 = self.store_event(
682+
data={
683+
"event_id": "a" * 32,
684+
"message": "First error",
685+
"timestamp": self.min_ago.isoformat(),
686+
},
687+
project_id=self.project.id,
688+
)
689+
event2 = self.store_event(
690+
data={
691+
"event_id": "b" * 32,
692+
"message": "Second error",
693+
"timestamp": self.min_ago.isoformat(),
694+
},
695+
project_id=self.project.id,
696+
)
697+
698+
issue_ids = [str(event1.group_id), str(event2.group_id)]
699+
700+
result = get_issues_stats(
701+
org_id=self.organization.id,
702+
issue_ids=issue_ids,
703+
project_ids=[self.project.id],
704+
query="is:unresolved",
705+
stats_period="24h",
706+
)
707+
708+
assert result is not None
709+
assert isinstance(result, list)
710+
assert len(result) == 2
711+
712+
# Verify each stat has the expected fields
713+
for stat in result:
714+
assert "id" in stat
715+
assert "count" in stat
716+
assert "userCount" in stat
717+
assert "firstSeen" in stat
718+
assert "lastSeen" in stat
719+
assert stat["id"] in issue_ids
720+
721+
# Verify stats field structure
722+
# stats should be a dict with stats_period keys (e.g., "24h", "14d")
723+
# Each value is an array of (timestamp, count) tuples
724+
assert "stats" in stat
725+
assert isinstance(stat["stats"], dict)
726+
# Should have the stats_period key we passed ("24h")
727+
assert "24h" in stat["stats"]
728+
# The value should be an array of tuples
729+
assert isinstance(stat["stats"]["24h"], list)
730+
# Each element should be a tuple of (timestamp, count) where both are numbers
731+
for data_point in stat["stats"]["24h"]:
732+
assert isinstance(data_point, tuple)
733+
assert len(data_point) == 2
734+
assert isinstance(data_point[0], (int, float)) # timestamp
735+
assert isinstance(data_point[1], (int, float)) # count
736+
737+
# Verify lifetime field structure
738+
# lifetime should be a dict with count, userCount, firstSeen, lastSeen
739+
assert "lifetime" in stat
740+
assert isinstance(stat["lifetime"], dict)
741+
assert "count" in stat["lifetime"]
742+
assert "userCount" in stat["lifetime"]
743+
assert "firstSeen" in stat["lifetime"]
744+
assert "lastSeen" in stat["lifetime"]
745+
# count should be a string representation of the number
746+
assert isinstance(stat["lifetime"]["count"], str)
747+
# userCount should be an integer
748+
assert isinstance(stat["lifetime"]["userCount"], int)
749+
# firstSeen and lastSeen are datetime objects or None
750+
assert stat["lifetime"]["firstSeen"] is None or isinstance(
751+
stat["lifetime"]["firstSeen"], datetime
752+
)
753+
assert stat["lifetime"]["lastSeen"] is None or isinstance(
754+
stat["lifetime"]["lastSeen"], datetime
755+
)
756+
757+
def test_get_issues_stats_with_multiple_projects(self):
758+
"""Test that get_issues_stats works with multiple project IDs"""
759+
project2 = self.create_project(organization=self.organization)
760+
761+
event1 = self.store_event(
762+
data={
763+
"event_id": "a" * 32,
764+
"message": "Project 1 error",
765+
"timestamp": self.min_ago.isoformat(),
766+
},
767+
project_id=self.project.id,
768+
)
769+
event2 = self.store_event(
770+
data={
771+
"event_id": "b" * 32,
772+
"message": "Project 2 error",
773+
"timestamp": self.min_ago.isoformat(),
774+
},
775+
project_id=project2.id,
776+
)
777+
778+
issue_ids = [str(event1.group_id), str(event2.group_id)]
779+
780+
result = get_issues_stats(
781+
org_id=self.organization.id,
782+
issue_ids=issue_ids,
783+
project_ids=[self.project.id, project2.id],
784+
query="is:unresolved",
785+
stats_period="24h",
786+
)
787+
788+
assert result is not None
789+
assert isinstance(result, list)
790+
# Should return stats for both issues
791+
assert len(result) >= 2
792+
returned_issue_ids = {stat["id"] for stat in result}
793+
assert str(event1.group_id) in returned_issue_ids
794+
assert str(event2.group_id) in returned_issue_ids
795+
796+
def test_get_issues_stats_nonexistent_org(self):
797+
"""Test that get_issues_stats returns None for nonexistent org"""
798+
result = get_issues_stats(
799+
org_id=999999,
800+
issue_ids=["123"],
801+
project_ids=[self.project.id],
802+
query="is:unresolved",
803+
stats_period="24h",
804+
)
805+
806+
assert result is None
807+
808+
def test_get_issues_stats_empty_issue_ids(self):
809+
"""Test that get_issues_stats handles empty issue IDs"""
810+
result = get_issues_stats(
811+
org_id=self.organization.id,
812+
issue_ids=[],
813+
project_ids=[self.project.id],
814+
query="is:unresolved",
815+
stats_period="24h",
816+
)
817+
818+
assert result is not None
819+
assert isinstance(result, list)
820+
assert len(result) == 0
821+
822+
def test_get_issues_stats_stats_and_lifetime_structure(self):
823+
"""Test that stats and lifetime fields have the correct structure"""
824+
# Create an issue
825+
event = self.store_event(
826+
data={
827+
"event_id": "a" * 32,
828+
"message": "Test error",
829+
"timestamp": self.min_ago.isoformat(),
830+
},
831+
project_id=self.project.id,
832+
)
833+
834+
result = get_issues_stats(
835+
org_id=self.organization.id,
836+
issue_ids=[str(event.group_id)],
837+
project_ids=[self.project.id],
838+
query="is:unresolved",
839+
stats_period="24h",
840+
)
841+
842+
assert result is not None
843+
assert len(result) == 1
844+
845+
stat = result[0]
846+
847+
# Verify stats structure:
848+
# stats is a dict where keys are stats_period strings (e.g., "24h", "14d")
849+
# and values are arrays of (timestamp, count) tuples
850+
assert "stats" in stat
851+
assert isinstance(stat["stats"], dict)
852+
assert "24h" in stat["stats"]
853+
assert isinstance(stat["stats"]["24h"], list)
854+
# Each element should be a tuple of (timestamp, count)
855+
for timepoint in stat["stats"]["24h"]:
856+
assert isinstance(timepoint, tuple)
857+
assert len(timepoint) == 2
858+
timestamp, count = timepoint
859+
assert isinstance(timestamp, (int, float))
860+
assert isinstance(count, (int, float))
861+
# Timestamp should be a reasonable Unix timestamp (seconds since epoch)
862+
# For 24h period, should be within last day
863+
assert timestamp > 0
864+
865+
# Verify lifetime structure:
866+
# lifetime is a dict with count (string), userCount (int), firstSeen (datetime), lastSeen (datetime)
867+
assert "lifetime" in stat
868+
assert isinstance(stat["lifetime"], dict)
869+
lifetime = stat["lifetime"]
870+
871+
# Required fields
872+
assert "count" in lifetime
873+
assert "userCount" in lifetime
874+
assert "firstSeen" in lifetime
875+
assert "lastSeen" in lifetime
876+
877+
# Field types
878+
assert isinstance(lifetime["count"], str)
879+
# Count should be a numeric string representing the total times seen
880+
# (e.g., "1", "42", "1000")
881+
assert lifetime["count"].isdigit()
882+
883+
assert isinstance(lifetime["userCount"], int)
884+
885+
# firstSeen and lastSeen are datetime objects or None
886+
if lifetime["firstSeen"] is not None:
887+
assert isinstance(lifetime["firstSeen"], datetime)
888+
889+
if lifetime["lastSeen"] is not None:
890+
assert isinstance(lifetime["lastSeen"], datetime)
891+
892+
# Optional stats field in lifetime (currently None in implementation)
893+
if "stats" in lifetime:
894+
assert lifetime["stats"] is None or isinstance(lifetime["stats"], dict)

0 commit comments

Comments
 (0)