Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 20 additions & 16 deletions src/sentry/seer/explorer/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -277,24 +277,25 @@ 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, int | str | dict | None] | None:
) -> 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.

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:
Expand All @@ -307,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)

Expand All @@ -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":
Expand Down Expand Up @@ -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)
Expand All @@ -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"],
}
99 changes: 63 additions & 36 deletions tests/sentry/seer/explorer/test_tools.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -605,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,
)
Expand All @@ -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":
Expand All @@ -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
Expand All @@ -684,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",
)
Expand All @@ -694,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",
)
Expand All @@ -721,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,
)
Expand All @@ -739,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",
)
Expand All @@ -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", {}))
Loading