Skip to content

Commit 25c1b28

Browse files
Lms24claude
andcommitted
fix(browser): Use drift-corrected time origin for INP, replay and profiling
`timestampInSeconds` re-derives its time origin when it detects clock drift, but `browserPerformanceTimeOrigin` caches the origin resolved at SDK init and never revisits it. Consumers that convert a `PerformanceEntry`'s monotonic `startTime` to wall clock time therefore end up on a different timeline than span and event timestamps once a correction has happened. Exposes the corrected origin as `correctedPerformanceTimeOrigin` and uses it for the consumers that outlive a span timeout: INP (reports on pagehide) and replay (sessions run up to an hour). Profiling already compensated for the SDK changing its time origin, but computed the adjustment against the stale cached value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 456dd81 commit 25c1b28

7 files changed

Lines changed: 119 additions & 13 deletions

File tree

‎packages/browser-utils/src/metrics/webVitalSpans.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Client, Integration, Span, SpanAttributes } from '@sentry/core';
22
import {
33
browserPerformanceTimeOrigin,
4+
correctedPerformanceTimeOrigin,
45
debug,
56
getActiveSpan,
67
getClient,
@@ -329,7 +330,9 @@ export function trackInpAsSpan(client: Client): void {
329330
export function _sendInpSpan(inpValue: number, entry: PerformanceEventTiming, standalone = false): void {
330331
DEBUG_BUILD && debug.log(`Sending INP span (${inpValue})`);
331332

332-
const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime);
333+
// INP reports on pagehide, potentially hours after the origin cached at init, so the corrected origin is used to stay
334+
// on the same timeline as span and event timestamps.
335+
const startTime = msToSec((correctedPerformanceTimeOrigin() as number) + entry.startTime);
333336
const duration = msToSec(inpValue);
334337
const interactionType = INP_ENTRY_MAP[entry.name];
335338

‎packages/browser-utils/test/metrics/webVitalSpans.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ vi.mock('@sentry/core', async () => {
1717
return {
1818
...actual,
1919
browserPerformanceTimeOrigin: vi.fn(),
20+
correctedPerformanceTimeOrigin: vi.fn(),
2021
timestampInSeconds: vi.fn(),
2122
getCurrentScope: vi.fn(),
2223
getClient: vi.fn(),
@@ -475,6 +476,7 @@ describe('_sendInpSpan', () => {
475476
beforeEach(() => {
476477
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
477478
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
479+
vi.mocked(SentryCore.correctedPerformanceTimeOrigin).mockReturnValue(1000);
478480
vi.mocked(htmlTreeAsString).mockReturnValue('<button>');
479481
vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
480482
vi.mocked(SentryCore.getActiveSpan).mockReturnValue(undefined);
@@ -592,6 +594,7 @@ describe('trackInpAsSpan', () => {
592594

593595
beforeEach(() => {
594596
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
597+
vi.mocked(SentryCore.correctedPerformanceTimeOrigin).mockReturnValue(1000);
595598
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
596599
vi.mocked(SentryCore.getActiveSpan).mockReturnValue(undefined);
597600
vi.mocked(SentryCore.startInactiveSpan).mockReturnValue({ end: vi.fn() } as any);

‎packages/browser/src/profiling/utils.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
ThreadCpuProfile,
1313
} from '@sentry/core/browser';
1414
import {
15-
browserPerformanceTimeOrigin,
15+
correctedPerformanceTimeOrigin,
1616
debug,
1717
DEFAULT_ENVIRONMENT,
1818
forEachEnvelopeItem,
@@ -339,7 +339,7 @@ function convertToContinuousProfile(input: {
339339
}
340340

341341
// Align timestamps to SDK time origin to match span/event timelines
342-
const perfOrigin = browserPerformanceTimeOrigin();
342+
const perfOrigin = correctedPerformanceTimeOrigin();
343343
const origin = typeof performance.timeOrigin === 'number' ? performance.timeOrigin : perfOrigin || 0;
344344
const adjustForOriginChange = origin - (perfOrigin || origin);
345345

@@ -412,7 +412,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi
412412
// when that happens, we need to ensure we are correcting the profile timings so the two timelines stay in sync.
413413
// Since JS self profiling time origin is always initialized to performance.timeOrigin, we need to adjust for
414414
// the drift between the SDK selected value and our profile time origin.
415-
const perfOrigin = browserPerformanceTimeOrigin();
415+
const perfOrigin = correctedPerformanceTimeOrigin();
416416
const origin = typeof performance.timeOrigin === 'number' ? performance.timeOrigin : perfOrigin || 0;
417417
const adjustForOriginChange = origin - (perfOrigin || origin);
418418

‎packages/core/src/shared-exports.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,12 @@ export {
274274
supportsReferrerPolicy,
275275
} from './utils/supports';
276276
export { SyncPromise, rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise';
277-
export { browserPerformanceTimeOrigin, dateTimestampInSeconds, timestampInSeconds } from './utils/time';
277+
export {
278+
browserPerformanceTimeOrigin,
279+
correctedPerformanceTimeOrigin,
280+
dateTimestampInSeconds,
281+
timestampInSeconds,
282+
} from './utils/time';
278283
export {
279284
TRACEPARENT_REGEXP,
280285
extractTraceparentData,

‎packages/core/src/utils/time.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export function dateTimestampInSeconds(): number {
3131
return safeDateNow() / ONE_SECOND_IN_MS;
3232
}
3333

34+
/**
35+
* The time origin `timestampInSeconds` currently maps the monotonic clock against, kept in sync with the corrections it
36+
* applies. `undefined` until the first `timestampInSeconds` call, and whenever the Performance API is unavailable.
37+
*/
38+
let _correctedTimeOrigin: number | undefined;
39+
3440
/**
3541
* Returns a wrapper around the native Performance API browser implementation, or undefined for browsers that do not
3642
* support the API.
@@ -47,7 +53,7 @@ function createUnixTimestampInSecondsFunc(): () => number {
4753

4854
// performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current
4955
// wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed.
50-
let timeOrigin = performance.timeOrigin;
56+
let timeOrigin = (_correctedTimeOrigin = performance.timeOrigin);
5157

5258
return () => {
5359
return withRandomSafeContext(() => {
@@ -66,7 +72,7 @@ function createUnixTimestampInSecondsFunc(): () => number {
6672
// See: https://github.com/mdn/content/issues/4713
6773
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
6874
if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) {
69-
timeOrigin = dateNow - performanceNow;
75+
timeOrigin = _correctedTimeOrigin = dateNow - performanceNow;
7076
}
7177

7278
return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS;
@@ -76,6 +82,20 @@ function createUnixTimestampInSecondsFunc(): () => number {
7682

7783
let _cachedTimestampInSeconds: (() => number) | undefined;
7884

85+
/**
86+
* Returns the time origin (in milliseconds since the UNIX epoch) that {@link timestampInSeconds} currently maps
87+
* `performance.now()` against, including any correction it has applied for clock drift.
88+
*
89+
* Use this over {@link browserPerformanceTimeOrigin} when converting a `PerformanceEntry`'s monotonic `startTime` to
90+
* wall clock time at the moment the entry is observed, so the result shares a timeline with span and event timestamps.
91+
* Returns `undefined` if the Performance API is unavailable, in which case monotonic timestamps cannot be converted.
92+
*/
93+
export function correctedPerformanceTimeOrigin(): number | undefined {
94+
// The origin is only populated once `timestampInSeconds` has resolved which clock source to use.
95+
timestampInSeconds();
96+
return _correctedTimeOrigin;
97+
}
98+
7999
/**
80100
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
81101
* availability of the Performance API.

‎packages/core/test/lib/utils/time.test.ts‎

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ async function getFreshPerformanceTimeOrigin() {
99

1010
let freshImportCounter = 0;
1111

12-
async function getFreshTimestampInSeconds(): Promise<() => number> {
12+
async function getFreshTimeModule(): Promise<{
13+
timestampInSeconds: () => number;
14+
correctedPerformanceTimeOrigin: () => number | undefined;
15+
}> {
1316
// A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would
1417
// otherwise hand out a cached module.
15-
const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`);
16-
return timeModule.timestampInSeconds;
18+
return import(`../../../src/utils/time?update=${freshImportCounter++}`);
19+
}
20+
21+
async function getFreshTimestampInSeconds(): Promise<() => number> {
22+
return (await getFreshTimeModule()).timestampInSeconds;
1723
}
1824

1925
const RELIABLE_THRESHOLD_MS = 300_000;
@@ -180,6 +186,75 @@ describe('timestampInSeconds', () => {
180186
});
181187
});
182188

189+
describe('correctedPerformanceTimeOrigin', () => {
190+
afterEach(() => {
191+
vi.useRealTimers();
192+
vi.unstubAllGlobals();
193+
});
194+
195+
it('returns `performance.timeOrigin` while the clocks agree', async () => {
196+
const currentTimeMs = 1767778040866;
197+
const timeSincePageloadMs = 1_000;
198+
const timeOrigin = currentTimeMs - timeSincePageloadMs;
199+
200+
vi.useFakeTimers();
201+
vi.setSystemTime(new Date(currentTimeMs));
202+
vi.stubGlobal('performance', { timeOrigin, now: () => timeSincePageloadMs });
203+
204+
const { correctedPerformanceTimeOrigin } = await getFreshTimeModule();
205+
206+
expect(correctedPerformanceTimeOrigin()).toBe(timeOrigin);
207+
});
208+
209+
it('returns the corrected origin after clock drift, without a prior `timestampInSeconds` call', async () => {
210+
const currentTimeMs = 1767778040866;
211+
const timeSincePageloadMs = 1_000;
212+
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;
213+
214+
vi.useFakeTimers();
215+
vi.setSystemTime(new Date(currentTimeMs));
216+
vi.stubGlobal('performance', {
217+
timeOrigin: currentTimeMs - timeSincePageloadMs,
218+
now: () => timeSincePageloadMs,
219+
});
220+
221+
const { correctedPerformanceTimeOrigin } = await getFreshTimeModule();
222+
223+
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
224+
225+
expect(correctedPerformanceTimeOrigin()).toBe(currentTimeMs + sleepDurationMs - timeSincePageloadMs);
226+
});
227+
228+
it('stays on the same timeline as `timestampInSeconds`', async () => {
229+
const currentTimeMs = 1767778040866;
230+
const timeSincePageloadMs = 1_000;
231+
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;
232+
233+
vi.useFakeTimers();
234+
vi.setSystemTime(new Date(currentTimeMs));
235+
vi.stubGlobal('performance', {
236+
timeOrigin: currentTimeMs - timeSincePageloadMs,
237+
now: () => timeSincePageloadMs,
238+
});
239+
240+
const { correctedPerformanceTimeOrigin, timestampInSeconds } = await getFreshTimeModule();
241+
242+
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
243+
244+
// Converting the current `performance.now()` against the origin must yield the same wall clock time that
245+
// `timestampInSeconds` reports, otherwise perf entries and spans land on diverging timelines.
246+
expect((correctedPerformanceTimeOrigin() as number) + timeSincePageloadMs).toBe(timestampInSeconds() * 1000);
247+
});
248+
249+
it('returns `undefined` if the performance API is unavailable', async () => {
250+
vi.stubGlobal('performance', undefined);
251+
252+
const { correctedPerformanceTimeOrigin } = await getFreshTimeModule();
253+
254+
expect(correctedPerformanceTimeOrigin()).toBeUndefined();
255+
});
256+
});
257+
183258
describe('browserPerformanceTimeOrigin', () => {
184259
it('returns `performance.timeOrigin` if it is available and reliable', async () => {
185260
const timeOrigin = await getFreshPerformanceTimeOrigin();

‎packages/replay-internal/src/util/createPerformanceEntries.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { browserPerformanceTimeOrigin } from '@sentry/core';
1+
import { correctedPerformanceTimeOrigin } from '@sentry/core';
22
import { record } from '@sentry/rrweb';
33
import { WINDOW } from '../constants';
44
import type {
@@ -87,9 +87,9 @@ function createPerformanceEntry(entry: AllPerformanceEntry): ReplayPerformanceEn
8787
}
8888

8989
function getAbsoluteTime(time: number): number {
90-
// browserPerformanceTimeOrigin can be undefined if `performance` or
90+
// correctedPerformanceTimeOrigin can be undefined if `performance` or
9191
// `performance.now` doesn't exist, but this is already checked by this integration
92-
return ((browserPerformanceTimeOrigin() || WINDOW.performance.timeOrigin) + time) / 1000;
92+
return ((correctedPerformanceTimeOrigin() || WINDOW.performance.timeOrigin) + time) / 1000;
9393
}
9494

9595
function createPaintEntry(entry: PerformancePaintTiming): ReplayPerformanceEntry<PaintData> {

0 commit comments

Comments
 (0)