Skip to content

Commit d26758e

Browse files
feat(feedback): label generation at ingest, stored as tags (#96390)
1 parent e28a732 commit d26758e

File tree

4 files changed

+293
-1
lines changed

4 files changed

+293
-1
lines changed

src/sentry/feedback/usecases/ingest/create_feedback.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88

99
import jsonschema
1010

11-
from sentry import options
11+
from sentry import features, options
1212
from sentry.constants import DataCategory
1313
from sentry.feedback.lib.utils import UNREAL_FEEDBACK_UNATTENDED_MESSAGE, FeedbackCreationSource
14+
from sentry.feedback.usecases.label_generation import (
15+
AI_LABEL_TAG_PREFIX,
16+
MAX_AI_LABELS,
17+
generate_labels,
18+
)
1419
from sentry.feedback.usecases.spam_detection import is_spam, spam_detection_enabled
1520
from sentry.issues.grouptype import FeedbackGroup
1621
from sentry.issues.issue_occurrence import IssueEvidence, IssueOccurrence
@@ -346,6 +351,30 @@ def create_feedback_issue(
346351
}
347352
)
348353

354+
# Generating labels using Seer, which will later be used to categorize feedbacks
355+
if (
356+
not is_message_spam
357+
and features.has("organizations:user-feedback-ai-categorization", project.organization)
358+
and features.has("organizations:gen-ai-features", project.organization)
359+
):
360+
try:
361+
labels = generate_labels(feedback_message, project.organization_id)
362+
if len(labels) > MAX_AI_LABELS:
363+
logger.info(
364+
"Feedback message has more than the maximum allowed labels.",
365+
extra={
366+
"project_id": project.id,
367+
"entrypoint": "create_feedback_issue",
368+
"feedback_message": feedback_message[:100],
369+
},
370+
)
371+
labels = labels[:MAX_AI_LABELS]
372+
373+
for idx, label in enumerate(labels):
374+
event_fixed["tags"][f"{AI_LABEL_TAG_PREFIX}.{idx}"] = label
375+
except Exception:
376+
logger.exception("Error generating labels", extra={"project_id": project.id})
377+
349378
# Set the user.email tag since we want to be able to display user.email on the feedback UI as a tag
350379
# as well as be able to write alert conditions on it
351380
user_email = get_path(event_fixed, "user", "email")
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import logging
2+
from typing import TypedDict
3+
4+
import requests
5+
from django.conf import settings
6+
7+
from sentry.seer.signed_seer_api import sign_with_seer_secret
8+
from sentry.utils import json, metrics
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class LabelRequest(TypedDict):
14+
"""Corresponds to GenerateFeedbackLabelsRequest in Seer."""
15+
16+
organization_id: int
17+
feedback_message: str
18+
19+
20+
AI_LABEL_TAG_PREFIX = "ai_categorization.label"
21+
# If Seer generates more labels, we truncate it to this many labels
22+
MAX_AI_LABELS = 15
23+
24+
SEER_GENERATE_LABELS_URL = f"{settings.SEER_AUTOFIX_URL}/v1/automation/summarize/feedback/labels"
25+
26+
27+
@metrics.wraps("feedback.generate_labels", sample_rate=1.0)
28+
def generate_labels(feedback_message: str, organization_id: int) -> list[str]:
29+
"""
30+
Generate labels for a feedback message.
31+
32+
The possible errors this can throw are:
33+
- request.exceptions.Timeout, request.exceptions.ConnectionError, etc. while making the request
34+
- request.exceptions.HTTPError (for raise_for_status)
35+
- requests.exceptions.JSONDecodeError or another decode error if the response is not valid JSON
36+
- KeyError / ValueError if the response JSON doesn't have the expected structure
37+
"""
38+
request = LabelRequest(
39+
organization_id=organization_id,
40+
feedback_message=feedback_message,
41+
)
42+
43+
serialized_request = json.dumps(request)
44+
45+
response = requests.post(
46+
SEER_GENERATE_LABELS_URL,
47+
data=serialized_request,
48+
headers={
49+
"content-type": "application/json;charset=utf-8",
50+
**sign_with_seer_secret(serialized_request.encode()),
51+
},
52+
timeout=10,
53+
)
54+
55+
if response.status_code != 200:
56+
logger.error(
57+
"Failed to generate labels",
58+
extra={
59+
"status_code": response.status_code,
60+
"response": response.text,
61+
"content": response.content,
62+
},
63+
)
64+
65+
response.raise_for_status()
66+
67+
labels = response.json()["data"]["labels"]
68+
69+
# Guaranteed to be a list of strings (validated in Seer)
70+
return labels

tests/sentry/feedback/usecases/ingest/test_create_feedback.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
get_feedback_title,
1414
validate_issue_platform_event_schema,
1515
)
16+
from sentry.feedback.usecases.label_generation import AI_LABEL_TAG_PREFIX, MAX_AI_LABELS
1617
from sentry.models.group import Group, GroupStatus
1718
from sentry.signals import first_feedback_received, first_new_feedback_received
1819
from sentry.testutils.helpers import Feature
@@ -935,3 +936,118 @@ def test_create_feedback_issue_title(default_project, mock_produce_occurrence_to
935936
"User Feedback: This is a very long feedback message that describes multiple..."
936937
)
937938
assert occurrence.issue_title == expected_title
939+
940+
941+
@django_db_all
942+
def test_create_feedback_adds_ai_labels(
943+
default_project, mock_produce_occurrence_to_kafka, monkeypatch
944+
):
945+
"""Test that create_feedback_issue adds AI labels to tags when label generation succeeds."""
946+
with Feature(
947+
{
948+
"organizations:user-feedback-ai-categorization": True,
949+
"organizations:gen-ai-features": True,
950+
}
951+
):
952+
event = mock_feedback_event(default_project.id)
953+
event["contexts"]["feedback"]["message"] = "The login button is broken and the UI is slow"
954+
955+
# This assumes that the maximum number of labels allowed is greater than 3
956+
def mock_generate_labels(*args, **kwargs):
957+
return ["User Interface", "Authentication", "Performance"]
958+
959+
monkeypatch.setattr(
960+
"sentry.feedback.usecases.ingest.create_feedback.generate_labels",
961+
mock_generate_labels,
962+
)
963+
964+
create_feedback_issue(event, default_project, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE)
965+
966+
assert mock_produce_occurrence_to_kafka.call_count == 1
967+
produced_event = mock_produce_occurrence_to_kafka.call_args.kwargs["event_data"]
968+
tags = produced_event["tags"]
969+
970+
ai_labels = [value for key, value in tags.items() if key.startswith(AI_LABEL_TAG_PREFIX)]
971+
assert len(ai_labels) == 3
972+
assert set(ai_labels) == {"User Interface", "Authentication", "Performance"}
973+
974+
975+
@django_db_all
976+
def test_create_feedback_handles_label_generation_errors(
977+
default_project, mock_produce_occurrence_to_kafka, monkeypatch
978+
):
979+
"""Test that create_feedback_issue continues to work even when generate_labels raises an error."""
980+
with Feature(
981+
{
982+
"organizations:user-feedback-ai-categorization": True,
983+
"organizations:gen-ai-features": True,
984+
}
985+
):
986+
event = mock_feedback_event(default_project.id)
987+
event["contexts"]["feedback"]["message"] = "This is a valid feedback message"
988+
989+
# Mock generate_labels to raise an exception
990+
def mock_generate_labels(*args, **kwargs):
991+
raise Exception("Label generation failed")
992+
993+
monkeypatch.setattr(
994+
"sentry.feedback.usecases.ingest.create_feedback.generate_labels",
995+
mock_generate_labels,
996+
)
997+
998+
# This should not raise an exception and should still create the feedback
999+
create_feedback_issue(event, default_project, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE)
1000+
1001+
# Verify that the feedback was still created successfully
1002+
assert mock_produce_occurrence_to_kafka.call_count == 1
1003+
1004+
produced_event = mock_produce_occurrence_to_kafka.call_args.kwargs["event_data"]
1005+
tags = produced_event["tags"]
1006+
1007+
ai_labels = [tag for tag in tags.keys() if tag.startswith(AI_LABEL_TAG_PREFIX)]
1008+
assert (
1009+
len(ai_labels) == 0
1010+
), "No AI categorization labels should be present when label generation fails"
1011+
1012+
1013+
@django_db_all
1014+
def test_create_feedback_truncates_ai_labels(
1015+
default_project, mock_produce_occurrence_to_kafka, monkeypatch
1016+
):
1017+
"""Test that create_feedback_issue truncates AI labels when more than MAX_AI_LABELS are returned."""
1018+
with Feature(
1019+
{
1020+
"organizations:user-feedback-ai-categorization": True,
1021+
"organizations:gen-ai-features": True,
1022+
}
1023+
):
1024+
event = mock_feedback_event(default_project.id)
1025+
event["contexts"]["feedback"][
1026+
"message"
1027+
] = "This is a very complex feedback with many issues"
1028+
1029+
# Mock generate_labels to return more than MAX_AI_LABELS labels
1030+
def mock_generate_labels(*args, **kwargs):
1031+
return [f"Label {i}" for i in range(MAX_AI_LABELS + 5)]
1032+
1033+
monkeypatch.setattr(
1034+
"sentry.feedback.usecases.ingest.create_feedback.generate_labels",
1035+
mock_generate_labels,
1036+
)
1037+
1038+
create_feedback_issue(event, default_project, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE)
1039+
1040+
assert mock_produce_occurrence_to_kafka.call_count == 1
1041+
1042+
produced_event = mock_produce_occurrence_to_kafka.call_args.kwargs["event_data"]
1043+
tags = produced_event["tags"]
1044+
1045+
ai_labels = [value for key, value in tags.items() if key.startswith(AI_LABEL_TAG_PREFIX)]
1046+
assert len(ai_labels) == MAX_AI_LABELS, "Should be truncated to exactly MAX_AI_LABELS"
1047+
1048+
for i in range(MAX_AI_LABELS):
1049+
assert tags[f"{AI_LABEL_TAG_PREFIX}.{i}"] == f"Label {i}"
1050+
1051+
# Verify that labels beyond MAX_AI_LABELS are not present
1052+
for i in range(MAX_AI_LABELS, MAX_AI_LABELS + 5):
1053+
assert f"{AI_LABEL_TAG_PREFIX}.{i}" not in tags
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import pytest
2+
import requests
3+
import responses
4+
5+
from sentry.feedback.usecases.label_generation import SEER_GENERATE_LABELS_URL, generate_labels
6+
from sentry.testutils.cases import TestCase
7+
from sentry.utils import json
8+
9+
10+
def mock_seer_response(**kwargs) -> None:
11+
"""Use with @responses.activate to cleanup after tests. Not compatible with store_replay."""
12+
responses.add(
13+
responses.POST,
14+
SEER_GENERATE_LABELS_URL,
15+
**kwargs,
16+
)
17+
18+
19+
class TestGenerateLabels(TestCase):
20+
@responses.activate
21+
def test_generate_labels_success_response(self):
22+
mock_seer_response(
23+
status=200,
24+
json={"data": {"labels": ["User Interface", "Navigation", "Right Sidebar"]}},
25+
)
26+
27+
labels = generate_labels(
28+
"I don't like the new right sidebar, it makes navigating everywhere hard!", 1
29+
)
30+
31+
test_request = responses.calls[0].request
32+
test_response = responses.calls[0].response
33+
34+
assert labels == ["User Interface", "Navigation", "Right Sidebar"]
35+
assert json.loads(test_request.body) == {
36+
"feedback_message": "I don't like the new right sidebar, it makes navigating everywhere hard!",
37+
"organization_id": 1,
38+
}
39+
assert test_response.status_code == 200
40+
41+
@responses.activate
42+
def test_generate_labels_failed_response(self):
43+
mock_seer_response(
44+
status=500,
45+
json={"error": "Internal Server Error"},
46+
)
47+
48+
with pytest.raises(requests.exceptions.HTTPError):
49+
generate_labels(
50+
"I don't like the new right sidebar, it makes navigating everywhere hard!", 1
51+
)
52+
53+
test_request = responses.calls[0].request
54+
test_response = responses.calls[0].response
55+
56+
assert test_response.status_code == 500
57+
assert json.loads(test_request.body) == {
58+
"feedback_message": "I don't like the new right sidebar, it makes navigating everywhere hard!",
59+
"organization_id": 1,
60+
}
61+
62+
@responses.activate
63+
def test_generate_labels_network_error(self):
64+
mock_seer_response(body=requests.exceptions.Timeout("Request timed out"))
65+
66+
with pytest.raises(requests.exceptions.Timeout):
67+
generate_labels(
68+
"I don't like the new right sidebar, it makes navigating everywhere hard!", 1
69+
)
70+
71+
test_request = responses.calls[0].request
72+
73+
assert len(responses.calls) == 1
74+
assert json.loads(test_request.body) == {
75+
"feedback_message": "I don't like the new right sidebar, it makes navigating everywhere hard!",
76+
"organization_id": 1,
77+
}

0 commit comments

Comments
 (0)