diff --git a/packages/replay-internal/src/util/createPerformanceEntries.ts b/packages/replay-internal/src/util/createPerformanceEntries.ts index 84de0ae28a60..8290117188b1 100644 --- a/packages/replay-internal/src/util/createPerformanceEntries.ts +++ b/packages/replay-internal/src/util/createPerformanceEntries.ts @@ -220,7 +220,10 @@ export function getCumulativeLayoutShift(metric: Metric): ReplayPerformanceEntry } } - return getWebVital(metric, 'cumulative-layout-shift', nodes, layoutShifts); + // The CLS value is a score, not a time, so we place the event at the last layout shift. A CLS of 0 has no layout + // shift, so it goes at the time origin. + const lastEntry = metric.entries[metric.entries.length - 1]; + return getWebVital(metric, 'cumulative-layout-shift', nodes, layoutShifts, lastEntry?.startTime ?? 0); } /** @@ -230,7 +233,8 @@ export function getInteractionToNextPaint(metric: Metric): ReplayPerformanceEntr // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- rule false positive: the cast exposes the entry's `target` field; tsc errors without it const lastEntry = metric.entries[metric.entries.length - 1] as (PerformanceEntry & { target?: Node }) | undefined; const node = lastEntry?.target ? [lastEntry.target] : undefined; - return getWebVital(metric, 'interaction-to-next-paint', node); + // The INP value is a duration, not a time, so we place the event at the interaction. + return getWebVital(metric, 'interaction-to-next-paint', node, undefined, lastEntry?.startTime ?? 0); } /** @@ -241,11 +245,12 @@ function getWebVital( name: string, nodes: Node[] | undefined, attributions?: WebVitalData['attributions'], + time = metric.value, ): ReplayPerformanceEntry { const value = metric.value; const rating = metric.rating; - const end = getAbsoluteTime(value); + const end = getAbsoluteTime(time); return { type: 'web-vital', diff --git a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts index b2f9713a88f6..4aa12e2d5c33 100644 --- a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts +++ b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts @@ -143,11 +143,14 @@ describe('Unit | util | createPerformanceEntries', () => { }); describe('getCumulativeLayoutShift', () => { - it('works with a CLS metric', async () => { + it('places the CLS event at the last layout shift', async () => { const metric = { - value: 5108.299, - rating: 'good' as const, - entries: [], + value: 0.15, + rating: 'needs-improvement' as const, + entries: [ + { entryType: 'layout-shift', startTime: 1000, value: 0.05, sources: [], hadRecentInput: false }, + { entryType: 'layout-shift', startTime: 5108.299, value: 0.1, sources: [], hadRecentInput: false }, + ] as unknown as PerformanceEntry[], }; const event = getCumulativeLayoutShift(metric); @@ -157,17 +160,56 @@ describe('Unit | util | createPerformanceEntries', () => { name: 'cumulative-layout-shift', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: [], attributions: [] }, + data: { + value: 0.15, + size: 0.15, + rating: 'needs-improvement', + nodeIds: [], + attributions: [ + { value: 0.05, nodeIds: undefined }, + { value: 0.1, nodeIds: undefined }, + ], + }, + }); + }); + + it('uses the time origin from when the last layout shift happened', () => { + const driftPointMs = 200_000; + const sleepDurationMs = 3_600_000; + vi.mocked(browserPerformanceTimeOrigin).mockImplementation((time = 0) => + time < driftPointMs ? TIME_ORIGIN : TIME_ORIGIN + sleepDurationMs, + ); + + const event = getCumulativeLayoutShift({ + value: 0.1, + rating: 'good', + entries: [ + { entryType: 'layout-shift', startTime: driftPointMs + 1000, value: 0.1, sources: [], hadRecentInput: false }, + ] as unknown as PerformanceEntry[], + }); + + expect(event.start).toBe((TIME_ORIGIN + sleepDurationMs + driftPointMs + 1000) / 1000); + }); + + it('places a CLS of 0 at the time origin', async () => { + const event = getCumulativeLayoutShift({ value: 0, rating: 'good', entries: [] }); + + expect(event).toEqual({ + type: 'web-vital', + name: 'cumulative-layout-shift', + start: TIME_ORIGIN / 1000, + end: TIME_ORIGIN / 1000, + data: { value: 0, size: 0, rating: 'good', nodeIds: [], attributions: [] }, }); }); }); describe('getInteractionToNextPaint', () => { - it('works with an INP metric', async () => { + it('places the INP event at the interaction', async () => { const metric = { - value: 5108.299, + value: 120, rating: 'good' as const, - entries: [], + entries: [{ name: 'click', entryType: 'event', startTime: 5108.299, duration: 120 }] as PerformanceEntry[], }; const event = getInteractionToNextPaint(metric); @@ -177,8 +219,26 @@ describe('Unit | util | createPerformanceEntries', () => { name: 'interaction-to-next-paint', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: undefined, attributions: undefined }, + data: { value: 120, size: 120, rating: 'good', nodeIds: undefined, attributions: undefined }, }); }); + + it('uses the time origin from when the interaction happened', () => { + const driftPointMs = 200_000; + const sleepDurationMs = 3_600_000; + vi.mocked(browserPerformanceTimeOrigin).mockImplementation((time = 0) => + time < driftPointMs ? TIME_ORIGIN : TIME_ORIGIN + sleepDurationMs, + ); + + const event = getInteractionToNextPaint({ + value: 120, + rating: 'good', + entries: [ + { name: 'click', entryType: 'event', startTime: driftPointMs + 1000, duration: 120 }, + ] as PerformanceEntry[], + }); + + expect(event.start).toBe((TIME_ORIGIN + sleepDurationMs + driftPointMs + 1000) / 1000); + }); }); });