Skip to content

fix(core): Compute span end time from performance.now() duration - #24903

Merged
Lms24 merged 6 commits into
lms/fix-core-browser-timestampInSeconds-offset-clockdriftfrom
lms/fix-core-span-end-monotonic-duration
Oct 7, 2026
Merged

Lms24 merged 6 commits into
lms/fix-core-browser-timestampInSeconds-offset-clockdriftfrom
lms/fix-core-span-end-monotonic-duration

Conversation

@Lms24

@Lms24 Lms24 commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

After Spans #23054, spans that run while the time origin is corrected (e.g. after the device slept) get the drift added to their duration. However, If the wall clock jumps backwards, the duration can even be negative. Also, adding sleep time to spans is arguably unexpected and unprecedented in comparison to OpenTelemetry.

This PR changes s span.end() without a explicit timestamp to compute the end as start time + performance.now() time since the start, like OpenTelemetry does. Start times still use timestampInSeconds(), so real-time started spans and spans from performance entries stay on the same timeline.

Tradeoff: when a correction happens while a span runs, its end is now based on the old time origin. So children or errors recorded after the correction can appear after the span ended. Spans with an explicit start time keep using timestampInSeconds() for the end.

@Lms24
Lms24 added this pull request to stack #24904 September 30, 2026 15:21
@github-actions

github-actions Bot commented Sep 30, 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.72 kB +0.41% +120 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% +232 B 🔺
@sentry/browser (incl. Tracing, Profiling) 54.73 kB +0.42% +225 B 🔺
@sentry/browser (incl. Tracing, Replay) 91.44 kB +0.24% +211 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 80.34 kB +0.21% +161 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 96.14 kB +0.23% +215 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 109.15 kB +0.24% +261 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% +97 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.53 kB +0.21% +153 B 🔺
CDN Bundle (incl. Tracing, Replay) 89.91 kB +0.2% +174 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 91.86 kB +0.19% +169 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 96.06 kB +0.17% +160 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 98.05 kB +0.19% +178 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.22 kB +0.11% +238 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 275.35 kB +0.17% +457 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 281.29 kB +0.17% +457 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 289.05 kB +0.16% +457 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 294.98 kB +0.16% +457 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.15% +213 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.17% +201 B 🔺
@sentry/aws-serverless 101.88 kB +0.19% +193 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

@Lms24
Lms24 force-pushed the lms/fix-core-span-end-monotonic-duration branch from d35bfbf to 9e95d55 Compare October 2, 2026 09:44
@Lms24 Lms24 self-assigned this Oct 2, 2026
@Lms24
Lms24 marked this pull request as ready for review October 2, 2026 10:13
@Lms24
Lms24 requested review from JPeer264, logaretm and mydea October 2, 2026 10:13
@Lms24
Lms24 force-pushed the lms/fix-core-span-end-monotonic-duration branch from 264fffd to 779d31b Compare October 2, 2026 10:18
@Lms24
Lms24 force-pushed the lms/fix-core-span-end-monotonic-duration branch from 779d31b to 8b5f7d7 Compare October 2, 2026 14:13
/**
* Returns `performance.now()` in milliseconds, or `undefined` if the Performance API is unavailable.
*/
export function safePerformanceNow(): number | undefined {

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: Should we make an oxlint rule to not use performance.now? For server runtimes it wouldn't be necessary, but for core and browser it might make sense

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.

For server runtimes it wouldn't be necessary

it's necessary specifically for server. The reason for the withRandomSafeContext wrapper is that NextJS cached components break when calling random functions without this special context hack.

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.

RE lint rule: Could make sense, but I'd do it separately. Also I think we already thought about this when we introduced withRandomSafeContext. Will check with Charly and Awad who implemented this originally.

@Lms24 Lms24 Oct 7, 2026 •

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.

Oh, turns out we already have this rule: no-unsafe-random-apis. However, it's missing some syntax variations at the moment. Will open a separate PR.

Comment thread packages/core/test/lib/tracing/sentrySpan.test.ts Outdated
Comment thread packages/core/src/tracing/sentrySpan.ts

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

LGTM! I think we talked about this, it's either we get correct timings or accurate durations. So, I think this is the right decision here.

@Lms24
Lms24 force-pushed the lms/fix-core-span-end-monotonic-duration branch 2 times, most recently from f3ec383 to b3c446c Compare October 6, 2026 10:01
@Lms24
Lms24 force-pushed the lms/fix-core-span-end-monotonic-duration branch 3 times, most recently from 5489794 to b573c7c Compare October 6, 2026 17:24
@Lms24
Lms24 force-pushed the lms/fix-core-span-end-monotonic-duration branch from b573c7c to 8e8641b Compare October 7, 2026 08:34
@Lms24
Lms24 force-pushed the lms/fix-core-span-end-monotonic-duration branch from 1dc236f to 50ac6a9 Compare October 7, 2026 09:23
Lms24 and others added 2 commits October 7, 2026 13:58
Spans that run while the time origin is reset (e.g. after the device slept)
got the drift added to their duration, which could even be negative when the
wall clock jumped backwards. Now `span.end()` without a timestamp adds the
`performance.now()` time since the span started to the start time, like
OpenTelemetry does. Spans with an explicit start time keep using
`timestampInSeconds()` for the end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lms24 and others added 4 commits October 7, 2026 13:58
Use loose null checks when deciding whether to compute the span end time from
the performance.now() duration, and move the misplaced _onSpanEnded doc comment
back to its method.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Lms24
Lms24 force-pushed the lms/fix-core-span-end-monotonic-duration branch from 50ac6a9 to 54b7759 Compare October 7, 2026 11:58
@Lms24
Lms24 merged commit 164794b into develop Oct 7, 2026
696 of 727 checks passed
@Lms24
Lms24 deleted the lms/fix-core-span-end-monotonic-duration branch October 7, 2026 15:02
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>
Lms24 added a commit that referenced this pull request Oct 7, 2026
Effect spans used Effect's own clock (a fixed origin plus a monotonic
clock) for their start, end and event times. #23054 makes Sentry correct
its clock for drift (e.g. after the device slept), so Effect spans could
end up shifted by the drift relative to the Sentry spans around them.
With this PR, an Effect span now starts on Sentry's clock, and its end
and event times are converted by their offset from the span's start.
This is the same per-span offset that OpenTelemetry's SDK uses. We also
do this for our spans now, see #24903 for details.

The PR keeps Effect's durations and any end time passed explicitly via
`span.end(time, exit)`. With `withTracerTiming(false)`, Effect passes
`0`, and we now use Sentry's clock instead of setting 1970 timestamps.

Co-authored-by: Claude Opus 5 <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.

3 participants