Skip to content

Commit fe479ea

Browse files
ref(replay-feedback): check Seer consent in Seer callsites (#97654)
Create and use a shared util for checking Seer org consent in Sentry, along with other required FFs and options, using [autofix](https://github.com/getsentry/sentry/blob/d1241729972adf1cb2601691c759827c758280e8/src/sentry/seer/autofix/autofix.py#L406-L415) as example. In tests, mocking this util decouples the specific Seer requirements from our dependencies. - ~~Now requires seer access in replay delete endpoints (before we only checked the summaries FF, but once that's rolled out we'll need the access check)~~ - Refactors title generation a bit so we check access in create_feedback instead of a util. **Followups:** Once this is deployed we can remove the org consent RPC calls for replay/feedback endpoints in Seer. Then a 2nd followup PR in Sentry can remove the org_id param from requests. Fixes REPLAY-619 --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 337028b commit fe479ea

13 files changed

+185
-163
lines changed

src/sentry/feedback/endpoints/organization_feedback_categories.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
)
2323
from sentry.grouping.utils import hash_from_values
2424
from sentry.models.organization import Organization
25+
from sentry.seer.seer_setup import has_seer_access
2526
from sentry.seer.signed_seer_api import sign_with_seer_secret
2627
from sentry.utils import json
2728
from sentry.utils.cache import cache
@@ -114,8 +115,10 @@ def get(self, request: Request, organization: Organization) -> Response:
114115
"organizations:user-feedback-ai-categorization-features",
115116
organization,
116117
actor=request.user,
117-
) or not features.has("organizations:gen-ai-features", organization, actor=request.user):
118-
return Response(status=404)
118+
) or not has_seer_access(organization, actor=request.user):
119+
return Response(
120+
{"detail": "AI categorization is not available for this organization."}, status=403
121+
)
119122

120123
try:
121124
start, end = get_date_range_from_stats_period(

src/sentry/feedback/endpoints/organization_feedback_summary.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from sentry.issues.grouptype import FeedbackGroup
2020
from sentry.models.group import Group, GroupStatus
2121
from sentry.models.organization import Organization
22+
from sentry.seer.seer_setup import has_seer_access
2223
from sentry.seer.signed_seer_api import sign_with_seer_secret
2324
from sentry.utils import json
2425
from sentry.utils.cache import cache
@@ -67,8 +68,10 @@ def get(self, request: Request, organization: Organization) -> Response:
6768

6869
if not features.has(
6970
"organizations:user-feedback-ai-summaries", organization, actor=request.user
70-
) or not features.has("organizations:gen-ai-features", organization, actor=request.user):
71-
return Response(status=403)
71+
) or not has_seer_access(organization, actor=request.user):
72+
return Response(
73+
{"detail": "AI summaries are not available for this organization."}, status=403
74+
)
7275

7376
try:
7477
start, end = get_date_range_from_stats_period(

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

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,15 @@
1818
generate_labels,
1919
)
2020
from sentry.feedback.usecases.spam_detection import is_spam, spam_detection_enabled
21-
from sentry.feedback.usecases.title_generation import (
22-
format_feedback_title,
23-
get_feedback_title_from_seer,
24-
should_get_ai_title,
25-
)
21+
from sentry.feedback.usecases.title_generation import get_feedback_title
2622
from sentry.issues.grouptype import FeedbackGroup
2723
from sentry.issues.issue_occurrence import IssueEvidence, IssueOccurrence
2824
from sentry.issues.json_schemas import EVENT_PAYLOAD_SCHEMA
2925
from sentry.issues.producer import PayloadType, produce_occurrence_to_kafka
3026
from sentry.issues.status_change_message import StatusChangeMessage
3127
from sentry.models.group import GroupStatus
3228
from sentry.models.project import Project
29+
from sentry.seer.seer_setup import has_seer_access
3330
from sentry.signals import first_feedback_received, first_new_feedback_received
3431
from sentry.types.group import GroupSubStatus
3532
from sentry.utils import json, metrics
@@ -280,6 +277,8 @@ def create_feedback_issue(
280277
},
281278
)
282279

280+
should_query_seer = not is_message_spam and has_seer_access(project.organization)
281+
283282
# Prepare the data for issue platform processing and attach useful tags.
284283

285284
# Note that some of the fields below like title and subtitle
@@ -291,17 +290,34 @@ def create_feedback_issue(
291290
)
292291
issue_fingerprint = [uuid4().hex]
293292

294-
ai_title = None
295-
if not is_message_spam and should_get_ai_title(project.organization):
296-
ai_title = get_feedback_title_from_seer(feedback_message, project.organization_id)
297-
formatted_title = format_feedback_title(ai_title or feedback_message)
293+
# TODO: clean up these metrics after the feature is rolled out.
294+
if is_message_spam:
295+
metrics.incr(
296+
"feedback.ai_title_generation.skipped",
297+
tags={"reason": "is_spam"},
298+
)
299+
elif not should_query_seer:
300+
metrics.incr(
301+
"feedback.ai_title_generation.skipped",
302+
tags={"reason": "gen_ai_disabled"},
303+
)
304+
elif not features.has("organizations:user-feedback-ai-titles", project.organization):
305+
metrics.incr(
306+
"feedback.ai_title_generation.skipped",
307+
tags={"reason": "feedback_ai_titles_disabled"},
308+
)
309+
310+
use_ai_title = should_query_seer and features.has(
311+
"organizations:user-feedback-ai-titles", project.organization
312+
)
313+
title = get_feedback_title(feedback_message, project.organization_id, use_ai_title)
298314

299315
occurrence = IssueOccurrence(
300316
id=uuid4().hex,
301317
event_id=event["event_id"],
302318
project_id=project.id,
303319
fingerprint=issue_fingerprint, # random UUID for fingerprint so feedbacks are grouped individually
304-
issue_title=formatted_title,
320+
issue_title=title,
305321
subtitle=feedback_message,
306322
resource_id=None,
307323
evidence_data=evidence_data,
@@ -323,10 +339,8 @@ def create_feedback_issue(
323339
)
324340

325341
# Generating labels using Seer, which will later be used to categorize feedbacks
326-
if (
327-
not is_message_spam
328-
and features.has("organizations:user-feedback-ai-categorization", project.organization)
329-
and features.has("organizations:gen-ai-features", project.organization)
342+
if should_query_seer and features.has(
343+
"organizations:user-feedback-ai-categorization", project.organization
330344
):
331345
try:
332346
labels = generate_labels(feedback_message, project.organization_id)

src/sentry/feedback/usecases/title_generation.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
import requests
77
from django.conf import settings
88

9-
from sentry import features
10-
from sentry.models.organization import Organization
119
from sentry.seer.signed_seer_api import sign_with_seer_secret
1210
from sentry.utils import json, metrics
1311

@@ -23,17 +21,6 @@ class GenerateFeedbackTitleRequest(TypedDict):
2321
feedback_message: str
2422

2523

26-
def should_get_ai_title(organization: Organization) -> bool:
27-
"""Check if AI title generation should be used for the given organization."""
28-
if not features.has("organizations:gen-ai-features", organization):
29-
return False
30-
31-
if not features.has("organizations:user-feedback-ai-titles", organization):
32-
return False
33-
34-
return True
35-
36-
3724
def format_feedback_title(title: str, max_words: int = 10) -> str:
3825
"""
3926
Clean and format a title for user feedback issues.
@@ -133,3 +120,15 @@ def make_seer_request(request: GenerateFeedbackTitleRequest) -> bytes:
133120
response.raise_for_status()
134121

135122
return response.content
123+
124+
125+
def get_feedback_title(feedback_message: str, organization_id: int, use_seer: bool) -> str:
126+
if use_seer:
127+
# Message is fallback if Seer fails.
128+
raw_title = (
129+
get_feedback_title_from_seer(feedback_message, organization_id) or feedback_message
130+
)
131+
else:
132+
raw_title = feedback_message
133+
134+
return format_feedback_title(raw_title)

src/sentry/replays/endpoints/project_replay_details.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,13 @@ def delete(self, request: Request, project: Project, replay_id: str) -> Response
9696
if has_archived_segment(project.id, replay_id):
9797
return Response(status=404)
9898

99+
# We don't check Seer features because an org may have previously had them on, then turned them off.
100+
has_seer_data = features.has("organizations:replay-ai-summaries", project.organization)
101+
99102
delete_replay.delay(
100103
project_id=project.id,
101104
replay_id=replay_id,
102-
has_seer_data=features.has("organizations:replay-ai-summaries", project.organization),
105+
has_seer_data=has_seer_data,
103106
)
104107

105108
return Response(status=204)

src/sentry/replays/endpoints/project_replay_jobs_delete.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ def post(self, request: Request, project) -> Response:
102102
status="pending",
103103
)
104104

105+
# We don't check Seer features because an org may have previously had them on, then turned them off.
105106
has_seer_data = features.has("organizations:replay-ai-summaries", project.organization)
106107

107108
# We always start with an offset of 0 (obviously) but future work doesn't need to obey

src/sentry/replays/endpoints/project_replay_summary.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from sentry.replays.post_process import process_raw_response
2323
from sentry.replays.query import query_replay_instance
2424
from sentry.replays.usecases.reader import fetch_segments_metadata, iter_segment_data
25+
from sentry.seer.seer_setup import has_seer_access
2526
from sentry.seer.signed_seer_api import sign_with_seer_secret
2627
from sentry.utils import json
2728

@@ -69,11 +70,6 @@ class ProjectReplaySummaryEndpoint(ProjectEndpoint):
6970
def __init__(self, **options) -> None:
7071
storage.initialize_client()
7172
super().__init__(**options)
72-
self.features = [
73-
"organizations:session-replay",
74-
"organizations:replay-ai-summaries",
75-
"organizations:gen-ai-features",
76-
]
7773

7874
def make_seer_request(self, url: str, post_body: dict[str, Any]) -> Response:
7975
"""Make a POST request to a Seer endpoint. Raises HTTPError and logs non-200 status codes."""
@@ -127,13 +123,23 @@ def make_seer_request(self, url: str, post_body: dict[str, Any]) -> Response:
127123
# Note any headers in the Seer response aren't returned.
128124
return Response(data=response.json(), status=response.status_code)
129125

126+
def has_replay_summary_access(self, project: Project, request: Request) -> bool:
127+
return (
128+
features.has("organizations:session-replay", project.organization, actor=request.user)
129+
and features.has(
130+
"organizations:replay-ai-summaries", project.organization, actor=request.user
131+
)
132+
and has_seer_access(project.organization, actor=request.user)
133+
)
134+
130135
def get(self, request: Request, project: Project, replay_id: str) -> Response:
131136
"""Poll for the status of a replay summary task in Seer."""
132-
if not all(
133-
features.has(feature, project.organization, actor=request.user)
134-
for feature in self.features
135-
):
136-
return self.respond(status=404)
137+
if not self.has_replay_summary_access(project, request):
138+
return self.respond(
139+
{"detail": "Replay summaries are not available for this organization."}, status=403
140+
)
141+
142+
# We skip checking Seer permissions here for performance, and because summaries can't be created without them anyway.
137143

138144
# Request Seer for the state of the summary task.
139145
return self.make_seer_request(
@@ -145,11 +151,10 @@ def get(self, request: Request, project: Project, replay_id: str) -> Response:
145151

146152
def post(self, request: Request, project: Project, replay_id: str) -> Response:
147153
"""Download replay segment data and parse it into logs. Then post to Seer to start a summary task."""
148-
if not all(
149-
features.has(feature, project.organization, actor=request.user)
150-
for feature in self.features
151-
):
152-
return self.respond(status=404)
154+
if not self.has_replay_summary_access(project, request):
155+
return self.respond(
156+
{"detail": "Replay summaries are not available for this organization."}, status=403
157+
)
153158

154159
filter_params = self.get_filter_params(request, project)
155160

src/sentry/seer/seer_setup.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1+
from django.contrib.auth.models import AnonymousUser
2+
3+
from sentry import features
4+
from sentry.models.organization import Organization
15
from sentry.models.promptsactivity import PromptsActivity
6+
from sentry.users.models.user import User
7+
from sentry.users.services.user.model import RpcUser
28

39
feature_name = "seer_autofix_setup_acknowledged"
410

@@ -18,3 +24,13 @@ def get_seer_org_acknowledgement(org_id: int) -> bool:
1824
organization_id=org_id,
1925
project_id=0,
2026
).exists()
27+
28+
29+
def has_seer_access(
30+
organization: Organization, actor: User | AnonymousUser | RpcUser | None = None
31+
) -> bool:
32+
return (
33+
features.has("organizations:gen-ai-features", organization, actor=actor)
34+
and not bool(organization.get_option("sentry:hide_ai_features"))
35+
and get_seer_org_acknowledgement(org_id=organization.id)
36+
)

tests/sentry/feedback/endpoints/test_organization_feedback_categories.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from sentry.issues.grouptype import FeedbackGroup
1212
from sentry.testutils.cases import APITestCase, SnubaTestCase
1313
from sentry.testutils.helpers.datetime import before_now
14-
from sentry.testutils.pytest.fixtures import django_db_all
1514
from sentry.testutils.silo import region_silo_test
1615
from tests.sentry.issues.test_utils import SearchIssueTestMixin
1716

@@ -46,14 +45,23 @@ def setUp(self) -> None:
4645
self.project2 = self.create_project(teams=[self.team])
4746
self.features = {
4847
"organizations:user-feedback-ai-categorization-features": True,
49-
"organizations:gen-ai-features": True,
5048
}
5149
self.url = reverse(
5250
self.endpoint,
5351
kwargs={"organization_id_or_slug": self.org.slug},
5452
)
53+
self.mock_has_seer_access_patcher = patch(
54+
"sentry.feedback.endpoints.organization_feedback_categories.has_seer_access",
55+
return_value=True,
56+
)
57+
self.mock_has_seer_access = self.mock_has_seer_access_patcher.start()
58+
5559
self._create_standard_feedbacks(self.project1.id)
5660

61+
def tearDown(self) -> None:
62+
self.mock_has_seer_access_patcher.stop()
63+
super().tearDown()
64+
5765
def _create_standard_feedbacks(self, project_id: int) -> None:
5866
"""Create a standard set of feedbacks for testing."""
5967
insert_time = before_now(hours=12)
@@ -156,12 +164,16 @@ def _create_standard_feedbacks(self, project_id: int) -> None:
156164
insert_time=insert_time,
157165
)
158166

159-
@django_db_all
160167
def test_get_feedback_categories_without_feature_flag(self) -> None:
161168
response = self.get_error_response(self.org.slug)
162-
assert response.status_code == 404
169+
assert response.status_code == 403
170+
171+
def test_get_feedback_categories_without_seer_access(self) -> None:
172+
self.mock_has_seer_access.return_value = False
173+
with self.feature(self.features):
174+
response = self.get_error_response(self.org.slug)
175+
assert response.status_code == 403
163176

164-
@django_db_all
165177
@patch(
166178
"sentry.feedback.endpoints.organization_feedback_categories.THRESHOLD_TO_GET_ASSOCIATED_LABELS",
167179
1,
@@ -213,7 +225,6 @@ def test_get_feedback_categories_basic(self) -> None:
213225
elif category["primaryLabel"] == "Authentication":
214226
assert category["feedbackCount"] == 3
215227

216-
@django_db_all
217228
@patch(
218229
"sentry.feedback.endpoints.organization_feedback_categories.THRESHOLD_TO_GET_ASSOCIATED_LABELS",
219230
1,
@@ -264,7 +275,6 @@ def test_get_feedback_categories_with_project_filter(self) -> None:
264275
elif category["primaryLabel"] == "Authentication":
265276
assert category["feedbackCount"] == 3
266277

267-
@django_db_all
268278
@patch(
269279
"sentry.feedback.endpoints.organization_feedback_categories.THRESHOLD_TO_GET_ASSOCIATED_LABELS",
270280
1,
@@ -333,7 +343,6 @@ def test_max_group_labels_limit(self) -> None:
333343
assert "Extra Label 2" not in associated_labels
334344
assert "Extra Label 3" not in associated_labels
335345

336-
@django_db_all
337346
@patch(
338347
"sentry.feedback.endpoints.organization_feedback_categories.THRESHOLD_TO_GET_ASSOCIATED_LABELS",
339348
1,
@@ -421,7 +430,6 @@ def test_filter_invalid_associated_labels_by_count_ratio(self) -> None:
421430
assert len(associated_labels) == 1
422431
assert associated_labels == ["Design"]
423432

424-
@django_db_all
425433
@patch(
426434
"sentry.feedback.endpoints.organization_feedback_categories.THRESHOLD_TO_GET_ASSOCIATED_LABELS",
427435
1,
@@ -435,7 +443,6 @@ def test_seer_timeout(self) -> None:
435443

436444
assert response.status_code == 500
437445

438-
@django_db_all
439446
@patch(
440447
"sentry.feedback.endpoints.organization_feedback_categories.THRESHOLD_TO_GET_ASSOCIATED_LABELS",
441448
1,
@@ -449,7 +456,6 @@ def test_seer_connection_error(self) -> None:
449456

450457
assert response.status_code == 500
451458

452-
@django_db_all
453459
@patch(
454460
"sentry.feedback.endpoints.organization_feedback_categories.THRESHOLD_TO_GET_ASSOCIATED_LABELS",
455461
1,
@@ -465,7 +471,6 @@ def test_seer_request_error(self) -> None:
465471

466472
assert response.status_code == 500
467473

468-
@django_db_all
469474
@patch(
470475
"sentry.feedback.endpoints.organization_feedback_categories.THRESHOLD_TO_GET_ASSOCIATED_LABELS",
471476
1,
@@ -480,7 +485,6 @@ def test_seer_http_errors(self) -> None:
480485

481486
assert response.status_code == 500
482487

483-
@django_db_all
484488
@responses.activate
485489
def test_fallback_to_primary_labels_when_below_threshold(self) -> None:
486490
"""Test that when feedback count is below THRESHOLD_TO_GET_ASSOCIATED_LABELS, we fall back to primary labels only."""

0 commit comments

Comments
 (0)