From 9d33aef846fb1b810a85205b4ec93862181fc4b6 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 17 Oct 2025 20:46:34 -0700 Subject: [PATCH 1/3] ref(seer): update explorer get_issue_details response type --- src/sentry/seer/explorer/tools.py | 26 ++++--- tests/sentry/seer/explorer/test_tools.py | 89 +++++++++++++++--------- 2 files changed, 73 insertions(+), 42 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 493de91b1c6ae8..5efdce69c54450 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -6,7 +6,7 @@ from sentry.api import client from sentry.api.serializers.base import serialize from sentry.api.serializers.models.event import EventSerializer, IssueEventSerializerResponse -from sentry.api.serializers.models.group import GroupSerializer +from sentry.api.serializers.models.group import BaseGroupSerializerResponse, GroupSerializer from sentry.api.utils import default_start_end_dates from sentry.constants import ObjectStatus from sentry.models.apikey import ApiKey @@ -289,12 +289,13 @@ def get_issue_details( Returns: A dict containing: - `issue`: Serialized issue with exactly one event in `issue.events`, selected - according to `selected_event`. + `issue`: Serialized issue details. + `tags_overview`: A summary of all tags in the issue. + `event`: Serialized event details, selected according to `selected_event`. `event_id`: The event ID of the selected event. `event_trace_id`: The trace ID of the selected event. - `tags_overview`: A summary of all tags in the issue. - `project_id`: The project ID of the issue. + `project_id`: The ID of the issue's project. + `project_slug`: The slug of the issue's project. Returns None when the event is not found or an error occurred. """ try: @@ -323,7 +324,9 @@ def get_issue_details( ) return None - serialized_group: dict[str, Any] = serialize(group, user=None, serializer=GroupSerializer()) + serialized_group: BaseGroupSerializerResponse = serialize( + group, user=None, serializer=GroupSerializer() + ) event: Event | GroupEvent | None if selected_event == "oldest": @@ -351,10 +354,9 @@ def get_issue_details( ) return None - serialized_event: IssueEventSerializerResponse | None = serialize( + serialized_event: IssueEventSerializerResponse = serialize( event, user=None, serializer=EventSerializer() ) - serialized_group["events"] = [serialized_event] try: tags_overview = get_all_tags_overview(group) @@ -366,9 +368,11 @@ def get_issue_details( tags_overview = None return { - "event_id": event.event_id, - "event_trace_id": event.trace_id, - "project_id": group.project_id, "issue": serialized_group, "tags_overview": tags_overview, + "event": serialized_event, + "event_id": event.event_id, + "event_trace_id": event.trace_id, + "project_id": int(serialized_group["project"]["id"]), + "project_slug": serialized_group["project"]["slug"], } diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 63f58d03f38810..94d050d2b27dce 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -1,8 +1,9 @@ import uuid -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import patch import pytest +from pydantic import BaseModel from sentry.models.group import Group from sentry.seer.explorer.tools import ( @@ -11,7 +12,7 @@ get_issue_details, get_trace_waterfall, ) -from sentry.seer.sentry_data_models import EAPTrace, IssueDetails +from sentry.seer.sentry_data_models import EAPTrace from sentry.testutils.cases import APITransactionTestCase, SnubaTestCase, SpanTestCase from sentry.testutils.helpers.datetime import before_now from sentry.utils.samples import load_data @@ -562,6 +563,50 @@ def test_get_trace_waterfall_sliding_window_beyond_limit(self) -> None: assert result is None +class _ExplorerIssueProject(BaseModel): + id: int + slug: str + + +class _ExplorerIssue(BaseModel): + """ + A subset of BaseGroupSerializerResponse fields, required for Seer Explorer. In prod we send the full response. + """ + + id: int + shortId: str + title: str + culprit: str | None + permalink: str + level: str + status: str + substatus: str | None + platform: str | None + priority: str | None + type: str + issueType: str + issueCategory: str + hasSeen: bool + project: _ExplorerIssueProject + + # Optionals + isUnhandled: bool | None = None + count: str | None = None + userCount: int | None = None + firstSeen: datetime | None = None + lastSeen: datetime | None = None + + +class _SentryEventData(BaseModel): + """ + Required fields for the serialized events used by Seer Explorer. + """ + + title: str + entries: list[dict] + tags: list[dict[str, str | None]] | None = None + + class TestGetIssueDetails(APITransactionTestCase, SnubaTestCase, OccurrenceTestMixin): @patch("sentry.models.group.get_recommended_event") @@ -617,37 +662,18 @@ def _test_get_issue_details_success( assert result is not None assert result["project_id"] == self.project.id + assert result["project_slug"] == self.project.slug assert result["tags_overview"] == mock_get_tags.return_value - # Validate structure and required fields of the main issue payload. - issue_dict = result["issue"] - assert isinstance(issue_dict, dict) - IssueDetails.parse_obj(issue_dict) - assert "id" in issue_dict - assert "shortId" in issue_dict - assert "status" in issue_dict - assert "substatus" in issue_dict - assert "culprit" in issue_dict - assert "level" in issue_dict - assert "issueType" in issue_dict - assert "issueCategory" in issue_dict - assert "hasSeen" in issue_dict - assert "assignedTo" in issue_dict - # count, userCount, firstSeen, lastSeen are optional. - - # Validate for some useful event fields. - event_dict = issue_dict["events"][0] + # Validate fields of the main issue payload. + assert isinstance(result["issue"], dict) + _ExplorerIssue.parse_obj(result["issue"]) + + # Validate fields of the selected event. + event_dict = result["event"] assert isinstance(event_dict, dict) - assert "id" in event_dict - assert "title" in event_dict - assert "message" in event_dict - assert "eventID" in event_dict - assert "projectID" in event_dict - assert "user" in event_dict - assert "platform" in event_dict - assert "dateReceived" in event_dict - assert "type" in event_dict - assert "contexts" in event_dict + _SentryEventData.parse_obj(event_dict) + assert result["event_id"] == event_dict["id"] # Check correct event is returned based on selected_event_type. if selected_event == "oldest": @@ -663,6 +689,7 @@ def _test_get_issue_details_success( # Check event_trace_id matches mocked trace context. if event_dict["id"] == events[0].event_id: + assert events[0].trace_id == event0_trace_id assert result["event_trace_id"] == event0_trace_id else: assert result["event_trace_id"] is None @@ -749,4 +776,4 @@ def test_get_issue_details_tags_exception(self, mock_get_tags): assert "event_trace_id" in result assert isinstance(result.get("project_id"), int) assert isinstance(result.get("issue"), dict) - IssueDetails.parse_obj(result.get("issue")) + _ExplorerIssue.parse_obj(result.get("issue", {})) From 3e25e4fc4a9530de9b6000fac1d16120dd431bbc Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 17 Oct 2025 21:18:37 -0700 Subject: [PATCH 2/3] fix type --- src/sentry/seer/explorer/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 5efdce69c54450..493d223d1b329f 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -280,7 +280,7 @@ def get_issue_details( issue_id: int | str, organization_id: int, selected_event: str, -) -> dict[str, int | str | dict | None] | None: +) -> dict[str, Any] | None: """ Args: issue_id: The issue/group ID (integer) or short ID (string) to look up. From 95c838d3f530f2ce35591bd3d042cb66ba1cfc24 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 18 Oct 2025 15:51:07 -0700 Subject: [PATCH 3/3] fix - enforce str type issue_id --- src/sentry/seer/explorer/tools.py | 8 ++++---- tests/sentry/seer/explorer/test_tools.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 493d223d1b329f..1c2791cf0324fc 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -277,13 +277,13 @@ def rpc_get_trace_waterfall(trace_id: str, organization_id: int) -> dict[str, An def get_issue_details( *, - issue_id: int | str, + issue_id: str, organization_id: int, selected_event: str, ) -> dict[str, Any] | None: """ Args: - issue_id: The issue/group ID (integer) or short ID (string) to look up. + issue_id: The issue/group ID (numeric) or short ID (string) to look up. organization_id: The ID of the issue's organization. selected_event: The event to return - "oldest", "latest", "recommended", or the event's UUID. @@ -308,12 +308,12 @@ def get_issue_details( return None try: - if isinstance(issue_id, int): + if issue_id.isdigit(): org_project_ids = Project.objects.filter( organization=organization, status=ObjectStatus.ACTIVE ).values_list("id", flat=True) - group = Group.objects.get(project_id__in=org_project_ids, id=issue_id) + group = Group.objects.get(project_id__in=org_project_ids, id=int(issue_id)) else: group = Group.objects.by_qualified_short_id(organization_id, issue_id) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 94d050d2b27dce..5db9276e25f270 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -650,7 +650,7 @@ def _test_get_issue_details_success( events[1].event_id[:8], ]: result = get_issue_details( - issue_id=group.qualified_short_id if use_short_id else group.id, + issue_id=group.qualified_short_id if use_short_id else str(group.id), organization_id=self.organization.id, selected_event=selected_event, ) @@ -711,7 +711,7 @@ def test_get_issue_details_nonexistent_organization(self): # Call with nonexistent organization ID. result = get_issue_details( - issue_id=group.id, + issue_id=str(group.id), organization_id=99999, selected_event="latest", ) @@ -721,7 +721,7 @@ def test_get_issue_details_nonexistent_group(self): """Test returns None when group doesn't exist.""" # Call with nonexistent group ID. result = get_issue_details( - issue_id=99999, + issue_id="99999", organization_id=self.organization.id, selected_event="latest", ) @@ -748,7 +748,7 @@ def test_get_issue_details_no_event_found( for et in ["oldest", "latest", "recommended"]: result = get_issue_details( - issue_id=group.id, + issue_id=str(group.id), organization_id=self.organization.id, selected_event=et, ) @@ -766,7 +766,7 @@ def test_get_issue_details_tags_exception(self, mock_get_tags): assert isinstance(group, Group) result = get_issue_details( - issue_id=group.id, + issue_id=str(group.id), organization_id=self.organization.id, selected_event="latest", )