Skip to content

Commit

Permalink
feat(replay): Replay Web Vital Breadcrumbs (#72949)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
c298lee committed Jun 20, 2024
1 parent 72fa7c8 commit f8cd519
Show file tree
Hide file tree
Showing 6 changed files with 81 additions and 35 deletions.
58 changes: 39 additions & 19 deletions static/app/utils/replays/getFrameDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,26 @@ import type {ReactNode} from 'react';
import ExternalLink from 'sentry/components/links/externalLink';
import CrumbErrorTitle from 'sentry/components/replays/breadcrumbs/errorTitle';
import SelectorList from 'sentry/components/replays/breadcrumbs/selectorList';
import {Tooltip} from 'sentry/components/tooltip';
import {
IconCursorArrow,
IconFire,
IconFix,
IconHappy,
IconInfo,
IconInput,
IconKeyDown,
IconLocation,
IconMegaphone,
IconMeh,
IconMobile,
IconSad,
IconSort,
IconTerminal,
IconUser,
IconWarning,
} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {explodeSlug} from 'sentry/utils';
import {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
import type {
BreadcrumbFrame,
Expand All @@ -42,6 +45,7 @@ import {
isDeadRageClick,
isRageClick,
} from 'sentry/utils/replays/types';
import {toTitleCase} from 'sentry/utils/string/toTitleCase';
import type {Color} from 'sentry/utils/theme';
import stripURLOrigin from 'sentry/utils/url/stripURLOrigin';

Expand Down Expand Up @@ -274,24 +278,40 @@ const MAPPER_FOR_FRAME: Record<string, (frame) => Details> = {
title: 'Navigation',
icon: <IconLocation size="xs" />,
}),
'largest-contentful-paint': (frame: WebVitalFrame) => ({
color: 'gray300',
description:
typeof frame.data.value === 'number' ? (
`${Math.round(frame.data.value)}ms`
) : (
<Tooltip
title={t(
'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.'
)}
>
<IconWarning />
</Tooltip>
),
tabKey: TabKey.NETWORK,
title: 'LCP',
icon: <IconInfo size="xs" />,
}),
'web-vital': (frame: WebVitalFrame) => {
switch (frame.data.rating) {
case 'good':
return {
color: 'green300',
description: tct('Good [value]ms', {
value: frame.data.value.toFixed(2),
}),
tabKey: TabKey.NETWORK,
title: toTitleCase(explodeSlug(frame.description)),
icon: <IconHappy size="xs" />,
};
case 'needs-improvement':
return {
color: 'yellow300',
description: tct('Meh [value]ms', {
value: frame.data.value.toFixed(2),
}),
tabKey: TabKey.NETWORK,
title: toTitleCase(explodeSlug(frame.description)),
icon: <IconMeh size="xs" />,
};
default:
return {
color: 'red300',
description: tct('Poor [value]ms', {
value: frame.data.value.toFixed(2),
}),
tabKey: TabKey.NETWORK,
title: toTitleCase(explodeSlug(frame.description)),
icon: <IconSad size="xs" />,
};
}
},
memory: () => ({
color: 'gray300',
description: undefined,
Expand Down
6 changes: 5 additions & 1 deletion static/app/utils/replays/hooks/useReplayReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {useMemo} from 'react';
import type {Group} from 'sentry/types/group';
import useReplayData from 'sentry/utils/replays/hooks/useReplayData';
import ReplayReader from 'sentry/utils/replays/replayReader';
import useOrganization from 'sentry/utils/useOrganization';

type Props = {
orgSlug: string;
Expand Down Expand Up @@ -43,15 +44,18 @@ export default function useReplayReader({orgSlug, replaySlug, clipWindow, group}
);
}, [clipWindow, firstMatchingError]);

const featureFlags = useOrganization().features;

const replay = useMemo(
() =>
ReplayReader.factory({
attachments,
clipWindow: memoizedClipWindow,
errors,
featureFlags,
replayRecord,
}),
[attachments, memoizedClipWindow, errors, replayRecord]
[attachments, memoizedClipWindow, errors, featureFlags, replayRecord]
);

return {
Expand Down
2 changes: 1 addition & 1 deletion static/app/utils/replays/replayDataUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export function replayTimestamps(
.map(rawCrumb => rawCrumb.timestamp)
.filter(Boolean);
const rawSpanDataFiltered = rawSpanData.filter(
({op}) => op !== 'largest-contentful-paint'
({op}) => op !== 'web-vital' && op !== 'largest-contentful-paint'
);
const spanStartTimestamps = rawSpanDataFiltered
.map(span => span.startTimestamp)
Expand Down
35 changes: 31 additions & 4 deletions static/app/utils/replays/replayReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ import {
EventType,
isDeadClick,
isDeadRageClick,
isLCPFrame,
isPaintFrame,
isWebVitalFrame,
} from 'sentry/utils/replays/types';
import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';

Expand Down Expand Up @@ -69,6 +69,11 @@ interface ReplayReaderParams {
* If provided, the replay will be clipped to this window.
*/
clipWindow?: ClipWindow;

/**
* The org's feature flags
*/
featureFlags?: string[];
}

type RequiredNotNull<T> = {
Expand Down Expand Up @@ -134,13 +139,25 @@ function removeDuplicateNavCrumbs(
}

export default class ReplayReader {
static factory({attachments, errors, replayRecord, clipWindow}: ReplayReaderParams) {
static factory({
attachments,
errors,
replayRecord,
clipWindow,
featureFlags,
}: ReplayReaderParams) {
if (!attachments || !replayRecord || !errors) {
return null;
}

try {
return new ReplayReader({attachments, errors, replayRecord, clipWindow});
return new ReplayReader({
attachments,
errors,
replayRecord,
featureFlags,
clipWindow,
});
} catch (err) {
Sentry.captureException(err);

Expand All @@ -151,6 +168,7 @@ export default class ReplayReader {
return new ReplayReader({
attachments: [],
errors: [],
featureFlags,
replayRecord,
clipWindow,
});
Expand All @@ -160,6 +178,7 @@ export default class ReplayReader {
private constructor({
attachments,
errors,
featureFlags,
replayRecord,
clipWindow,
}: RequiredNotNull<ReplayReaderParams>) {
Expand Down Expand Up @@ -205,6 +224,7 @@ export default class ReplayReader {

// Hydrate the data we were given
this._replayRecord = replayRecord;
this._featureFlags = featureFlags;
// Errors don't need to be sorted here, they will be merged with breadcrumbs
// and spans in the getter and then sorted together.
const {errorFrames, feedbackFrames} = hydrateErrors(replayRecord, errors);
Expand Down Expand Up @@ -244,6 +264,7 @@ export default class ReplayReader {
private _cacheKey: string;
private _duration: Duration = duration(0);
private _errors: ErrorFrame[] = [];
private _featureFlags: string[] | undefined = [];
private _optionFrame: undefined | OptionFrame;
private _replayRecord: ReplayRecord;
private _sortedBreadcrumbFrames: BreadcrumbFrame[] = [];
Expand Down Expand Up @@ -469,6 +490,7 @@ export default class ReplayReader {
this._trimFramesToClipWindow(
[
...this.getPerfFrames(),
...this.getWebVitalFrames(),
...this._sortedBreadcrumbFrames.filter(frame =>
[
'replay.hydrate-error',
Expand Down Expand Up @@ -506,7 +528,12 @@ export default class ReplayReader {
return [...uniqueCrumbs, ...spans].sort(sortFrames);
});

getLPCFrames = memoize(() => this._sortedSpanFrames.filter(isLCPFrame));
getWebVitalFrames = memoize(() => {
if (this._featureFlags?.includes('session-replay-web-vitals')) {
return this._sortedSpanFrames.filter(isWebVitalFrame);
}
return [];
});

getVideoEvents = () => this._videoEvents;

Expand Down
11 changes: 3 additions & 8 deletions static/app/utils/replays/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,8 @@ export function isConsoleFrame(frame: BreadcrumbFrame): frame is ConsoleFrame {
return false;
}

export function isLCPFrame(frame: SpanFrame): frame is WebVitalFrame {
return (
frame.op === 'largest-contentful-paint' ||
frame.op === 'cumulative-layout-shift' ||
frame.op === 'first-input-delay' ||
frame.op === 'interaction-to-next-paint'
);
export function isWebVitalFrame(frame: SpanFrame): frame is WebVitalFrame {
return frame.op === 'web-vital';
}

export function isPaintFrame(frame: SpanFrame): frame is PaintFrame {
Expand Down Expand Up @@ -318,7 +313,7 @@ export type ResourceFrame = HydratedSpan<
// This list should match each of the operations used in `HydratedSpan` above
// And any app-specific types that we hydrate (ie: replay.start & replay.end).
export const SpanOps = [
'largest-contentful-paint',
'web-vital',
'memory',
'navigation.back_forward',
'navigation.navigate',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const TYPE_TO_LABEL: Record<string, string> = {
rageOrMulti: 'Rage & Multi Click',
rageOrDead: 'Rage & Dead Click',
hydrateError: 'Hydration Error',
lcp: 'LCP',
webVital: 'Web Vital',
click: 'User Click',
keydown: 'KeyDown',
input: 'Input',
Expand All @@ -71,7 +71,7 @@ const OPORCATEGORY_TO_TYPE: Record<string, keyof typeof TYPE_TO_LABEL> = {
'ui.multiClick': 'rageOrMulti',
'ui.slowClickDetected': 'rageOrDead',
'replay.hydrate-error': 'hydrateError',
'largest-contentful-paint': 'lcp',
'web-vital': 'webVital',
'ui.click': 'click',
'ui.tap': 'tap',
'ui.keyDown': 'keydown',
Expand Down

0 comments on commit f8cd519

Please sign in to comment.