From 61aa19ce3c3ea2138113ccf0c0e8c6a0db313f98 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:50:18 -0800 Subject: [PATCH 1/8] ref(explorer): rpc to support issue/event detail queries with event id only --- src/sentry/seer/endpoints/seer_rpc.py | 2 + src/sentry/seer/explorer/tools.py | 170 ++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index b3c6aab07cced4..3e7094380016ff 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -92,6 +92,7 @@ from sentry.seer.explorer.tools import ( execute_table_query, execute_timeseries_query, + get_issue_and_event_details, get_issue_details, get_replay_metadata, get_repository_definition, @@ -1209,6 +1210,7 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s "get_issues_for_transaction": rpc_get_issues_for_transaction, "get_trace_waterfall": rpc_get_trace_waterfall, "get_issue_details": get_issue_details, + "get_issue_and_event_details": get_issue_and_event_details, "get_profile_flamegraph": rpc_get_profile_flamegraph, "execute_table_query": execute_table_query, "execute_timeseries_query": execute_timeseries_query, diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 829b27324a3d8a..9ade3c3b08d4c9 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -725,6 +725,176 @@ def get_issue_details( } +def get_issue_and_event_details( + *, + organization_id: int, + issue_id: str | None, + selected_event: str, +) -> dict[str, Any] | None: + """ + Tool to get details for a Sentry issue and one of its associated events. The issue_id can be ommitted so it + is automatically looked up from the event info. In this case, if the event is ungrouped we return no issue data. + Event details are always returned. + + Args: + organization_id: The ID of the organization to query. + issue_id: The issue/group ID (numeric) or short ID (string) to look up. If None, we try to fill this in with the event's group_id. + selected_event: + If issue_id is provided, this is the event to return and must exist in the issue - the options are "oldest", "latest", "recommended", or a UUID. + If issue_id is not provided, this must be a UUID. + + Returns: + A dict containing: + Issue fields (nullable iff issue_id is None): + `issue`: Serialized issue details. + `tags_overview`: A summary of all tags in the issue. + `event_timeseries`: Event counts over time for the issue. + `timeseries_stats_period`: The stats period used for the event timeseries. + `timeseries_interval`: The interval used for the event timeseries. + + Non-nullable fields: + `event`: Serialized event details. + `event_id`: The event ID of the selected event. + `event_trace_id`: The trace ID of the selected event. + `project_id`: The event and issue's project ID. + `project_slug`: The event and issue's project slug. + + Returns None when the requested event or issue is not found, or an error occurred. + """ + try: + organization = Organization.objects.get(id=organization_id) + except Organization.DoesNotExist: + logger.warning( + "Organization does not exist", + extra={"organization_id": organization_id, "issue_id": issue_id}, + ) + return None + + org_project_ids = list( + Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE).values_list( + "id", flat=True + ) + ) + + event: Event | GroupEvent | None = None + group: Group | None = None + serialized_group: dict | None = None + tags_overview: dict | None = None + timeseries: dict | None = None + timeseries_stats_period: str | None = None + timeseries_interval: str | None = None + + # First fetch event by ID if issue_id is not provided. Use this to get the issue ID, if any. + if issue_id is None: + events_result = eventstore.backend.get_events( + filter=eventstore.Filter( + event_ids=[selected_event], + organization_id=organization_id, + project_ids=org_project_ids, + ), + limit=1, + tenant_ids={"organization_id": organization_id}, + ) + if not events_result: + logger.warning( + "Could not find the requested event ID", + extra={ + "organization_id": organization_id, + "issue_id": issue_id, + "selected_event": selected_event, + }, + ) + return None + + event = events_result[0] + issue_id = getattr(event, "group_id", None) + issue_id = str(issue_id) if issue_id else None + + # Fetch the issue data, tags overview, and timeseries. If the previously fetched event does not have a group ID, this is skipped. + if issue_id is not None: + try: + if issue_id.isdigit(): + 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) + + except Group.DoesNotExist: + logger.warning( + "Requested issue does not exist for organization", + extra={"organization_id": organization_id, "issue_id": issue_id}, + ) + return None + + assert isinstance(group, Group) + + serialized_group = dict(serialize(group, user=None, serializer=GroupSerializer())) + # Add issueTypeDescription as it provides better context for LLMs. Note the initial type should be BaseGroupSerializerResponse. + serialized_group["issueTypeDescription"] = group.issue_type.description + + try: + tags_overview = get_all_tags_overview(group) + except Exception: + logger.exception( + "Failed to get tags overview for issue", + extra={"organization_id": organization_id, "issue_id": issue_id}, + ) + + ts_result = _get_issue_event_timeseries( + organization=organization, + project_id=group.project_id, + issue_short_id=group.qualified_short_id, + first_seen_delta=datetime.now(UTC) - group.first_seen, + ) + if ts_result: + timeseries, timeseries_stats_period, timeseries_interval = ts_result + + # Fetch event from group, if not already fetched. + if event is None and group is not None: + if selected_event == "oldest": + event = group.get_oldest_event() + elif selected_event == "latest": + event = group.get_latest_event() + elif selected_event == "recommended": + event = group.get_recommended_event() + else: + event = eventstore.backend.get_event_by_id( + project_id=group.project_id, + event_id=selected_event, + group_id=group.id, + tenant_ids={"organization_id": organization_id}, + ) + + if not event: + logger.warning( + "Could not find the selected event for the issue", + extra={ + "organization_id": organization_id, + "issue_id": issue_id, + "selected_event": selected_event, + }, + ) + return None + + # Serialize event. + assert isinstance(event, Event | GroupEvent) + serialized_event: IssueEventSerializerResponse = serialize( + event, user=None, serializer=EventSerializer() + ) + + return { + "issue": serialized_group, + "event_timeseries": timeseries, + "timeseries_stats_period": timeseries_stats_period, + "timeseries_interval": timeseries_interval, + "tags_overview": tags_overview, + "event": serialized_event, + "event_id": event.event_id, + "event_trace_id": event.trace_id, + "project_id": event.project_id, + "project_slug": event.project.slug, + } + + def get_replay_metadata( *, replay_id: str, From e644765fe3e973fddff477d4fea422a7b492a584 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:54:06 -0800 Subject: [PATCH 2/8] comment --- src/sentry/seer/explorer/tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 9ade3c3b08d4c9..896d700e7144f5 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -733,8 +733,8 @@ def get_issue_and_event_details( ) -> dict[str, Any] | None: """ Tool to get details for a Sentry issue and one of its associated events. The issue_id can be ommitted so it - is automatically looked up from the event info. In this case, if the event is ungrouped we return no issue data. - Event details are always returned. + is automatically looked up from the event's grouping info. If the rare case the event is ungrouped, we return + null for all issue fields. Event details are always returned. Args: organization_id: The ID of the organization to query. From 2af54009d34a9dab57c1327052131321710184b3 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 22:54:59 +0000 Subject: [PATCH 3/8] :hammer_and_wrench: apply pre-commit fixes --- 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 896d700e7144f5..3205d749174506 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -733,7 +733,7 @@ def get_issue_and_event_details( ) -> dict[str, Any] | None: """ Tool to get details for a Sentry issue and one of its associated events. The issue_id can be ommitted so it - is automatically looked up from the event's grouping info. If the rare case the event is ungrouped, we return + is automatically looked up from the event's grouping info. If the rare case the event is ungrouped, we return null for all issue fields. Event details are always returned. Args: From 65d7bcd0f2eb19f30368b300c1ab4521043fdaee Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:01:43 -0800 Subject: [PATCH 4/8] raise for invalid uuid --- src/sentry/seer/explorer/tools.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 3205d749174506..f952bc5b22021f 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -786,6 +786,7 @@ def get_issue_and_event_details( # First fetch event by ID if issue_id is not provided. Use this to get the issue ID, if any. if issue_id is None: + uuid.UUID(selected_event) # Raises ValueError if not valid UUID events_result = eventstore.backend.get_events( filter=eventstore.Filter( event_ids=[selected_event], @@ -857,6 +858,7 @@ def get_issue_and_event_details( elif selected_event == "recommended": event = group.get_recommended_event() else: + uuid.UUID(selected_event) # Raises ValueError if not valid UUID event = eventstore.backend.get_event_by_id( project_id=group.project_id, event_id=selected_event, From c82038ec39e998f794ebd88fb3bb09e0a886e23d Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 22 Nov 2025 22:57:37 -0800 Subject: [PATCH 5/8] refactor src and tests --- src/sentry/seer/explorer/tools.py | 40 +++++++------ tests/sentry/seer/explorer/test_tools.py | 73 ++++++++++++++---------- 2 files changed, 63 insertions(+), 50 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index f952bc5b22021f..4824eb1ed358d7 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -784,9 +784,12 @@ def get_issue_and_event_details( timeseries_stats_period: str | None = None timeseries_interval: str | None = None - # First fetch event by ID if issue_id is not provided. Use this to get the issue ID, if any. + # Fetch the group object. if issue_id is None: + # Fetch event by ID if issue_id is not provided. Use this to fetch the group. uuid.UUID(selected_event) # Raises ValueError if not valid UUID + + # We can't use get_event_by_id since we don't know the project yet. events_result = eventstore.backend.get_events( filter=eventstore.Filter( event_ids=[selected_event], @@ -808,11 +811,11 @@ def get_issue_and_event_details( return None event = events_result[0] - issue_id = getattr(event, "group_id", None) - issue_id = str(issue_id) if issue_id else None + group = getattr(event, "group", None) + issue_id = str(group.id) if group else None - # Fetch the issue data, tags overview, and timeseries. If the previously fetched event does not have a group ID, this is skipped. - if issue_id is not None: + else: + # Fetch the group from issue_id. try: if issue_id.isdigit(): group = Group.objects.get(project_id__in=org_project_ids, id=int(issue_id)) @@ -826,8 +829,8 @@ def get_issue_and_event_details( ) return None - assert isinstance(group, Group) - + # Get the issue data, tags overview, and event count timeseries. + if isinstance(group, Group): serialized_group = dict(serialize(group, user=None, serializer=GroupSerializer())) # Add issueTypeDescription as it provides better context for LLMs. Note the initial type should be BaseGroupSerializerResponse. serialized_group["issueTypeDescription"] = group.issue_type.description @@ -850,7 +853,7 @@ def get_issue_and_event_details( timeseries, timeseries_stats_period, timeseries_interval = ts_result # Fetch event from group, if not already fetched. - if event is None and group is not None: + if event is None and isinstance(group, Group): if selected_event == "oldest": event = group.get_oldest_event() elif selected_event == "latest": @@ -866,19 +869,18 @@ def get_issue_and_event_details( tenant_ids={"organization_id": organization_id}, ) - if not event: - logger.warning( - "Could not find the selected event for the issue", - extra={ - "organization_id": organization_id, - "issue_id": issue_id, - "selected_event": selected_event, - }, - ) - return None + if event is None: + logger.warning( + "Could not find the selected event.", + extra={ + "organization_id": organization_id, + "issue_id": issue_id, + "selected_event": selected_event, + }, + ) + return None # Serialize event. - assert isinstance(event, Event | GroupEvent) serialized_event: IssueEventSerializerResponse = serialize( event, user=None, serializer=EventSerializer() ) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index a6bdf017816230..ca1986078b7f87 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -17,7 +17,7 @@ EVENT_TIMESERIES_RESOLUTIONS, execute_table_query, execute_timeseries_query, - get_issue_details, + get_issue_and_event_details, get_replay_metadata, get_repository_definition, get_trace_waterfall, @@ -716,7 +716,7 @@ class _IssueMetadata(BaseModel): priority: str | None type: str issueType: str - issueTypeDescription: str # Extra field added by get_issue_details. + issueTypeDescription: str # Extra field added by get_issue_and_event_details. issueCategory: str hasSeen: bool project: _Project @@ -740,7 +740,7 @@ class _SentryEventData(BaseModel): tags: list[dict[str, str | None]] | None = None -class TestGetIssueDetails(APITransactionTestCase, SnubaTestCase, OccurrenceTestMixin): +class TestGetIssueAndEventDetails(APITransactionTestCase, SnubaTestCase, OccurrenceTestMixin): def _validate_event_timeseries(self, timeseries: dict): assert isinstance(timeseries, dict) assert "count()" in timeseries @@ -757,7 +757,7 @@ def _validate_event_timeseries(self, timeseries: dict): @patch("sentry.models.group.get_recommended_event") @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def _test_get_issue_details_success( + def _test_get_issue_and_event_details_success( self, mock_get_tags, mock_get_recommended_event, @@ -793,20 +793,15 @@ def _test_get_issue_details_success( "oldest", "latest", "recommended", - events[1].event_id, - events[1].event_id[:8], + uuid.UUID(events[1].event_id).hex, # no dashes + str(uuid.UUID(events[1].event_id)), # with dashes ]: - result = get_issue_details( + result = get_issue_and_event_details( issue_id=group.qualified_short_id if use_short_id else str(group.id), organization_id=self.organization.id, selected_event=selected_event, ) - # Short event IDs not supported. - if len(selected_event) == 8: - assert result is None - continue - assert result is not None assert result["project_id"] == self.project.id assert result["project_slug"] == self.project.slug @@ -832,7 +827,9 @@ def _test_get_issue_details_success( event_dict["id"] == mock_get_recommended_event.return_value.event_id ), selected_event else: - assert event_dict["id"] == selected_event, selected_event + assert ( + uuid.UUID(event_dict["id"]).hex == uuid.UUID(selected_event).hex + ), selected_event # Check event_trace_id matches mocked trace context. if event_dict["id"] == events[0].event_id: @@ -844,13 +841,25 @@ def _test_get_issue_details_success( # Validate timeseries dict structure. self._validate_event_timeseries(result["event_timeseries"]) - def test_get_issue_details_success_int_id(self): - self._test_get_issue_details_success(use_short_id=False) + # Short IDs and non-uuid not supported + for selected_event in [ + events[1].event_id[:8], + "potato", + ]: + with pytest.raises(ValueError, match="badly formed hexadecimal UUID string"): + get_issue_and_event_details( + issue_id=group.qualified_short_id if use_short_id else str(group.id), + organization_id=self.organization.id, + selected_event=selected_event, + ) + + def test_get_issue_and_event_details_success_int_id(self): + self._test_get_issue_and_event_details_success(use_short_id=False) - def test_get_issue_details_success_short_id(self): - self._test_get_issue_details_success(use_short_id=True) + def test_get_issue_and_event_details_success_short_id(self): + self._test_get_issue_and_event_details_success(use_short_id=True) - def test_get_issue_details_nonexistent_organization(self): + def test_get_issue_and_event_details_nonexistent_organization(self): """Test returns None when organization doesn't exist.""" # Create a valid group. data = load_data("python", timestamp=before_now(minutes=5)) @@ -860,17 +869,17 @@ def test_get_issue_details_nonexistent_organization(self): assert isinstance(group, Group) # Call with nonexistent organization ID. - result = get_issue_details( + result = get_issue_and_event_details( issue_id=str(group.id), organization_id=99999, selected_event="latest", ) assert result is None - def test_get_issue_details_nonexistent_group(self): + def test_get_issue_and_event_details_nonexistent_group(self): """Test returns None when group doesn't exist.""" # Call with nonexistent group ID. - result = get_issue_details( + result = get_issue_and_event_details( issue_id="99999", organization_id=self.organization.id, selected_event="latest", @@ -880,7 +889,7 @@ def test_get_issue_details_nonexistent_group(self): @patch("sentry.models.group.get_oldest_or_latest_event") @patch("sentry.models.group.get_recommended_event") @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def test_get_issue_details_no_event_found( + def test_get_issue_and_event_details_no_event_found( self, mock_get_tags, mock_get_recommended_event, mock_get_oldest_or_latest_event ): mock_get_tags.return_value = {"tags_overview": [{"key": "test_tag", "top_values": []}]} @@ -897,7 +906,7 @@ def test_get_issue_details_no_event_found( assert isinstance(group, Group) for et in ["oldest", "latest", "recommended"]: - result = get_issue_details( + result = get_issue_and_event_details( issue_id=str(group.id), organization_id=self.organization.id, selected_event=et, @@ -905,7 +914,7 @@ def test_get_issue_details_no_event_found( assert result is None, et @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def test_get_issue_details_tags_exception(self, mock_get_tags): + def test_get_issue_and_event_details_tags_exception(self, mock_get_tags): mock_get_tags.side_effect = Exception("Test exception") """Test other fields are returned with null tags_overview when tag util fails.""" # Create a valid group. @@ -915,7 +924,7 @@ def test_get_issue_details_tags_exception(self, mock_get_tags): group = event.group assert isinstance(group, Group) - result = get_issue_details( + result = get_issue_and_event_details( issue_id=str(group.id), organization_id=self.organization.id, selected_event="latest", @@ -930,7 +939,7 @@ def test_get_issue_details_tags_exception(self, mock_get_tags): @patch("sentry.models.group.get_recommended_event") @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def test_get_issue_details_with_assigned_user( + def test_get_issue_and_event_details_with_assigned_user( self, mock_get_tags, mock_get_recommended_event, @@ -946,7 +955,7 @@ def test_get_issue_details_with_assigned_user( # Create assignee. GroupAssignee.objects.create(group=group, project=self.project, user_id=self.user.id) - result = get_issue_details( + result = get_issue_and_event_details( issue_id=str(group.id), organization_id=self.organization.id, selected_event="recommended", @@ -962,7 +971,9 @@ def test_get_issue_details_with_assigned_user( @patch("sentry.models.group.get_recommended_event") @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def test_get_issue_details_with_assigned_team(self, mock_get_tags, mock_get_recommended_event): + def test_get_issue_and_event_details_with_assigned_team( + self, mock_get_tags, mock_get_recommended_event + ): mock_get_tags.return_value = {"tags_overview": [{"key": "test_tag", "top_values": []}]} data = load_data("python", timestamp=before_now(minutes=5)) event = self.store_event(data=data, project_id=self.project.id) @@ -974,7 +985,7 @@ def test_get_issue_details_with_assigned_team(self, mock_get_tags, mock_get_reco # Create assignee. GroupAssignee.objects.create(group=group, project=self.project, team=self.team) - result = get_issue_details( + result = get_issue_and_event_details( issue_id=str(group.id), organization_id=self.organization.id, selected_event="recommended", @@ -991,7 +1002,7 @@ def test_get_issue_details_with_assigned_team(self, mock_get_tags, mock_get_reco @patch("sentry.seer.explorer.tools.client") @patch("sentry.models.group.get_recommended_event") @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def test_get_issue_details_timeseries_resolution( + def test_get_issue_and_event_details_timeseries_resolution( self, mock_get_tags, mock_get_recommended_event, @@ -1025,7 +1036,7 @@ def test_get_issue_details_timeseries_resolution( assert isinstance(group, Group) assert group.first_seen == first_seen - result = get_issue_details( + result = get_issue_and_event_details( issue_id=str(group.id), organization_id=self.organization.id, selected_event="recommended", From 5709f1e4796b3bf74fa4cdadbccde35394636055 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sun, 23 Nov 2025 02:21:33 -0800 Subject: [PATCH 6/8] finalize w coverage --- src/sentry/seer/explorer/tools.py | 76 +++++++++++----------- tests/sentry/seer/explorer/test_tools.py | 80 +++++++++++++++++------- 2 files changed, 98 insertions(+), 58 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 4824eb1ed358d7..4aca6015b5d2a6 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -733,26 +733,25 @@ def get_issue_and_event_details( ) -> dict[str, Any] | None: """ Tool to get details for a Sentry issue and one of its associated events. The issue_id can be ommitted so it - is automatically looked up from the event's grouping info. If the rare case the event is ungrouped, we return - null for all issue fields. Event details are always returned. + is automatically looked up from the event's grouping info. Args: organization_id: The ID of the organization to query. - issue_id: The issue/group ID (numeric) or short ID (string) to look up. If None, we try to fill this in with the event's group_id. + issue_id: The issue/group ID (numeric) or short ID (string) to look up. If None, we fill this in with the event's `group` property, which we assume is always present. selected_event: If issue_id is provided, this is the event to return and must exist in the issue - the options are "oldest", "latest", "recommended", or a UUID. If issue_id is not provided, this must be a UUID. Returns: A dict containing: - Issue fields (nullable iff issue_id is None): + Issue fields: `issue`: Serialized issue details. `tags_overview`: A summary of all tags in the issue. `event_timeseries`: Event counts over time for the issue. `timeseries_stats_period`: The stats period used for the event timeseries. `timeseries_interval`: The interval used for the event timeseries. - Non-nullable fields: + Event fields: `event`: Serialized event details. `event_id`: The event ID of the selected event. `event_trace_id`: The trace ID of the selected event. @@ -775,21 +774,17 @@ def get_issue_and_event_details( "id", flat=True ) ) + if not org_project_ids: + return None event: Event | GroupEvent | None = None - group: Group | None = None - serialized_group: dict | None = None - tags_overview: dict | None = None - timeseries: dict | None = None - timeseries_stats_period: str | None = None - timeseries_interval: str | None = None + group: Group # Fetch the group object. if issue_id is None: - # Fetch event by ID if issue_id is not provided. Use this to fetch the group. + # If issue_id is not provided, first find the event. Then use this to fetch the group. uuid.UUID(selected_event) # Raises ValueError if not valid UUID - - # We can't use get_event_by_id since we don't know the project yet. + # We can't use get_event_by_id since we don't know the exact project yet. events_result = eventstore.backend.get_events( filter=eventstore.Filter( event_ids=[selected_event], @@ -811,8 +806,15 @@ def get_issue_and_event_details( return None event = events_result[0] - group = getattr(event, "group", None) - issue_id = str(group.id) if group else None + assert event is not None + if event.group is None: + logger.warning( + "Event is not associated with a group", + extra={"organization_id": organization_id, "event_id": event.event_id}, + ) + return None + + group = event.group else: # Fetch the group from issue_id. @@ -830,30 +832,32 @@ def get_issue_and_event_details( return None # Get the issue data, tags overview, and event count timeseries. - if isinstance(group, Group): - serialized_group = dict(serialize(group, user=None, serializer=GroupSerializer())) - # Add issueTypeDescription as it provides better context for LLMs. Note the initial type should be BaseGroupSerializerResponse. - serialized_group["issueTypeDescription"] = group.issue_type.description - - try: - tags_overview = get_all_tags_overview(group) - except Exception: - logger.exception( - "Failed to get tags overview for issue", - extra={"organization_id": organization_id, "issue_id": issue_id}, - ) + serialized_group = dict(serialize(group, user=None, serializer=GroupSerializer())) + # Add issueTypeDescription as it provides better context for LLMs. Note the initial type should be BaseGroupSerializerResponse. + serialized_group["issueTypeDescription"] = group.issue_type.description - ts_result = _get_issue_event_timeseries( - organization=organization, - project_id=group.project_id, - issue_short_id=group.qualified_short_id, - first_seen_delta=datetime.now(UTC) - group.first_seen, + try: + tags_overview = get_all_tags_overview(group) + except Exception: + logger.exception( + "Failed to get tags overview for issue", + extra={"organization_id": organization_id, "issue_id": issue_id}, ) - if ts_result: - timeseries, timeseries_stats_period, timeseries_interval = ts_result + tags_overview = None + + ts_result = _get_issue_event_timeseries( + organization=organization, + project_id=group.project_id, + issue_short_id=group.qualified_short_id, + first_seen_delta=datetime.now(UTC) - group.first_seen, + ) + if ts_result: + timeseries, timeseries_stats_period, timeseries_interval = ts_result + else: + timeseries, timeseries_stats_period, timeseries_interval = None, None, None # Fetch event from group, if not already fetched. - if event is None and isinstance(group, Group): + if event is None: if selected_event == "oldest": event = group.get_oldest_event() elif selected_event == "latest": diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index ca1986078b7f87..7aad9e3f11b9b9 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -761,7 +761,7 @@ def _test_get_issue_and_event_details_success( self, mock_get_tags, mock_get_recommended_event, - use_short_id: bool, + issue_id_type: Literal["int_id", "short_id", "none"], ): """Test the queries and response format for a group of error events, and multiple event types.""" mock_get_tags.return_value = {"tags_overview": [{"key": "test_tag", "top_values": []}]} @@ -789,15 +789,41 @@ def _test_get_issue_and_event_details_success( assert events[1].group_id == group.id assert events[2].group_id == group.id - for selected_event in [ - "oldest", - "latest", - "recommended", - uuid.UUID(events[1].event_id).hex, # no dashes - str(uuid.UUID(events[1].event_id)), # with dashes - ]: + issue_id_param = ( + group.qualified_short_id + if issue_id_type == "short_id" + else str(group.id) if issue_id_type == "int_id" else None + ) + + if issue_id_param is None: + valid_selected_events = [ + uuid.UUID(events[1].event_id).hex, # no dashes + str(uuid.UUID(events[1].event_id)), # with dashes + ] + invalid_selected_events = [ + "oldest", + "latest", + "recommended", + events[1].event_id[:8], + "potato", + ] + + else: + valid_selected_events = [ + "oldest", + "latest", + "recommended", + uuid.UUID(events[1].event_id).hex, # no dashes + str(uuid.UUID(events[1].event_id)), # with dashes + ] + invalid_selected_events = [ + events[1].event_id[:8], + "potato", + ] + + for selected_event in valid_selected_events: result = get_issue_and_event_details( - issue_id=group.qualified_short_id if use_short_id else str(group.id), + issue_id=issue_id_param, organization_id=self.organization.id, selected_event=selected_event, ) @@ -841,23 +867,22 @@ def _test_get_issue_and_event_details_success( # Validate timeseries dict structure. self._validate_event_timeseries(result["event_timeseries"]) - # Short IDs and non-uuid not supported - for selected_event in [ - events[1].event_id[:8], - "potato", - ]: + for selected_event in invalid_selected_events: with pytest.raises(ValueError, match="badly formed hexadecimal UUID string"): get_issue_and_event_details( - issue_id=group.qualified_short_id if use_short_id else str(group.id), + issue_id=issue_id_param, organization_id=self.organization.id, selected_event=selected_event, ) def test_get_issue_and_event_details_success_int_id(self): - self._test_get_issue_and_event_details_success(use_short_id=False) + self._test_get_issue_and_event_details_success(issue_id_type="int_id") def test_get_issue_and_event_details_success_short_id(self): - self._test_get_issue_and_event_details_success(use_short_id=True) + self._test_get_issue_and_event_details_success(issue_id_type="short_id") + + def test_get_issue_and_event_details_success_null_issue_id(self): + self._test_get_issue_and_event_details_success(issue_id_type="none") def test_get_issue_and_event_details_nonexistent_organization(self): """Test returns None when organization doesn't exist.""" @@ -876,9 +901,9 @@ def test_get_issue_and_event_details_nonexistent_organization(self): ) assert result is None - def test_get_issue_and_event_details_nonexistent_group(self): - """Test returns None when group doesn't exist.""" - # Call with nonexistent group ID. + def test_get_issue_and_event_details_nonexistent_issue(self): + """Test returns None when the requested issue doesn't exist.""" + # Call with nonexistent issue ID. result = get_issue_and_event_details( issue_id="99999", organization_id=self.organization.id, @@ -892,12 +917,13 @@ def test_get_issue_and_event_details_nonexistent_group(self): def test_get_issue_and_event_details_no_event_found( self, mock_get_tags, mock_get_recommended_event, mock_get_oldest_or_latest_event ): + """Test returns None when issue is found but selected_event is not.""" mock_get_tags.return_value = {"tags_overview": [{"key": "test_tag", "top_values": []}]} mock_get_recommended_event.return_value = None mock_get_oldest_or_latest_event.return_value = None # Create events with shared stacktrace (should have same group) - for i in range(3): + for i in range(2): data = load_data("python", timestamp=before_now(minutes=5 - i)) data["exception"] = {"values": [{"type": "Exception", "value": "Test exception"}]} event = self.store_event(data=data, project_id=self.project.id) @@ -905,7 +931,7 @@ def test_get_issue_and_event_details_no_event_found( group = event.group assert isinstance(group, Group) - for et in ["oldest", "latest", "recommended"]: + for et in ["oldest", "latest", "recommended", uuid.uuid4().hex]: result = get_issue_and_event_details( issue_id=str(group.id), organization_id=self.organization.id, @@ -913,6 +939,16 @@ def test_get_issue_and_event_details_no_event_found( ) assert result is None, et + def test_get_issue_and_event_details_no_event_found_null_issue_id(self): + """Test returns None when issue_id is not provided and selected_event is not found.""" + _ = self.project # Create an active project. + result = get_issue_and_event_details( + issue_id=None, + organization_id=self.organization.id, + selected_event=uuid.uuid4().hex, + ) + assert result is None + @patch("sentry.seer.explorer.tools.get_all_tags_overview") def test_get_issue_and_event_details_tags_exception(self, mock_get_tags): mock_get_tags.side_effect = Exception("Test exception") From 57ac4d28ead286bdaf6797225e9929f80c4d4d24 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sun, 23 Nov 2025 02:25:18 -0800 Subject: [PATCH 7/8] rename tests --- tests/sentry/seer/explorer/test_tools.py | 32 +++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 7aad9e3f11b9b9..42fb6e6c9ed571 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -757,7 +757,7 @@ def _validate_event_timeseries(self, timeseries: dict): @patch("sentry.models.group.get_recommended_event") @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def _test_get_issue_and_event_details_success( + def _test_get_ie_details_basic( self, mock_get_tags, mock_get_recommended_event, @@ -875,16 +875,16 @@ def _test_get_issue_and_event_details_success( selected_event=selected_event, ) - def test_get_issue_and_event_details_success_int_id(self): - self._test_get_issue_and_event_details_success(issue_id_type="int_id") + def test_get_ie_details_basic_int_id(self): + self._test_get_ie_details_basic(issue_id_type="int_id") - def test_get_issue_and_event_details_success_short_id(self): - self._test_get_issue_and_event_details_success(issue_id_type="short_id") + def test_get_ie_details_basic_short_id(self): + self._test_get_ie_details_basic(issue_id_type="short_id") - def test_get_issue_and_event_details_success_null_issue_id(self): - self._test_get_issue_and_event_details_success(issue_id_type="none") + def test_get_ie_details_basic_null_issue_id(self): + self._test_get_ie_details_basic(issue_id_type="none") - def test_get_issue_and_event_details_nonexistent_organization(self): + def test_get_ie_details_nonexistent_organization(self): """Test returns None when organization doesn't exist.""" # Create a valid group. data = load_data("python", timestamp=before_now(minutes=5)) @@ -901,7 +901,7 @@ def test_get_issue_and_event_details_nonexistent_organization(self): ) assert result is None - def test_get_issue_and_event_details_nonexistent_issue(self): + def test_get_ie_details_nonexistent_issue(self): """Test returns None when the requested issue doesn't exist.""" # Call with nonexistent issue ID. result = get_issue_and_event_details( @@ -914,7 +914,7 @@ def test_get_issue_and_event_details_nonexistent_issue(self): @patch("sentry.models.group.get_oldest_or_latest_event") @patch("sentry.models.group.get_recommended_event") @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def test_get_issue_and_event_details_no_event_found( + def test_get_ie_details_no_event_found( self, mock_get_tags, mock_get_recommended_event, mock_get_oldest_or_latest_event ): """Test returns None when issue is found but selected_event is not.""" @@ -939,7 +939,7 @@ def test_get_issue_and_event_details_no_event_found( ) assert result is None, et - def test_get_issue_and_event_details_no_event_found_null_issue_id(self): + def test_get_ie_details_no_event_found_null_issue_id(self): """Test returns None when issue_id is not provided and selected_event is not found.""" _ = self.project # Create an active project. result = get_issue_and_event_details( @@ -950,7 +950,7 @@ def test_get_issue_and_event_details_no_event_found_null_issue_id(self): assert result is None @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def test_get_issue_and_event_details_tags_exception(self, mock_get_tags): + def test_get_ie_details_tags_exception(self, mock_get_tags): mock_get_tags.side_effect = Exception("Test exception") """Test other fields are returned with null tags_overview when tag util fails.""" # Create a valid group. @@ -975,7 +975,7 @@ def test_get_issue_and_event_details_tags_exception(self, mock_get_tags): @patch("sentry.models.group.get_recommended_event") @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def test_get_issue_and_event_details_with_assigned_user( + def test_get_ie_details_with_assigned_user( self, mock_get_tags, mock_get_recommended_event, @@ -1007,9 +1007,7 @@ def test_get_issue_and_event_details_with_assigned_user( @patch("sentry.models.group.get_recommended_event") @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def test_get_issue_and_event_details_with_assigned_team( - self, mock_get_tags, mock_get_recommended_event - ): + def test_get_ie_details_with_assigned_team(self, mock_get_tags, mock_get_recommended_event): mock_get_tags.return_value = {"tags_overview": [{"key": "test_tag", "top_values": []}]} data = load_data("python", timestamp=before_now(minutes=5)) event = self.store_event(data=data, project_id=self.project.id) @@ -1038,7 +1036,7 @@ def test_get_issue_and_event_details_with_assigned_team( @patch("sentry.seer.explorer.tools.client") @patch("sentry.models.group.get_recommended_event") @patch("sentry.seer.explorer.tools.get_all_tags_overview") - def test_get_issue_and_event_details_timeseries_resolution( + def test_get_ie_details_timeseries_resolution( self, mock_get_tags, mock_get_recommended_event, From f9c71ab36ddfdca66a76806fa18f20448b648faa Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sun, 23 Nov 2025 02:31:23 -0800 Subject: [PATCH 8/8] docstr --- src/sentry/seer/explorer/tools.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 4aca6015b5d2a6..b36afaeff989d5 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -732,19 +732,19 @@ def get_issue_and_event_details( selected_event: str, ) -> dict[str, Any] | None: """ - Tool to get details for a Sentry issue and one of its associated events. The issue_id can be ommitted so it - is automatically looked up from the event's grouping info. + Tool to get details for a Sentry issue and one of its associated events. null issue_id can be passed so the + is issue is looked up from the event. We assume the event is always associated with an issue, otherwise None is returned. Args: organization_id: The ID of the organization to query. - issue_id: The issue/group ID (numeric) or short ID (string) to look up. If None, we fill this in with the event's `group` property, which we assume is always present. + issue_id: The issue/group ID (numeric) or short ID (string) to look up. If None, we fill this in with the event's `group` property. selected_event: If issue_id is provided, this is the event to return and must exist in the issue - the options are "oldest", "latest", "recommended", or a UUID. If issue_id is not provided, this must be a UUID. Returns: A dict containing: - Issue fields: + Issue fields: aside from `issue` these are nullable if an error occurred. `issue`: Serialized issue details. `tags_overview`: A summary of all tags in the issue. `event_timeseries`: Event counts over time for the issue. @@ -754,7 +754,7 @@ def get_issue_and_event_details( Event fields: `event`: Serialized event details. `event_id`: The event ID of the selected event. - `event_trace_id`: The trace ID of the selected event. + `event_trace_id`: The trace ID of the selected event. Nullable. `project_id`: The event and issue's project ID. `project_slug`: The event and issue's project slug.