Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(replay): Replay Web Vital Breadcrumbs #72949

Merged
merged 9 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
c298lee marked this conversation as resolved.
Show resolved Hide resolved
);
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
Loading