Skip to content

Commit ded4f36

Browse files
committed
fix(core): Account for clock drift on every timestampInSeconds call
1 parent b0a7cc6 commit ded4f36

2 files changed

Lines changed: 208 additions & 17 deletions

File tree

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

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { GLOBAL_OBJ } from './worldwide';
33

44
const ONE_SECOND_IN_MS = 1000;
55

6+
/**
7+
* Maximum tolerated difference between the monotonic clock and the wall clock before we consider
8+
* the monotonic clock's time origin stale.
9+
*/
10+
const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds
11+
612
/**
713
* A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance}
814
* for accessing a high-resolution monotonic clock.
@@ -39,19 +45,32 @@ function createUnixTimestampInSecondsFunc(): () => number {
3945
return dateTimestampInSeconds;
4046
}
4147

42-
const timeOrigin = performance.timeOrigin;
43-
4448
// performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current
4549
// wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed.
46-
//
47-
// TODO: This does not account for the case where the monotonic clock that powers performance.now() drifts from the
48-
// wall clock time, which causes the returned timestamp to be inaccurate. We should investigate how to detect and
49-
// correct for this.
50-
// See: https://github.com/getsentry/sentry-javascript/issues/2590
51-
// See: https://github.com/mdn/content/issues/4713
52-
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
50+
let timeOrigin = performance.timeOrigin;
51+
5352
return () => {
54-
return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS;
53+
return withRandomSafeContext(() => {
54+
const performanceNow = performance.now();
55+
const dateNow = Date.now();
56+
57+
// `timeOrigin + performance.now()` only equals wall clock time for as long as both clocks advance in lockstep.
58+
// performance.now() stops advancing while the device is asleep, so it under-counts elapsed wall time; conversely
59+
// the wall clock itself can be stepped by Network Time Protocol (NTP) or the user. Either way the two drift apart
60+
// by arbitrary amounts. Re-deriving the origin restores absolute accuracy while still taking elapsed time from
61+
// the monotonic clock, so durations keep sub-millisecond precision and cannot run backwards.
62+
// Timestamps taken before a correction are measured against a different origin than those taken after it, so a
63+
// span that starts before one and ends after it absorbs the drift into its duration. Spans that lie entirely on
64+
// one side of a correction are unaffected.
65+
// See: https://github.com/getsentry/sentry-javascript/issues/2590
66+
// See: https://github.com/mdn/content/issues/4713
67+
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
68+
if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) {
69+
timeOrigin = dateNow - performanceNow;
70+
}
71+
72+
return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS;
73+
});
5574
};
5675
}
5776

@@ -61,10 +80,12 @@ let _cachedTimestampInSeconds: (() => number) | undefined;
6180
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
6281
* availability of the Performance API.
6382
*
64-
* BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is
65-
* asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The
66-
* skew can grow to arbitrary amounts like days, weeks or months.
67-
* See https://github.com/getsentry/sentry-javascript/issues/2590.
83+
* Because the Performance API's clock and the wall clock can drift apart (the former stops while the computer is
84+
* asleep, the latter can be stepped by NTP or the user), the time origin they are combined against is re-derived from
85+
* `Date.now()` whenever the two disagree by more than {@link CLOCK_DRIFT_THRESHOLD_MS}. Two timestamps taken on either
86+
* side of such a correction are skewed relative to each other by the amount of drift, so a span that starts before a
87+
* correction and ends after it reports the wall clock time elapsed rather than the time the monotonic clock was
88+
* running. See https://github.com/getsentry/sentry-javascript/issues/2590.
6889
*/
6990
export function timestampInSeconds(): number {
7091
// 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 {
92113
return undefined;
93114
}
94115

95-
const threshold = 300_000; // 5 minutes in milliseconds
96116
const performanceNow = withRandomSafeContext(() => performance.now());
97117
const dateNow = safeDateNow();
98118

99119
const timeOrigin = performance.timeOrigin;
100120
if (typeof timeOrigin === 'number') {
101121
const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow);
102-
if (timeOriginDelta < threshold) {
122+
if (timeOriginDelta < CLOCK_DRIFT_THRESHOLD_MS) {
103123
return timeOrigin;
104124
}
105125
}

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

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from 'vitest';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
22

33
async function getFreshPerformanceTimeOrigin() {
44
// Adding the query param with the date, forces a fresh import each time this is called
@@ -7,8 +7,179 @@ async function getFreshPerformanceTimeOrigin() {
77
return timeModule.browserPerformanceTimeOrigin();
88
}
99

10+
let freshImportCounter = 0;
11+
12+
async function getFreshTimestampInSeconds(): Promise<() => number> {
13+
// A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would
14+
// otherwise hand out a cached module.
15+
const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`);
16+
return timeModule.timestampInSeconds;
17+
}
18+
1019
const RELIABLE_THRESHOLD_MS = 300_000;
1120

21+
describe('timestampInSeconds', () => {
22+
afterEach(() => {
23+
vi.useRealTimers();
24+
vi.unstubAllGlobals();
25+
});
26+
27+
it('derives the timestamp from `performance.timeOrigin` and `performance.now()`', async () => {
28+
const currentTimeMs = 1767778040866;
29+
const timeSincePageloadMs = 1_234.56789;
30+
31+
vi.useFakeTimers();
32+
vi.setSystemTime(new Date(currentTimeMs));
33+
vi.stubGlobal('performance', {
34+
timeOrigin: currentTimeMs - timeSincePageloadMs,
35+
now: () => timeSincePageloadMs,
36+
});
37+
38+
const timestampInSeconds = await getFreshTimestampInSeconds();
39+
40+
expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
41+
});
42+
43+
it('falls back to `Date.now()` if the performance API is unavailable', async () => {
44+
const currentTimeMs = 1767778040866;
45+
46+
vi.useFakeTimers();
47+
vi.setSystemTime(new Date(currentTimeMs));
48+
vi.stubGlobal('performance', undefined);
49+
50+
const timestampInSeconds = await getFreshTimestampInSeconds();
51+
52+
expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
53+
});
54+
55+
it('keeps using `performance.timeOrigin` while the clocks agree', async () => {
56+
const currentTimeMs = 1767778040866;
57+
// Below the drift threshold, so the (inaccurate) time origin must be preserved.
58+
const timeOriginSkewMs = RELIABLE_THRESHOLD_MS - 2_000;
59+
60+
let timeSincePageloadMs = 1_000;
61+
62+
vi.useFakeTimers();
63+
vi.setSystemTime(new Date(currentTimeMs));
64+
vi.stubGlobal('performance', {
65+
timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs,
66+
now: () => timeSincePageloadMs,
67+
});
68+
69+
const timestampInSeconds = await getFreshTimestampInSeconds();
70+
71+
expect(timestampInSeconds()).toBe((currentTimeMs + timeOriginSkewMs) / 1000);
72+
73+
timeSincePageloadMs = 5_000;
74+
vi.setSystemTime(new Date(currentTimeMs + 4_000));
75+
76+
expect(timestampInSeconds()).toBe((currentTimeMs + 4_000 + timeOriginSkewMs) / 1000);
77+
});
78+
79+
it('re-derives the time origin once the monotonic clock drifts from the wall clock', async () => {
80+
const currentTimeMs = 1767778040866;
81+
const timeSincePageloadMs = 1_000;
82+
83+
// The monotonic clock pauses during sleep, so the wall clock advances much further than it does.
84+
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;
85+
86+
vi.useFakeTimers();
87+
vi.setSystemTime(new Date(currentTimeMs));
88+
vi.stubGlobal('performance', {
89+
timeOrigin: currentTimeMs - timeSincePageloadMs,
90+
now: () => timeSincePageloadMs,
91+
});
92+
93+
const timestampInSeconds = await getFreshTimestampInSeconds();
94+
95+
expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
96+
97+
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
98+
99+
expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs) / 1000);
100+
});
101+
102+
it('keeps deriving elapsed time from the monotonic clock after re-deriving the time origin', async () => {
103+
const currentTimeMs = 1767778040866;
104+
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;
105+
106+
let timeSincePageloadMs = 1_000;
107+
108+
vi.useFakeTimers();
109+
vi.setSystemTime(new Date(currentTimeMs));
110+
vi.stubGlobal('performance', {
111+
timeOrigin: currentTimeMs - timeSincePageloadMs,
112+
now: () => timeSincePageloadMs,
113+
});
114+
115+
const timestampInSeconds = await getFreshTimestampInSeconds();
116+
117+
timestampInSeconds();
118+
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
119+
const afterCorrection = timestampInSeconds();
120+
121+
// `Date.now()` deliberately stays put while the monotonic clock advances sub-millisecond, proving the elapsed
122+
// time comes from `performance.now()` rather than from the coarser wall clock.
123+
timeSincePageloadMs += 0.25;
124+
expect(timestampInSeconds()).toBeCloseTo(afterCorrection + 0.25 / 1000, 10);
125+
});
126+
127+
it('does not re-derive the time origin repeatedly once the clocks agree again', async () => {
128+
const currentTimeMs = 1767778040866;
129+
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;
130+
131+
let timeSincePageloadMs = 1_000;
132+
133+
vi.useFakeTimers();
134+
vi.setSystemTime(new Date(currentTimeMs));
135+
vi.stubGlobal('performance', {
136+
timeOrigin: currentTimeMs - timeSincePageloadMs,
137+
now: () => timeSincePageloadMs,
138+
});
139+
140+
const timestampInSeconds = await getFreshTimestampInSeconds();
141+
142+
timestampInSeconds();
143+
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
144+
timestampInSeconds();
145+
146+
// Advance both clocks in lockstep: the re-derived time origin must stay valid, so timestamps track the wall clock
147+
// exactly rather than oscillating between the two sources.
148+
for (let i = 1; i <= 3; i++) {
149+
timeSincePageloadMs += 1_000;
150+
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs + i * 1_000));
151+
expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs + i * 1_000) / 1000);
152+
}
153+
});
154+
155+
it('produces monotonically increasing timestamps when the wall clock steps backwards', async () => {
156+
const currentTimeMs = 1767778040866;
157+
158+
let timeSincePageloadMs = 1_000;
159+
160+
vi.useFakeTimers();
161+
vi.setSystemTime(new Date(currentTimeMs));
162+
vi.stubGlobal('performance', {
163+
timeOrigin: currentTimeMs - timeSincePageloadMs,
164+
now: () => timeSincePageloadMs,
165+
});
166+
167+
const timestampInSeconds = await getFreshTimestampInSeconds();
168+
169+
const before = timestampInSeconds();
170+
171+
// A backwards wall clock step (NTP correction, user changing the clock) beyond the threshold.
172+
vi.setSystemTime(new Date(currentTimeMs - RELIABLE_THRESHOLD_MS - 60_000));
173+
timeSincePageloadMs += 1_000;
174+
const afterStep = timestampInSeconds();
175+
176+
// The correction itself moves the timestamp backwards, but elapsed time afterwards is still monotonic.
177+
timeSincePageloadMs += 1_000;
178+
expect(timestampInSeconds()).toBeGreaterThan(afterStep);
179+
expect(before).toBeGreaterThan(afterStep);
180+
});
181+
});
182+
12183
describe('browserPerformanceTimeOrigin', () => {
13184
it('returns `performance.timeOrigin` if it is available and reliable', async () => {
14185
const timeOrigin = await getFreshPerformanceTimeOrigin();

0 commit comments

Comments
 (0)