Skip to content

Commit e9b1ae8

Browse files
leeandherclaude
andcommitted
ref(integrations): Switch to TypedDict and improve logging for external issue AI generation
Replace NamedTuple with TypedDict for GeneratedExternalIssueDetails to better represent the dict-based return type from Seer. Add exc_info=True to error and warning logs so tracebacks are captured in Sentry. Fix test mock that would KeyError on empty TypedDict construction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9cdf1cc commit e9b1ae8

4 files changed

Lines changed: 37 additions & 35 deletions

File tree

src/sentry/integrations/mixins/issues.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,11 @@ def get_create_issue_config(
165165
default_title = self.get_group_title(group, event, **kwargs)
166166
default_description = self.get_group_description(group, event, **kwargs)
167167

168-
llm_title, llm_description = maybe_generate_external_issue_details(
169-
group=group, user=user, event=event
170-
)
171-
title = llm_title if llm_title else default_title
168+
llm_details = maybe_generate_external_issue_details(group=group, user=user, event=event)
169+
title = llm_details["title"] if llm_details["title"] else default_title
172170
description = (
173-
f"**{default_title}**\n\n{llm_description}\n\n---\n\n{default_description}"
174-
if llm_description
171+
f"**{default_title}**\n\n{llm_details['description']}\n\n---\n\n{default_description}"
172+
if llm_details["description"]
175173
else default_description
176174
)
177175

src/sentry/integrations/utils/external_issues.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4-
from typing import Any, NamedTuple
4+
from typing import Any, TypedDict
55

66
from sentry import features
77
from sentry.models.group import Group
@@ -57,9 +57,14 @@ def _build_event_context(group: Group, event: Any | None = None) -> str:
5757
return context
5858

5959

60+
class GeneratedExternalIssueDetails(TypedDict):
61+
title: str | None
62+
description: str | None
63+
64+
6065
def _make_generate_external_issue_details_request(
6166
group: Group, event: Any | None = None, viewer_context: SeerViewerContext | None = None
62-
) -> dict[str, str] | None:
67+
) -> GeneratedExternalIssueDetails | None:
6368
logging_ctx: dict[str, Any] = {"group_id": group.id, "viewer_context": viewer_context}
6469
context = _build_event_context(group, event=event)
6570

@@ -89,13 +94,17 @@ def _make_generate_external_issue_details_request(
8994
try:
9095
data = response.json()
9196
except (json.JSONDecodeError, ValueError):
92-
logger.warning("external_issues.seer_response_json_failed", extra=logging_ctx)
97+
logger.warning(
98+
"external_issues.seer_response_json_failed", extra=logging_ctx, exc_info=True
99+
)
93100
return None
94101
content = data.get("content")
95102
try:
96103
content = json.loads(content)
97104
except (json.JSONDecodeError, TypeError, ValueError):
98-
logger.warning("external_issues.seer_response_parse_failed", extra=logging_ctx)
105+
logger.warning(
106+
"external_issues.seer_response_parse_failed", extra=logging_ctx, exc_info=True
107+
)
99108
return None
100109

101110
title = content.get("title")
@@ -109,35 +118,28 @@ def _make_generate_external_issue_details_request(
109118
return None
110119

111120

112-
class GeneratedIssueDetails(NamedTuple):
113-
title: str | None = None
114-
description: str | None = None
115-
116-
117121
def maybe_generate_external_issue_details(
118122
*, group: Group, user: User | RpcUser, event: GroupEvent | None = None
119-
) -> GeneratedIssueDetails:
123+
) -> GeneratedExternalIssueDetails:
120124
organization = group.organization
125+
empty_result = GeneratedExternalIssueDetails(title=None, description=None)
121126
if not features.has("organizations:gen-ai-features", organization, actor=user):
122-
return GeneratedIssueDetails()
127+
return empty_result
123128
if organization.get_option("sentry:hide_ai_features", False):
124-
return GeneratedIssueDetails()
129+
return empty_result
125130
if not features.has("organizations:external-issues-ai-generate", organization, actor=user):
126-
return GeneratedIssueDetails()
131+
return empty_result
127132

128133
try:
129134
viewer_context = SeerViewerContext(organization_id=organization.id, user_id=user.id)
130135
result = _make_generate_external_issue_details_request(
131136
group, event=event, viewer_context=viewer_context
132137
)
133-
# Open except block is wide but allows us to fallback to default title/description if anything fails.
134138
except Exception:
135-
logger.error("external_issues.generate_issue_details_failed")
136-
return GeneratedIssueDetails()
139+
logger.error("external_issues.generate_issue_details_failed", exc_info=True)
140+
return empty_result
137141

138142
if not result:
139-
return GeneratedIssueDetails()
143+
return empty_result
140144

141-
title: str | None = result.get("title")
142-
description: str | None = result.get("description")
143-
return GeneratedIssueDetails(title=title, description=description)
145+
return result

tests/sentry/integrations/test_issues.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sentry.integrations.models.external_issue import ExternalIssue
1010
from sentry.integrations.models.organization_integration import OrganizationIntegration
1111
from sentry.integrations.services.integration import integration_service
12-
from sentry.integrations.utils.external_issues import GeneratedIssueDetails
12+
from sentry.integrations.utils.external_issues import GeneratedExternalIssueDetails
1313
from sentry.models.activity import Activity
1414
from sentry.models.group import Group, GroupStatus
1515
from sentry.models.grouplink import GroupLink
@@ -743,7 +743,7 @@ def test_annotations(self) -> None:
743743

744744
@patch("sentry.integrations.mixins.issues.maybe_generate_external_issue_details")
745745
def test_ai_text_replaces_defaults(self, mock_generate: MagicMock) -> None:
746-
mock_generate.return_value = GeneratedIssueDetails(
746+
mock_generate.return_value = GeneratedExternalIssueDetails(
747747
title="LLM Title",
748748
description="LLM Description",
749749
)
@@ -759,7 +759,7 @@ def test_ai_text_replaces_defaults(self, mock_generate: MagicMock) -> None:
759759

760760
@patch("sentry.integrations.mixins.issues.maybe_generate_external_issue_details")
761761
def test_falls_back_when_ai_returns_empty(self, mock_generate: MagicMock) -> None:
762-
mock_generate.return_value = GeneratedIssueDetails()
762+
mock_generate.return_value = GeneratedExternalIssueDetails(title=None, description=None)
763763

764764
config = self.installation.get_create_issue_config(self.group, self.user)
765765

tests/sentry/integrations/utils/test_external_issues.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from sentry.integrations.utils.external_issues import (
66
MAX_CONTEXT_LENGTH,
7-
GeneratedIssueDetails,
7+
GeneratedExternalIssueDetails,
88
_make_generate_external_issue_details_request,
99
maybe_generate_external_issue_details,
1010
)
@@ -108,7 +108,7 @@ def setUp(self) -> None:
108108
def test_feature_flag_disabled_returns_empty(self, mock_request: MagicMock) -> None:
109109
result = maybe_generate_external_issue_details(group=self.group, user=self.user)
110110

111-
assert result == GeneratedIssueDetails()
111+
assert result == GeneratedExternalIssueDetails(title=None, description=None)
112112
mock_request.assert_not_called()
113113

114114
@patch("sentry.integrations.utils.external_issues.make_llm_generate_request")
@@ -120,15 +120,15 @@ def test_hide_ai_features_returns_empty(self, mock_request: MagicMock) -> None:
120120
):
121121
result = maybe_generate_external_issue_details(group=self.group, user=self.user)
122122

123-
assert result == GeneratedIssueDetails()
123+
assert result == GeneratedExternalIssueDetails(title=None, description=None)
124124
mock_request.assert_not_called()
125125

126126
@patch("sentry.integrations.utils.external_issues.make_llm_generate_request")
127127
def test_gen_ai_features_disabled_returns_empty(self, mock_request: MagicMock) -> None:
128128
with self.feature("organizations:external-issues-ai-generate"):
129129
result = maybe_generate_external_issue_details(group=self.group, user=self.user)
130130

131-
assert result == GeneratedIssueDetails()
131+
assert result == GeneratedExternalIssueDetails(title=None, description=None)
132132
mock_request.assert_not_called()
133133

134134
@patch("sentry.integrations.utils.external_issues.make_llm_generate_request")
@@ -140,7 +140,7 @@ def test_exception_returns_empty(self, mock_request: MagicMock) -> None:
140140
):
141141
result = maybe_generate_external_issue_details(group=self.group, user=self.user)
142142

143-
assert result == GeneratedIssueDetails()
143+
assert result == GeneratedExternalIssueDetails(title=None, description=None)
144144

145145
@patch("sentry.integrations.utils.external_issues.make_llm_generate_request")
146146
def test_successful_returns_details(self, mock_request: MagicMock) -> None:
@@ -155,4 +155,6 @@ def test_successful_returns_details(self, mock_request: MagicMock) -> None:
155155
):
156156
result = maybe_generate_external_issue_details(group=self.group, user=self.user)
157157

158-
assert result == GeneratedIssueDetails(title="AI Title", description="AI Description")
158+
assert result == GeneratedExternalIssueDetails(
159+
title="AI Title", description="AI Description"
160+
)

0 commit comments

Comments
 (0)