Skip to content

Commit 6c33bfc

Browse files
authored
fix: Reimplement timestamp computation (#2947)
This is a partial improvement to address inconsistencies in browsers and how they implement performance.now(). It mitigates observing timestamps in the past for pageload transactions, but gives no guarantees to navigation transactions. Depending on the platform, the clock used in performance.now() may stop when the computer goes to sleep, creating an ever increasing skew. This skew is more likely to manifest in navigation transactions. Notable Changes - Do not polyfill/patch performance.timeOrigin to avoid changing behavior of third parties that may depend on it. Instead, use an explicit browserPerformanceTimeOrigin where needed. - Use a timestamp based on the Date API for error events, breadcrumbs and envelope header. This should fully resolve the problem of ingesting events with timestamps in the past, as long as the client wall clock is reasonably in sync. - Apply an equivalent workaround that we used for React Native to all JavaScript environments, essentially resetting the timeOrigin used to compute monotonic timestamps when execution starts.
1 parent 2c51883 commit 6c33bfc

File tree

8 files changed

+134
-81
lines changed

8 files changed

+134
-81
lines changed

packages/apm/src/integrations/tracing.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Hub } from '@sentry/hub';
44
import { Event, EventProcessor, Integration, Severity, Span, SpanContext, TransactionContext } from '@sentry/types';
55
import {
66
addInstrumentationHandler,
7+
browserPerformanceTimeOrigin,
78
getGlobalObject,
89
isInstanceOf,
910
isMatchingPattern,
@@ -570,7 +571,7 @@ export class Tracing implements Integration {
570571
* @param transactionSpan The transaction span
571572
*/
572573
private static _addPerformanceEntries(transactionSpan: SpanClass): void {
573-
if (!global.performance || !global.performance.getEntries) {
574+
if (!global.performance || !global.performance.getEntries || !browserPerformanceTimeOrigin) {
574575
// Gatekeeper if performance API not available
575576
return;
576577
}
@@ -587,7 +588,7 @@ export class Tracing implements Integration {
587588
}
588589
}
589590

590-
const timeOrigin = Tracing._msToSec(performance.timeOrigin);
591+
const timeOrigin = Tracing._msToSec(browserPerformanceTimeOrigin);
591592

592593
// eslint-disable-next-line jsdoc/require-jsdoc
593594
function addPerformanceNavigationTiming(parent: Span, entry: { [key: string]: number }, event: string): void {

packages/core/src/baseclient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
import { Scope } from '@sentry/hub';
33
import { Client, Event, EventHint, Integration, IntegrationClass, Options, Severity } from '@sentry/types';
44
import {
5+
dateTimestampInSeconds,
56
Dsn,
67
isPrimitive,
78
isThenable,
89
logger,
910
normalize,
1011
SyncPromise,
11-
timestampWithMs,
1212
truncate,
1313
uuid4,
1414
} from '@sentry/utils';
@@ -256,7 +256,7 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
256256
const prepared: Event = {
257257
...event,
258258
event_id: event.event_id || (hint && hint.event_id ? hint.event_id : uuid4()),
259-
timestamp: event.timestamp || timestampWithMs(),
259+
timestamp: event.timestamp || dateTimestampInSeconds(),
260260
};
261261

262262
this._applyClientOptions(prepared);

packages/core/src/request.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Event } from '@sentry/types';
2-
import { timestampWithMs } from '@sentry/utils';
32

43
import { API } from './api';
54

@@ -34,7 +33,7 @@ export function eventToSentryRequest(event: Event, api: API): SentryRequest {
3433
event_id: event.event_id,
3534
// We need to add * 1000 since we divide it by 1000 by default but JS works with ms precision
3635
// The reason we use timestampWithMs here is that all clocks across the SDK use the same clock
37-
sent_at: new Date(timestampWithMs() * 1000).toISOString(),
36+
sent_at: new Date().toISOString(),
3837
});
3938
const itemHeaders = JSON.stringify({
4039
type: event.type,

packages/core/test/lib/base.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ jest.mock('@sentry/utils', () => {
4343
timestampWithMs(): number {
4444
return 2020;
4545
},
46+
dateTimestampInSeconds(): number {
47+
return 2020;
48+
},
4649
};
4750
});
4851

packages/hub/src/hub.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
TransactionContext,
1919
User,
2020
} from '@sentry/types';
21-
import { consoleSandbox, getGlobalObject, isNodeEnv, logger, timestampWithMs, uuid4 } from '@sentry/utils';
21+
import { consoleSandbox, dateTimestampInSeconds, getGlobalObject, isNodeEnv, logger, uuid4 } from '@sentry/utils';
2222

2323
import { Carrier, DomainAsCarrier, Layer } from './interfaces';
2424
import { Scope } from './scope';
@@ -242,7 +242,7 @@ export class Hub implements HubInterface {
242242
return;
243243
}
244244

245-
const timestamp = timestampWithMs();
245+
const timestamp = dateTimestampInSeconds();
246246
const mergedBreadcrumb = { timestamp, ...breadcrumb };
247247
const finalBreadcrumb = beforeBreadcrumb
248248
? (consoleSandbox(() => beforeBreadcrumb(mergedBreadcrumb, hint)) as Breadcrumb | null)

packages/hub/src/scope.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
Transaction,
1717
User,
1818
} from '@sentry/types';
19-
import { getGlobalObject, isPlainObject, isThenable, SyncPromise, timestampWithMs } from '@sentry/utils';
19+
import { dateTimestampInSeconds, getGlobalObject, isPlainObject, isThenable, SyncPromise } from '@sentry/utils';
2020

2121
/**
2222
* Holds additional event information. {@link Scope.applyToEvent} will be
@@ -302,7 +302,7 @@ export class Scope implements ScopeInterface {
302302
*/
303303
public addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this {
304304
const mergedBreadcrumb = {
305-
timestamp: timestampWithMs(),
305+
timestamp: dateTimestampInSeconds(),
306306
...breadcrumb,
307307
};
308308

packages/tracing/src/browser/metrics.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable max-lines */
22
/* eslint-disable @typescript-eslint/no-explicit-any */
33
import { SpanContext } from '@sentry/types';
4-
import { getGlobalObject, logger } from '@sentry/utils';
4+
import { browserPerformanceTimeOrigin, getGlobalObject, logger } from '@sentry/utils';
55

66
import { Span } from '../span';
77
import { Transaction } from '../transaction';
@@ -27,7 +27,7 @@ export class MetricsInstrumentation {
2727

2828
/** Add performance related spans to a transaction */
2929
public addPerformanceEntries(transaction: Transaction): void {
30-
if (!global || !global.performance || !global.performance.getEntries) {
30+
if (!global || !global.performance || !global.performance.getEntries || !browserPerformanceTimeOrigin) {
3131
// Gatekeeper if performance API not available
3232
return;
3333
}
@@ -44,7 +44,7 @@ export class MetricsInstrumentation {
4444
}
4545
}
4646

47-
const timeOrigin = msToSec(performance.timeOrigin);
47+
const timeOrigin = msToSec(browserPerformanceTimeOrigin);
4848
let entryScriptSrc: string | undefined;
4949

5050
if (global.document) {

packages/utils/src/time.ts

Lines changed: 118 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,148 @@
11
import { getGlobalObject } from './misc';
22
import { dynamicRequire, isNodeEnv } from './node';
33

4-
const INITIAL_TIME = Date.now();
4+
/**
5+
* An object that can return the current timestamp in seconds since the UNIX epoch.
6+
*/
7+
interface TimestampSource {
8+
nowSeconds(): number;
9+
}
10+
11+
/**
12+
* A TimestampSource implementation for environments that do not support the Performance Web API natively.
13+
*
14+
* Note that this TimestampSource does not use a monotonic clock. A call to `nowSeconds` may return a timestamp earlier
15+
* than a previously returned value. We do not try to emulate a monotonic behavior in order to facilitate debugging. It
16+
* is more obvious to explain "why does my span have negative duration" than "why my spans have zero duration".
17+
*/
18+
const dateTimestampSource: TimestampSource = {
19+
nowSeconds: () => Date.now() / 1000,
20+
};
521

622
/**
7-
* Cross platform compatible partial performance implementation
23+
* A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance}
24+
* for accessing a high resolution monotonic clock.
825
*/
9-
interface CrossPlatformPerformance {
26+
interface Performance {
27+
/**
28+
* The millisecond timestamp at which measurement began, measured in Unix time.
29+
*/
1030
timeOrigin: number;
1131
/**
12-
* Returns the current timestamp in ms
32+
* Returns the current millisecond timestamp, where 0 represents the start of measurement.
1333
*/
1434
now(): number;
1535
}
1636

17-
let prevNow = 0;
18-
19-
const performanceFallback: CrossPlatformPerformance = {
20-
now(): number {
21-
let now = Date.now() - INITIAL_TIME;
22-
if (now < prevNow) {
23-
now = prevNow;
24-
}
25-
prevNow = now;
26-
return now;
27-
},
28-
timeOrigin: INITIAL_TIME,
29-
};
30-
31-
const crossPlatformPerformance: CrossPlatformPerformance = ((): CrossPlatformPerformance => {
32-
// React Native's performance.now() starts with a gigantic offset, so we need to wrap it.
33-
if (isReactNative()) {
34-
return getReactNativePerformanceWrapper();
35-
}
36-
37-
if (isNodeEnv()) {
38-
try {
39-
const perfHooks = dynamicRequire(module, 'perf_hooks') as { performance: CrossPlatformPerformance };
40-
return perfHooks.performance;
41-
} catch (_) {
42-
return performanceFallback;
43-
}
44-
}
45-
37+
/**
38+
* Returns a wrapper around the native Performance API browser implementation, or undefined for browsers that do not
39+
* support the API.
40+
*
41+
* Wrapping the native API works around differences in behavior from different browsers.
42+
*/
43+
function getBrowserPerformance(): Performance | undefined {
4644
const { performance } = getGlobalObject<Window>();
47-
4845
if (!performance || !performance.now) {
49-
return performanceFallback;
46+
return undefined;
5047
}
5148

52-
// Polyfill for performance.timeOrigin.
49+
// Replace performance.timeOrigin with our own timeOrigin based on Date.now().
5350
//
54-
// While performance.timing.navigationStart is deprecated in favor of performance.timeOrigin, performance.timeOrigin
55-
// is not as widely supported. Namely, performance.timeOrigin is undefined in Safari as of writing.
56-
if (performance.timeOrigin === undefined) {
57-
// As of writing, performance.timing is not available in Web Workers in mainstream browsers, so it is not always a
58-
// valid fallback. In the absence of a initial time provided by the browser, fallback to INITIAL_TIME.
59-
// @ts-ignore ignored because timeOrigin is a readonly property but we want to override
60-
// eslint-disable-next-line deprecation/deprecation
61-
performance.timeOrigin = (performance.timing && performance.timing.navigationStart) || INITIAL_TIME;
62-
}
51+
// This is a partial workaround for browsers reporting performance.timeOrigin such that performance.timeOrigin +
52+
// performance.now() gives a date arbitrarily in the past.
53+
//
54+
// Additionally, computing timeOrigin in this way fills the gap for browsers where performance.timeOrigin is
55+
// undefined.
56+
//
57+
// The assumption that performance.timeOrigin + performance.now() ~= Date.now() is flawed, but we depend on it to
58+
// interact with data coming out of performance entries.
59+
//
60+
// Note that despite recommendations against it in the spec, browsers implement the Performance API with a clock that
61+
// might stop when the computer is asleep (and perhaps under other circumstances). Such behavior causes
62+
// performance.timeOrigin + performance.now() to have an arbitrary skew over Date.now(). In laptop computers, we have
63+
// observed skews that can be as long as days, weeks or months.
64+
//
65+
// See https://github.com/getsentry/sentry-javascript/issues/2590.
66+
//
67+
// BUG: despite our best intentions, this workaround has its limitations. It mostly addresses timings of pageload
68+
// transactions, but ignores the skew built up over time that can aversely affect timestamps of navigation
69+
// transactions of long-lived web pages.
70+
const timeOrigin = Date.now() - performance.now();
6371

64-
return performance;
65-
})();
72+
return {
73+
now: () => performance.now(),
74+
timeOrigin,
75+
};
76+
}
6677

6778
/**
68-
* Returns a timestamp in seconds with milliseconds precision since the UNIX epoch calculated with the monotonic clock.
79+
* Returns the native Performance API implementation from Node.js. Returns undefined in old Node.js versions that don't
80+
* implement the API.
6981
*/
70-
export function timestampWithMs(): number {
71-
return (crossPlatformPerformance.timeOrigin + crossPlatformPerformance.now()) / 1000;
82+
function getNodePerformance(): Performance | undefined {
83+
try {
84+
const perfHooks = dynamicRequire(module, 'perf_hooks') as { performance: Performance };
85+
return perfHooks.performance;
86+
} catch (_) {
87+
return undefined;
88+
}
7289
}
7390

7491
/**
75-
* Determines if running in react native
92+
* The Performance API implementation for the current platform, if available.
7693
*/
77-
function isReactNative(): boolean {
78-
return getGlobalObject<Window>().navigator?.product === 'ReactNative';
79-
}
94+
const platformPerformance: Performance | undefined = isNodeEnv() ? getNodePerformance() : getBrowserPerformance();
95+
96+
const timestampSource: TimestampSource =
97+
platformPerformance === undefined
98+
? dateTimestampSource
99+
: {
100+
nowSeconds: () => (platformPerformance.timeOrigin + platformPerformance.now()) / 1000,
101+
};
80102

81103
/**
82-
* Performance wrapper for react native as performance.now() has been found to start off with an unusual offset.
104+
* Returns a timestamp in seconds since the UNIX epoch using the Date API.
83105
*/
84-
function getReactNativePerformanceWrapper(): CrossPlatformPerformance {
85-
// Performance only available >= RN 0.63
86-
const { performance } = getGlobalObject<Window>();
87-
if (performance && typeof performance.now === 'function') {
88-
const INITIAL_OFFSET = performance.now();
106+
export const dateTimestampInSeconds = dateTimestampSource.nowSeconds.bind(dateTimestampSource);
107+
108+
/**
109+
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
110+
* availability of the Performance API.
111+
*
112+
* See `usingPerformanceAPI` to test whether the Performance API is used.
113+
*
114+
* BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is
115+
* asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The
116+
* skew can grow to arbitrary amounts like days, weeks or months.
117+
* See https://github.com/getsentry/sentry-javascript/issues/2590.
118+
*/
119+
export const timestampInSeconds = timestampSource.nowSeconds.bind(timestampSource);
89120

90-
return {
91-
now(): number {
92-
return performance.now() - INITIAL_OFFSET;
93-
},
94-
timeOrigin: INITIAL_TIME,
95-
};
121+
// Re-exported with an old name for backwards-compatibility.
122+
export const timestampWithMs = timestampInSeconds;
123+
124+
/**
125+
* A boolean that is true when timestampInSeconds uses the Performance API to produce monotonic timestamps.
126+
*/
127+
export const usingPerformanceAPI = platformPerformance !== undefined;
128+
129+
/**
130+
* The number of milliseconds since the UNIX epoch. This value is only usable in a browser, and only when the
131+
* performance API is available.
132+
*/
133+
export const browserPerformanceTimeOrigin = ((): number | undefined => {
134+
const { performance } = getGlobalObject<Window>();
135+
if (!performance) {
136+
return undefined;
96137
}
97-
return performanceFallback;
98-
}
138+
if (performance.timeOrigin) {
139+
return performance.timeOrigin;
140+
}
141+
// While performance.timing.navigationStart is deprecated in favor of performance.timeOrigin, performance.timeOrigin
142+
// is not as widely supported. Namely, performance.timeOrigin is undefined in Safari as of writing.
143+
// Also as of writing, performance.timing is not available in Web Workers in mainstream browsers, so it is not always
144+
// a valid fallback. In the absence of an initial time provided by the browser, fallback to the current time from the
145+
// Date API.
146+
// eslint-disable-next-line deprecation/deprecation
147+
return (performance.timing && performance.timing.navigationStart) || Date.now();
148+
})();

0 commit comments

Comments
 (0)