Skip to content

fix(core): Account for clock drift on every timestampInSeconds call - #23054

Draft
Lms24 wants to merge 6 commits into
developfrom
lms/fix-core-browser-timestampInSeconds-offset-clockdrift
Draft

Lms24 wants to merge 6 commits into
developfrom
lms/fix-core-browser-timestampInSeconds-offset-clockdrift

Conversation

@Lms24

@Lms24 Lms24 commented Aug 5, 2026 •

Copy link
Copy Markdown
Member

timestampInSeconds combines performance.timeOrigin with performance.now(), and on some platforms performance.now() stops while the device sleeps. After a sleep, spans, logs and metrics therefore end up behind the wall clock (errors and breadcrumbs use Date.now(), so they don't). This PR now checks both clocks on every call and re-derives the origin once they differ by more than 5 minutes. Elapsed time still comes from performance.now(), so it keeps sub-millisecond precision.

The correction only applies going forward. Performance entries are often converted long after they happen (INP and CLS on pagehide, replay entries on flush), so the SDK keeps the old origins and performanceTimeToSeconds() converts each monotonic time against the origin in effect when it was measured. Web vitals, replay and continuous profiling now use it.

Fixes #2590
Supersedes #22488, #22585, #23067, #23068

🤖 Generated with Claude Code

@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.

Stale Bugbot comment from a previous run.

Comment thread packages/core/src/utils/time.ts
@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

size-limit report 📦

⚠️ Warning: Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.

Path Size % Change Change
@sentry/browser 29.36 kB +0.41% +118 B 🔺
@sentry/browser - with treeshaking flags 27.62 kB +0.42% +113 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing 27.51 kB +0.42% +113 B 🔺
@sentry/browser (incl. Tracing) 51.3 kB +0.3% +152 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 51.32 kB +0.29% +145 B 🔺
@sentry/browser (incl. Tracing, Profiling) 54.28 kB +0.2% +103 B 🔺
@sentry/browser (incl. Tracing, Replay) 90.89 kB +0.15% +129 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 79.99 kB +0.17% +132 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 95.59 kB +0.14% +126 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 108.56 kB +0.14% +145 B 🔺
@sentry/browser (incl. Feedback) 46.87 kB +0.24% +110 B 🔺
@sentry/browser (incl. sendFeedback) 34.42 kB +0.36% +121 B 🔺
@sentry/browser (incl. FeedbackAsync) 39.52 kB +0.28% +108 B 🔺
@sentry/browser (incl. Metrics) 30.36 kB +0.38% +114 B 🔺
@sentry/browser (incl. Logs) 30.64 kB +0.41% +123 B 🔺
@sentry/browser (incl. Metrics & Logs) 31.3 kB +0.38% +117 B 🔺
@sentry/react 31.19 kB +0.34% +104 B 🔺
@sentry/react (incl. Tracing) 53.69 kB +0.28% +148 B 🔺
@sentry/vue 36.85 kB +0.32% +117 B 🔺
@sentry/vue (incl. Tracing) 53.84 kB +0.26% +137 B 🔺
@sentry/svelte 29.38 kB +0.41% +119 B 🔺
CDN Bundle 31.13 kB +0.38% +117 B 🔺
CDN Bundle (incl. Tracing) 51.91 kB +0.26% +131 B 🔺
CDN Bundle (incl. Logs, Metrics) 33.35 kB +0.18% +57 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 53.9 kB +0.3% +157 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 74.11 kB +0.15% +108 B 🔺
CDN Bundle (incl. Tracing, Replay) 89.5 kB +0.16% +137 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 91.47 kB +0.15% +133 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 95.66 kB +0.14% +125 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 97.64 kB +0.15% +142 B 🔺
CDN Bundle - uncompressed 91.92 kB +0.28% +253 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 154.25 kB +0.15% +220 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 98.39 kB +0.17% +158 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 160.21 kB +0.14% +220 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 228.01 kB +0.1% +208 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 273.98 kB +0.09% +220 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 279.92 kB +0.08% +220 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 287.68 kB +0.08% +220 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 293.61 kB +0.08% +220 B 🔺
@sentry/nextjs (client) 55.93 kB +0.28% +152 B 🔺
@sentry/sveltekit (client) 51.72 kB +0.25% +126 B 🔺
@sentry/core/server 40.1 kB +0.3% +116 B 🔺
@sentry/core/browser 13.74 kB +0.81% +110 B 🔺
@sentry/node 142 kB +0.09% +122 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 83 kB +0.14% +115 B 🔺
@sentry/node - without tracing 91.01 kB +0.14% +124 B 🔺
@sentry/node - without channel injection 120.39 kB +0.12% +136 B 🔺
@sentry/aws-serverless 99.27 kB +0.14% +131 B 🔺
@sentry/cloudflare (withSentry) - minified 206.88 kB +0.13% +261 B 🔺
@sentry/cloudflare (withSentry) 515.05 kB +0.21% +1.03 kB 🔺

View base workflow run

@Lms24

Lms24 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

The two-origin divergence Bugbot flagged is real: timestampInSeconds corrects its origin while browserPerformanceTimeOrigin keeps the one cached at init.

Fixed in #23067, stacked on top of this PR, which exposes the corrected origin and switches over the consumers where the divergence persists (INP, replay, and profiling's adjustForOriginChange, which was compensating against the stale value). #23068 then handles the case where replay observes an entry before a correction but converts it after.

Keeping it out of this PR so each change stays independently reviewable — this one is limited to how timestampInSeconds itself behaves.

@bezata

bezata commented Aug 5, 2026

Copy link
Copy Markdown

Confirmed on a real iPhone running React Native 0.86 with @sentry/core@10.67.0.

One structured log embedded its emission wall time in the message body:

  • embedded Date.now(): 1785945903901 (2026-08-05T16:05:03.901Z)
  • Sentry-stored log timestamp: 2026-08-03T07:19:17Z
  • displacement: approximately 204,346,901 ms (2.365 days)

The full structured-log stream was present under the older window with the same displacement, while error events remained wall-clock-correct. This matches the React Native Apple clock change in react-native#55977 and the symptom reported in getsentry/sentry-react-native#6510.

We applied the same per-call re-anchoring shape downstream as a version-pinned patch. Package-level regression tests against the real @sentry/core package cover an already-skewed first call, drift re-accumulating after initialization, the sub-threshold path, and no-thrash behavior. Live post-patch device verification is still pending, so I am not claiming field recovery yet.

One potentially useful addition to this PR's test suite: the current sleep test starts with an aligned first call and then accumulates drift. Our device also exercised the other entry condition, where timeOrigin + performance.now() was already about 2.37 days behind Date.now() before the first observed timestampInSeconds() call. A first-call pre-existing-skew case would pin that production shape directly.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has gone three weeks without activity. In another week, I will close it.

But! If you comment or otherwise update it, I will reset the clock, and if you apply the label PR: no-auto-close I will leave it alone ... forever!

@plgrazon

plgrazon commented Sep 1, 2026

Copy link
Copy Markdown

Getting the same issue, has this been closed permanently?

@Lms24

Lms24 commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

@plgrazon no, this is still WIP but the change is non-trivial. I'm currently completely booked on the new JS major but I hope to get some time to pick this up next week again.

@plgrazon

plgrazon commented Sep 2, 2026

Copy link
Copy Markdown

@Lms24 no worries. thank you!

Lms24 and others added 3 commits September 28, 2026 15:01
…red with

`timestampInSeconds` re-derives its time origin when it detects clock drift, so
a single origin is only valid for part of a page's lifetime. Consumers that
convert a `PerformanceEntry`'s monotonic `startTime` to wall clock time have no
way to know which one applied to a given entry, and `browserPerformanceTimeOrigin`
caches the origin resolved at SDK init and never revisits it.

Adds `performanceTimeToSeconds`, which keeps the superseded origins around and
picks the one that was in effect when the passed time was measured. Entries
reported long after the fact — INP on pagehide, replay entries buffered until
flush — therefore stay on the timeline they were recorded on instead of being
retroactively shifted by a drift that happened afterwards.

The correction boundary is the `performance.now()` value the drift was detected
at, which is an upper bound on where it actually happened; that is as close as
it can be pinned down without a second clock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… own time origin

Routes the consumers that convert a monotonic `PerformanceEntry` time long after
the entry was recorded through `performanceTimeToSeconds`, so a clock drift
correction no longer shifts entries that were timed correctly:

- INP and CLS report on pagehide, potentially hours after the interaction or
  layout shift they describe. LCP keeps the cached origin: it starts *at* the
  origin by construction, so moving it would detach it from its pageload parent.
- Replay buffers raw entries and only converts them on flush, which for a
  long-running session can be minutes later.
- Continuous profiling samples are `performance.timeOrigin`-relative like any
  other monotonic time.

Also drops `adjustForOriginChange` from `convertJSSelfProfileToSampledFormat`.
`elapsed_since_start_ns` is a difference between two raw monotonic values, so no
origin belongs in it at all - the profile is anchored to the wall clock by the
enclosing payload's `timestamp`. The term was harmless only because it evaluated
to ~0 whenever the SDK origin matched `performance.timeOrigin`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Lms24
Lms24 force-pushed the lms/fix-core-browser-timestampInSeconds-offset-clockdrift branch from 456dd81 to ded4f36 Compare September 28, 2026 13:10
@Lms24
Lms24 removed this pull request from stack #23069 September 28, 2026 14:25

@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 2 potential issues.

Fix All in Cursor

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

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1a9120e. Configure here.

Comment thread packages/browser-utils/src/web-vitals/spans.ts
Comment thread packages/replay-internal/src/util/createPerformanceEntries.ts
Lms24 and others added 3 commits September 28, 2026 16:40
…igin

`browserPerformanceTimeOrigin` was cached at init, so after a drift correction
resource, long task, long animation frame, click and user timing spans landed a
whole sleep before the navigation they belong to, and most were dropped by the
"started before the navigation" checks. It now takes the monotonic time being
converted and returns the origin in effect then, defaulting to the page load.

Also:
- Soft navigation LCP resolves against the origin at the navigation start.
- A correction now applies from the previous check on, so the interaction that
  wakes the SDK up after a sleep lands on the corrected timeline.
- The page load origin survives the segment cap.
- Without `performance.timeOrigin`, monotonic times convert against
  `Date.now() - performance.now()` again instead of producing `NaN`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A drift correction applies from the last check at which both clocks agreed, so
entries recorded between that check and the device going to sleep land on the
wrong side of it. Devices usually hide the page before sleeping and show it
again on wake, so checking the clocks on `visibilitychange` pins the correction
to the sleep itself rather than to whenever the SDK next takes a timestamp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
At 5 minutes, any sleep shorter than that left spans, logs and metrics behind
the wall clock (and errors) for the rest of the page's life. 15 seconds is still
far above `Date.now()` jitter and typical NTP adjustments, so it only catches
real drift.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Timing issues using Performance API

3 participants