Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions packages/core/src/utils/time.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ import { GLOBAL_OBJ } from './worldwide';

const ONE_SECOND_IN_MS = 1000;

/**
* Maximum tolerated difference between the monotonic clock and the wall clock before we consider
* the monotonic clock's time origin stale.
*/
const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds

/**
* A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance}
* for accessing a high-resolution monotonic clock.
Expand DownExpand Up@@ -39,19 +45,32 @@ function createUnixTimestampInSecondsFunc(): () => number {
return dateTimestampInSeconds;
}

const timeOrigin = performance.timeOrigin;

// performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current
// wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed.
//
// TODO: This does not account for the case where the monotonic clock that powers performance.now() drifts from the
// wall clock time, which causes the returned timestamp to be inaccurate. We should investigate how to detect and
// correct for this.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
let timeOrigin = performance.timeOrigin;

return () => {
return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS;
return withRandomSafeContext(() => {
const performanceNow = performance.now();
const dateNow = Date.now();

// `timeOrigin + performance.now()` only equals wall clock time for as long as both clocks advance in lockstep.
// performance.now() stops advancing while the device is asleep, so it under-counts elapsed wall time; conversely
// the wall clock itself can be stepped by Network Time Protocol (NTP) or the user. Either way the two drift apart
// by arbitrary amounts. Re-deriving the origin restores absolute accuracy while still taking elapsed time from
// the monotonic clock, so durations keep sub-millisecond precision and cannot run backwards.
// Timestamps taken before a correction are measured against a different origin than those taken after it, so a
// span that starts before one and ends after it absorbs the drift into its duration. Spans that lie entirely on
// one side of a correction are unaffected.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) {
timeOrigin = dateNow - performanceNow;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased timestamps leave performance entries behind

Medium Severity

timestampInSeconds() updates only its private timeOrigin, while browserPerformanceTimeOrigin() retains its cached pre-drift origin. After sleep, spans use the corrected timeline but performance entries and profiles remain offset, causing post-wake telemetry to be dropped or assigned incorrect times.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 456dd81. Configure here.


return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS;
});
};
}

Expand All@@ -61,10 +80,12 @@ let _cachedTimestampInSeconds: (() => number) | undefined;
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
* availability of the Performance API.
*
* BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is
* asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The
* skew can grow to arbitrary amounts like days, weeks or months.
* See https://github.com/getsentry/sentry-javascript/issues/2590.
* Because the Performance API's clock and the wall clock can drift apart (the former stops while the computer is
* asleep, the latter can be stepped by NTP or the user), the time origin they are combined against is re-derived from
* `Date.now()` whenever the two disagree by more than {@link CLOCK_DRIFT_THRESHOLD_MS}. Two timestamps taken on either
* side of such a correction are skewed relative to each other by the amount of drift, so a span that starts before a
* correction and ends after it reports the wall clock time elapsed rather than the time the monotonic clock was
* running. See https://github.com/getsentry/sentry-javascript/issues/2590.
*/
export function timestampInSeconds(): number {
// We store this in a closure so that we don't have to create a new function every time this is called.
Expand DownExpand Up@@ -92,14 +113,13 @@ function getBrowserTimeOrigin(): number | undefined {
return undefined;
}

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

const timeOrigin = performance.timeOrigin;
if (typeof timeOrigin === 'number') {
const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow);
if (timeOriginDelta < threshold) {
if (timeOriginDelta < CLOCK_DRIFT_THRESHOLD_MS) {
return timeOrigin;
}
}
Expand Down
173 changes: 172 additions & 1 deletion packages/core/test/lib/utils/time.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

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

let freshImportCounter = 0;

async function getFreshTimestampInSeconds(): Promise<() => number> {
// A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would
// otherwise hand out a cached module.
const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`);
return timeModule.timestampInSeconds;
}

const RELIABLE_THRESHOLD_MS = 300_000;

describe('timestampInSeconds', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('derives the timestamp from `performance.timeOrigin` and `performance.now()`', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_234.56789;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('falls back to `Date.now()` if the performance API is unavailable', async () => {
const currentTimeMs = 1767778040866;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', undefined);

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('keeps using `performance.timeOrigin` while the clocks agree', async () => {
const currentTimeMs = 1767778040866;
// Below the drift threshold, so the (inaccurate) time origin must be preserved.
const timeOriginSkewMs = RELIABLE_THRESHOLD_MS - 2_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe((currentTimeMs + timeOriginSkewMs) / 1000);

timeSincePageloadMs = 5_000;
vi.setSystemTime(new Date(currentTimeMs + 4_000));

expect(timestampInSeconds()).toBe((currentTimeMs + 4_000 + timeOriginSkewMs) / 1000);
});

it('re-derives the time origin once the monotonic clock drifts from the wall clock', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_000;

// The monotonic clock pauses during sleep, so the wall clock advances much further than it does.
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);

vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));

expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs) / 1000);
});

it('keeps deriving elapsed time from the monotonic clock after re-deriving the time origin', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
const afterCorrection = timestampInSeconds();

// `Date.now()` deliberately stays put while the monotonic clock advances sub-millisecond, proving the elapsed
// time comes from `performance.now()` rather than from the coarser wall clock.
timeSincePageloadMs += 0.25;
expect(timestampInSeconds()).toBeCloseTo(afterCorrection + 0.25 / 1000, 10);
});

it('does not re-derive the time origin repeatedly once the clocks agree again', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
timestampInSeconds();

// Advance both clocks in lockstep: the re-derived time origin must stay valid, so timestamps track the wall clock
// exactly rather than oscillating between the two sources.
for (let i = 1; i <= 3; i++) {
timeSincePageloadMs += 1_000;
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs + i * 1_000));
expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs + i * 1_000) / 1000);
}
});

it('produces monotonically increasing timestamps when the wall clock steps backwards', async () => {
const currentTimeMs = 1767778040866;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

const before = timestampInSeconds();

// A backwards wall clock step (NTP correction, user changing the clock) beyond the threshold.
vi.setSystemTime(new Date(currentTimeMs - RELIABLE_THRESHOLD_MS - 60_000));
timeSincePageloadMs += 1_000;
const afterStep = timestampInSeconds();

// The correction itself moves the timestamp backwards, but elapsed time afterwards is still monotonic.
timeSincePageloadMs += 1_000;
expect(timestampInSeconds()).toBeGreaterThan(afterStep);
expect(before).toBeGreaterThan(afterStep);
});
});

describe('browserPerformanceTimeOrigin', () => {
it('returns `performance.timeOrigin` if it is available and reliable', async () => {
const timeOrigin = await getFreshPerformanceTimeOrigin();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions packages/core/src/utils/time.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ import { GLOBAL_OBJ } from './worldwide';

const ONE_SECOND_IN_MS = 1000;

/**
* Maximum tolerated difference between the monotonic clock and the wall clock before we consider
* the monotonic clock's time origin stale.
*/
const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds

/**
* A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance}
* for accessing a high-resolution monotonic clock.
Expand DownExpand Up@@ -39,19 +45,32 @@ function createUnixTimestampInSecondsFunc(): () => number {
return dateTimestampInSeconds;
}

const timeOrigin = performance.timeOrigin;

// performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current
// wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed.
//
// TODO: This does not account for the case where the monotonic clock that powers performance.now() drifts from the
// wall clock time, which causes the returned timestamp to be inaccurate. We should investigate how to detect and
// correct for this.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
let timeOrigin = performance.timeOrigin;

return () => {
return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS;
return withRandomSafeContext(() => {
const performanceNow = performance.now();
const dateNow = Date.now();

// `timeOrigin + performance.now()` only equals wall clock time for as long as both clocks advance in lockstep.
// performance.now() stops advancing while the device is asleep, so it under-counts elapsed wall time; conversely
// the wall clock itself can be stepped by Network Time Protocol (NTP) or the user. Either way the two drift apart
// by arbitrary amounts. Re-deriving the origin restores absolute accuracy while still taking elapsed time from
// the monotonic clock, so durations keep sub-millisecond precision and cannot run backwards.
// Timestamps taken before a correction are measured against a different origin than those taken after it, so a
// span that starts before one and ends after it absorbs the drift into its duration. Spans that lie entirely on
// one side of a correction are unaffected.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) {
timeOrigin = dateNow - performanceNow;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased timestamps leave performance entries behind

Medium Severity

timestampInSeconds() updates only its private timeOrigin, while browserPerformanceTimeOrigin() retains its cached pre-drift origin. After sleep, spans use the corrected timeline but performance entries and profiles remain offset, causing post-wake telemetry to be dropped or assigned incorrect times.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 456dd81. Configure here.


return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS;
});
};
}

Expand All@@ -61,10 +80,12 @@ let _cachedTimestampInSeconds: (() => number) | undefined;
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
* availability of the Performance API.
*
* BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is
* asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The
* skew can grow to arbitrary amounts like days, weeks or months.
* See https://github.com/getsentry/sentry-javascript/issues/2590.
* Because the Performance API's clock and the wall clock can drift apart (the former stops while the computer is
* asleep, the latter can be stepped by NTP or the user), the time origin they are combined against is re-derived from
* `Date.now()` whenever the two disagree by more than {@link CLOCK_DRIFT_THRESHOLD_MS}. Two timestamps taken on either
* side of such a correction are skewed relative to each other by the amount of drift, so a span that starts before a
* correction and ends after it reports the wall clock time elapsed rather than the time the monotonic clock was
* running. See https://github.com/getsentry/sentry-javascript/issues/2590.
*/
export function timestampInSeconds(): number {
// We store this in a closure so that we don't have to create a new function every time this is called.
Expand DownExpand Up@@ -92,14 +113,13 @@ function getBrowserTimeOrigin(): number | undefined {
return undefined;
}

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

const timeOrigin = performance.timeOrigin;
if (typeof timeOrigin === 'number') {
const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow);
if (timeOriginDelta < threshold) {
if (timeOriginDelta < CLOCK_DRIFT_THRESHOLD_MS) {
return timeOrigin;
}
}
Expand Down
173 changes: 172 additions & 1 deletion packages/core/test/lib/utils/time.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

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

let freshImportCounter = 0;

async function getFreshTimestampInSeconds(): Promise<() => number> {
// A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would
// otherwise hand out a cached module.
const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`);
return timeModule.timestampInSeconds;
}

const RELIABLE_THRESHOLD_MS = 300_000;

describe('timestampInSeconds', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('derives the timestamp from `performance.timeOrigin` and `performance.now()`', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_234.56789;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('falls back to `Date.now()` if the performance API is unavailable', async () => {
const currentTimeMs = 1767778040866;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', undefined);

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('keeps using `performance.timeOrigin` while the clocks agree', async () => {
const currentTimeMs = 1767778040866;
// Below the drift threshold, so the (inaccurate) time origin must be preserved.
const timeOriginSkewMs = RELIABLE_THRESHOLD_MS - 2_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe((currentTimeMs + timeOriginSkewMs) / 1000);

timeSincePageloadMs = 5_000;
vi.setSystemTime(new Date(currentTimeMs + 4_000));

expect(timestampInSeconds()).toBe((currentTimeMs + 4_000 + timeOriginSkewMs) / 1000);
});

it('re-derives the time origin once the monotonic clock drifts from the wall clock', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_000;

// The monotonic clock pauses during sleep, so the wall clock advances much further than it does.
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);

vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));

expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs) / 1000);
});

it('keeps deriving elapsed time from the monotonic clock after re-deriving the time origin', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
const afterCorrection = timestampInSeconds();

// `Date.now()` deliberately stays put while the monotonic clock advances sub-millisecond, proving the elapsed
// time comes from `performance.now()` rather than from the coarser wall clock.
timeSincePageloadMs += 0.25;
expect(timestampInSeconds()).toBeCloseTo(afterCorrection + 0.25 / 1000, 10);
});

it('does not re-derive the time origin repeatedly once the clocks agree again', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
timestampInSeconds();

// Advance both clocks in lockstep: the re-derived time origin must stay valid, so timestamps track the wall clock
// exactly rather than oscillating between the two sources.
for (let i = 1; i <= 3; i++) {
timeSincePageloadMs += 1_000;
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs + i * 1_000));
expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs + i * 1_000) / 1000);
}
});

it('produces monotonically increasing timestamps when the wall clock steps backwards', async () => {
const currentTimeMs = 1767778040866;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

const before = timestampInSeconds();

// A backwards wall clock step (NTP correction, user changing the clock) beyond the threshold.
vi.setSystemTime(new Date(currentTimeMs - RELIABLE_THRESHOLD_MS - 60_000));
timeSincePageloadMs += 1_000;
const afterStep = timestampInSeconds();

// The correction itself moves the timestamp backwards, but elapsed time afterwards is still monotonic.
timeSincePageloadMs += 1_000;
expect(timestampInSeconds()).toBeGreaterThan(afterStep);
expect(before).toBeGreaterThan(afterStep);
});
});

describe('browserPerformanceTimeOrigin', () => {
it('returns `performance.timeOrigin` if it is available and reliable', async () => {
const timeOrigin = await getFreshPerformanceTimeOrigin();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions packages/core/src/utils/time.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ import { GLOBAL_OBJ } from './worldwide';

const ONE_SECOND_IN_MS = 1000;

/**
* Maximum tolerated difference between the monotonic clock and the wall clock before we consider
* the monotonic clock's time origin stale.
*/
const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds

/**
* A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance}
* for accessing a high-resolution monotonic clock.
Expand DownExpand Up@@ -39,19 +45,32 @@ function createUnixTimestampInSecondsFunc(): () => number {
return dateTimestampInSeconds;
}

const timeOrigin = performance.timeOrigin;

// performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current
// wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed.
//
// TODO: This does not account for the case where the monotonic clock that powers performance.now() drifts from the
// wall clock time, which causes the returned timestamp to be inaccurate. We should investigate how to detect and
// correct for this.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
let timeOrigin = performance.timeOrigin;

return () => {
return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS;
return withRandomSafeContext(() => {
const performanceNow = performance.now();
const dateNow = Date.now();

// `timeOrigin + performance.now()` only equals wall clock time for as long as both clocks advance in lockstep.
// performance.now() stops advancing while the device is asleep, so it under-counts elapsed wall time; conversely
// the wall clock itself can be stepped by Network Time Protocol (NTP) or the user. Either way the two drift apart
// by arbitrary amounts. Re-deriving the origin restores absolute accuracy while still taking elapsed time from
// the monotonic clock, so durations keep sub-millisecond precision and cannot run backwards.
// Timestamps taken before a correction are measured against a different origin than those taken after it, so a
// span that starts before one and ends after it absorbs the drift into its duration. Spans that lie entirely on
// one side of a correction are unaffected.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) {
timeOrigin = dateNow - performanceNow;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased timestamps leave performance entries behind

Medium Severity

timestampInSeconds() updates only its private timeOrigin, while browserPerformanceTimeOrigin() retains its cached pre-drift origin. After sleep, spans use the corrected timeline but performance entries and profiles remain offset, causing post-wake telemetry to be dropped or assigned incorrect times.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 456dd81. Configure here.


return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS;
});
};
}

Expand All@@ -61,10 +80,12 @@ let _cachedTimestampInSeconds: (() => number) | undefined;
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
* availability of the Performance API.
*
* BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is
* asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The
* skew can grow to arbitrary amounts like days, weeks or months.
* See https://github.com/getsentry/sentry-javascript/issues/2590.
* Because the Performance API's clock and the wall clock can drift apart (the former stops while the computer is
* asleep, the latter can be stepped by NTP or the user), the time origin they are combined against is re-derived from
* `Date.now()` whenever the two disagree by more than {@link CLOCK_DRIFT_THRESHOLD_MS}. Two timestamps taken on either
* side of such a correction are skewed relative to each other by the amount of drift, so a span that starts before a
* correction and ends after it reports the wall clock time elapsed rather than the time the monotonic clock was
* running. See https://github.com/getsentry/sentry-javascript/issues/2590.
*/
export function timestampInSeconds(): number {
// We store this in a closure so that we don't have to create a new function every time this is called.
Expand DownExpand Up@@ -92,14 +113,13 @@ function getBrowserTimeOrigin(): number | undefined {
return undefined;
}

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

const timeOrigin = performance.timeOrigin;
if (typeof timeOrigin === 'number') {
const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow);
if (timeOriginDelta < threshold) {
if (timeOriginDelta < CLOCK_DRIFT_THRESHOLD_MS) {
return timeOrigin;
}
}
Expand Down
173 changes: 172 additions & 1 deletion packages/core/test/lib/utils/time.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

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

let freshImportCounter = 0;

async function getFreshTimestampInSeconds(): Promise<() => number> {
// A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would
// otherwise hand out a cached module.
const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`);
return timeModule.timestampInSeconds;
}

const RELIABLE_THRESHOLD_MS = 300_000;

describe('timestampInSeconds', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('derives the timestamp from `performance.timeOrigin` and `performance.now()`', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_234.56789;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('falls back to `Date.now()` if the performance API is unavailable', async () => {
const currentTimeMs = 1767778040866;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', undefined);

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('keeps using `performance.timeOrigin` while the clocks agree', async () => {
const currentTimeMs = 1767778040866;
// Below the drift threshold, so the (inaccurate) time origin must be preserved.
const timeOriginSkewMs = RELIABLE_THRESHOLD_MS - 2_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe((currentTimeMs + timeOriginSkewMs) / 1000);

timeSincePageloadMs = 5_000;
vi.setSystemTime(new Date(currentTimeMs + 4_000));

expect(timestampInSeconds()).toBe((currentTimeMs + 4_000 + timeOriginSkewMs) / 1000);
});

it('re-derives the time origin once the monotonic clock drifts from the wall clock', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_000;

// The monotonic clock pauses during sleep, so the wall clock advances much further than it does.
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);

vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));

expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs) / 1000);
});

it('keeps deriving elapsed time from the monotonic clock after re-deriving the time origin', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
const afterCorrection = timestampInSeconds();

// `Date.now()` deliberately stays put while the monotonic clock advances sub-millisecond, proving the elapsed
// time comes from `performance.now()` rather than from the coarser wall clock.
timeSincePageloadMs += 0.25;
expect(timestampInSeconds()).toBeCloseTo(afterCorrection + 0.25 / 1000, 10);
});

it('does not re-derive the time origin repeatedly once the clocks agree again', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
timestampInSeconds();

// Advance both clocks in lockstep: the re-derived time origin must stay valid, so timestamps track the wall clock
// exactly rather than oscillating between the two sources.
for (let i = 1; i <= 3; i++) {
timeSincePageloadMs += 1_000;
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs + i * 1_000));
expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs + i * 1_000) / 1000);
}
});

it('produces monotonically increasing timestamps when the wall clock steps backwards', async () => {
const currentTimeMs = 1767778040866;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

const before = timestampInSeconds();

// A backwards wall clock step (NTP correction, user changing the clock) beyond the threshold.
vi.setSystemTime(new Date(currentTimeMs - RELIABLE_THRESHOLD_MS - 60_000));
timeSincePageloadMs += 1_000;
const afterStep = timestampInSeconds();

// The correction itself moves the timestamp backwards, but elapsed time afterwards is still monotonic.
timeSincePageloadMs += 1_000;
expect(timestampInSeconds()).toBeGreaterThan(afterStep);
expect(before).toBeGreaterThan(afterStep);
});
});

describe('browserPerformanceTimeOrigin', () => {
it('returns `performance.timeOrigin` if it is available and reliable', async () => {
const timeOrigin = await getFreshPerformanceTimeOrigin();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions packages/core/src/utils/time.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ import { GLOBAL_OBJ } from './worldwide';

const ONE_SECOND_IN_MS = 1000;

/**
* Maximum tolerated difference between the monotonic clock and the wall clock before we consider
* the monotonic clock's time origin stale.
*/
const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds

/**
* A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance}
* for accessing a high-resolution monotonic clock.
Expand DownExpand Up@@ -39,19 +45,32 @@ function createUnixTimestampInSecondsFunc(): () => number {
return dateTimestampInSeconds;
}

const timeOrigin = performance.timeOrigin;

// performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current
// wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed.
//
// TODO: This does not account for the case where the monotonic clock that powers performance.now() drifts from the
// wall clock time, which causes the returned timestamp to be inaccurate. We should investigate how to detect and
// correct for this.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
let timeOrigin = performance.timeOrigin;

return () => {
return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS;
return withRandomSafeContext(() => {
const performanceNow = performance.now();
const dateNow = Date.now();

// `timeOrigin + performance.now()` only equals wall clock time for as long as both clocks advance in lockstep.
// performance.now() stops advancing while the device is asleep, so it under-counts elapsed wall time; conversely
// the wall clock itself can be stepped by Network Time Protocol (NTP) or the user. Either way the two drift apart
// by arbitrary amounts. Re-deriving the origin restores absolute accuracy while still taking elapsed time from
// the monotonic clock, so durations keep sub-millisecond precision and cannot run backwards.
// Timestamps taken before a correction are measured against a different origin than those taken after it, so a
// span that starts before one and ends after it absorbs the drift into its duration. Spans that lie entirely on
// one side of a correction are unaffected.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) {
timeOrigin = dateNow - performanceNow;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased timestamps leave performance entries behind

Medium Severity

timestampInSeconds() updates only its private timeOrigin, while browserPerformanceTimeOrigin() retains its cached pre-drift origin. After sleep, spans use the corrected timeline but performance entries and profiles remain offset, causing post-wake telemetry to be dropped or assigned incorrect times.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 456dd81. Configure here.


return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS;
});
};
}

Expand All@@ -61,10 +80,12 @@ let _cachedTimestampInSeconds: (() => number) | undefined;
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
* availability of the Performance API.
*
* BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is
* asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The
* skew can grow to arbitrary amounts like days, weeks or months.
* See https://github.com/getsentry/sentry-javascript/issues/2590.
* Because the Performance API's clock and the wall clock can drift apart (the former stops while the computer is
* asleep, the latter can be stepped by NTP or the user), the time origin they are combined against is re-derived from
* `Date.now()` whenever the two disagree by more than {@link CLOCK_DRIFT_THRESHOLD_MS}. Two timestamps taken on either
* side of such a correction are skewed relative to each other by the amount of drift, so a span that starts before a
* correction and ends after it reports the wall clock time elapsed rather than the time the monotonic clock was
* running. See https://github.com/getsentry/sentry-javascript/issues/2590.
*/
export function timestampInSeconds(): number {
// We store this in a closure so that we don't have to create a new function every time this is called.
Expand DownExpand Up@@ -92,14 +113,13 @@ function getBrowserTimeOrigin(): number | undefined {
return undefined;
}

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

const timeOrigin = performance.timeOrigin;
if (typeof timeOrigin === 'number') {
const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow);
if (timeOriginDelta < threshold) {
if (timeOriginDelta < CLOCK_DRIFT_THRESHOLD_MS) {
return timeOrigin;
}
}
Expand Down
173 changes: 172 additions & 1 deletion packages/core/test/lib/utils/time.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

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

let freshImportCounter = 0;

async function getFreshTimestampInSeconds(): Promise<() => number> {
// A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would
// otherwise hand out a cached module.
const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`);
return timeModule.timestampInSeconds;
}

const RELIABLE_THRESHOLD_MS = 300_000;

describe('timestampInSeconds', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('derives the timestamp from `performance.timeOrigin` and `performance.now()`', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_234.56789;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('falls back to `Date.now()` if the performance API is unavailable', async () => {
const currentTimeMs = 1767778040866;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', undefined);

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('keeps using `performance.timeOrigin` while the clocks agree', async () => {
const currentTimeMs = 1767778040866;
// Below the drift threshold, so the (inaccurate) time origin must be preserved.
const timeOriginSkewMs = RELIABLE_THRESHOLD_MS - 2_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe((currentTimeMs + timeOriginSkewMs) / 1000);

timeSincePageloadMs = 5_000;
vi.setSystemTime(new Date(currentTimeMs + 4_000));

expect(timestampInSeconds()).toBe((currentTimeMs + 4_000 + timeOriginSkewMs) / 1000);
});

it('re-derives the time origin once the monotonic clock drifts from the wall clock', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_000;

// The monotonic clock pauses during sleep, so the wall clock advances much further than it does.
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);

vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));

expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs) / 1000);
});

it('keeps deriving elapsed time from the monotonic clock after re-deriving the time origin', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
const afterCorrection = timestampInSeconds();

// `Date.now()` deliberately stays put while the monotonic clock advances sub-millisecond, proving the elapsed
// time comes from `performance.now()` rather than from the coarser wall clock.
timeSincePageloadMs += 0.25;
expect(timestampInSeconds()).toBeCloseTo(afterCorrection + 0.25 / 1000, 10);
});

it('does not re-derive the time origin repeatedly once the clocks agree again', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
timestampInSeconds();

// Advance both clocks in lockstep: the re-derived time origin must stay valid, so timestamps track the wall clock
// exactly rather than oscillating between the two sources.
for (let i = 1; i <= 3; i++) {
timeSincePageloadMs += 1_000;
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs + i * 1_000));
expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs + i * 1_000) / 1000);
}
});

it('produces monotonically increasing timestamps when the wall clock steps backwards', async () => {
const currentTimeMs = 1767778040866;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

const before = timestampInSeconds();

// A backwards wall clock step (NTP correction, user changing the clock) beyond the threshold.
vi.setSystemTime(new Date(currentTimeMs - RELIABLE_THRESHOLD_MS - 60_000));
timeSincePageloadMs += 1_000;
const afterStep = timestampInSeconds();

// The correction itself moves the timestamp backwards, but elapsed time afterwards is still monotonic.
timeSincePageloadMs += 1_000;
expect(timestampInSeconds()).toBeGreaterThan(afterStep);
expect(before).toBeGreaterThan(afterStep);
});
});

describe('browserPerformanceTimeOrigin', () => {
it('returns `performance.timeOrigin` if it is available and reliable', async () => {
const timeOrigin = await getFreshPerformanceTimeOrigin();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions packages/core/src/utils/time.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ import { GLOBAL_OBJ } from './worldwide';

const ONE_SECOND_IN_MS = 1000;

/**
* Maximum tolerated difference between the monotonic clock and the wall clock before we consider
* the monotonic clock's time origin stale.
*/
const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds

/**
* A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance}
* for accessing a high-resolution monotonic clock.
Expand DownExpand Up@@ -39,19 +45,32 @@ function createUnixTimestampInSecondsFunc(): () => number {
return dateTimestampInSeconds;
}

const timeOrigin = performance.timeOrigin;

// performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current
// wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed.
//
// TODO: This does not account for the case where the monotonic clock that powers performance.now() drifts from the
// wall clock time, which causes the returned timestamp to be inaccurate. We should investigate how to detect and
// correct for this.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
let timeOrigin = performance.timeOrigin;

return () => {
return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS;
return withRandomSafeContext(() => {
const performanceNow = performance.now();
const dateNow = Date.now();

// `timeOrigin + performance.now()` only equals wall clock time for as long as both clocks advance in lockstep.
// performance.now() stops advancing while the device is asleep, so it under-counts elapsed wall time; conversely
// the wall clock itself can be stepped by Network Time Protocol (NTP) or the user. Either way the two drift apart
// by arbitrary amounts. Re-deriving the origin restores absolute accuracy while still taking elapsed time from
// the monotonic clock, so durations keep sub-millisecond precision and cannot run backwards.
// Timestamps taken before a correction are measured against a different origin than those taken after it, so a
// span that starts before one and ends after it absorbs the drift into its duration. Spans that lie entirely on
// one side of a correction are unaffected.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) {
timeOrigin = dateNow - performanceNow;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased timestamps leave performance entries behind

Medium Severity

timestampInSeconds() updates only its private timeOrigin, while browserPerformanceTimeOrigin() retains its cached pre-drift origin. After sleep, spans use the corrected timeline but performance entries and profiles remain offset, causing post-wake telemetry to be dropped or assigned incorrect times.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 456dd81. Configure here.


return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS;
});
};
}

Expand All@@ -61,10 +80,12 @@ let _cachedTimestampInSeconds: (() => number) | undefined;
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
* availability of the Performance API.
*
* BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is
* asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The
* skew can grow to arbitrary amounts like days, weeks or months.
* See https://github.com/getsentry/sentry-javascript/issues/2590.
* Because the Performance API's clock and the wall clock can drift apart (the former stops while the computer is
* asleep, the latter can be stepped by NTP or the user), the time origin they are combined against is re-derived from
* `Date.now()` whenever the two disagree by more than {@link CLOCK_DRIFT_THRESHOLD_MS}. Two timestamps taken on either
* side of such a correction are skewed relative to each other by the amount of drift, so a span that starts before a
* correction and ends after it reports the wall clock time elapsed rather than the time the monotonic clock was
* running. See https://github.com/getsentry/sentry-javascript/issues/2590.
*/
export function timestampInSeconds(): number {
// We store this in a closure so that we don't have to create a new function every time this is called.
Expand DownExpand Up@@ -92,14 +113,13 @@ function getBrowserTimeOrigin(): number | undefined {
return undefined;
}

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

const timeOrigin = performance.timeOrigin;
if (typeof timeOrigin === 'number') {
const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow);
if (timeOriginDelta < threshold) {
if (timeOriginDelta < CLOCK_DRIFT_THRESHOLD_MS) {
return timeOrigin;
}
}
Expand Down
173 changes: 172 additions & 1 deletion packages/core/test/lib/utils/time.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

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

let freshImportCounter = 0;

async function getFreshTimestampInSeconds(): Promise<() => number> {
// A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would
// otherwise hand out a cached module.
const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`);
return timeModule.timestampInSeconds;
}

const RELIABLE_THRESHOLD_MS = 300_000;

describe('timestampInSeconds', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('derives the timestamp from `performance.timeOrigin` and `performance.now()`', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_234.56789;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('falls back to `Date.now()` if the performance API is unavailable', async () => {
const currentTimeMs = 1767778040866;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', undefined);

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('keeps using `performance.timeOrigin` while the clocks agree', async () => {
const currentTimeMs = 1767778040866;
// Below the drift threshold, so the (inaccurate) time origin must be preserved.
const timeOriginSkewMs = RELIABLE_THRESHOLD_MS - 2_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe((currentTimeMs + timeOriginSkewMs) / 1000);

timeSincePageloadMs = 5_000;
vi.setSystemTime(new Date(currentTimeMs + 4_000));

expect(timestampInSeconds()).toBe((currentTimeMs + 4_000 + timeOriginSkewMs) / 1000);
});

it('re-derives the time origin once the monotonic clock drifts from the wall clock', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_000;

// The monotonic clock pauses during sleep, so the wall clock advances much further than it does.
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);

vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));

expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs) / 1000);
});

it('keeps deriving elapsed time from the monotonic clock after re-deriving the time origin', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
const afterCorrection = timestampInSeconds();

// `Date.now()` deliberately stays put while the monotonic clock advances sub-millisecond, proving the elapsed
// time comes from `performance.now()` rather than from the coarser wall clock.
timeSincePageloadMs += 0.25;
expect(timestampInSeconds()).toBeCloseTo(afterCorrection + 0.25 / 1000, 10);
});

it('does not re-derive the time origin repeatedly once the clocks agree again', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
timestampInSeconds();

// Advance both clocks in lockstep: the re-derived time origin must stay valid, so timestamps track the wall clock
// exactly rather than oscillating between the two sources.
for (let i = 1; i <= 3; i++) {
timeSincePageloadMs += 1_000;
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs + i * 1_000));
expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs + i * 1_000) / 1000);
}
});

it('produces monotonically increasing timestamps when the wall clock steps backwards', async () => {
const currentTimeMs = 1767778040866;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

const before = timestampInSeconds();

// A backwards wall clock step (NTP correction, user changing the clock) beyond the threshold.
vi.setSystemTime(new Date(currentTimeMs - RELIABLE_THRESHOLD_MS - 60_000));
timeSincePageloadMs += 1_000;
const afterStep = timestampInSeconds();

// The correction itself moves the timestamp backwards, but elapsed time afterwards is still monotonic.
timeSincePageloadMs += 1_000;
expect(timestampInSeconds()).toBeGreaterThan(afterStep);
expect(before).toBeGreaterThan(afterStep);
});
});

describe('browserPerformanceTimeOrigin', () => {
it('returns `performance.timeOrigin` if it is available and reliable', async () => {
const timeOrigin = await getFreshPerformanceTimeOrigin();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions packages/core/src/utils/time.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ import { GLOBAL_OBJ } from './worldwide';

const ONE_SECOND_IN_MS = 1000;

/**
* Maximum tolerated difference between the monotonic clock and the wall clock before we consider
* the monotonic clock's time origin stale.
*/
const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds

/**
* A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance}
* for accessing a high-resolution monotonic clock.
Expand DownExpand Up@@ -39,19 +45,32 @@ function createUnixTimestampInSecondsFunc(): () => number {
return dateTimestampInSeconds;
}

const timeOrigin = performance.timeOrigin;

// performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current
// wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed.
//
// TODO: This does not account for the case where the monotonic clock that powers performance.now() drifts from the
// wall clock time, which causes the returned timestamp to be inaccurate. We should investigate how to detect and
// correct for this.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
let timeOrigin = performance.timeOrigin;

return () => {
return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS;
return withRandomSafeContext(() => {
const performanceNow = performance.now();
const dateNow = Date.now();

// `timeOrigin + performance.now()` only equals wall clock time for as long as both clocks advance in lockstep.
// performance.now() stops advancing while the device is asleep, so it under-counts elapsed wall time; conversely
// the wall clock itself can be stepped by Network Time Protocol (NTP) or the user. Either way the two drift apart
// by arbitrary amounts. Re-deriving the origin restores absolute accuracy while still taking elapsed time from
// the monotonic clock, so durations keep sub-millisecond precision and cannot run backwards.
// Timestamps taken before a correction are measured against a different origin than those taken after it, so a
// span that starts before one and ends after it absorbs the drift into its duration. Spans that lie entirely on
// one side of a correction are unaffected.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) {
timeOrigin = dateNow - performanceNow;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased timestamps leave performance entries behind

Medium Severity

timestampInSeconds() updates only its private timeOrigin, while browserPerformanceTimeOrigin() retains its cached pre-drift origin. After sleep, spans use the corrected timeline but performance entries and profiles remain offset, causing post-wake telemetry to be dropped or assigned incorrect times.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 456dd81. Configure here.


return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS;
});
};
}

Expand All@@ -61,10 +80,12 @@ let _cachedTimestampInSeconds: (() => number) | undefined;
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
* availability of the Performance API.
*
* BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is
* asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The
* skew can grow to arbitrary amounts like days, weeks or months.
* See https://github.com/getsentry/sentry-javascript/issues/2590.
* Because the Performance API's clock and the wall clock can drift apart (the former stops while the computer is
* asleep, the latter can be stepped by NTP or the user), the time origin they are combined against is re-derived from
* `Date.now()` whenever the two disagree by more than {@link CLOCK_DRIFT_THRESHOLD_MS}. Two timestamps taken on either
* side of such a correction are skewed relative to each other by the amount of drift, so a span that starts before a
* correction and ends after it reports the wall clock time elapsed rather than the time the monotonic clock was
* running. See https://github.com/getsentry/sentry-javascript/issues/2590.
*/
export function timestampInSeconds(): number {
// We store this in a closure so that we don't have to create a new function every time this is called.
Expand DownExpand Up@@ -92,14 +113,13 @@ function getBrowserTimeOrigin(): number | undefined {
return undefined;
}

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

const timeOrigin = performance.timeOrigin;
if (typeof timeOrigin === 'number') {
const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow);
if (timeOriginDelta < threshold) {
if (timeOriginDelta < CLOCK_DRIFT_THRESHOLD_MS) {
return timeOrigin;
}
}
Expand Down
173 changes: 172 additions & 1 deletion packages/core/test/lib/utils/time.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

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

let freshImportCounter = 0;

async function getFreshTimestampInSeconds(): Promise<() => number> {
// A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would
// otherwise hand out a cached module.
const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`);
return timeModule.timestampInSeconds;
}

const RELIABLE_THRESHOLD_MS = 300_000;

describe('timestampInSeconds', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('derives the timestamp from `performance.timeOrigin` and `performance.now()`', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_234.56789;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('falls back to `Date.now()` if the performance API is unavailable', async () => {
const currentTimeMs = 1767778040866;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', undefined);

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('keeps using `performance.timeOrigin` while the clocks agree', async () => {
const currentTimeMs = 1767778040866;
// Below the drift threshold, so the (inaccurate) time origin must be preserved.
const timeOriginSkewMs = RELIABLE_THRESHOLD_MS - 2_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe((currentTimeMs + timeOriginSkewMs) / 1000);

timeSincePageloadMs = 5_000;
vi.setSystemTime(new Date(currentTimeMs + 4_000));

expect(timestampInSeconds()).toBe((currentTimeMs + 4_000 + timeOriginSkewMs) / 1000);
});

it('re-derives the time origin once the monotonic clock drifts from the wall clock', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_000;

// The monotonic clock pauses during sleep, so the wall clock advances much further than it does.
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);

vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));

expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs) / 1000);
});

it('keeps deriving elapsed time from the monotonic clock after re-deriving the time origin', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
const afterCorrection = timestampInSeconds();

// `Date.now()` deliberately stays put while the monotonic clock advances sub-millisecond, proving the elapsed
// time comes from `performance.now()` rather than from the coarser wall clock.
timeSincePageloadMs += 0.25;
expect(timestampInSeconds()).toBeCloseTo(afterCorrection + 0.25 / 1000, 10);
});

it('does not re-derive the time origin repeatedly once the clocks agree again', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
timestampInSeconds();

// Advance both clocks in lockstep: the re-derived time origin must stay valid, so timestamps track the wall clock
// exactly rather than oscillating between the two sources.
for (let i = 1; i <= 3; i++) {
timeSincePageloadMs += 1_000;
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs + i * 1_000));
expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs + i * 1_000) / 1000);
}
});

it('produces monotonically increasing timestamps when the wall clock steps backwards', async () => {
const currentTimeMs = 1767778040866;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

const before = timestampInSeconds();

// A backwards wall clock step (NTP correction, user changing the clock) beyond the threshold.
vi.setSystemTime(new Date(currentTimeMs - RELIABLE_THRESHOLD_MS - 60_000));
timeSincePageloadMs += 1_000;
const afterStep = timestampInSeconds();

// The correction itself moves the timestamp backwards, but elapsed time afterwards is still monotonic.
timeSincePageloadMs += 1_000;
expect(timestampInSeconds()).toBeGreaterThan(afterStep);
expect(before).toBeGreaterThan(afterStep);
});
});

describe('browserPerformanceTimeOrigin', () => {
it('returns `performance.timeOrigin` if it is available and reliable', async () => {
const timeOrigin = await getFreshPerformanceTimeOrigin();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions packages/core/src/utils/time.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ import { GLOBAL_OBJ } from './worldwide';

const ONE_SECOND_IN_MS = 1000;

/**
* Maximum tolerated difference between the monotonic clock and the wall clock before we consider
* the monotonic clock's time origin stale.
*/
const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds

/**
* A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance}
* for accessing a high-resolution monotonic clock.
Expand DownExpand Up@@ -39,19 +45,32 @@ function createUnixTimestampInSecondsFunc(): () => number {
return dateTimestampInSeconds;
}

const timeOrigin = performance.timeOrigin;

// performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current
// wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed.
//
// TODO: This does not account for the case where the monotonic clock that powers performance.now() drifts from the
// wall clock time, which causes the returned timestamp to be inaccurate. We should investigate how to detect and
// correct for this.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
let timeOrigin = performance.timeOrigin;

return () => {
return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS;
return withRandomSafeContext(() => {
const performanceNow = performance.now();
const dateNow = Date.now();

// `timeOrigin + performance.now()` only equals wall clock time for as long as both clocks advance in lockstep.
// performance.now() stops advancing while the device is asleep, so it under-counts elapsed wall time; conversely
// the wall clock itself can be stepped by Network Time Protocol (NTP) or the user. Either way the two drift apart
// by arbitrary amounts. Re-deriving the origin restores absolute accuracy while still taking elapsed time from
// the monotonic clock, so durations keep sub-millisecond precision and cannot run backwards.
// Timestamps taken before a correction are measured against a different origin than those taken after it, so a
// span that starts before one and ends after it absorbs the drift into its duration. Spans that lie entirely on
// one side of a correction are unaffected.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) {
timeOrigin = dateNow - performanceNow;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased timestamps leave performance entries behind

Medium Severity

timestampInSeconds() updates only its private timeOrigin, while browserPerformanceTimeOrigin() retains its cached pre-drift origin. After sleep, spans use the corrected timeline but performance entries and profiles remain offset, causing post-wake telemetry to be dropped or assigned incorrect times.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 456dd81. Configure here.


return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS;
});
};
}

Expand All@@ -61,10 +80,12 @@ let _cachedTimestampInSeconds: (() => number) | undefined;
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
* availability of the Performance API.
*
* BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is
* asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The
* skew can grow to arbitrary amounts like days, weeks or months.
* See https://github.com/getsentry/sentry-javascript/issues/2590.
* Because the Performance API's clock and the wall clock can drift apart (the former stops while the computer is
* asleep, the latter can be stepped by NTP or the user), the time origin they are combined against is re-derived from
* `Date.now()` whenever the two disagree by more than {@link CLOCK_DRIFT_THRESHOLD_MS}. Two timestamps taken on either
* side of such a correction are skewed relative to each other by the amount of drift, so a span that starts before a
* correction and ends after it reports the wall clock time elapsed rather than the time the monotonic clock was
* running. See https://github.com/getsentry/sentry-javascript/issues/2590.
*/
export function timestampInSeconds(): number {
// We store this in a closure so that we don't have to create a new function every time this is called.
Expand DownExpand Up@@ -92,14 +113,13 @@ function getBrowserTimeOrigin(): number | undefined {
return undefined;
}

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

const timeOrigin = performance.timeOrigin;
if (typeof timeOrigin === 'number') {
const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow);
if (timeOriginDelta < threshold) {
if (timeOriginDelta < CLOCK_DRIFT_THRESHOLD_MS) {
return timeOrigin;
}
}
Expand Down
173 changes: 172 additions & 1 deletion packages/core/test/lib/utils/time.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

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

let freshImportCounter = 0;

async function getFreshTimestampInSeconds(): Promise<() => number> {
// A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would
// otherwise hand out a cached module.
const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`);
return timeModule.timestampInSeconds;
}

const RELIABLE_THRESHOLD_MS = 300_000;

describe('timestampInSeconds', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('derives the timestamp from `performance.timeOrigin` and `performance.now()`', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_234.56789;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('falls back to `Date.now()` if the performance API is unavailable', async () => {
const currentTimeMs = 1767778040866;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', undefined);

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('keeps using `performance.timeOrigin` while the clocks agree', async () => {
const currentTimeMs = 1767778040866;
// Below the drift threshold, so the (inaccurate) time origin must be preserved.
const timeOriginSkewMs = RELIABLE_THRESHOLD_MS - 2_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe((currentTimeMs + timeOriginSkewMs) / 1000);

timeSincePageloadMs = 5_000;
vi.setSystemTime(new Date(currentTimeMs + 4_000));

expect(timestampInSeconds()).toBe((currentTimeMs + 4_000 + timeOriginSkewMs) / 1000);
});

it('re-derives the time origin once the monotonic clock drifts from the wall clock', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_000;

// The monotonic clock pauses during sleep, so the wall clock advances much further than it does.
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);

vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));

expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs) / 1000);
});

it('keeps deriving elapsed time from the monotonic clock after re-deriving the time origin', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
const afterCorrection = timestampInSeconds();

// `Date.now()` deliberately stays put while the monotonic clock advances sub-millisecond, proving the elapsed
// time comes from `performance.now()` rather than from the coarser wall clock.
timeSincePageloadMs += 0.25;
expect(timestampInSeconds()).toBeCloseTo(afterCorrection + 0.25 / 1000, 10);
});

it('does not re-derive the time origin repeatedly once the clocks agree again', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
timestampInSeconds();

// Advance both clocks in lockstep: the re-derived time origin must stay valid, so timestamps track the wall clock
// exactly rather than oscillating between the two sources.
for (let i = 1; i <= 3; i++) {
timeSincePageloadMs += 1_000;
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs + i * 1_000));
expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs + i * 1_000) / 1000);
}
});

it('produces monotonically increasing timestamps when the wall clock steps backwards', async () => {
const currentTimeMs = 1767778040866;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

const before = timestampInSeconds();

// A backwards wall clock step (NTP correction, user changing the clock) beyond the threshold.
vi.setSystemTime(new Date(currentTimeMs - RELIABLE_THRESHOLD_MS - 60_000));
timeSincePageloadMs += 1_000;
const afterStep = timestampInSeconds();

// The correction itself moves the timestamp backwards, but elapsed time afterwards is still monotonic.
timeSincePageloadMs += 1_000;
expect(timestampInSeconds()).toBeGreaterThan(afterStep);
expect(before).toBeGreaterThan(afterStep);
});
});

describe('browserPerformanceTimeOrigin', () => {
it('returns `performance.timeOrigin` if it is available and reliable', async () => {
const timeOrigin = await getFreshPerformanceTimeOrigin();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions packages/core/src/utils/time.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,12 @@ import { GLOBAL_OBJ } from './worldwide';

const ONE_SECOND_IN_MS = 1000;

/**
* Maximum tolerated difference between the monotonic clock and the wall clock before we consider
* the monotonic clock's time origin stale.
*/
const CLOCK_DRIFT_THRESHOLD_MS = 300_000; // 5 minutes in milliseconds

/**
* A partial definition of the [Performance Web API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Performance}
* for accessing a high-resolution monotonic clock.
Expand DownExpand Up@@ -39,19 +45,32 @@ function createUnixTimestampInSecondsFunc(): () => number {
return dateTimestampInSeconds;
}

const timeOrigin = performance.timeOrigin;

// performance.now() is a monotonic clock, which means it starts at 0 when the process begins. To get the current
// wall clock time (actual UNIX timestamp), we need to add the starting time origin and the current time elapsed.
//
// TODO: This does not account for the case where the monotonic clock that powers performance.now() drifts from the
// wall clock time, which causes the returned timestamp to be inaccurate. We should investigate how to detect and
// correct for this.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
let timeOrigin = performance.timeOrigin;

return () => {
return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS;
return withRandomSafeContext(() => {
const performanceNow = performance.now();
const dateNow = Date.now();

// `timeOrigin + performance.now()` only equals wall clock time for as long as both clocks advance in lockstep.
// performance.now() stops advancing while the device is asleep, so it under-counts elapsed wall time; conversely
// the wall clock itself can be stepped by Network Time Protocol (NTP) or the user. Either way the two drift apart
// by arbitrary amounts. Re-deriving the origin restores absolute accuracy while still taking elapsed time from
// the monotonic clock, so durations keep sub-millisecond precision and cannot run backwards.
// Timestamps taken before a correction are measured against a different origin than those taken after it, so a
// span that starts before one and ends after it absorbs the drift into its duration. Spans that lie entirely on
// one side of a correction are unaffected.
// See: https://github.com/getsentry/sentry-javascript/issues/2590
// See: https://github.com/mdn/content/issues/4713
// See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6
if (Math.abs(timeOrigin + performanceNow - dateNow) > CLOCK_DRIFT_THRESHOLD_MS) {
timeOrigin = dateNow - performanceNow;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased timestamps leave performance entries behind

Medium Severity

timestampInSeconds() updates only its private timeOrigin, while browserPerformanceTimeOrigin() retains its cached pre-drift origin. After sleep, spans use the corrected timeline but performance entries and profiles remain offset, causing post-wake telemetry to be dropped or assigned incorrect times.

Additional Locations (1)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 456dd81. Configure here.


return (timeOrigin + performanceNow) / ONE_SECOND_IN_MS;
});
};
}

Expand All@@ -61,10 +80,12 @@ let _cachedTimestampInSeconds: (() => number) | undefined;
* Returns a timestamp in seconds since the UNIX epoch using either the Performance or Date APIs, depending on the
* availability of the Performance API.
*
* BUG: Note that because of how browsers implement the Performance API, the clock might stop when the computer is
* asleep. This creates a skew between `dateTimestampInSeconds` and `timestampInSeconds`. The
* skew can grow to arbitrary amounts like days, weeks or months.
* See https://github.com/getsentry/sentry-javascript/issues/2590.
* Because the Performance API's clock and the wall clock can drift apart (the former stops while the computer is
* asleep, the latter can be stepped by NTP or the user), the time origin they are combined against is re-derived from
* `Date.now()` whenever the two disagree by more than {@link CLOCK_DRIFT_THRESHOLD_MS}. Two timestamps taken on either
* side of such a correction are skewed relative to each other by the amount of drift, so a span that starts before a
* correction and ends after it reports the wall clock time elapsed rather than the time the monotonic clock was
* running. See https://github.com/getsentry/sentry-javascript/issues/2590.
*/
export function timestampInSeconds(): number {
// We store this in a closure so that we don't have to create a new function every time this is called.
Expand DownExpand Up@@ -92,14 +113,13 @@ function getBrowserTimeOrigin(): number | undefined {
return undefined;
}

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

const timeOrigin = performance.timeOrigin;
if (typeof timeOrigin === 'number') {
const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow);
if (timeOriginDelta < threshold) {
if (timeOriginDelta < CLOCK_DRIFT_THRESHOLD_MS) {
return timeOrigin;
}
}
Expand Down
173 changes: 172 additions & 1 deletion packages/core/test/lib/utils/time.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

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

let freshImportCounter = 0;

async function getFreshTimestampInSeconds(): Promise<() => number> {
// A counter rather than `Date.now()`: these tests run under fake timers, which freeze the wall clock and would
// otherwise hand out a cached module.
const timeModule = await import(`../../../src/utils/time?update=${freshImportCounter++}`);
return timeModule.timestampInSeconds;
}

const RELIABLE_THRESHOLD_MS = 300_000;

describe('timestampInSeconds', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('derives the timestamp from `performance.timeOrigin` and `performance.now()`', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_234.56789;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('falls back to `Date.now()` if the performance API is unavailable', async () => {
const currentTimeMs = 1767778040866;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', undefined);

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);
});

it('keeps using `performance.timeOrigin` while the clocks agree', async () => {
const currentTimeMs = 1767778040866;
// Below the drift threshold, so the (inaccurate) time origin must be preserved.
const timeOriginSkewMs = RELIABLE_THRESHOLD_MS - 2_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs + timeOriginSkewMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe((currentTimeMs + timeOriginSkewMs) / 1000);

timeSincePageloadMs = 5_000;
vi.setSystemTime(new Date(currentTimeMs + 4_000));

expect(timestampInSeconds()).toBe((currentTimeMs + 4_000 + timeOriginSkewMs) / 1000);
});

it('re-derives the time origin once the monotonic clock drifts from the wall clock', async () => {
const currentTimeMs = 1767778040866;
const timeSincePageloadMs = 1_000;

// The monotonic clock pauses during sleep, so the wall clock advances much further than it does.
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

expect(timestampInSeconds()).toBe(currentTimeMs / 1000);

vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));

expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs) / 1000);
});

it('keeps deriving elapsed time from the monotonic clock after re-deriving the time origin', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
const afterCorrection = timestampInSeconds();

// `Date.now()` deliberately stays put while the monotonic clock advances sub-millisecond, proving the elapsed
// time comes from `performance.now()` rather than from the coarser wall clock.
timeSincePageloadMs += 0.25;
expect(timestampInSeconds()).toBeCloseTo(afterCorrection + 0.25 / 1000, 10);
});

it('does not re-derive the time origin repeatedly once the clocks agree again', async () => {
const currentTimeMs = 1767778040866;
const sleepDurationMs = RELIABLE_THRESHOLD_MS + 60_000;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

timestampInSeconds();
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs));
timestampInSeconds();

// Advance both clocks in lockstep: the re-derived time origin must stay valid, so timestamps track the wall clock
// exactly rather than oscillating between the two sources.
for (let i = 1; i <= 3; i++) {
timeSincePageloadMs += 1_000;
vi.setSystemTime(new Date(currentTimeMs + sleepDurationMs + i * 1_000));
expect(timestampInSeconds()).toBe((currentTimeMs + sleepDurationMs + i * 1_000) / 1000);
}
});

it('produces monotonically increasing timestamps when the wall clock steps backwards', async () => {
const currentTimeMs = 1767778040866;

let timeSincePageloadMs = 1_000;

vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimeMs));
vi.stubGlobal('performance', {
timeOrigin: currentTimeMs - timeSincePageloadMs,
now: () => timeSincePageloadMs,
});

const timestampInSeconds = await getFreshTimestampInSeconds();

const before = timestampInSeconds();

// A backwards wall clock step (NTP correction, user changing the clock) beyond the threshold.
vi.setSystemTime(new Date(currentTimeMs - RELIABLE_THRESHOLD_MS - 60_000));
timeSincePageloadMs += 1_000;
const afterStep = timestampInSeconds();

// The correction itself moves the timestamp backwards, but elapsed time afterwards is still monotonic.
timeSincePageloadMs += 1_000;
expect(timestampInSeconds()).toBeGreaterThan(afterStep);
expect(before).toBeGreaterThan(afterStep);
});
});

describe('browserPerformanceTimeOrigin', () => {
it('returns `performance.timeOrigin` if it is available and reliable', async () => {
const timeOrigin = await getFreshPerformanceTimeOrigin();
Expand Down
Loading