From 2979fe9b7b8682f36028e22fc55b43e83a71c1ab Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 2 Oct 2026 14:57:24 +0200 Subject: [PATCH 1/2] fix(replay): Place CLS web vital at the last layout shift The replay CLS event used the CLS score as its timestamp, so it always landed a fraction of a millisecond after page load. Use the start time of the last layout shift instead, like the CLS span in tracing does. This also picks the time origin from when the shift happened. Co-Authored-By: Claude --- .../src/util/createPerformanceEntries.ts | 8 ++- .../unit/util/createPerformanceEntry.test.ts | 52 +++++++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/replay-internal/src/util/createPerformanceEntries.ts b/packages/replay-internal/src/util/createPerformanceEntries.ts index 84de0ae28a60..5ed6682377be 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); } /** @@ -241,11 +244,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..6cbd3ae8d7e4 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,7 +160,46 @@ 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: [] }, }); }); }); From 10bc6d67cf4c2dbe2cbc2682f53270041af1f43c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 2 Oct 2026 15:43:54 +0200 Subject: [PATCH 2/2] fix(replay): Place INP web vital at the interaction Like CLS, the replay INP event used the metric value as its timestamp. For INP that value is a duration, so the event landed shortly after page load. Use the start time of the interaction instead. Co-Authored-By: Claude --- .../src/util/createPerformanceEntries.ts | 3 ++- .../unit/util/createPerformanceEntry.test.ts | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/replay-internal/src/util/createPerformanceEntries.ts b/packages/replay-internal/src/util/createPerformanceEntries.ts index 5ed6682377be..8290117188b1 100644 --- a/packages/replay-internal/src/util/createPerformanceEntries.ts +++ b/packages/replay-internal/src/util/createPerformanceEntries.ts @@ -233,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); } /** diff --git a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts index 6cbd3ae8d7e4..4aa12e2d5c33 100644 --- a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts +++ b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts @@ -205,11 +205,11 @@ describe('Unit | util | createPerformanceEntries', () => { }); 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); @@ -219,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); + }); }); });