Skip to content

fix(replay): Place CLS and INP web vitals at the right time - #24990

Open
Lms24 wants to merge 3 commits into
developfrom
lms/fix-replay-cls-timestamp
Open

Lms24 wants to merge 3 commits into
developfrom
lms/fix-replay-cls-timestamp

Conversation

@Lms24

@Lms24 Lms24 commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

The Replay integration adding web vital breadcrumbs used the metric value as the timestamp for CLS and INP web vitals. This is incorrect because that value is a score for CLS and a duration for INP, so both events always landed just after page load. This was a pre-existing bug, but it showed up during work on #23054. With this PR, CLS goes at the last layout shift and INP at the interaction, like we already do with spans in tracing. Both also get the time origin from when that happened, so they stay correct after a clock drift correction. A CLS of 0 has no layout shift, so it stays at the start of its navigation

@Lms24
Lms24 added this pull request to stack #24904 October 2, 2026 12:58
@Lms24 Lms24 changed the title fix(replay): Place CLS web vital at the last layout shift fix(replay): Place CLS and INP web vitals at the right time Oct 2, 2026
@Lms24
Lms24 force-pushed the lms/fix-replay-cls-timestamp branch from 53c97f7 to 10bc6d6 Compare October 2, 2026 14:13
@Lms24
Lms24 marked this pull request as ready for review October 2, 2026 14:15
@Lms24
Lms24 requested a review from a team as a code owner October 2, 2026 14:15
@Lms24
Lms24 requested review from logaretm and msonnb and removed request for a team October 2, 2026 14:15
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 29.72 kB +0.41% +119 B 🔺
@sentry/browser - with treeshaking flags 27.86 kB +0.39% +107 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing 27.75 kB +0.38% +103 B 🔺
@sentry/browser (incl. Tracing) 51.75 kB +0.46% +233 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 51.75 kB +0.46% +233 B 🔺
@sentry/browser (incl. Tracing, Profiling) 54.73 kB +0.42% +225 B 🔺
@sentry/browser (incl. Tracing, Replay) 91.46 kB +0.26% +236 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 80.37 kB +0.23% +183 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 96.17 kB +0.25% +239 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 109.18 kB +0.27% +286 B 🔺
@sentry/browser (incl. Feedback) 47.24 kB +0.26% +122 B 🔺
@sentry/browser (incl. sendFeedback) 34.77 kB +0.34% +117 B 🔺
@sentry/browser (incl. FeedbackAsync) 39.85 kB +0.26% +100 B 🔺
@sentry/browser (incl. Metrics) 30.72 kB +0.37% +112 B 🔺
@sentry/browser (incl. Logs) 31.01 kB +0.39% +119 B 🔺
@sentry/browser (incl. Metrics & Logs) 31.67 kB +0.38% +117 B 🔺
@sentry/react 31.54 kB +0.35% +107 B 🔺
@sentry/react (incl. Tracing) 54.07 kB +0.44% +236 B 🔺
@sentry/vue 37.75 kB +0.55% +204 B 🔺
@sentry/vue (incl. Tracing) 54.67 kB +0.51% +276 B 🔺
@sentry/svelte 29.74 kB +0.39% +114 B 🔺
@sentry/remix (Remix 3 client bundle) 56.76 kB +0.39% +215 B 🔺
CDN Bundle 31.43 kB +0.31% +96 B 🔺
CDN Bundle (incl. Tracing) 52.28 kB +0.41% +210 B 🔺
CDN Bundle (incl. Logs, Metrics) 33.64 kB +0.24% +79 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 54.23 kB +0.38% +203 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 74.56 kB +0.24% +175 B 🔺
CDN Bundle (incl. Tracing, Replay) 89.93 kB +0.22% +193 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 91.89 kB +0.21% +190 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 96.08 kB +0.19% +177 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 98.07 kB +0.21% +197 B 🔺
CDN Bundle - uncompressed 92.72 kB +0.28% +253 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 155.25 kB +0.32% +482 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 99.25 kB +0.22% +212 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 161.21 kB +0.3% +482 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 229.29 kB +0.14% +311 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 275.42 kB +0.2% +530 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 281.36 kB +0.19% +530 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 289.12 kB +0.19% +530 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 295.05 kB +0.18% +530 B 🔺
@sentry/nextjs (client) 56.45 kB +0.46% +256 B 🔺
@sentry/sveltekit (client) 52.14 kB +0.45% +233 B 🔺
@sentry/core/server 40.85 kB +0.49% +198 B 🔺
@sentry/core/browser 13.71 kB +1.48% +199 B 🔺
@sentry/node 145.78 kB +0.09% +121 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 83.33 kB +0.14% +109 B 🔺
@sentry/node - without tracing 93.66 kB +0.22% +203 B 🔺
@sentry/node - without channel injection 123.92 kB +0.09% +110 B 🔺
@sentry/aws-serverless 101.88 kB +0.19% +192 B 🔺
@sentry/cloudflare (withSentry) - minified 209.67 kB +0.31% +638 B 🔺
@sentry/cloudflare (withSentry) 519.89 kB +0.41% +2.09 kB 🔺

View base workflow run

name: string,
nodes: Node[] | undefined,
attributions?: WebVitalData['attributions'],
time = metric.value,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: Can't we set this inside this function? It seem we set INP and CLS both the "last entry", but they are both setting it to metric.entries[metric.entries.length - 1]?.startTime ?? 0. Couldn't this be potentially:

const time = metric.entries[metric.entries.length - 1] ?? metric.value

It is a different logic than the current implemented, but asking since LCP doesn't make use of the "last entry". If LCP shouldn't make use of it, maybe we should document when this should be overwritten

@logaretm logaretm Oct 5, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: +1, LCP's last entry startTime is its render time, so it works there too. metric.value is wrong as a time for soft navs, prerender and bfcache.

For bfcache there are no entries, so we could fall back to the navigation start:

const lastEntry = metric.entries[metric.entries.length - 1];
const time = lastEntry?.startTime ?? (metric.navigationStartTime ?? 0) + metric.value;

This also would put a CLS of 0 on its soft nav which is correct.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good points, thanks! I went with your suggestion @logaretm

@logaretm logaretm left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should LCP get the same treatment? Also a note on the timing because we have several timing corrections depending on the navigation type.

// 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: a soft nav with a CLS of 0 lands at page load here.

metric.navigationStartTime ?? 0 would put it on its navigation, like _sendClsSpan does.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thx! Went with the suggestion.

name: string,
nodes: Node[] | undefined,
attributions?: WebVitalData['attributions'],
time = metric.value,

@logaretm logaretm Oct 5, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: +1, LCP's last entry startTime is its render time, so it works there too. metric.value is wrong as a time for soft navs, prerender and bfcache.

For bfcache there are no entries, so we could fall back to the navigation start:

const lastEntry = metric.entries[metric.entries.length - 1];
const time = lastEntry?.startTime ?? (metric.navigationStartTime ?? 0) + metric.value;

This also would put a CLS of 0 on its soft nav which is correct.

@Lms24
Lms24 requested a review from a team as a code owner October 6, 2026 09:58
@Lms24
Lms24 requested review from chargome and s1gr1d and removed request for a team October 6, 2026 09:58
@Lms24
Lms24 force-pushed the lms/fix-replay-cls-timestamp branch from 10bc6d6 to e7aa72f Compare October 6, 2026 09:58
@Lms24
Lms24 force-pushed the lms/fix-replay-cls-timestamp branch from e7aa72f to 1825836 Compare October 6, 2026 10:01

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1825836. Configure here.

Comment thread packages/replay-internal/src/util/createPerformanceEntries.ts Outdated
@Lms24
Lms24 force-pushed the lms/fix-replay-cls-timestamp branch 2 times, most recently from 3c0856f to ddb99d3 Compare October 6, 2026 16:04
@Lms24
Lms24 force-pushed the lms/fix-replay-cls-timestamp branch from ddb99d3 to 1ae96f8 Compare October 6, 2026 17:24
@Lms24
Lms24 force-pushed the lms/fix-replay-cls-timestamp branch from 1ae96f8 to 47e4946 Compare October 7, 2026 08:34
@Lms24
Lms24 force-pushed the lms/fix-replay-cls-timestamp branch from 47e4946 to 4a8b3bc Compare October 7, 2026 08:48
Comment thread packages/replay-internal/src/util/createPerformanceEntries.ts Outdated
@Lms24
Lms24 force-pushed the lms/fix-replay-cls-timestamp branch 2 times, most recently from 7b429e3 to c94f742 Compare October 7, 2026 11:58
@Lms24 Lms24 self-assigned this Oct 7, 2026
Base automatically changed from lms/fix-effect-tracer-clock-drift to develop October 7, 2026 15:02
Lms24 and others added 3 commits October 7, 2026 17:02
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Soft navigation and bfcache metrics are relative to the start of their
navigation, not the document time origin. A CLS of 0 or a bfcache LCP
has no entry to place the event at, so it landed at page load. Fall
back to the navigation start plus the value instead, like tracing does.

All three vitals now share this logic in getWebVital. LCP also uses its
last entry now, which is the render time, so a soft navigation LCP is
no longer placed relative to page load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Lms24
Lms24 force-pushed the lms/fix-replay-cls-timestamp branch from f867ef9 to c43e986 Compare October 7, 2026 15:02

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants