From ded4f368346d988529e99ce4a84abe4ef0f755d8 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 5 Aug 2026 14:13:57 +0200 Subject: [PATCH 1/6] fix(core): Account for clock drift on every `timestampInSeconds` call --- packages/core/src/utils/time.ts | 52 +++++-- packages/core/test/lib/utils/time.test.ts | 173 +++++++++++++++++++++- 2 files changed, 208 insertions(+), 17 deletions(-) diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index b736f546c9c5..00283bc6e48f 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -3,6 +3,12 @@ import { GLOBAL_OBJ } from './worldwide'; const ONE_SECOND_IN_MS = 1000; +/** + * Maximum tolerated difference between the monotonic clock and the wall clock before we consider + * the monotonic clock's time origin stale. + */ +const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds + /** * A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance} * for accessing a high-resolution monotonic clock. @@ -39,19 +45,32 @@ function createUnixTimestampInSecondsFunc(): () => number { return dateTimestampInSeconds; } - const timeOrigin = performance.timeOrigin; - // performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current // wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed. - // - // TODO: This does not account for the case where the monotonic clock that powers performance.now() drifts from the - // wall clock time, which causes the returned timestamp to be inaccurate. We should investigate how to detect and - // correct for this. - // See: https://github.com/getsentry/sentry-javascript/issues/2590 - // See: https://github.com/mdn/content/issues/4713 - // See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6 + let timeOrigin = performance.timeOrigin; + return () => { - return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS; + return withRandomSafeContext(() => { + const performanceNow = performance.now(); + const dateNow = Date.now(); + + // `timeOrigin + performance.now()` only equals wall clock time for as long as both clocks advance in lockstep. + // performance.now() stops advancing while the device is asleep, so it under-counts elapsed wall time; conversely + // the wall clock itself can be stepped by Network Time Protocol (NTP) or the user. Either way the two drift apart + // by arbitrary amounts. Re-deriving the origin restores absolute accuracy while still taking elapsed time from + // the monotonic clock, so durations keep sub-millisecond precision and cannot run backwards. + // Timestamps taken before a correction are measured against a different origin than those taken after it, so a + // span that starts before one and ends after it absorbs the drift into its duration. Spans that lie entirely on + // one side of a correction are unaffected. + // See: https://github.com/getsentry/sentry-javascript/issues/2590 + // See: https://github.com/mdn/content/issues/4713 + // See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6 + if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) { + timeOrigin = dateNow - performanceNow; + } + + return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS; + }); }; } @@ -61,10 +80,12 @@ let _cachedTimestampInSeconds: (() => number) | undefined; * Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the * availability of the Performance API. * - * BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is - * asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The - * skew can grow to arbitrary amounts like days, weeks or months. - * See https://github.com/getsentry/sentry-javascript/issues/2590. + * Because the Performance API's clock and the wall clock can drift apart (the former stops while the computer is + * asleep, the latter can be stepped by NTP or the user), the time origin they are combined against is re-derived from + * `Date.now()` whenever the two disagree by more than {@link CLOCK_DRIFT_THRESHOLD_MS}. Two timestamps taken on either + * side of such a correction are skewed relative to each other by the amount of drift, so a span that starts before a + * correction and ends after it reports the wall clock time elapsed rather than the time the monotonic clock was + * running. See https://github.com/getsentry/sentry-javascript/issues/2590. */ export function timestampInSeconds(): number { // We store this in a closure so that we don't have to create a new function every time this is called. @@ -92,14 +113,13 @@ function getBrowserTimeOrigin(): number | undefined { return undefined; } - const threshold = 300_000; // 5 minutes in milliseconds const performanceNow = withRandomSafeContext(() => performance.now()); const dateNow = safeDateNow(); const timeOrigin = performance.timeOrigin; if (typeof timeOrigin === 'number') { const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow); - if (timeOriginDelta < threshold) { + if (timeOriginDelta < CLOCK_DRIFT_THRESHOLD_MS) { return timeOrigin; } } diff --git a/packages/core/test/lib/utils/time.test.ts b/packages/core/test/lib/utils/time.test.ts index 50ae3641701f..70fc7e5c0243 100644 --- a/packages/core/test/lib/utils/time.test.ts +++ b/packages/core/test/lib/utils/time.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; async function getFreshPerformanceTimeOrigin() { // Adding the query param with the date, forces a fresh import each time this is called @@ -7,8 +7,179 @@ async function getFreshPerformanceTimeOrigin() { return timeModule.browserPerformanceTimeOrigin(); } +let freshImportCounter = 0; + +async function getFreshTimestampInSeconds(): Promise<() => number> { + // A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would + // otherwise hand out a cached module. + const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`); + return timeModule.timestampInSeconds; +} + const RELIABLE_THRESHOLD_MS = 300_000; +describe('timestampInSeconds', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('derives the timestamp from `performance.timeOrigin` and `performance.now()`', async () => { + const currentTimeMs = 1767778040866; + const timeSincePageloadMs = 1_234.56789; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: currentTimeMs - timeSincePageloadMs, + now: () => timeSincePageloadMs, + }); + + const timestampInSeconds = await getFreshTimestampInSeconds(); + + expect(timestampInSeconds()).toBe(currentTimeMs / 1000); + }); + + it('falls back to `Date.now()` if the performance API is unavailable', async () => { + const currentTimeMs = 1767778040866; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', undefined); + + const timestampInSeconds = await getFreshTimestampInSeconds(); + + expect(timestampInSeconds()).toBe(currentTimeMs / 1000); + }); + + it('keeps using `performance.timeOrigin` while the clocks agree', async () => { + const currentTimeMs = 1767778040866; + // Below the drift threshold, so the (inaccurate) time origin must be preserved. + const timeOriginSkewMs = RELIABLE_THRESHOLD_MS - 2_000; + + let timeSincePageloadMs = 1_000; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs, + now: () => timeSincePageloadMs, + }); + + const timestampInSeconds = await getFreshTimestampInSeconds(); + + expect(timestampInSeconds()).toBe((currentTimeMs + timeOriginSkewMs) / 1000); + + timeSincePageloadMs = 5_000; + vi.setSystemTime(new Date(currentTimeMs + 4_000)); + + expect(timestampInSeconds()).toBe((currentTimeMs + 4_000 + timeOriginSkewMs) / 1000); + }); + + it('re-derives the time origin once the monotonic clock drifts from the wall clock', async () => { + const currentTimeMs = 1767778040866; + const timeSincePageloadMs = 1_000; + + // The monotonic clock pauses during sleep, so the wall clock advances much further than it does. + const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: currentTimeMs - timeSincePageloadMs, + now: () => timeSincePageloadMs, + }); + + const timestampInSeconds = await getFreshTimestampInSeconds(); + + expect(timestampInSeconds()).toBe(currentTimeMs / 1000); + + vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs)); + + expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs) / 1000); + }); + + it('keeps deriving elapsed time from the monotonic clock after re-deriving the time origin', async () => { + const currentTimeMs = 1767778040866; + const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000; + + let timeSincePageloadMs = 1_000; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: currentTimeMs - timeSincePageloadMs, + now: () => timeSincePageloadMs, + }); + + const timestampInSeconds = await getFreshTimestampInSeconds(); + + timestampInSeconds(); + vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs)); + const afterCorrection = timestampInSeconds(); + + // `Date.now()` deliberately stays put while the monotonic clock advances sub-millisecond, proving the elapsed + // time comes from `performance.now()` rather than from the coarser wall clock. + timeSincePageloadMs += 0.25; + expect(timestampInSeconds()).toBeCloseTo(afterCorrection + 0.25 / 1000, 10); + }); + + it('does not re-derive the time origin repeatedly once the clocks agree again', async () => { + const currentTimeMs = 1767778040866; + const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000; + + let timeSincePageloadMs = 1_000; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: currentTimeMs - timeSincePageloadMs, + now: () => timeSincePageloadMs, + }); + + const timestampInSeconds = await getFreshTimestampInSeconds(); + + timestampInSeconds(); + vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs)); + timestampInSeconds(); + + // Advance both clocks in lockstep: the re-derived time origin must stay valid, so timestamps track the wall clock + // exactly rather than oscillating between the two sources. + for (let i = 1; i <= 3; i++) { + timeSincePageloadMs += 1_000; + vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs + i * 1_000)); + expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs + i * 1_000) / 1000); + } + }); + + it('produces monotonically increasing timestamps when the wall clock steps backwards', async () => { + const currentTimeMs = 1767778040866; + + let timeSincePageloadMs = 1_000; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: currentTimeMs - timeSincePageloadMs, + now: () => timeSincePageloadMs, + }); + + const timestampInSeconds = await getFreshTimestampInSeconds(); + + const before = timestampInSeconds(); + + // A backwards wall clock step (NTP correction, user changing the clock) beyond the threshold. + vi.setSystemTime(new Date(currentTimeMs - RELIABLE_THRESHOLD_MS - 60_000)); + timeSincePageloadMs += 1_000; + const afterStep = timestampInSeconds(); + + // The correction itself moves the timestamp backwards, but elapsed time afterwards is still monotonic. + timeSincePageloadMs += 1_000; + expect(timestampInSeconds()).toBeGreaterThan(afterStep); + expect(before).toBeGreaterThan(afterStep); + }); +}); + describe('browserPerformanceTimeOrigin', () => { it('returns `performance.timeOrigin` if it is available and reliable', async () => { const timeOrigin = await getFreshPerformanceTimeOrigin(); From 793d21cafa80c83bed7597196ceebcc84f9688e8 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 7 Aug 2026 14:53:08 +0200 Subject: [PATCH 2/6] fix(core): Resolve monotonic times against the origin they were measured with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/core/src/index.ts | 7 +- packages/core/src/utils/time.ts | 73 +++++++++++ packages/core/test/lib/utils/time.test.ts | 146 +++++++++++++++++++++- 3 files changed, 222 insertions(+), 4 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 20b52be30603..5e4da3058682 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -276,7 +276,12 @@ export { supportsReferrerPolicy, } from './utils/supports'; export { SyncPromise, rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise'; -export { browserPerformanceTimeOrigin, dateTimestampInSeconds, timestampInSeconds } from './utils/time'; +export { + browserPerformanceTimeOrigin, + dateTimestampInSeconds, + performanceTimeToSeconds, + timestampInSeconds, +} from './utils/time'; export { TRACEPARENT_REGEXP, extractTraceparentData, diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index 00283bc6e48f..076e6d408422 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -9,6 +9,14 @@ const ONE_SECOND_IN_MS = 1000; */ const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds +/** + * Upper bound on the number of drift corrections {@link performanceTimeToSeconds} remembers. A page has to survive that + * many separate clock jumps to reach it, so the cap only keeps a pathological clock from growing the list without + * bound. Once reached, the oldest segments are merged away, which at worst makes the very earliest monotonic + * timestamps convert against a later origin — the same result as not tracking segments at all. + */ +const MAX_TIME_ORIGIN_SEGMENTS = 30; + /** * A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance} * for accessing a high-resolution monotonic clock. @@ -31,6 +39,27 @@ export function dateTimestampInSeconds(): number { return safeDateNow() / ONE_SECOND_IN_MS; } +/** + * A stretch of the monotonic clock's timeline and the wall clock time its zero maps to. + * + * The monotonic→wall mapping is piecewise: a drift correction replaces the origin from that point on, but leaves the + * mapping for everything that came before it intact. Keeping the superseded origins around lets a monotonic timestamp + * be converted against the origin that was in effect when the timestamp was taken, rather than the one in effect when + * the conversion happens to run. + */ +interface TimeOriginSegment { + /** The `performance.now()` value from which `origin` applies. */ + from: number; + /** Milliseconds since the UNIX epoch that `performance.now() === 0` corresponds to. */ + origin: number; +} + +/** + * Time origins in effect over the lifetime of the page, oldest first. Empty until the first `timestampInSeconds` call, + * and whenever the Performance API is unavailable. + */ +let _timeOriginSegments: TimeOriginSegment[] = []; + /** * Returns a wrapper around the native Performance API browser implementation, or undefined for browsers that do not * support the API. @@ -48,6 +77,8 @@ function createUnixTimestampInSecondsFunc(): () => number { // performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current // wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed. let timeOrigin = performance.timeOrigin; + _timeOriginSegments = [{ from: 0, origin: timeOrigin }]; + let isFirstCall = true; return () => { return withRandomSafeContext(() => { @@ -67,7 +98,20 @@ function createUnixTimestampInSecondsFunc(): () => number { // See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6 if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) { timeOrigin = dateNow - performanceNow; + // The very first check runs before any timestamp has been handed out, so a `performance.timeOrigin` that was + // already unreliable at startup never applied to anything and is replaced outright. Later corrections only + // apply from the point they are detected at — an upper bound on where the drift actually happened, and as + // close as it can be pinned down without a second clock. Timestamps taken before that keep their old origin. + if (isFirstCall) { + _timeOriginSegments = [{ from: 0, origin: timeOrigin }]; + } else { + _timeOriginSegments.push({ from: performanceNow, origin: timeOrigin }); + if (_timeOriginSegments.length > MAX_TIME_ORIGIN_SEGMENTS) { + _timeOriginSegments.shift(); + } + } } + isFirstCall = false; return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS; }); @@ -76,6 +120,35 @@ function createUnixTimestampInSecondsFunc(): () => number { let _cachedTimestampInSeconds: (() => number) | undefined; +/** + * Converts a monotonic time from the Performance API (a `PerformanceEntry`'s `startTime`, a profiler sample's + * `timestamp`, or any other `performance.now()`-relative value in milliseconds) to a wall clock timestamp in seconds + * since the UNIX epoch, on the same timeline as {@link timestampInSeconds}. + * + * Prefer this over combining a monotonic time with {@link browserPerformanceTimeOrigin} by hand: because the SDK + * re-derives its time origin when the monotonic and wall clocks drift apart, a single origin is only valid for part of + * the page's lifetime. This picks the origin that was in effect when the passed time was measured, so entries reported + * long after the fact (INP on pagehide, replay entries buffered until flush) do not get retroactively shifted by a + * drift that happened after they were recorded. + * + * Returns `undefined` if the Performance API is unavailable, in which case monotonic times cannot be converted at all. + */ +export function performanceTimeToSeconds(monotonicTimeInMs: number): number | undefined { + // Segments are only populated once `timestampInSeconds` has resolved which clock source to use. + timestampInSeconds(); + + let segment: TimeOriginSegment | undefined; + for (const candidate of _timeOriginSegments) { + // Times preceding the oldest retained segment fall back to it, which is the best guess available for them. + if (segment && candidate.from > monotonicTimeInMs) { + break; + } + segment = candidate; + } + + return segment && (segment.origin + monotonicTimeInMs) / ONE_SECOND_IN_MS; +} + /** * Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the * availability of the Performance API. diff --git a/packages/core/test/lib/utils/time.test.ts b/packages/core/test/lib/utils/time.test.ts index 70fc7e5c0243..c1999fdf2dd0 100644 --- a/packages/core/test/lib/utils/time.test.ts +++ b/packages/core/test/lib/utils/time.test.ts @@ -9,11 +9,17 @@ async function getFreshPerformanceTimeOrigin() { let freshImportCounter = 0; -async function getFreshTimestampInSeconds(): Promise<() => number> { +async function getFreshTimeModule(): Promise<{ + timestampInSeconds: () => number; + performanceTimeToSeconds: (monotonicTimeInMs: number) => number | undefined; +}> { // A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would // otherwise hand out a cached module. - const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`); - return timeModule.timestampInSeconds; + return import(`../../../src/utils/time?update=${freshImportCounter++}`); +} + +async function getFreshTimestampInSeconds(): Promise<() => number> { + return (await getFreshTimeModule()).timestampInSeconds; } const RELIABLE_THRESHOLD_MS = 300_000; @@ -180,6 +186,140 @@ describe('timestampInSeconds', () => { }); }); +describe('performanceTimeToSeconds', () => { + const currentTimeMs = 1767778040866; + const timeSincePageloadMs = 1_000; + const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000; + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('converts against `performance.timeOrigin` while the clocks agree', async () => { + const timeOrigin = currentTimeMs - timeSincePageloadMs; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { timeOrigin, now: () => timeSincePageloadMs }); + + const { performanceTimeToSeconds } = await getFreshTimeModule(); + + expect(performanceTimeToSeconds(500)).toBe((timeOrigin + 500) / 1000); + }); + + it('converts a time taken after a correction against the corrected origin', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: currentTimeMs - timeSincePageloadMs, + // The monotonic clock pauses during sleep, so it barely advances while the wall clock jumps ahead. + now: () => timeSincePageloadMs, + }); + + const { performanceTimeToSeconds, timestampInSeconds } = await getFreshTimeModule(); + + timestampInSeconds(); + vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs)); + + // Converting the current `performance.now()` must yield the same wall clock time that `timestampInSeconds` + // reports, otherwise perf entries and spans land on diverging timelines. + expect(performanceTimeToSeconds(timeSincePageloadMs)).toBe(timestampInSeconds()); + expect(performanceTimeToSeconds(timeSincePageloadMs)).toBe((currentTimeMs + sleepDurationMs) / 1000); + }); + + it('keeps converting a time taken before a correction against the origin that was in effect then', async () => { + let monotonicNowMs = timeSincePageloadMs; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: currentTimeMs - timeSincePageloadMs, + now: () => monotonicNowMs, + }); + + const { performanceTimeToSeconds, timestampInSeconds } = await getFreshTimeModule(); + + // An entry observed before the drift. Its wall clock time is known exactly at this point. + const entryStartTime = 500; + const entryTimestampBefore = performanceTimeToSeconds(entryStartTime); + expect(entryTimestampBefore).toBe((currentTimeMs - timeSincePageloadMs + entryStartTime) / 1000); + + // The device sleeps, the drift is detected, and the origin is re-derived. + vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs)); + monotonicNowMs += 10; + timestampInSeconds(); + + // Converting the same entry now must not retroactively shift it by the drift. + expect(performanceTimeToSeconds(entryStartTime)).toBe(entryTimestampBefore); + }); + + it('converts times on either side of a correction against their respective origins', async () => { + let monotonicNowMs = timeSincePageloadMs; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: currentTimeMs - timeSincePageloadMs, + now: () => monotonicNowMs, + }); + + const { performanceTimeToSeconds, timestampInSeconds } = await getFreshTimeModule(); + + timestampInSeconds(); + + const monotonicAdvanceMs = 10; + vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs)); + const correctionPointMs = (monotonicNowMs += monotonicAdvanceMs); + timestampInSeconds(); + + const beforeCorrection = performanceTimeToSeconds(correctionPointMs - 1) as number; + const afterCorrection = performanceTimeToSeconds(correctionPointMs + 1) as number; + + // The two are 2ms apart on the monotonic clock, but the origins they resolve to are a whole sleep apart: the wall + // clock advanced `sleepDurationMs` while the monotonic clock only advanced `monotonicAdvanceMs`. + const driftMs = sleepDurationMs - monotonicAdvanceMs; + expect(afterCorrection - beforeCorrection).toBeCloseTo((driftMs + 2) / 1000, 6); + }); + + it('converts times preceding the oldest known origin against that origin', async () => { + const timeOrigin = currentTimeMs - timeSincePageloadMs; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { timeOrigin, now: () => timeSincePageloadMs }); + + const { performanceTimeToSeconds } = await getFreshTimeModule(); + + expect(performanceTimeToSeconds(0)).toBe(timeOrigin / 1000); + }); + + it('never converts against a `performance.timeOrigin` that was already unreliable at startup', async () => { + // Some browsers report a bogus `performance.timeOrigin`. It never described a real point in time, so no monotonic + // time should ever be converted against it — not even one measured before the SDK first looked at the clock. + const timeOriginSkewMs = RELIABLE_THRESHOLD_MS + 60_000; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs, + now: () => timeSincePageloadMs, + }); + + const { performanceTimeToSeconds } = await getFreshTimeModule(); + + expect(performanceTimeToSeconds(0)).toBe((currentTimeMs - timeSincePageloadMs) / 1000); + }); + + it('returns `undefined` if the performance API is unavailable', async () => { + vi.stubGlobal('performance', undefined); + + const { performanceTimeToSeconds } = await getFreshTimeModule(); + + expect(performanceTimeToSeconds(500)).toBeUndefined(); + }); +}); + describe('browserPerformanceTimeOrigin', () => { it('returns `performance.timeOrigin` if it is available and reliable', async () => { const timeOrigin = await getFreshPerformanceTimeOrigin(); From 1a9120efd1128b32651bf0da8f40323359bf350e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 7 Aug 2026 14:54:01 +0200 Subject: [PATCH 3/6] fix(browser): Convert late-reported performance entries against their 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) --- .../browser-utils/src/web-vitals/spans.ts | 16 ++++++---- .../test/web-vitals/spans.test.ts | 29 +++++++++++++++++++ packages/browser/src/profiling/utils.ts | 13 ++++----- .../src/util/createPerformanceEntries.ts | 12 +++++--- .../test/integration/flush.test.ts | 9 ++++++ .../unit/util/createPerformanceEntry.test.ts | 29 +++++++++++++++++++ 6 files changed, 91 insertions(+), 17 deletions(-) diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 424180427141..0543fb6c6a80 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -7,6 +7,7 @@ import { getClient, getRootSpan, hasSpanStreamingEnabled, + performanceTimeToSeconds, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON, @@ -297,12 +298,13 @@ export function _sendClsSpan( ): void { DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); - const performanceTimeOrigin = browserPerformanceTimeOrigin(); // A CLS of 0 has no shift to place the span at. It is reported when the navigation it was // measured on is already over - the next soft navigation, or pagehide - so the current time would // land it outside that navigation, on the route that follows it. const offset = entry?.startTime ?? navigationStartTime ?? 0; - const startTime = performanceTimeOrigin ? msToSec(performanceTimeOrigin + offset) : timestampInSeconds(); + // Layout shifts can happen at any point in the page's life, but are only reported on pagehide, so the entry is + // converted against the time origin that was in effect when the shift happened. + const startTime = performanceTimeToSeconds(offset) ?? timestampInSeconds(); const firstSourceNode = entry?.sources[0]?.node; const selector = entry ? htmlTreeAsString(firstSourceNode) : undefined; const componentName = firstSourceNode ? getComponentName(firstSourceNode) : null; @@ -345,7 +347,9 @@ export function _sendClsSpan( */ export function trackInpAsSpan(client: Client, perNavigation = false): void { const performance = getBrowserPerformanceAPI(); - if (!performance || !browserPerformanceTimeOrigin()) { + // `performanceTimeToSeconds` is what the entries are converted with, so it also decides whether they can be converted + // at all — `browserPerformanceTimeOrigin` has a `Date.now()` fallback that it does not share. + if (!performance || performanceTimeToSeconds(0) === undefined) { return; } @@ -409,9 +413,9 @@ export function _sendInpSpan( // A web vital span carries the metric, not a real interaction timing, so an INP without an entry // is still worth reporting. It just has no element or interaction type to describe, and is placed // at the start of the navigation it belongs to rather than at the interaction. - const startTime = msToSec( - (browserPerformanceTimeOrigin() as number) + (entry?.startTime ?? metric?.navigationStartTime ?? 0), - ); + // INP reports on pagehide, potentially long after the interaction itself, so the entry is converted against the time + // origin that was in effect when it happened rather than the one in effect now. + const startTime = performanceTimeToSeconds(entry?.startTime ?? metric?.navigationStartTime ?? 0) as number; const duration = msToSec(inpValue); // An INP without an entry has no interaction type to report. It still has to land inside the // `ui.interaction.*` family, because falling outside it would hide exactly the fast navigations diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index f9182b52d153..915252bdc0cf 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -22,6 +22,7 @@ vi.mock('@sentry/core', async () => { return { ...actual, browserPerformanceTimeOrigin: vi.fn(), + performanceTimeToSeconds: vi.fn(), timestampInSeconds: vi.fn(), getCurrentScope: vi.fn(), getClient: vi.fn(), @@ -497,6 +498,7 @@ describe('_sendClsSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.performanceTimeToSeconds).mockImplementation(time => (1000 + time) / 1000); vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any); @@ -590,6 +592,7 @@ describe('_sendClsSpan', () => { it('falls back to the current time when there is no performance time origin', () => { vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(undefined); + vi.mocked(SentryCore.performanceTimeToSeconds).mockReturnValue(undefined); _sendClsSpan(0, undefined); @@ -612,6 +615,7 @@ describe('_sendInpSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.performanceTimeToSeconds).mockImplementation(time => (1000 + time) / 1000); vi.mocked(htmlTreeAsString).mockReturnValue('