Skip to content

fix(core): Account for clock drift in initial timestampInSeconds() call - #22488

Closed
Lms24 wants to merge 2 commits into
developfrom
lms/fix-timeStampInSeconds-partially
Closed

Lms24 wants to merge 2 commits into
developfrom
lms/fix-timeStampInSeconds-partially

Conversation

@Lms24

@Lms24 Lms24 commented Jul 22, 2026 •

Copy link
Copy Markdown
Member

#22375 reports a valid bug which is that timestampInSeconds() may return an inaccurate time stamp, since we don't detect clock drift between the performance.timeOrigin + performance.now() timestamp and Date.now().

This PR intentionally only partially fixes this problem by adding a clock drift check only to the initial timestampInSeconds call. This should account for cases where the monotonic performance.now() clock is already off at SDK init time but won't catch clock drift later on.

From here we have two options:

  1. Also add the drift validity check to subsequent calls. This means we'll bypass caching (what we do now) until we detect clock drift, adding performance overhead by running the validity check on every timestampInSeconds call until then.
  2. Drop using the performance time APIs entirely and only return Date.now(). This should give us more accurate timing overall but:
    • we loose nanoseconds precision
    • we'd arguably now report accurate and inaccurate times in one trace because some spans are created from PerformanceEntry entries, which include relative timestamps that might have also suffered from clock drift.

I also still need to look at the sent_at header which should prompt Relay to perform clock-drift normalization but I'm not yet sure to which extent.

Keeping this draft as a possible mitigation open for now

@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 27.8 kB +0.17% +47 B 🔺
@sentry/browser - with treeshaking flags 26.24 kB +0.18% +46 B 🔺
@sentry/browser (incl. Tracing) 46.6 kB +0.04% +14 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 48.41 kB +0.04% +17 B 🔺
@sentry/browser (incl. Tracing, Profiling) 51.42 kB +0.07% +31 B 🔺
@sentry/browser (incl. Tracing, Replay) 85.88 kB +0.06% +45 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 75.51 kB +0.06% +38 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 90.61 kB +0.07% +55 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 103.23 kB +0.02% +18 B 🔺
@sentry/browser (incl. Feedback) 44.99 kB +0.11% +46 B 🔺
@sentry/browser (incl. sendFeedback) 32.59 kB +0.12% +39 B 🔺
@sentry/browser (incl. FeedbackAsync) 37.72 kB +0.1% +34 B 🔺
@sentry/browser (incl. Metrics) 28.89 kB +0.19% +52 B 🔺
@sentry/browser (incl. Logs) 29.12 kB +0.22% +62 B 🔺
@sentry/browser (incl. Metrics & Logs) 29.82 kB +0.18% +52 B 🔺
@sentry/react 29.6 kB +0.16% +46 B 🔺
@sentry/react (incl. Tracing) 48.9 kB +0.07% +31 B 🔺
@sentry/vue 33.23 kB +0.16% +52 B 🔺
@sentry/vue (incl. Tracing) 48.6 kB +0.08% +38 B 🔺
@sentry/svelte 27.82 kB +0.17% +47 B 🔺
CDN Bundle 30.2 kB +0.15% +44 B 🔺
CDN Bundle (incl. Tracing) 48.58 kB +0.04% +18 B 🔺
CDN Bundle (incl. Logs, Metrics) 31.78 kB +0.13% +41 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 49.9 kB +0.09% +42 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 71.04 kB +0.06% +36 B 🔺
CDN Bundle (incl. Tracing, Replay) 86.09 kB +0.05% +37 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 87.41 kB +0.05% +35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 91.9 kB +0.04% +35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 93.18 kB +0.06% +51 B 🔺
CDN Bundle - uncompressed 90 kB +0.13% +109 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 146.85 kB +0.06% +81 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 94.68 kB +0.09% +81 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 150.83 kB +0.06% +81 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 219.44 kB +0.04% +81 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 266.09 kB +0.04% +81 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 270.05 kB +0.04% +81 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 279.79 kB +0.03% +81 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 283.74 kB +0.03% +81 B 🔺
@sentry/nextjs (client) 51.42 kB +0.09% +42 B 🔺
@sentry/sveltekit (client) 47.04 kB +0.08% +34 B 🔺
@sentry/core/server 80.33 kB +0.04% +25 B 🔺
@sentry/core/browser 66.75 kB +0.07% +42 B 🔺
@sentry/node-core 63.26 kB +0.07% +43 B 🔺
@sentry/node 124.35 kB +0.05% +53 B 🔺
@sentry/node (incl. diagnostics channel injection) 149.79 kB +0.02% +19 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 70.03 kB - -
@sentry/node/light 51.38 kB +0.09% +46 B 🔺
@sentry/node - without tracing 74.96 kB +0.07% +47 B 🔺
@sentry/aws-serverless 84.2 kB +0.05% +34 B 🔺
@sentry/cloudflare (withSentry) - minified 196.74 kB +0.08% +156 B 🔺
@sentry/cloudflare (withSentry) 484.48 kB +0.14% +645 B 🔺

View base workflow run

@Lms24

Lms24 commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

superseded by #23054

@Lms24 Lms24 closed this Aug 6, 2026
Lms24 added a commit that referenced this pull request Oct 7, 2026
This PR fixes* clock drift that occurs when devices go to sleep. After
devices sleep for a few seconds, the browser's monotonic clock (accessed
via `performance.now()`) stops counting. Once the sleep stops, the
monotonic clock resumes right where it left off before the sleep,
causing significant discrepancies between the actual time (wall clock)
and the browser's monotonic clock time. Or in other words, there are two
ways to get an absolute timestamp:

- Using the monotonic clock via `peformance.timeOrigin +
performance.now()` - has high, sub-millisecond precision and a guarantee
that there are no time jumps or adjustments. But suffers from the sleep
problem described above
- Using the wall clock via `Date.now()` - lower millisecond precision,
is known to be corrected (NTP or via users) and can even cause back
jumps. But no sleep problem.

With this fix, we make the following adjustments, to kinda get the best
of both worlds:

- Every `timestampInSecondsCall` checks the monotonic against the wall
clock. If a threshold of difference is enocuntered, it corrects the
`timeOrigin` so that we can keep using the precise monotonic clock, but
anchor its relative time to a corrected time origin.
- On every time origin correction, we remember the previous origin (up
to 30 corrections). Needed for performance entries
- Makes `browserPerformanceTimeOrigin` a time origin corrected helper
function, where you pass in a relative time that comes from monotonic
clock relative timestamps and it returns the time origin that was most
accurate at that relative time point.
- All spans and other telemetry we create from browsers
`PerformanceEntry` objects carry relative times which are completely
sleep-drift uncorrected. We use `browserPerformanceTimeOrigin` to return
a corrected time stamp so that we can create an absolute timestamp and
bring the performance entries to their respective actual time.

### FAQ

If you think this sounds complicated, I agree. So let me answer the most
obvious questions, because I asked myself these a lot, too:

**Why not just always use `Date.now()` and avoid the complicated click
drift detection and correction logic?**

The main issue with this is that we still need to rely on relative
monotonic clock timestamps for performance entries. We cannot correct
them just with `Date.now()` alone but we still need to anchor them. So
either we rely on the original `window.timeOrigin` and therefore have
all performance entry telemetry happen much "earlier" than its
surrounding telemetry relying on `Date.now()`, or we keep the time
origin correction logic. But even in the second case (where we already
pay the tax for drift detection and correction), telemetry would still
have two different anchors: `Date.now()` for regular telemetry and the
corrected time origin + monotonic time for performance entry telemetry.

Another reason is that the monotonic clock gives us sun-millisecond
precision while the wall clock stops at a millisecond resolution.

**Why the reduction from a drift detection threshold of 5 minutes to
just one second?**

Because we keep everything centered around detection and origin offset
correction, these timestamps need to be as accurate as possible. On
phones, short frequent sleeps are very likely to happen. A lot of sleeps
can accumulate until that 5 minutes threshold is reached. So it's really
important we make these timestamps as accurate as possible.

Fwiw, I reproduced this locally and the drift is already noticeable
after just a few seconds of sleep. The 5 minutes threshold was arguably
far too big beforehand.


**Is there precedence for all of this stuff?**

Somewhat, but I'd argue we're doing a bit more: OTel uses a mixture of
`Date.now()` and `performance.now`:
- Spans start at `Date.now()` and at start time, they record a
`performance.now()` timestamp. On span end, they also take
`performance.now()`, compute the diff and convert that to an end
timestamp based on the start timestamp + diff. This is neat because it
guarantees monotony in span durations. I stole this in #24903

In other places, OTel relies purely on Date.now(), and for performance
entries, they simply take uncorrected timestamps. So our fix is more
complete.

\* **So... we're good now?**

Well, not perfectly and we never will. With this choice we make another
commitment (which we already did previously in less obvious cases) to
`Date.now()`. This value can drift as well, just not for sleeps:

- NTP adjustments: Can make hard correction of the wall clock, or make
the clock tick just a bit faster or slower until the device wall clock
synced with the network time.
- User adjustments: Users can adjust the device time at any time into
any direction.

I think we'll have to live with both and I'm not particularly worried
about them. My main objective is getting rid of the sleep drift.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant