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..b36afaeff989d5 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -725,6 +725,184 @@ 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. 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. + 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: 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. + `timeseries_stats_period`: The stats period used for the event timeseries. + `timeseries_interval`: The interval used for the event timeseries. + + 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. Nullable. + `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 + ) + ) + if not org_project_ids: + return None + + event: Event | GroupEvent | None = None + group: Group + + # Fetch the group object. + if issue_id is None: + # 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 exact project yet. + 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] + 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. + 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 + + # Get the issue data, tags overview, and event count timeseries. + 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}, + ) + 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: + 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: + 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, + group_id=group.id, + tenant_ids={"organization_id": organization_id}, + ) + + 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. + 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, diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index a6bdf017816230..42fb6e6c9ed571 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,11 +757,11 @@ 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_ie_details_basic( 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,24 +789,45 @@ def _test_get_issue_details_success( assert events[1].group_id == group.id assert events[2].group_id == group.id - for selected_event in [ - "oldest", - "latest", - "recommended", - events[1].event_id, - events[1].event_id[:8], - ]: - result = get_issue_details( - issue_id=group.qualified_short_id if use_short_id else str(group.id), + 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=issue_id_param, 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 +853,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 +867,24 @@ 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) + for selected_event in invalid_selected_events: + with pytest.raises(ValueError, match="badly formed hexadecimal UUID string"): + get_issue_and_event_details( + issue_id=issue_id_param, + organization_id=self.organization.id, + selected_event=selected_event, + ) + + def test_get_ie_details_basic_int_id(self): + self._test_get_ie_details_basic(issue_id_type="int_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_details_success_short_id(self): - self._test_get_issue_details_success(use_short_id=True) + def test_get_ie_details_basic_null_issue_id(self): + self._test_get_ie_details_basic(issue_id_type="none") - def test_get_issue_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)) @@ -860,17 +894,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): - """Test returns None when group doesn't exist.""" - # Call with nonexistent group ID. - result = get_issue_details( + 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( issue_id="99999", organization_id=self.organization.id, selected_event="latest", @@ -880,15 +914,16 @@ 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_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.""" 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) @@ -896,16 +931,26 @@ def test_get_issue_details_no_event_found( group = event.group assert isinstance(group, Group) - for et in ["oldest", "latest", "recommended"]: - result = get_issue_details( + 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, selected_event=et, ) assert result is None, et + 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( + 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_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. @@ -915,7 +960,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 +975,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_ie_details_with_assigned_user( self, mock_get_tags, mock_get_recommended_event, @@ -946,7 +991,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 +1007,7 @@ 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_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) @@ -974,7 +1019,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 +1036,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_ie_details_timeseries_resolution( self, mock_get_tags, mock_get_recommended_event, @@ -1025,7 +1070,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",