Skip to content

Commit f68dd04

Browse files
antonisclaude
andauthored
fix(core): Guard against unreliable performance.timeOrigin (#6654)
* fix(core): Guard against unreliable performance.timeOrigin on RN >= 0.86 On iOS with React Native >= 0.86, `performance.now()` (mach_absolute_time) and `performance.timeOrigin` (steady_clock, cached once) diverge by ~device uptime. `@sentry/core` timestamps spans and logs with `timeOrigin + performance.now()`, so those payloads drift hours/days into the past and are silently dropped at ingestion (transport still reports HTTP 200), while error events (which use `Date.now()`) are unaffected. `ensureReliablePerformanceTimeOrigin()` runs at `init()` and neutralizes `performance.timeOrigin` (sets it to 0) when it diverges from `Date.now()` by more than 5 minutes, making `@sentry/core` fall back to the `Date.now()` path for span and log timestamps. Fixes #6630 Fixes #6510 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(core): Add @sentry/core integration coverage for performance timeOrigin guard Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(core): Cover @sentry/core import-before-init ordering for timeOrigin guard Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fcb71ca commit f68dd04

4 files changed

Lines changed: 243 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
### Fixes
1717

18+
- Prevent silently dropped logs and spans on iOS with React Native >= 0.86 caused by an unreliable `performance.timeOrigin` ([#6654](https://github.com/getsentry/sentry-react-native/pull/6654))
1819
- Fix Metro bundler crash on Expo static/EAS Update exports ([#6652](https://github.com/getsentry/sentry-react-native/pull/6652))
1920
- No longer logs `NSNull cannot be converted` warnings on iOS with the New Architecture when clearing a scope context ([#6651](https://github.com/getsentry/sentry-react-native/pull/6651))
2021
- `time_to_initial_display`/`time_to_full_display` now measure the actual screen render for apps whose first navigation happens well after app start ([#6626](https://github.com/getsentry/sentry-react-native/pull/6626))

packages/core/src/js/sdk.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { useEncodePolyfill } from './transports/encodePolyfill';
3737
import { DEFAULT_BUFFER_SIZE, makeNativeTransportFactory } from './transports/native';
3838
import { getDefaultEnvironment, isExpoGo, isRunningInMetroDevServer, isWeb } from './utils/environment';
3939
import { registerFeatureMarker } from './utils/featureMarkers';
40+
import { ensureReliablePerformanceTimeOrigin } from './utils/performanceclock';
4041
import { getDefaultRelease } from './utils/release';
4142
import { safeFactory, safeTracesSampler } from './utils/safe';
4243
import { checkSentryJsSdkVersionMismatch } from './utils/sdkVersionCheck';
@@ -72,6 +73,12 @@ export function init(passedOptions: ReactNativeOptions): void {
7273
return;
7374
}
7475

76+
// Guard against an unreliable `performance.timeOrigin` before any span or log is
77+
// timestamped by `@sentry/core` (it caches the origin on first use). See #6630 and
78+
// `ensureReliablePerformanceTimeOrigin`. The warning is deferred until after
79+
// `initAndBind` enables the debug logger.
80+
const timeOriginDriftMs = ensureReliablePerformanceTimeOrigin();
81+
7582
const userOptions = {
7683
...RN_GLOBAL_OBJ.__SENTRY_OPTIONS__,
7784
...passedOptions,
@@ -183,8 +190,14 @@ export function init(passedOptions: ReactNativeOptions): void {
183190
defaultIntegrations,
184191
});
185192
initAndBind(ReactNativeClient, options);
186-
// Must run after `initAndBind`: that is where `@sentry/core` enables the debug logger
187-
// (`debug.enable()` when `debug: true`), so `debug.warn` is a no-op before it.
193+
// The following must run after `initAndBind`: that is where `@sentry/core` enables the debug
194+
// logger (`debug.enable()` when `debug: true`), so `debug.warn` is a no-op before it.
195+
if (timeOriginDriftMs !== undefined) {
196+
debug.warn(
197+
`[ReactNative] performance.timeOrigin diverged from Date.now() by ${Math.round(timeOriginDriftMs)}ms; ` +
198+
'falling back to Date.now() for span and log timestamps (see #6630).',
199+
);
200+
}
188201
warnIfReplayIntegrationMissing(options);
189202
if (__DEV__) {
190203
checkSentryJsSdkVersionMismatch();
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { RN_GLOBAL_OBJ } from './worldwide';
2+
3+
/**
4+
* Divergence (ms) beyond which `performance.timeOrigin` is considered unreliable.
5+
* Mirrors the 5-minute guard `@sentry/core`'s `getBrowserTimeOrigin` already
6+
* applies to the browser time origin, but which is not wired into the span/log
7+
* timestamp path (`createUnixTimestampInSecondsFunc`).
8+
*/
9+
const TIME_ORIGIN_DRIFT_THRESHOLD_MS = 3e5;
10+
11+
interface PerformanceLike {
12+
now?: () => number;
13+
timeOrigin?: number;
14+
}
15+
16+
/**
17+
* Neutralizes an unreliable `performance.timeOrigin` so `@sentry/core` timestamps
18+
* spans and logs with `Date.now()` instead of `timeOrigin + performance.now()`.
19+
*
20+
* Background (#6630): on iOS with React Native >= 0.86, `performance.now()` is
21+
* backed by `mach_absolute_time()` while `performance.timeOrigin` is derived from
22+
* a different clock reference (`std::chrono::steady_clock`) and cached once. The
23+
* two can diverge by ~device uptime, so `timeOrigin + performance.now()` — which
24+
* `@sentry/core` uses for span and log timestamps — drifts hours or days into the
25+
* past. Such payloads are silently dropped during ingestion (the transport still
26+
* reports HTTP 200), while error events (which use `Date.now()`) are unaffected.
27+
* Before RN 0.86 the modules exposed no truthy `timeOrigin`, so `@sentry/core`
28+
* already fell back to `Date.now()`; this restores that behavior when the clock
29+
* is broken.
30+
*
31+
* Must run before the first `@sentry/core` timestamp (it caches the origin on
32+
* first use), i.e. before `initAndBind`. Because `initAndBind` is also where the
33+
* debug logger is enabled, this returns the corrected drift instead of logging in
34+
* place, so the caller can warn once logging is live.
35+
*
36+
* Self-gating: only acts when the drift exceeds the threshold, so healthy runtimes
37+
* (and platforms where the pair is consistent) keep the high-resolution clock.
38+
*
39+
* @returns the corrected drift in milliseconds when `timeOrigin` was neutralized,
40+
* or `undefined` when the clock was left untouched.
41+
*/
42+
export function ensureReliablePerformanceTimeOrigin(): number | undefined {
43+
const performance = (RN_GLOBAL_OBJ as { performance?: PerformanceLike }).performance;
44+
if (!performance || typeof performance.now !== 'function' || typeof performance.timeOrigin !== 'number') {
45+
return undefined;
46+
}
47+
48+
const drift = Math.abs(performance.timeOrigin + performance.now() - Date.now());
49+
if (drift <= TIME_ORIGIN_DRIFT_THRESHOLD_MS) {
50+
return undefined;
51+
}
52+
53+
try {
54+
// Falsy timeOrigin makes `@sentry/core`'s createUnixTimestampInSecondsFunc gate
55+
// (`!performance.timeOrigin`) fall back to `dateTimestampInSeconds` (Date.now).
56+
Object.defineProperty(performance, 'timeOrigin', {
57+
configurable: true,
58+
value: 0,
59+
});
60+
return drift;
61+
} catch (_e) {
62+
return undefined;
63+
}
64+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { ensureReliablePerformanceTimeOrigin } from '../../src/js/utils/performanceclock';
2+
3+
describe('ensureReliablePerformanceTimeOrigin', () => {
4+
const originalPerformance = (globalThis as { performance?: unknown }).performance;
5+
const NOW = 1_700_000_000_000;
6+
7+
beforeEach(() => {
8+
jest.spyOn(Date, 'now').mockReturnValue(NOW);
9+
});
10+
11+
afterEach(() => {
12+
if (originalPerformance !== undefined) {
13+
(globalThis as { performance?: unknown }).performance = originalPerformance;
14+
} else {
15+
delete (globalThis as { performance?: unknown }).performance;
16+
}
17+
jest.restoreAllMocks();
18+
});
19+
20+
const setPerformance = (value: unknown): void => {
21+
(globalThis as { performance?: unknown }).performance = value;
22+
};
23+
24+
it('neutralizes timeOrigin and returns the drift when timeOrigin + now() drifts far from Date.now()', () => {
25+
// timeOrigin + now() = 100 + 1000 = 1100, ~1.7e12 ms behind Date.now(): far past the threshold.
26+
setPerformance({ now: () => 1000, timeOrigin: 100 });
27+
28+
const drift = ensureReliablePerformanceTimeOrigin();
29+
30+
expect((globalThis as { performance: { timeOrigin: number } }).performance.timeOrigin).toBe(0);
31+
expect(drift).toBe(NOW - 1100);
32+
});
33+
34+
it('leaves a healthy timeOrigin untouched and returns undefined', () => {
35+
// timeOrigin + now() === Date.now(): no drift.
36+
const timeOrigin = NOW - 5000;
37+
setPerformance({ now: () => 5000, timeOrigin });
38+
39+
const drift = ensureReliablePerformanceTimeOrigin();
40+
41+
expect((globalThis as { performance: { timeOrigin: number } }).performance.timeOrigin).toBe(timeOrigin);
42+
expect(drift).toBeUndefined();
43+
});
44+
45+
it('leaves timeOrigin untouched when drift is within the threshold', () => {
46+
// 60s of drift, under the 5-minute threshold.
47+
const timeOrigin = NOW - 5000 - 60_000;
48+
setPerformance({ now: () => 5000, timeOrigin });
49+
50+
const drift = ensureReliablePerformanceTimeOrigin();
51+
52+
expect((globalThis as { performance: { timeOrigin: number } }).performance.timeOrigin).toBe(timeOrigin);
53+
expect(drift).toBeUndefined();
54+
});
55+
56+
it('does nothing when performance is missing', () => {
57+
delete (globalThis as { performance?: unknown }).performance;
58+
59+
expect(ensureReliablePerformanceTimeOrigin()).toBeUndefined();
60+
expect((globalThis as { performance?: unknown }).performance).toBeUndefined();
61+
});
62+
63+
it('does nothing when timeOrigin is not a number', () => {
64+
setPerformance({ now: () => 1000 });
65+
66+
expect(ensureReliablePerformanceTimeOrigin()).toBeUndefined();
67+
expect((globalThis as { performance: { timeOrigin?: number } }).performance.timeOrigin).toBeUndefined();
68+
});
69+
70+
it('does nothing when now is not a function', () => {
71+
setPerformance({ timeOrigin: 100 });
72+
73+
expect(ensureReliablePerformanceTimeOrigin()).toBeUndefined();
74+
expect((globalThis as { performance: { timeOrigin: number } }).performance.timeOrigin).toBe(100);
75+
});
76+
77+
it('returns undefined without throwing when timeOrigin cannot be redefined', () => {
78+
// A non-configurable `timeOrigin` makes `Object.defineProperty` throw; the guard must swallow it.
79+
const performance = { now: () => 1000 };
80+
Object.defineProperty(performance, 'timeOrigin', { configurable: false, value: 100 });
81+
setPerformance(performance);
82+
83+
expect(() => ensureReliablePerformanceTimeOrigin()).not.toThrow();
84+
expect(ensureReliablePerformanceTimeOrigin()).toBeUndefined();
85+
expect((globalThis as { performance: { timeOrigin: number } }).performance.timeOrigin).toBe(100);
86+
});
87+
});
88+
89+
// Integration coverage against the real `@sentry/core` timestamp function, not a stub.
90+
// This exercises the property the fix actually relies on: `@sentry/core` builds its
91+
// timestamp closure lazily on the FIRST `timestampInSeconds()` call and reads the same
92+
// global `performance` object the guard mutates. `jest.isolateModules` gives each case a
93+
// fresh module registry so `@sentry/core` re-derives its lazily-cached timestamp function.
94+
describe('ensureReliablePerformanceTimeOrigin against the real @sentry/core timestampInSeconds', () => {
95+
const originalPerformance = (globalThis as { performance?: unknown }).performance;
96+
const NOW = 1_700_000_000_000;
97+
98+
const requireFreshTimestampInSeconds = (): (() => number) => {
99+
let timestampInSeconds!: () => number;
100+
jest.isolateModules(() => {
101+
timestampInSeconds = require('@sentry/core').timestampInSeconds;
102+
});
103+
return timestampInSeconds;
104+
};
105+
106+
beforeEach(() => {
107+
jest.spyOn(Date, 'now').mockReturnValue(NOW);
108+
// Stale clock: timeOrigin + now() = 1100ms, ~1.7e12ms behind Date.now() — the #6630 shape.
109+
(globalThis as { performance?: unknown }).performance = { now: () => 1000, timeOrigin: 100 };
110+
});
111+
112+
afterEach(() => {
113+
if (originalPerformance !== undefined) {
114+
(globalThis as { performance?: unknown }).performance = originalPerformance;
115+
} else {
116+
delete (globalThis as { performance?: unknown }).performance;
117+
}
118+
jest.restoreAllMocks();
119+
});
120+
121+
it('reproduces the bug: a stale timeOrigin drifts @sentry/core span/log timestamps', () => {
122+
const timestampInSeconds = requireFreshTimestampInSeconds();
123+
124+
// (100 + 1000) / 1000 = 1.1s — the high-resolution path, wildly behind Date.now()/1000.
125+
expect(timestampInSeconds()).toBeCloseTo(1.1, 5);
126+
expect(timestampInSeconds()).not.toBeCloseTo(NOW / 1000, 0);
127+
});
128+
129+
it('running the guard before the first timestamp makes @sentry/core fall back to Date.now()', () => {
130+
ensureReliablePerformanceTimeOrigin();
131+
132+
// Guard ran first, so @sentry/core captures a zeroed (falsy) timeOrigin on first use and
133+
// gates onto the `dateTimestampInSeconds` (Date.now) path — the same path errors already use.
134+
const timestampInSeconds = requireFreshTimestampInSeconds();
135+
136+
expect(timestampInSeconds()).toBeCloseTo(NOW / 1000, 5);
137+
});
138+
139+
it('importing @sentry/core before the guard does not pre-cache the origin, so the guard still forces the Date.now() fallback', () => {
140+
jest.isolateModules(() => {
141+
// Realistic startup order: @sentry/core is imported at app boot, before init() runs the
142+
// guard. Importing it must NOT capture the origin — only the first timestampInSeconds() does.
143+
const { timestampInSeconds } = require('@sentry/core');
144+
145+
ensureReliablePerformanceTimeOrigin();
146+
147+
// First call happens after the guard, so the closure captures the zeroed origin.
148+
expect(timestampInSeconds()).toBeCloseTo(NOW / 1000, 5);
149+
});
150+
});
151+
152+
it('is order-dependent: running after the first timestamp cannot repair the cached closure', () => {
153+
// @sentry/core caches the drifted closure on first use, before the guard runs.
154+
const timestampInSeconds = requireFreshTimestampInSeconds();
155+
expect(timestampInSeconds()).toBeCloseTo(1.1, 5);
156+
157+
ensureReliablePerformanceTimeOrigin();
158+
159+
// The closure captured the original timeOrigin as a local const, so zeroing the property
160+
// afterwards is a no-op. This is why the guard must run before `initAndBind`.
161+
expect(timestampInSeconds()).toBeCloseTo(1.1, 5);
162+
});
163+
});

0 commit comments

Comments
 (0)