Skip to content

Commit f8cd519

Browse files
authored
feat(replay): Replay Web Vital Breadcrumbs (#72949)
Enables replay web vital breadcrumbs if feature flag is on. Example: <img width="1480" alt="image" src="https://github.com/getsentry/sentry/assets/55311782/6c2afeac-2655-4332-96ba-f16d498ee918"> Relates to #69881
1 parent 72fa7c8 commit f8cd519

File tree

6 files changed

+81
-35
lines changed

6 files changed

+81
-35
lines changed

static/app/utils/replays/getFrameDetails.tsx

+39-19
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,26 @@ import type {ReactNode} from 'react';
33
import ExternalLink from 'sentry/components/links/externalLink';
44
import CrumbErrorTitle from 'sentry/components/replays/breadcrumbs/errorTitle';
55
import SelectorList from 'sentry/components/replays/breadcrumbs/selectorList';
6-
import {Tooltip} from 'sentry/components/tooltip';
76
import {
87
IconCursorArrow,
98
IconFire,
109
IconFix,
10+
IconHappy,
1111
IconInfo,
1212
IconInput,
1313
IconKeyDown,
1414
IconLocation,
1515
IconMegaphone,
16+
IconMeh,
1617
IconMobile,
18+
IconSad,
1719
IconSort,
1820
IconTerminal,
1921
IconUser,
2022
IconWarning,
2123
} from 'sentry/icons';
2224
import {t, tct} from 'sentry/locale';
25+
import {explodeSlug} from 'sentry/utils';
2326
import {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
2427
import type {
2528
BreadcrumbFrame,
@@ -42,6 +45,7 @@ import {
4245
isDeadRageClick,
4346
isRageClick,
4447
} from 'sentry/utils/replays/types';
48+
import {toTitleCase} from 'sentry/utils/string/toTitleCase';
4549
import type {Color} from 'sentry/utils/theme';
4650
import stripURLOrigin from 'sentry/utils/url/stripURLOrigin';
4751

@@ -274,24 +278,40 @@ const MAPPER_FOR_FRAME: Record<string, (frame) => Details> = {
274278
title: 'Navigation',
275279
icon: <IconLocation size="xs" />,
276280
}),
277-
'largest-contentful-paint': (frame: WebVitalFrame) => ({
278-
color: 'gray300',
279-
description:
280-
typeof frame.data.value === 'number' ? (
281-
`${Math.round(frame.data.value)}ms`
282-
) : (
283-
<Tooltip
284-
title={t(
285-
'This replay uses a SDK version that is subject to inaccurate LCP values. Please upgrade to the latest version for best results if you have not already done so.'
286-
)}
287-
>
288-
<IconWarning />
289-
</Tooltip>
290-
),
291-
tabKey: TabKey.NETWORK,
292-
title: 'LCP',
293-
icon: <IconInfo size="xs" />,
294-
}),
281+
'web-vital': (frame: WebVitalFrame) => {
282+
switch (frame.data.rating) {
283+
case 'good':
284+
return {
285+
color: 'green300',
286+
description: tct('Good [value]ms', {
287+
value: frame.data.value.toFixed(2),
288+
}),
289+
tabKey: TabKey.NETWORK,
290+
title: toTitleCase(explodeSlug(frame.description)),
291+
icon: <IconHappy size="xs" />,
292+
};
293+
case 'needs-improvement':
294+
return {
295+
color: 'yellow300',
296+
description: tct('Meh [value]ms', {
297+
value: frame.data.value.toFixed(2),
298+
}),
299+
tabKey: TabKey.NETWORK,
300+
title: toTitleCase(explodeSlug(frame.description)),
301+
icon: <IconMeh size="xs" />,
302+
};
303+
default:
304+
return {
305+
color: 'red300',
306+
description: tct('Poor [value]ms', {
307+
value: frame.data.value.toFixed(2),
308+
}),
309+
tabKey: TabKey.NETWORK,
310+
title: toTitleCase(explodeSlug(frame.description)),
311+
icon: <IconSad size="xs" />,
312+
};
313+
}
314+
},
295315
memory: () => ({
296316
color: 'gray300',
297317
description: undefined,

static/app/utils/replays/hooks/useReplayReader.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {useMemo} from 'react';
33
import type {Group} from 'sentry/types/group';
44
import useReplayData from 'sentry/utils/replays/hooks/useReplayData';
55
import ReplayReader from 'sentry/utils/replays/replayReader';
6+
import useOrganization from 'sentry/utils/useOrganization';
67

78
type Props = {
89
orgSlug: string;
@@ -43,15 +44,18 @@ export default function useReplayReader({orgSlug, replaySlug, clipWindow, group}
4344
);
4445
}, [clipWindow, firstMatchingError]);
4546

47+
const featureFlags = useOrganization().features;
48+
4649
const replay = useMemo(
4750
() =>
4851
ReplayReader.factory({
4952
attachments,
5053
clipWindow: memoizedClipWindow,
5154
errors,
55+
featureFlags,
5256
replayRecord,
5357
}),
54-
[attachments, memoizedClipWindow, errors, replayRecord]
58+
[attachments, memoizedClipWindow, errors, featureFlags, replayRecord]
5559
);
5660

5761
return {

static/app/utils/replays/replayDataUtils.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export function replayTimestamps(
7272
.map(rawCrumb => rawCrumb.timestamp)
7373
.filter(Boolean);
7474
const rawSpanDataFiltered = rawSpanData.filter(
75-
({op}) => op !== 'largest-contentful-paint'
75+
({op}) => op !== 'web-vital' && op !== 'largest-contentful-paint'
7676
);
7777
const spanStartTimestamps = rawSpanDataFiltered
7878
.map(span => span.startTimestamp)

static/app/utils/replays/replayReader.tsx

+31-4
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ import {
3838
EventType,
3939
isDeadClick,
4040
isDeadRageClick,
41-
isLCPFrame,
4241
isPaintFrame,
42+
isWebVitalFrame,
4343
} from 'sentry/utils/replays/types';
4444
import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
4545

@@ -69,6 +69,11 @@ interface ReplayReaderParams {
6969
* If provided, the replay will be clipped to this window.
7070
*/
7171
clipWindow?: ClipWindow;
72+
73+
/**
74+
* The org's feature flags
75+
*/
76+
featureFlags?: string[];
7277
}
7378

7479
type RequiredNotNull<T> = {
@@ -134,13 +139,25 @@ function removeDuplicateNavCrumbs(
134139
}
135140

136141
export default class ReplayReader {
137-
static factory({attachments, errors, replayRecord, clipWindow}: ReplayReaderParams) {
142+
static factory({
143+
attachments,
144+
errors,
145+
replayRecord,
146+
clipWindow,
147+
featureFlags,
148+
}: ReplayReaderParams) {
138149
if (!attachments || !replayRecord || !errors) {
139150
return null;
140151
}
141152

142153
try {
143-
return new ReplayReader({attachments, errors, replayRecord, clipWindow});
154+
return new ReplayReader({
155+
attachments,
156+
errors,
157+
replayRecord,
158+
featureFlags,
159+
clipWindow,
160+
});
144161
} catch (err) {
145162
Sentry.captureException(err);
146163

@@ -151,6 +168,7 @@ export default class ReplayReader {
151168
return new ReplayReader({
152169
attachments: [],
153170
errors: [],
171+
featureFlags,
154172
replayRecord,
155173
clipWindow,
156174
});
@@ -160,6 +178,7 @@ export default class ReplayReader {
160178
private constructor({
161179
attachments,
162180
errors,
181+
featureFlags,
163182
replayRecord,
164183
clipWindow,
165184
}: RequiredNotNull<ReplayReaderParams>) {
@@ -205,6 +224,7 @@ export default class ReplayReader {
205224

206225
// Hydrate the data we were given
207226
this._replayRecord = replayRecord;
227+
this._featureFlags = featureFlags;
208228
// Errors don't need to be sorted here, they will be merged with breadcrumbs
209229
// and spans in the getter and then sorted together.
210230
const {errorFrames, feedbackFrames} = hydrateErrors(replayRecord, errors);
@@ -244,6 +264,7 @@ export default class ReplayReader {
244264
private _cacheKey: string;
245265
private _duration: Duration = duration(0);
246266
private _errors: ErrorFrame[] = [];
267+
private _featureFlags: string[] | undefined = [];
247268
private _optionFrame: undefined | OptionFrame;
248269
private _replayRecord: ReplayRecord;
249270
private _sortedBreadcrumbFrames: BreadcrumbFrame[] = [];
@@ -469,6 +490,7 @@ export default class ReplayReader {
469490
this._trimFramesToClipWindow(
470491
[
471492
...this.getPerfFrames(),
493+
...this.getWebVitalFrames(),
472494
...this._sortedBreadcrumbFrames.filter(frame =>
473495
[
474496
'replay.hydrate-error',
@@ -506,7 +528,12 @@ export default class ReplayReader {
506528
return [...uniqueCrumbs, ...spans].sort(sortFrames);
507529
});
508530

509-
getLPCFrames = memoize(() => this._sortedSpanFrames.filter(isLCPFrame));
531+
getWebVitalFrames = memoize(() => {
532+
if (this._featureFlags?.includes('session-replay-web-vitals')) {
533+
return this._sortedSpanFrames.filter(isWebVitalFrame);
534+
}
535+
return [];
536+
});
510537

511538
getVideoEvents = () => this._videoEvents;
512539

static/app/utils/replays/types.tsx

+3-8
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,8 @@ export function isConsoleFrame(frame: BreadcrumbFrame): frame is ConsoleFrame {
143143
return false;
144144
}
145145

146-
export function isLCPFrame(frame: SpanFrame): frame is WebVitalFrame {
147-
return (
148-
frame.op === 'largest-contentful-paint' ||
149-
frame.op === 'cumulative-layout-shift' ||
150-
frame.op === 'first-input-delay' ||
151-
frame.op === 'interaction-to-next-paint'
152-
);
146+
export function isWebVitalFrame(frame: SpanFrame): frame is WebVitalFrame {
147+
return frame.op === 'web-vital';
153148
}
154149

155150
export function isPaintFrame(frame: SpanFrame): frame is PaintFrame {
@@ -318,7 +313,7 @@ export type ResourceFrame = HydratedSpan<
318313
// This list should match each of the operations used in `HydratedSpan` above
319314
// And any app-specific types that we hydrate (ie: replay.start & replay.end).
320315
export const SpanOps = [
321-
'largest-contentful-paint',
316+
'web-vital',
322317
'memory',
323318
'navigation.back_forward',
324319
'navigation.navigate',

static/app/views/replays/detail/breadcrumbs/useBreadcrumbFilters.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const TYPE_TO_LABEL: Record<string, string> = {
4545
rageOrMulti: 'Rage & Multi Click',
4646
rageOrDead: 'Rage & Dead Click',
4747
hydrateError: 'Hydration Error',
48-
lcp: 'LCP',
48+
webVital: 'Web Vital',
4949
click: 'User Click',
5050
keydown: 'KeyDown',
5151
input: 'Input',
@@ -71,7 +71,7 @@ const OPORCATEGORY_TO_TYPE: Record<string, keyof typeof TYPE_TO_LABEL> = {
7171
'ui.multiClick': 'rageOrMulti',
7272
'ui.slowClickDetected': 'rageOrDead',
7373
'replay.hydrate-error': 'hydrateError',
74-
'largest-contentful-paint': 'lcp',
74+
'web-vital': 'webVital',
7575
'ui.click': 'click',
7676
'ui.tap': 'tap',
7777
'ui.keyDown': 'keydown',

0 commit comments

Comments
 (0)