From fd7ccbbb52d1c7c62b003795066191bbc1d717ad Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 5 Aug 2026 18:30:27 +0200 Subject: [PATCH] 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) --- .../browser-utils/src/web-vitals/spans.ts | 5 +- .../test/web-vitals/spans.test.ts | 4 + packages/browser/src/profiling/utils.ts | 4 +- packages/core/src/index.ts | 7 +- packages/core/src/utils/time.ts | 24 +++++- packages/core/test/lib/utils/time.test.ts | 81 ++++++++++++++++++- .../src/util/createPerformanceEntries.ts | 6 +- 7 files changed, 119 insertions(+), 12 deletions(-) diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 424180427141..8167978c5f9e 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -2,6 +2,7 @@ import type { Client, Span, SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, + correctedPerformanceTimeOrigin, debug, getActiveSpan, getClient, @@ -409,8 +410,10 @@ 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. + // INP reports on pagehide, potentially hours after the origin cached at init, so the corrected origin is used to stay + // on the same timeline as span and event timestamps. const startTime = msToSec( - (browserPerformanceTimeOrigin() as number) + (entry?.startTime ?? metric?.navigationStartTime ?? 0), + (correctedPerformanceTimeOrigin() as number) + (entry?.startTime ?? metric?.navigationStartTime ?? 0), ); const duration = msToSec(inpValue); // An INP without an entry has no interaction type to report. It still has to land inside the diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index f9182b52d153..be18908676ff 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(), + correctedPerformanceTimeOrigin: vi.fn(), timestampInSeconds: vi.fn(), getCurrentScope: vi.fn(), getClient: vi.fn(), @@ -612,6 +613,7 @@ describe('_sendInpSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.correctedPerformanceTimeOrigin).mockReturnValue(1000); vi.mocked(htmlTreeAsString).mockReturnValue('