Skip to content

Commit 098d2e4

Browse files
feat(performance): Add OrganizationUserIssueEndpoint for user-defined web vitals issues (#97576)
- Adds a new `web_vitals` issue group type. - Adds a `/user-issue/` POST endpoint that creates a user defined issue group and occurence when called. - Currently, only `web_vitals` type issues are accepted. We may extend to support other insights issue types in the future. - Synthetic issue events and occurences are created based on arguments provided by the user to the endpoint The purpose of this new group type and endpoint is to allow users to manually flag when one of their routes exhibits poor web vital scores by creating a new issue group, which enables debugging using Seer Autofix. This feature is in an experimental phase.
1 parent e372ef9 commit 098d2e4

File tree

4 files changed

+428
-0
lines changed

4 files changed

+428
-0
lines changed

src/sentry/api/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@
304304
from sentry.issues.endpoints.organization_issues_resolved_in_release import (
305305
OrganizationIssuesResolvedInReleaseEndpoint,
306306
)
307+
from sentry.issues.endpoints.project_user_issue import ProjectUserIssueEndpoint
307308
from sentry.issues.endpoints.team_all_unresolved_issues import TeamAllUnresolvedIssuesEndpoint
308309
from sentry.issues.endpoints.team_issue_breakdown import TeamIssueBreakdownEndpoint
309310
from sentry.monitors.endpoints.organization_monitor_checkin_index import (
@@ -3079,6 +3080,12 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
30793080
ProjectSeerPreferencesEndpoint.as_view(),
30803081
name="sentry-api-0-project-seer-preferences",
30813082
),
3083+
# User Issue
3084+
re_path(
3085+
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/user-issue/$",
3086+
ProjectUserIssueEndpoint.as_view(),
3087+
name="sentry-api-0-project-user-issue",
3088+
),
30823089
*preprod_urls.preprod_urlpatterns,
30833090
]
30843091

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
from datetime import UTC, datetime
2+
from uuid import uuid4
3+
4+
from rest_framework import serializers
5+
from rest_framework.request import Request
6+
from rest_framework.response import Response
7+
8+
from sentry import features
9+
from sentry.api.api_owners import ApiOwner
10+
from sentry.api.api_publish_status import ApiPublishStatus
11+
from sentry.api.base import region_silo_endpoint
12+
from sentry.api.bases.project import ProjectEndpoint
13+
from sentry.grouping.grouptype import ErrorGroupType
14+
from sentry.issues.grouptype import GroupType, WebVitalsGroup
15+
from sentry.issues.issue_occurrence import IssueEvidence, IssueOccurrence
16+
from sentry.issues.producer import PayloadType, produce_occurrence_to_kafka
17+
from sentry.models.organization import Organization
18+
from sentry.models.project import Project
19+
20+
21+
class BaseUserIssueFormatter:
22+
def __init__(self, data: dict):
23+
self.data = data
24+
25+
def get_issue_type(self) -> type[GroupType]:
26+
raise NotImplementedError
27+
28+
def get_issue_title(self) -> str:
29+
raise NotImplementedError
30+
31+
def get_issue_subtitle(self) -> str:
32+
raise NotImplementedError
33+
34+
def create_fingerprint(self) -> list[str]:
35+
raise NotImplementedError
36+
37+
def get_tags(self) -> dict:
38+
raise NotImplementedError
39+
40+
def get_evidence(self) -> tuple[dict, list[IssueEvidence]]:
41+
raise NotImplementedError
42+
43+
44+
class DefaultUserIssueFormatter(BaseUserIssueFormatter):
45+
def get_issue_type(self) -> type[GroupType]:
46+
return ErrorGroupType
47+
48+
def get_issue_title(self) -> str:
49+
return f"{self.data.get('transaction')}"
50+
51+
def get_issue_subtitle(self) -> str:
52+
return f"User flagged issue on {self.data.get('transaction')}"
53+
54+
def create_fingerprint(self) -> list[str]:
55+
# By default, return a random UUID for a unique group
56+
return [uuid4().hex]
57+
58+
def get_tags(self) -> dict:
59+
return {"transaction": self.data.get("transaction")}
60+
61+
def get_evidence(self) -> tuple[dict, list[IssueEvidence]]:
62+
transaction = self.data.get("transaction", "")
63+
64+
evidence_data = {
65+
"transaction": transaction,
66+
}
67+
68+
evidence_display = [
69+
IssueEvidence(
70+
name="Transaction",
71+
value=transaction,
72+
important=False,
73+
),
74+
]
75+
return (evidence_data, evidence_display)
76+
77+
78+
class WebVitalsUserIssueFormatter(BaseUserIssueFormatter):
79+
def get_issue_type(self) -> type[GroupType]:
80+
return WebVitalsGroup
81+
82+
def get_issue_title(self) -> str:
83+
vital = self.data.get("vital", "")
84+
return f"{vital.upper()} score needs improvement"
85+
86+
def get_issue_subtitle(self) -> str:
87+
vital = self.data.get("vital", "")
88+
transaction = self.data.get("transaction", "")
89+
a_or_an = "an" if vital in ["lcp", "fcp", "inp"] else "a"
90+
return f"{transaction} has {a_or_an} {vital.upper()} score of {self.data.get("score")}"
91+
92+
def create_fingerprint(self) -> list[str]:
93+
vital = self.data.get("vital", "")
94+
transaction = self.data.get("transaction", "")
95+
return [f"insights-web-vitals-{vital}-{transaction}"]
96+
97+
def get_tags(self) -> dict:
98+
vital = self.data.get("vital", "")
99+
transaction = self.data.get("transaction", "")
100+
return {
101+
"transaction": transaction,
102+
"web_vital": vital,
103+
"score": self.data.get("score"),
104+
}
105+
106+
def get_evidence(self) -> tuple[dict, list[IssueEvidence]]:
107+
vital = self.data.get("vital", "")
108+
score = self.data.get("score")
109+
transaction = self.data.get("transaction", "")
110+
111+
evidence_data = {
112+
"transaction": transaction,
113+
"vital": vital,
114+
"score": score,
115+
}
116+
117+
evidence_display = [
118+
IssueEvidence(
119+
name="Transaction",
120+
value=transaction,
121+
important=False,
122+
),
123+
IssueEvidence(
124+
name="Web Vital",
125+
value=vital.upper(),
126+
important=True,
127+
),
128+
IssueEvidence(
129+
name="Score",
130+
value=str(score),
131+
important=True,
132+
),
133+
]
134+
135+
return (evidence_data, evidence_display)
136+
137+
138+
ISSUE_TYPE_CHOICES = [
139+
WebVitalsGroup.slug,
140+
]
141+
142+
143+
class ProjectUserIssueRequestSerializer(serializers.Serializer):
144+
transaction = serializers.CharField(required=True)
145+
issueType = serializers.ChoiceField(required=True, choices=ISSUE_TYPE_CHOICES)
146+
147+
148+
class WebVitalsIssueDataSerializer(ProjectUserIssueRequestSerializer):
149+
score = serializers.IntegerField(required=True, min_value=0, max_value=100)
150+
vital = serializers.ChoiceField(required=True, choices=["lcp", "fcp", "cls", "inp", "ttfb"])
151+
152+
153+
@region_silo_endpoint
154+
class ProjectUserIssueEndpoint(ProjectEndpoint):
155+
publish_status = {
156+
"POST": ApiPublishStatus.EXPERIMENTAL,
157+
}
158+
owner = ApiOwner.VISIBILITY
159+
160+
def get_formatter(self, data: dict) -> BaseUserIssueFormatter:
161+
if data.get("issueType") == WebVitalsGroup.slug:
162+
return WebVitalsUserIssueFormatter(data)
163+
return DefaultUserIssueFormatter(data)
164+
165+
def get_serializer(self, data: dict) -> serializers.Serializer:
166+
if data.get("issueType") == WebVitalsGroup.slug:
167+
return WebVitalsIssueDataSerializer(data=data)
168+
return ProjectUserIssueRequestSerializer(data=data)
169+
170+
def has_feature(self, organization: Organization, request: Request) -> bool:
171+
return features.has(
172+
"organizations:performance-web-vitals-seer-suggestions",
173+
organization,
174+
actor=request.user,
175+
) and features.has(
176+
"organizations:issue-web-vitals-ingest", organization, actor=request.user
177+
)
178+
179+
def post(self, request: Request, project: Project) -> Response:
180+
"""
181+
Create a user defined issue.
182+
"""
183+
184+
organization = project.organization
185+
186+
if not self.has_feature(organization, request):
187+
return Response(status=404)
188+
189+
serializer = self.get_serializer(request.data)
190+
191+
if not serializer.is_valid():
192+
return Response(serializer.errors, status=400)
193+
194+
validated_data = serializer.validated_data
195+
196+
formatter = self.get_formatter(validated_data)
197+
198+
issue_type = formatter.get_issue_type()
199+
200+
fingerprint = formatter.create_fingerprint()
201+
202+
formatted_title = formatter.get_issue_title()
203+
formatted_subtitle = formatter.get_issue_subtitle()
204+
205+
event_id = uuid4().hex
206+
now = datetime.now(UTC)
207+
208+
event_data = {
209+
"event_id": event_id,
210+
"project_id": project.id,
211+
"platform": project.platform,
212+
"timestamp": now.isoformat(),
213+
"received": now.isoformat(),
214+
"tags": formatter.get_tags(),
215+
}
216+
217+
(evidence_data, evidence_display) = formatter.get_evidence()
218+
219+
occurence = IssueOccurrence(
220+
id=uuid4().hex,
221+
event_id=event_id,
222+
project_id=project.id,
223+
fingerprint=fingerprint,
224+
issue_title=formatted_title,
225+
subtitle=formatted_subtitle,
226+
resource_id=None,
227+
evidence_data=evidence_data,
228+
evidence_display=evidence_display,
229+
type=issue_type,
230+
detection_time=now,
231+
culprit=validated_data.get("transaction"),
232+
level="info",
233+
)
234+
235+
produce_occurrence_to_kafka(
236+
payload_type=PayloadType.OCCURRENCE, occurrence=occurence, event_data=event_data
237+
)
238+
239+
return Response(status=200)

src/sentry/issues/grouptype.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,19 @@ class MetricIssuePOC(GroupType):
690690
enable_status_change_workflow_notifications = False
691691

692692

693+
@dataclass(frozen=True)
694+
class WebVitalsGroup(GroupType):
695+
type_id = 10001
696+
slug = "web_vitals"
697+
description = "Web Vitals"
698+
category = GroupCategory.PERFORMANCE.value
699+
category_v2 = GroupCategory.FRONTEND.value
700+
enable_auto_resolve = False
701+
enable_escalation_detection = False
702+
enable_status_change_workflow_notifications = False
703+
enable_workflow_notifications = False
704+
705+
693706
def should_create_group(
694707
grouptype: type[GroupType],
695708
client: RedisCluster | StrictRedis,

0 commit comments

Comments
 (0)