Skip to content

Commit 829de88

Browse files
feat(performance): Updates Web Vitals issue detection to consolidate lcp, fcp, and ttfb into a single issue (#103272)
LCP, FCP, and TTFB issues often share similar root causes and fixes when analyzed by Seer. To reduce duplicate similar issues, updates Web Vitals issue detection to consolidate low LCP, FCP, and TTFB scores into a single `rendering` grouping/fingerprint. - Slightly changes issue event tags to support this. Each web vital score and value is stored as its own tag on the issue event. - The trace sample for the event is the p75 sample chosen from the worst scoring vital. - Slight change to title and subtitle generation (this will likely be replaced in a separate pr for further title improvement)
1 parent 2a2afa2 commit 829de88

File tree

4 files changed

+306
-49
lines changed

4 files changed

+306
-49
lines changed

src/sentry/tasks/web_vitals_issue_detection.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
from sentry.taskworker.namespaces import issues_tasks
1919
from sentry.web_vitals.issue_platform_adapter import send_web_vitals_issue_to_platform
2020
from sentry.web_vitals.query import get_trace_by_web_vital_measurement
21-
from sentry.web_vitals.types import WebVitalIssueDetectionType, WebVitalIssueGroupData
21+
from sentry.web_vitals.types import (
22+
WebVitalIssueDetectionGroupingType,
23+
WebVitalIssueDetectionType,
24+
WebVitalIssueGroupData,
25+
)
2226

2327
logger = logging.getLogger("sentry.tasks.web_vitals_issue_detection")
2428

@@ -27,6 +31,13 @@
2731
SCORE_THRESHOLD = 0.9 # Scores below this threshold will create web vital issues
2832
DEFAULT_SAMPLES_COUNT_THRESHOLD = 10
2933
VITALS: list[WebVitalIssueDetectionType] = ["lcp", "fcp", "cls", "ttfb", "inp"]
34+
VITAL_GROUPING_MAP: dict[WebVitalIssueDetectionType, WebVitalIssueDetectionGroupingType] = {
35+
"lcp": "rendering",
36+
"fcp": "rendering",
37+
"ttfb": "rendering",
38+
"cls": "cls",
39+
"inp": "inp",
40+
}
3041

3142

3243
def get_enabled_project_ids() -> list[int]:
@@ -85,11 +96,18 @@ def detect_web_vitals_issues_for_project(project_id: int) -> None:
8596
project_id, limit=TRANSACTIONS_PER_PROJECT_LIMIT
8697
)
8798
for web_vital_issue_group in web_vital_issue_groups:
88-
p75_vital_value = web_vital_issue_group["value"]
99+
scores = web_vital_issue_group["scores"]
100+
values = web_vital_issue_group["values"]
101+
102+
# We can only use a single trace sample for an issue event
103+
# Use the p75 of the worst performing vital
104+
vital = sorted(scores.items(), key=lambda item: item[1])[0][0]
105+
p75_vital_value = values[vital]
106+
89107
trace = get_trace_by_web_vital_measurement(
90108
web_vital_issue_group["transaction"],
91109
project_id,
92-
web_vital_issue_group["vital"],
110+
vital,
93111
p75_vital_value,
94112
start_time_delta=DEFAULT_START_TIME_DELTA,
95113
)
@@ -167,7 +185,9 @@ def get_highest_opportunity_page_vitals_for_project(
167185
sampling_mode="NORMAL",
168186
)
169187

170-
web_vital_issue_groups: list[WebVitalIssueGroupData] = []
188+
web_vital_issue_groups: dict[
189+
tuple[WebVitalIssueDetectionGroupingType, str], WebVitalIssueGroupData
190+
] = {}
171191
seen_names = set()
172192
for row in result.get("data", []):
173193
name = row.get("transaction")
@@ -178,6 +198,7 @@ def get_highest_opportunity_page_vitals_for_project(
178198
if normalized_name in seen_names:
179199
continue
180200
seen_names.add(normalized_name)
201+
181202
for vital in VITALS:
182203
score = row.get(f"performance_score(measurements.score.{vital})")
183204
p75_value = row.get(f"p75(measurements.{vital})")
@@ -190,17 +211,23 @@ def get_highest_opportunity_page_vitals_for_project(
190211
and enough_samples
191212
and p75_value is not None
192213
):
193-
web_vital_issue_groups.append(
194-
{
214+
if (VITAL_GROUPING_MAP[vital], name) not in web_vital_issue_groups:
215+
web_vital_issue_groups[(VITAL_GROUPING_MAP[vital], name)] = {
195216
"transaction": name,
196-
"vital": vital,
197-
"score": score,
198217
"project": project,
199-
"value": p75_value,
218+
"vital_grouping": VITAL_GROUPING_MAP[vital],
219+
"scores": {vital: score},
220+
"values": {vital: p75_value},
200221
}
201-
)
202-
203-
return web_vital_issue_groups
222+
else:
223+
web_vital_issue_groups[(VITAL_GROUPING_MAP[vital], name)]["scores"][
224+
vital
225+
] = score
226+
web_vital_issue_groups[(VITAL_GROUPING_MAP[vital], name)]["values"][
227+
vital
228+
] = p75_value
229+
230+
return list(web_vital_issue_groups.values())
204231

205232

206233
def check_seer_setup_for_project(project: Project) -> bool:

src/sentry/web_vitals/issue_platform_adapter.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
from sentry.issues.issue_occurrence import IssueEvidence, IssueOccurrence
88
from sentry.issues.producer import PayloadType, produce_occurrence_to_kafka
99
from sentry.models.group import Group, GroupStatus
10-
from sentry.web_vitals.types import WebVitalIssueDetectionType, WebVitalIssueGroupData
10+
from sentry.web_vitals.types import WebVitalIssueDetectionGroupingType, WebVitalIssueGroupData
1111

1212

13-
def create_fingerprint(vital: WebVitalIssueDetectionType, transaction: str) -> str:
14-
prehashed_fingerprint = f"insights-web-vitals-{vital}-{transaction}"
13+
def create_fingerprint(vital_grouping: WebVitalIssueDetectionGroupingType, transaction: str) -> str:
14+
prehashed_fingerprint = f"insights-web-vitals-{vital_grouping}-{transaction}"
1515
fingerprint = hashlib.sha1((prehashed_fingerprint).encode()).hexdigest()
1616
return fingerprint
1717

@@ -24,15 +24,19 @@ def send_web_vitals_issue_to_platform(data: WebVitalIssueGroupData, trace_id: st
2424
event_id = uuid4().hex
2525
now = datetime.now(UTC)
2626
transaction = data["transaction"]
27-
vital = data["vital"]
27+
scores = data["scores"]
28+
values = data["values"]
2829

2930
tags = {
3031
"transaction": data["transaction"],
31-
"web_vital": vital,
32-
"score": f"{data['score']:.2g}",
33-
vital: f"{data['value']}",
3432
}
3533

34+
# These should already match, but use the intersection to be safe
35+
vitals = scores.keys() & values.keys()
36+
for vital in vitals:
37+
tags[f"{vital}_score"] = f"{scores[vital]:.2g}"
38+
tags[vital] = f"{values[vital]}"
39+
3640
event_data = {
3741
"event_id": event_id,
3842
"project_id": data["project"].id,
@@ -63,10 +67,21 @@ def send_web_vitals_issue_to_platform(data: WebVitalIssueGroupData, trace_id: st
6367
]
6468

6569
# TODO: Add better titles and subtitles
66-
title = f"{data['vital'].upper()} score needs improvement"
67-
subtitle = f"{transaction} has a {data['vital'].upper()} score of {data['score']:.2g}"
68-
69-
fingerprint = create_fingerprint(data["vital"], transaction)
70+
if data["vital_grouping"] == "rendering":
71+
title = "Render time Web Vital scores need improvement"
72+
else:
73+
title = f"{data['vital_grouping'].upper()} score needs improvement"
74+
subtitle_parts = []
75+
for vital in data["scores"]:
76+
a_or_an = "an" if vital in ("lcp", "fcp", "inp") else "a"
77+
subtitle_parts.append(f"{a_or_an} {vital.upper()} score of {data['scores'][vital]:.2g}")
78+
if len(subtitle_parts) > 1:
79+
scores_text = ", ".join(subtitle_parts[:-1]) + " and " + subtitle_parts[-1]
80+
else:
81+
scores_text = subtitle_parts[0]
82+
subtitle = f"{transaction} has {scores_text}"
83+
84+
fingerprint = create_fingerprint(data["vital_grouping"], transaction)
7085

7186
occurence = IssueOccurrence(
7287
id=uuid4().hex,
@@ -90,7 +105,7 @@ def send_web_vitals_issue_to_platform(data: WebVitalIssueGroupData, trace_id: st
90105

91106

92107
def check_unresolved_web_vitals_issue_exists(data: WebVitalIssueGroupData) -> bool:
93-
fingerprint = create_fingerprint(data["vital"], data["transaction"])
108+
fingerprint = create_fingerprint(data["vital_grouping"], data["transaction"])
94109
fingerprint_hash = hash_fingerprint([fingerprint])[0]
95110

96111
return Group.objects.filter(

src/sentry/web_vitals/types.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
from sentry.models.project import Project
44

55
WebVitalIssueDetectionType = Literal["lcp", "fcp", "cls", "ttfb", "inp"]
6+
WebVitalIssueDetectionGroupingType = Literal["rendering", "cls", "inp"]
67

78

89
class WebVitalIssueGroupData(TypedDict):
910
transaction: str
10-
vital: WebVitalIssueDetectionType
11-
score: float
1211
project: Project
13-
value: float
12+
vital_grouping: WebVitalIssueDetectionGroupingType
13+
scores: dict[WebVitalIssueDetectionType, float]
14+
values: dict[WebVitalIssueDetectionType, float]

0 commit comments

Comments
 (0)