diff --git a/static/app/utils/replays/getFrameDetails.tsx b/static/app/utils/replays/getFrameDetails.tsx index 2d9d5d5e2e576a..a274443483ffcd 100644 --- a/static/app/utils/replays/getFrameDetails.tsx +++ b/static/app/utils/replays/getFrameDetails.tsx @@ -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, @@ -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'; @@ -274,24 +278,40 @@ const MAPPER_FOR_FRAME: Record Details> = { title: 'Navigation', icon: , }), - 'largest-contentful-paint': (frame: WebVitalFrame) => ({ - color: 'gray300', - description: - typeof frame.data.value === 'number' ? ( - `${Math.round(frame.data.value)}ms` - ) : ( - - - - ), - tabKey: TabKey.NETWORK, - title: 'LCP', - icon: , - }), + '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: , + }; + 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: , + }; + default: + return { + color: 'red300', + description: tct('Poor [value]ms', { + value: frame.data.value.toFixed(2), + }), + tabKey: TabKey.NETWORK, + title: toTitleCase(explodeSlug(frame.description)), + icon: , + }; + } + }, memory: () => ({ color: 'gray300', description: undefined, diff --git a/static/app/utils/replays/hooks/useReplayReader.tsx b/static/app/utils/replays/hooks/useReplayReader.tsx index cc46ccfe21e98e..a17cb4c6f14646 100644 --- a/static/app/utils/replays/hooks/useReplayReader.tsx +++ b/static/app/utils/replays/hooks/useReplayReader.tsx @@ -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; @@ -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 { diff --git a/static/app/utils/replays/replayDataUtils.tsx b/static/app/utils/replays/replayDataUtils.tsx index 18dcefbb4e03bc..a8a1f46fc53aba 100644 --- a/static/app/utils/replays/replayDataUtils.tsx +++ b/static/app/utils/replays/replayDataUtils.tsx @@ -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) diff --git a/static/app/utils/replays/replayReader.tsx b/static/app/utils/replays/replayReader.tsx index 9331e6a2172dfd..9092f4591e2ea4 100644 --- a/static/app/utils/replays/replayReader.tsx +++ b/static/app/utils/replays/replayReader.tsx @@ -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'; @@ -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 = { @@ -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); @@ -151,6 +168,7 @@ export default class ReplayReader { return new ReplayReader({ attachments: [], errors: [], + featureFlags, replayRecord, clipWindow, }); @@ -160,6 +178,7 @@ export default class ReplayReader { private constructor({ attachments, errors, + featureFlags, replayRecord, clipWindow, }: RequiredNotNull) { @@ -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); @@ -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[] = []; @@ -469,6 +490,7 @@ export default class ReplayReader { this._trimFramesToClipWindow( [ ...this.getPerfFrames(), + ...this.getWebVitalFrames(), ...this._sortedBreadcrumbFrames.filter(frame => [ 'replay.hydrate-error', @@ -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; diff --git a/static/app/utils/replays/types.tsx b/static/app/utils/replays/types.tsx index ab0a198c0f0552..6a6133909f1e4d 100644 --- a/static/app/utils/replays/types.tsx +++ b/static/app/utils/replays/types.tsx @@ -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 { @@ -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', diff --git a/static/app/views/replays/detail/breadcrumbs/useBreadcrumbFilters.tsx b/static/app/views/replays/detail/breadcrumbs/useBreadcrumbFilters.tsx index f45dd4bddd1189..7e90013f0343b4 100644 --- a/static/app/views/replays/detail/breadcrumbs/useBreadcrumbFilters.tsx +++ b/static/app/views/replays/detail/breadcrumbs/useBreadcrumbFilters.tsx @@ -45,7 +45,7 @@ const TYPE_TO_LABEL: Record = { rageOrMulti: 'Rage & Multi Click', rageOrDead: 'Rage & Dead Click', hydrateError: 'Hydration Error', - lcp: 'LCP', + webVital: 'Web Vital', click: 'User Click', keydown: 'KeyDown', input: 'Input', @@ -71,7 +71,7 @@ const OPORCATEGORY_TO_TYPE: Record = { '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',