From 6e1678a847fae33bffbd197e30033e7379ada251 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 5 Jan 2026 15:33:09 +0200 Subject: [PATCH 01/24] chore(eslint): create a random id gen rule finder --- packages/core/.eslintrc.js | 11 ++ packages/eslint-plugin-sdk/src/index.js | 1 + .../src/rules/no-unsafe-random-apis.js | 124 ++++++++++++++ .../lib/rules/no-unsafe-random-apis.test.ts | 157 ++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js create mode 100644 packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index 5a021c016763..5ce5d0f72cd2 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -1,4 +1,15 @@ module.exports = { extends: ['../../.eslintrc.js'], ignorePatterns: ['rollup.npm.config.mjs'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/eslint-plugin-sdk/src/index.js b/packages/eslint-plugin-sdk/src/index.js index 24cc9c4cc00c..c23a1afcd373 100644 --- a/packages/eslint-plugin-sdk/src/index.js +++ b/packages/eslint-plugin-sdk/src/index.js @@ -15,5 +15,6 @@ module.exports = { 'no-regexp-constructor': require('./rules/no-regexp-constructor'), 'no-focused-tests': require('./rules/no-focused-tests'), 'no-skipped-tests': require('./rules/no-skipped-tests'), + 'no-unsafe-random-apis': require('./rules/no-unsafe-random-apis'), }, }; diff --git a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js new file mode 100644 index 000000000000..c5fff8c56960 --- /dev/null +++ b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js @@ -0,0 +1,124 @@ +'use strict'; + +/** + * @fileoverview Rule to enforce wrapping random/time APIs with runInRandomSafeContext + * + * This rule detects uses of APIs that generate random values or time-based values + * and ensures they are wrapped with `runInRandomSafeContext()` to ensure safe + * random number generation in certain contexts (e.g., React Server Components with caching). + */ + +// APIs that should be wrapped with runInRandomSafeContext +const UNSAFE_MEMBER_CALLS = [ + { object: 'Date', property: 'now' }, + { object: 'Math', property: 'random' }, + { object: 'performance', property: 'now' }, + { object: 'crypto', property: 'randomUUID' }, + { object: 'crypto', property: 'getRandomValues' }, +]; + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Enforce wrapping random/time APIs (Date.now, Math.random, performance.now, crypto.randomUUID) with runInRandomSafeContext', + category: 'Best Practices', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + unsafeRandomApi: + '{{ api }} should be wrapped with runInRandomSafeContext() to ensure safe random/time value generation. Use: runInRandomSafeContext(() => {{ api }}). You can disable this rule with an eslint-disable comment if this usage is intentional.', + }, + }, + create: function (context) { + /** + * Check if a node is inside a runInRandomSafeContext call + */ + function isInsideRunInRandomSafeContext(node) { + let current = node.parent; + + while (current) { + // Check if we're inside a callback passed to runInRandomSafeContext + if ( + current.type === 'CallExpression' && + current.callee.type === 'Identifier' && + current.callee.name === 'runInRandomSafeContext' + ) { + return true; + } + + // Also check for arrow functions or regular functions passed to runInRandomSafeContext + if ( + (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') && + current.parent?.type === 'CallExpression' && + current.parent.callee.type === 'Identifier' && + current.parent.callee.name === 'runInRandomSafeContext' + ) { + return true; + } + + current = current.parent; + } + + return false; + } + + /** + * Check if a node is inside the safeRandomGeneratorRunner.ts file (the definition file) + */ + function isInSafeRandomGeneratorRunner(_node) { + const filename = context.getFilename(); + return filename.includes('safeRandomGeneratorRunner'); + } + + return { + CallExpression(node) { + // Skip if we're in the safeRandomGeneratorRunner.ts file itself + if (isInSafeRandomGeneratorRunner(node)) { + return; + } + + // Check for member expression calls like Date.now(), Math.random(), etc. + if (node.callee.type === 'MemberExpression') { + const callee = node.callee; + + // Get the object name (e.g., 'Date', 'Math', 'performance', 'crypto') + let objectName = null; + if (callee.object.type === 'Identifier') { + objectName = callee.object.name; + } + + // Get the property name (e.g., 'now', 'random', 'randomUUID') + let propertyName = null; + if (callee.property.type === 'Identifier') { + propertyName = callee.property.name; + } else if (callee.computed && callee.property.type === 'Literal') { + propertyName = callee.property.value; + } + + if (!objectName || !propertyName) { + return; + } + + // Check if this is one of the unsafe APIs + const isUnsafeApi = UNSAFE_MEMBER_CALLS.some( + api => api.object === objectName && api.property === propertyName, + ); + + if (isUnsafeApi && !isInsideRunInRandomSafeContext(node)) { + context.report({ + node, + messageId: 'unsafeRandomApi', + data: { + api: `${objectName}.${propertyName}()`, + }, + }); + } + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts new file mode 100644 index 000000000000..f76166831d93 --- /dev/null +++ b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts @@ -0,0 +1,157 @@ +import { RuleTester } from 'eslint'; +import { describe, test } from 'vitest'; +// @ts-expect-error untyped module +import rule from '../../../src/rules/no-unsafe-random-apis'; + +describe('no-unsafe-random-apis', () => { + test('ruleTester', () => { + const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + }, + }); + + ruleTester.run('no-unsafe-random-apis', rule, { + valid: [ + // Wrapped with runInRandomSafeContext - arrow function + { + code: 'runInRandomSafeContext(() => Date.now())', + }, + { + code: 'runInRandomSafeContext(() => Math.random())', + }, + { + code: 'runInRandomSafeContext(() => performance.now())', + }, + { + code: 'runInRandomSafeContext(() => crypto.randomUUID())', + }, + { + code: 'runInRandomSafeContext(() => crypto.getRandomValues(new Uint8Array(16)))', + }, + // Wrapped with runInRandomSafeContext - regular function + { + code: 'runInRandomSafeContext(function() { return Date.now(); })', + }, + // Nested inside runInRandomSafeContext + { + code: 'runInRandomSafeContext(() => { const x = Date.now(); return x + Math.random(); })', + }, + // Expression inside runInRandomSafeContext + { + code: 'runInRandomSafeContext(() => Date.now() / 1000)', + }, + // Other unrelated calls should be fine + { + code: 'const x = someObject.now()', + }, + { + code: 'const x = Date.parse("2021-01-01")', + }, + { + code: 'const x = Math.floor(5.5)', + }, + { + code: 'const x = performance.mark("test")', + }, + ], + invalid: [ + // Direct Date.now() calls + { + code: 'const time = Date.now()', + errors: [ + { + messageId: 'unsafeRandomApi', + data: { api: 'Date.now()' }, + }, + ], + }, + // Direct Math.random() calls + { + code: 'const random = Math.random()', + errors: [ + { + messageId: 'unsafeRandomApi', + data: { api: 'Math.random()' }, + }, + ], + }, + // Direct performance.now() calls + { + code: 'const perf = performance.now()', + errors: [ + { + messageId: 'unsafeRandomApi', + data: { api: 'performance.now()' }, + }, + ], + }, + // Direct crypto.randomUUID() calls + { + code: 'const uuid = crypto.randomUUID()', + errors: [ + { + messageId: 'unsafeRandomApi', + data: { api: 'crypto.randomUUID()' }, + }, + ], + }, + // Direct crypto.getRandomValues() calls + { + code: 'const bytes = crypto.getRandomValues(new Uint8Array(16))', + errors: [ + { + messageId: 'unsafeRandomApi', + data: { api: 'crypto.getRandomValues()' }, + }, + ], + }, + // Inside a function but not wrapped + { + code: 'function getTime() { return Date.now(); }', + errors: [ + { + messageId: 'unsafeRandomApi', + data: { api: 'Date.now()' }, + }, + ], + }, + // Inside an arrow function but not wrapped with runInRandomSafeContext + { + code: 'const getTime = () => Date.now()', + errors: [ + { + messageId: 'unsafeRandomApi', + data: { api: 'Date.now()' }, + }, + ], + }, + // Inside someOtherWrapper + { + code: 'someOtherWrapper(() => Date.now())', + errors: [ + { + messageId: 'unsafeRandomApi', + data: { api: 'Date.now()' }, + }, + ], + }, + // Multiple violations + { + code: 'const a = Date.now(); const b = Math.random();', + errors: [ + { + messageId: 'unsafeRandomApi', + data: { api: 'Date.now()' }, + }, + { + messageId: 'unsafeRandomApi', + data: { api: 'Math.random()' }, + }, + ], + }, + ], + }); + }); +}); + From 708280b445640afdb7c6a1223461eda4748cc8b9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 5 Jan 2026 15:33:50 +0200 Subject: [PATCH 02/24] feat: implement a safe random runner --- .../src/utils/safeRandomGeneratorRunner.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 packages/core/src/utils/safeRandomGeneratorRunner.ts diff --git a/packages/core/src/utils/safeRandomGeneratorRunner.ts b/packages/core/src/utils/safeRandomGeneratorRunner.ts new file mode 100644 index 000000000000..148718453ad7 --- /dev/null +++ b/packages/core/src/utils/safeRandomGeneratorRunner.ts @@ -0,0 +1,41 @@ +import { GLOBAL_OBJ } from './worldwide'; + +type SafeRandomContextRunner = (callback: () => T) => T; + +let RESOLVED_RUNNER: SafeRandomContextRunner | undefined; + +/** + * Simple wrapper that allows SDKs to *secretly* set context wrapper to generate safe random IDs in cache components contexts + */ +export function runInRandomSafeContext(cb: () => T): T { + // Skips future symbol lookups if we've already resolved the runner once + if (RESOLVED_RUNNER) { + return RESOLVED_RUNNER(cb); + } + + const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: SafeRandomContextRunner } = GLOBAL_OBJ; + if (!(sym in globalWithSymbol) || typeof globalWithSymbol[sym] !== 'function') { + return cb(); + } + + RESOLVED_RUNNER = globalWithSymbol[sym]; + + return globalWithSymbol[sym](cb); +} + +/** + * Returns the current date and time wrapped in a safe context runner. + * @returns number The current date and time. + */ +export function safeDateNow(): number { + return runInRandomSafeContext(() => Date.now()); +} + +/** + * Returns a random number between 0 and 1 wrapped in a safe context runner. + * @returns number A random number between 0 and 1. + */ +export function safeMathRandom(): number { + return runInRandomSafeContext(() => Math.random()); +} From de30928ba64e5fbfe5422fc203333cf7c35c6ea8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 5 Jan 2026 15:34:08 +0200 Subject: [PATCH 03/24] fix: wrap all random APIs with the safe runner --- packages/core/src/client.ts | 3 ++- packages/core/src/integrations/mcp-server/correlation.ts | 3 ++- packages/core/src/scope.ts | 5 +++-- packages/core/src/tracing/trace.ts | 3 ++- packages/core/src/utils/misc.ts | 6 ++++-- packages/core/src/utils/ratelimit.ts | 7 ++++--- packages/core/src/utils/time.ts | 5 +++-- packages/core/src/utils/tracing.ts | 9 +++++---- packages/nextjs/src/server/index.ts | 2 ++ 9 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index aad363905a68..a2a4166ae02a 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -45,6 +45,7 @@ import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; +import { safeMathRandom } from './utils/safeRandomGeneratorRunner'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning } from './utils/spanUtils'; import { rejectedSyncPromise } from './utils/syncpromise'; @@ -1288,7 +1289,7 @@ export abstract class Client { // 0.0 === 0% events are sent // Sampling for transaction happens somewhere else const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); - if (isError && typeof parsedSampleRate === 'number' && Math.random() > parsedSampleRate) { + if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { this.recordDroppedEvent('sample_rate', 'error'); return rejectedSyncPromise( _makeDoNotSendEventError( diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 22517306c7cb..044f7f443bdb 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -8,6 +8,7 @@ import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types-hoist/span'; +import { safeDateNow } from '../../utils/safeRandomGeneratorRunner'; import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes'; import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction'; import { buildServerAttributesFromInfo, extractSessionDataFromInitializeResponse } from './sessionExtraction'; @@ -46,7 +47,7 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI spanMap.set(requestId, { span, method, - startTime: Date.now(), + startTime: safeDateNow(), }); } diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 0639cdb845f1..b923fdeb5d04 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -22,6 +22,7 @@ import { isPlainObject } from './utils/is'; import { merge } from './utils/merge'; import { uuid4 } from './utils/misc'; import { generateTraceId } from './utils/propagationContext'; +import { safeMathRandom } from './utils/safeRandomGeneratorRunner'; import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope'; import { truncate } from './utils/string'; import { dateTimestampInSeconds } from './utils/time'; @@ -168,7 +169,7 @@ export class Scope { this._sdkProcessingMetadata = {}; this._propagationContext = { traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }; } @@ -550,7 +551,7 @@ export class Scope { this._session = undefined; _setSpanForScope(this, undefined); this._attachments = []; - this.setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() }); + this.setPropagationContext({ traceId: generateTraceId(), sampleRand: safeMathRandom() }); this._notifyScopeListeners(); return this; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index b147bb92fa63..411bbdcbf5b3 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -17,6 +17,7 @@ import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { parseSampleRate } from '../utils/parseSampleRate'; import { generateTraceId } from '../utils/propagationContext'; +import { safeMathRandom } from '../utils/safeRandomGeneratorRunner'; import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope'; import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tracing'; @@ -293,7 +294,7 @@ export function startNewTrace(callback: () => T): T { return withScope(scope => { scope.setPropagationContext({ traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }); DEBUG_BUILD && debug.log(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); return withActiveSpan(null, callback); diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index 69cd217345b8..da3801d4c593 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -3,6 +3,7 @@ import type { Exception } from '../types-hoist/exception'; import type { Mechanism } from '../types-hoist/mechanism'; import type { StackFrame } from '../types-hoist/stackframe'; import { addNonEnumerableProperty } from './object'; +import { runInRandomSafeContext, safeMathRandom } from './safeRandomGeneratorRunner'; import { snipLine } from './string'; import { GLOBAL_OBJ } from './worldwide'; @@ -24,7 +25,7 @@ function getCrypto(): CryptoInternal | undefined { let emptyUuid: string | undefined; function getRandomByte(): number { - return Math.random() * 16; + return safeMathRandom() * 16; } /** @@ -35,7 +36,8 @@ function getRandomByte(): number { export function uuid4(crypto = getCrypto()): string { try { if (crypto?.randomUUID) { - return crypto.randomUUID().replace(/-/g, ''); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return runInRandomSafeContext(() => crypto.randomUUID!().replace(/-/g, '')); } } catch { // some runtimes can crash invoking crypto diff --git a/packages/core/src/utils/ratelimit.ts b/packages/core/src/utils/ratelimit.ts index 4cb8cb9d07a5..f96e602772aa 100644 --- a/packages/core/src/utils/ratelimit.ts +++ b/packages/core/src/utils/ratelimit.ts @@ -1,5 +1,6 @@ import type { DataCategory } from '../types-hoist/datacategory'; import type { TransportMakeRequestResponse } from '../types-hoist/transport'; +import { runInRandomSafeContext, safeDateNow } from './safeRandomGeneratorRunner'; // Intentionally keeping the key broad, as we don't know for sure what rate limit headers get returned from backend export type RateLimits = Record; @@ -12,7 +13,7 @@ export const DEFAULT_RETRY_AFTER = 60 * 1000; // 60 seconds * @param now current unix timestamp * */ -export function parseRetryAfterHeader(header: string, now: number = Date.now()): number { +export function parseRetryAfterHeader(header: string, now: number = runInRandomSafeContext(() => Date.now())): number { const headerDelay = parseInt(`${header}`, 10); if (!isNaN(headerDelay)) { return headerDelay * 1000; @@ -40,7 +41,7 @@ export function disabledUntil(limits: RateLimits, dataCategory: DataCategory): n /** * Checks if a category is rate limited */ -export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = Date.now()): boolean { +export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = safeDateNow()): boolean { return disabledUntil(limits, dataCategory) > now; } @@ -52,7 +53,7 @@ export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, no export function updateRateLimits( limits: RateLimits, { statusCode, headers }: TransportMakeRequestResponse, - now: number = Date.now(), + now: number = safeDateNow(), ): RateLimits { const updatedRateLimits: RateLimits = { ...limits, diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index ecaca1ea9e9b..d0d6a5acdec2 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -1,3 +1,4 @@ +import { runInRandomSafeContext } from './safeRandomGeneratorRunner'; import { GLOBAL_OBJ } from './worldwide'; const ONE_SECOND_IN_MS = 1000; @@ -21,7 +22,7 @@ interface Performance { * Returns a timestamp in seconds since the UNIX epoch using the Date API. */ export function dateTimestampInSeconds(): number { - return Date.now() / ONE_SECOND_IN_MS; + return runInRandomSafeContext(() => Date.now() / ONE_SECOND_IN_MS); } /** @@ -50,7 +51,7 @@ function createUnixTimestampInSecondsFunc(): () => number { // See: https://github.com/mdn/content/issues/4713 // See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6 return () => { - return (timeOrigin + performance.now()) / ONE_SECOND_IN_MS; + return runInRandomSafeContext(() => (timeOrigin + performance.now()) / ONE_SECOND_IN_MS); }; } diff --git a/packages/core/src/utils/tracing.ts b/packages/core/src/utils/tracing.ts index aa5a15153674..76f34bdca51f 100644 --- a/packages/core/src/utils/tracing.ts +++ b/packages/core/src/utils/tracing.ts @@ -7,6 +7,7 @@ import { baggageHeaderToDynamicSamplingContext } from './baggage'; import { extractOrgIdFromClient } from './dsn'; import { parseSampleRate } from './parseSampleRate'; import { generateSpanId, generateTraceId } from './propagationContext'; +import { safeMathRandom } from './safeRandomGeneratorRunner'; // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- RegExp is used for readability here export const TRACEPARENT_REGEXP = new RegExp( @@ -65,7 +66,7 @@ export function propagationContextFromHeaders( if (!traceparentData?.traceId) { return { traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }; } @@ -133,12 +134,12 @@ function getSampleRandFromTraceparentAndDsc( if (parsedSampleRate && traceparentData?.parentSampled !== undefined) { return traceparentData.parentSampled ? // Returns a sample rand with positive sampling decision [0, sampleRate) - Math.random() * parsedSampleRate + safeMathRandom() * parsedSampleRate : // Returns a sample rand with negative sampling decision [sampleRate, 1) - parsedSampleRate + Math.random() * (1 - parsedSampleRate); + parsedSampleRate + safeMathRandom() * (1 - parsedSampleRate); } else { // If nothing applies, return a random sample rand. - return Math.random(); + return safeMathRandom(); } } diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 18f3db003177..91d1dd65ca06 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -34,6 +34,7 @@ import { isBuild } from '../common/utils/isBuild'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { handleOnSpanStart } from './handleOnSpanStart'; +import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; export * from '@sentry/node'; @@ -92,6 +93,7 @@ export function showReportDialog(): void { /** Inits the Sentry NextJS SDK on node. */ export function init(options: NodeOptions): NodeClient | undefined { + prepareSafeIdGeneratorContext(); if (isBuild()) { return; } From 9031948285dbb4c52c738a8e3668da2b86a5e92e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 5 Jan 2026 15:34:52 +0200 Subject: [PATCH 04/24] fix: set the escape hatch in next sdk --- .../src/server/prepareSafeIdGeneratorContext.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts new file mode 100644 index 000000000000..dbf7e15a86d8 --- /dev/null +++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts @@ -0,0 +1,16 @@ +import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { DEBUG_BUILD } from '../common/debug-build'; + +type SafeRandomContextRunner = (callback: () => T) => T; + +/** + * Prepares the global object to generate safe random IDs in cache components contexts + * See: https://github.com/getsentry/sentry-javascript/blob/ceb003c15973c2d8f437dfb7025eedffbc8bc8b0/packages/core/src/utils/propagationContext.ts#L1 + */ +export function prepareSafeIdGeneratorContext(): void { + const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: SafeRandomContextRunner } = GLOBAL_OBJ; + globalWithSymbol[sym] = AsyncLocalStorage.snapshot(); + + DEBUG_BUILD && debug.log('[@sentry/nextjs] Prepared safe random ID generator context'); +} From fe8f4bc987c0d830fdec331a14f2d51202a75c2f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 5 Jan 2026 15:44:33 +0200 Subject: [PATCH 05/24] fix: format --- .../lib/rules/no-unsafe-random-apis.test.ts | 1 - .../server/prepareSafeIdGeneratorContext.ts | 28 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts index f76166831d93..3432b158b41e 100644 --- a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts +++ b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts @@ -154,4 +154,3 @@ describe('no-unsafe-random-apis', () => { }); }); }); - diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts index dbf7e15a86d8..b959ba039cd4 100644 --- a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts +++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts @@ -3,6 +3,8 @@ import { DEBUG_BUILD } from '../common/debug-build'; type SafeRandomContextRunner = (callback: () => T) => T; +type OriginalAsyncLocalStorage = typeof AsyncLocalStorage; + /** * Prepares the global object to generate safe random IDs in cache components contexts * See: https://github.com/getsentry/sentry-javascript/blob/ceb003c15973c2d8f437dfb7025eedffbc8bc8b0/packages/core/src/utils/propagationContext.ts#L1 @@ -10,7 +12,31 @@ type SafeRandomContextRunner = (callback: () => T) => T; export function prepareSafeIdGeneratorContext(): void { const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: SafeRandomContextRunner } = GLOBAL_OBJ; - globalWithSymbol[sym] = AsyncLocalStorage.snapshot(); + const als = getAsyncLocalStorage(); + if (!als) { + DEBUG_BUILD && + debug.warn( + '[@sentry/nextjs] No AsyncLocalStorage found in the runtime, skipping safe random ID generator context preparation, you may see some errors with Cache components.', + ); + return; + } + globalWithSymbol[sym] = als.snapshot(); DEBUG_BUILD && debug.log('[@sentry/nextjs] Prepared safe random ID generator context'); } + +function getAsyncLocalStorage(): OriginalAsyncLocalStorage | undefined { + // May exist in the Next.js runtime globals + if ('AsyncLocalStorage' in GLOBAL_OBJ) { + return AsyncLocalStorage; + } + + // Try to resolve it dynamically without synchronously importing the module + if ('getBuiltinModule' in process && typeof process.getBuiltinModule === 'function') { + const { AsyncLocalStorage } = process.getBuiltinModule('async_hooks') ?? {}; + + return AsyncLocalStorage as OriginalAsyncLocalStorage; + } + + return undefined; +} From 4b27d517cf379e629c32c6370604f951ec8df97b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 5 Jan 2026 16:13:54 +0200 Subject: [PATCH 06/24] fix: also patch otel pkg --- packages/core/src/index.ts | 5 +++++ packages/opentelemetry/.eslintrc.js | 11 +++++++++++ packages/opentelemetry/src/sampler.ts | 3 ++- packages/opentelemetry/src/spanExporter.ts | 9 +++++---- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e4b48f24de2f..46b98cc98c9a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -514,3 +514,8 @@ export type { UnstableRollupPluginOptions, UnstableWebpackPluginOptions, } from './build-time-plugins/buildTimeOptionsBase'; +export { + runInRandomSafeContext as _INTERNAL_runInRandomSafeContext, + safeDateNow as _INTERNAL_safeDateNow, + safeMathRandom as _INTERNAL_safeMathRandom, +} from './utils/safeRandomGeneratorRunner'; diff --git a/packages/opentelemetry/.eslintrc.js b/packages/opentelemetry/.eslintrc.js index fdb9952bae52..4b5e6310c8ee 100644 --- a/packages/opentelemetry/.eslintrc.js +++ b/packages/opentelemetry/.eslintrc.js @@ -3,4 +3,15 @@ module.exports = { node: true, }, extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index e06fe51bfd2a..7f7edd441612 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -12,6 +12,7 @@ import { } from '@opentelemetry/semantic-conventions'; import type { Client, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_safeMathRandom, baggageHeaderToDynamicSamplingContext, debug, hasSpansEnabled, @@ -121,7 +122,7 @@ export class SentrySampler implements Sampler { const dscString = parentContext?.traceState ? parentContext.traceState.get(SENTRY_TRACE_STATE_DSC) : undefined; const dsc = dscString ? baggageHeaderToDynamicSamplingContext(dscString) : undefined; - const sampleRand = parseSampleRate(dsc?.sample_rand) ?? Math.random(); + const sampleRand = parseSampleRate(dsc?.sample_rand) ?? _INTERNAL_safeMathRandom(); const [sampled, sampleRate, localSampleRateWasApplied] = sampleSpan( options, diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index ea85641387a5..f02df1d9d56c 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -12,6 +12,7 @@ import type { TransactionSource, } from '@sentry/core'; import { + _INTERNAL_safeDateNow, captureEvent, convertSpanLinksForEnvelope, debounce, @@ -82,7 +83,7 @@ export class SentrySpanExporter { }) { this._finishedSpanBucketSize = options?.timeout || DEFAULT_TIMEOUT; this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined); - this._lastCleanupTimestampInS = Math.floor(Date.now() / 1000); + this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); this._spansToBucketEntry = new WeakMap(); this._sentSpans = new Map(); this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 }); @@ -93,7 +94,7 @@ export class SentrySpanExporter { * This is called by the span processor whenever a span is ended. */ public export(span: ReadableSpan): void { - const currentTimestampInS = Math.floor(Date.now() / 1000); + const currentTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); if (this._lastCleanupTimestampInS !== currentTimestampInS) { let droppedSpanCount = 0; @@ -146,7 +147,7 @@ export class SentrySpanExporter { `SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`, ); - const expirationDate = Date.now() + DEFAULT_TIMEOUT * 1000; + const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000; for (const span of sentSpans) { this._sentSpans.set(span.spanContext().spanId, expirationDate); @@ -226,7 +227,7 @@ export class SentrySpanExporter { /** Remove "expired" span id entries from the _sentSpans cache. */ private _flushSentSpanCache(): void { - const currentTimestamp = Date.now(); + const currentTimestamp = _INTERNAL_safeDateNow(); // Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297 for (const [spanId, expirationTime] of this._sentSpans.entries()) { if (expirationTime <= currentTimestamp) { From 824d1b0dcb3e3b3ac0708ab6c6375ec7704f9649 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 6 Jan 2026 16:49:41 +0200 Subject: [PATCH 07/24] refactor: better comments and remove duplication --- packages/core/src/index.ts | 1 + packages/core/src/utils/ratelimit.ts | 4 ++-- .../core/src/utils/safeRandomGeneratorRunner.ts | 6 +++--- .../src/server/prepareSafeIdGeneratorContext.ts | 13 ++++++++----- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 46b98cc98c9a..d15be512310b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -518,4 +518,5 @@ export { runInRandomSafeContext as _INTERNAL_runInRandomSafeContext, safeDateNow as _INTERNAL_safeDateNow, safeMathRandom as _INTERNAL_safeMathRandom, + type RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, } from './utils/safeRandomGeneratorRunner'; diff --git a/packages/core/src/utils/ratelimit.ts b/packages/core/src/utils/ratelimit.ts index f96e602772aa..8c724fe3892b 100644 --- a/packages/core/src/utils/ratelimit.ts +++ b/packages/core/src/utils/ratelimit.ts @@ -1,6 +1,6 @@ import type { DataCategory } from '../types-hoist/datacategory'; import type { TransportMakeRequestResponse } from '../types-hoist/transport'; -import { runInRandomSafeContext, safeDateNow } from './safeRandomGeneratorRunner'; +import { safeDateNow } from './safeRandomGeneratorRunner'; // Intentionally keeping the key broad, as we don't know for sure what rate limit headers get returned from backend export type RateLimits = Record; @@ -13,7 +13,7 @@ export const DEFAULT_RETRY_AFTER = 60 * 1000; // 60 seconds * @param now current unix timestamp * */ -export function parseRetryAfterHeader(header: string, now: number = runInRandomSafeContext(() => Date.now())): number { +export function parseRetryAfterHeader(header: string, now: number = safeDateNow()): number { const headerDelay = parseInt(`${header}`, 10); if (!isNaN(headerDelay)) { return headerDelay * 1000; diff --git a/packages/core/src/utils/safeRandomGeneratorRunner.ts b/packages/core/src/utils/safeRandomGeneratorRunner.ts index 148718453ad7..871beac4fccb 100644 --- a/packages/core/src/utils/safeRandomGeneratorRunner.ts +++ b/packages/core/src/utils/safeRandomGeneratorRunner.ts @@ -1,8 +1,8 @@ import { GLOBAL_OBJ } from './worldwide'; -type SafeRandomContextRunner = (callback: () => T) => T; +export type RandomSafeContextRunner = (callback: () => T) => T; -let RESOLVED_RUNNER: SafeRandomContextRunner | undefined; +let RESOLVED_RUNNER: RandomSafeContextRunner | undefined; /** * Simple wrapper that allows SDKs to *secretly* set context wrapper to generate safe random IDs in cache components contexts @@ -14,7 +14,7 @@ export function runInRandomSafeContext(cb: () => T): T { } const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); - const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: SafeRandomContextRunner } = GLOBAL_OBJ; + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ; if (!(sym in globalWithSymbol) || typeof globalWithSymbol[sym] !== 'function') { return cb(); } diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts index b959ba039cd4..c9136b285ed3 100644 --- a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts +++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts @@ -1,8 +1,8 @@ -import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { type _INTERNAL_RandomSafeContextRunner as RandomSafeContextRunner, debug, GLOBAL_OBJ } from '@sentry/core'; import { DEBUG_BUILD } from '../common/debug-build'; -type SafeRandomContextRunner = (callback: () => T) => T; - +// Inline AsyncLocalStorage interface from current types +// Avoids conflict with resolving it from getBuiltinModule type OriginalAsyncLocalStorage = typeof AsyncLocalStorage; /** @@ -11,7 +11,7 @@ type OriginalAsyncLocalStorage = typeof AsyncLocalStorage; */ export function prepareSafeIdGeneratorContext(): void { const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); - const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: SafeRandomContextRunner } = GLOBAL_OBJ; + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ; const als = getAsyncLocalStorage(); if (!als) { DEBUG_BUILD && @@ -27,11 +27,14 @@ export function prepareSafeIdGeneratorContext(): void { function getAsyncLocalStorage(): OriginalAsyncLocalStorage | undefined { // May exist in the Next.js runtime globals - if ('AsyncLocalStorage' in GLOBAL_OBJ) { + // Doesn't exist in some of our tests + if (typeof AsyncLocalStorage !== 'undefined') { return AsyncLocalStorage; } // Try to resolve it dynamically without synchronously importing the module + // This is done to avoid importing the module synchronously at the top + // which means this is safe across runtimes if ('getBuiltinModule' in process && typeof process.getBuiltinModule === 'function') { const { AsyncLocalStorage } = process.getBuiltinModule('async_hooks') ?? {}; From 9f402286dab2e2c13b59416b0fbe3185589f2c73 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 6 Jan 2026 16:52:36 +0200 Subject: [PATCH 08/24] feat: eslint rule message improvements --- .../src/rules/no-unsafe-random-apis.js | 55 +++++++++++++------ .../lib/rules/no-unsafe-random-apis.test.ts | 30 ++++------ 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js index c5fff8c56960..3b112bacf84b 100644 --- a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js +++ b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js @@ -8,13 +8,33 @@ * random number generation in certain contexts (e.g., React Server Components with caching). */ -// APIs that should be wrapped with runInRandomSafeContext +// APIs that should be wrapped with runInRandomSafeContext, with their specific messages const UNSAFE_MEMBER_CALLS = [ - { object: 'Date', property: 'now' }, - { object: 'Math', property: 'random' }, - { object: 'performance', property: 'now' }, - { object: 'crypto', property: 'randomUUID' }, - { object: 'crypto', property: 'getRandomValues' }, + { + object: 'Date', + property: 'now', + messageId: 'unsafeDateNow', + }, + { + object: 'Math', + property: 'random', + messageId: 'unsafeMathRandom', + }, + { + object: 'performance', + property: 'now', + messageId: 'unsafePerformanceNow', + }, + { + object: 'crypto', + property: 'randomUUID', + messageId: 'unsafeCryptoRandomUUID', + }, + { + object: 'crypto', + property: 'getRandomValues', + messageId: 'unsafeCryptoGetRandomValues', + }, ]; module.exports = { @@ -29,8 +49,16 @@ module.exports = { fixable: null, schema: [], messages: { - unsafeRandomApi: - '{{ api }} should be wrapped with runInRandomSafeContext() to ensure safe random/time value generation. Use: runInRandomSafeContext(() => {{ api }}). You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeDateNow: + '`Date.now()` should be replaced with `safeDateNow()` from `@sentry/core` to ensure safe time value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeMathRandom: + '`Math.random()` should be replaced with `safeMathRandom()` from `@sentry/core` to ensure safe random value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafePerformanceNow: + '`performance.now()` should be wrapped with `runInRandomSafeContext()` to ensure safe time value generation. Use: `runInRandomSafeContext(() => performance.now())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeCryptoRandomUUID: + '`crypto.randomUUID()` should be wrapped with `runInRandomSafeContext()` to ensure safe random value generation. Use: `runInRandomSafeContext(() => crypto.randomUUID())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeCryptoGetRandomValues: + '`crypto.getRandomValues()` should be wrapped with `runInRandomSafeContext()` to ensure safe random value generation. Use: `runInRandomSafeContext(() => crypto.getRandomValues(...))`. You can disable this rule with an eslint-disable comment if this usage is intentional.', }, }, create: function (context) { @@ -104,17 +132,12 @@ module.exports = { } // Check if this is one of the unsafe APIs - const isUnsafeApi = UNSAFE_MEMBER_CALLS.some( - api => api.object === objectName && api.property === propertyName, - ); + const unsafeApi = UNSAFE_MEMBER_CALLS.find(api => api.object === objectName && api.property === propertyName); - if (isUnsafeApi && !isInsideRunInRandomSafeContext(node)) { + if (unsafeApi && !isInsideRunInRandomSafeContext(node)) { context.report({ node, - messageId: 'unsafeRandomApi', - data: { - api: `${objectName}.${propertyName}()`, - }, + messageId: unsafeApi.messageId, }); } } diff --git a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts index 3432b158b41e..a16b85c597b6 100644 --- a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts +++ b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts @@ -61,8 +61,7 @@ describe('no-unsafe-random-apis', () => { code: 'const time = Date.now()', errors: [ { - messageId: 'unsafeRandomApi', - data: { api: 'Date.now()' }, + messageId: 'unsafeDateNow', }, ], }, @@ -71,8 +70,7 @@ describe('no-unsafe-random-apis', () => { code: 'const random = Math.random()', errors: [ { - messageId: 'unsafeRandomApi', - data: { api: 'Math.random()' }, + messageId: 'unsafeMathRandom', }, ], }, @@ -81,8 +79,7 @@ describe('no-unsafe-random-apis', () => { code: 'const perf = performance.now()', errors: [ { - messageId: 'unsafeRandomApi', - data: { api: 'performance.now()' }, + messageId: 'unsafePerformanceNow', }, ], }, @@ -91,8 +88,7 @@ describe('no-unsafe-random-apis', () => { code: 'const uuid = crypto.randomUUID()', errors: [ { - messageId: 'unsafeRandomApi', - data: { api: 'crypto.randomUUID()' }, + messageId: 'unsafeCryptoRandomUUID', }, ], }, @@ -101,8 +97,7 @@ describe('no-unsafe-random-apis', () => { code: 'const bytes = crypto.getRandomValues(new Uint8Array(16))', errors: [ { - messageId: 'unsafeRandomApi', - data: { api: 'crypto.getRandomValues()' }, + messageId: 'unsafeCryptoGetRandomValues', }, ], }, @@ -111,8 +106,7 @@ describe('no-unsafe-random-apis', () => { code: 'function getTime() { return Date.now(); }', errors: [ { - messageId: 'unsafeRandomApi', - data: { api: 'Date.now()' }, + messageId: 'unsafeDateNow', }, ], }, @@ -121,8 +115,7 @@ describe('no-unsafe-random-apis', () => { code: 'const getTime = () => Date.now()', errors: [ { - messageId: 'unsafeRandomApi', - data: { api: 'Date.now()' }, + messageId: 'unsafeDateNow', }, ], }, @@ -131,8 +124,7 @@ describe('no-unsafe-random-apis', () => { code: 'someOtherWrapper(() => Date.now())', errors: [ { - messageId: 'unsafeRandomApi', - data: { api: 'Date.now()' }, + messageId: 'unsafeDateNow', }, ], }, @@ -141,12 +133,10 @@ describe('no-unsafe-random-apis', () => { code: 'const a = Date.now(); const b = Math.random();', errors: [ { - messageId: 'unsafeRandomApi', - data: { api: 'Date.now()' }, + messageId: 'unsafeDateNow', }, { - messageId: 'unsafeRandomApi', - data: { api: 'Math.random()' }, + messageId: 'unsafeMathRandom', }, ], }, From d029ed2989f0b03861c3c19af6f326c0d57f6d1b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 6 Jan 2026 16:57:07 +0200 Subject: [PATCH 09/24] style: enable the rule in all next sdk dep graph --- packages/nextjs/.eslintrc.js | 9 +++++++++ packages/node-core/.eslintrc.js | 9 +++++++++ packages/node-core/src/integrations/context.ts | 6 +++--- packages/node/.eslintrc.js | 9 +++++++++ packages/vercel-edge/.eslintrc.js | 9 +++++++++ 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/.eslintrc.js b/packages/nextjs/.eslintrc.js index 1f0ae547d4e0..4a5bdd17795e 100644 --- a/packages/nextjs/.eslintrc.js +++ b/packages/nextjs/.eslintrc.js @@ -7,6 +7,9 @@ module.exports = { jsx: true, }, extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, overrides: [ { files: ['scripts/**/*.ts'], @@ -27,5 +30,11 @@ module.exports = { globalThis: 'readonly', }, }, + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, ], }; diff --git a/packages/node-core/.eslintrc.js b/packages/node-core/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/node-core/.eslintrc.js +++ b/packages/node-core/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index cad8a1c4a443..c0bc1be1ffd9 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -15,7 +15,7 @@ import type { IntegrationFn, OsContext, } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { _INTERNAL_safeDateNow, defineIntegration } from '@sentry/core'; export const readFileAsync = promisify(readFile); export const readDirAsync = promisify(readdir); @@ -204,7 +204,7 @@ function getCultureContext(): CultureContext | undefined { */ export function getAppContext(): AppContext { const app_memory = process.memoryUsage().rss; - const app_start_time = new Date(Date.now() - process.uptime() * 1000).toISOString(); + const app_start_time = new Date(_INTERNAL_safeDateNow() - process.uptime() * 1000).toISOString(); // https://nodejs.org/api/process.html#processavailablememory const appContext: AppContext = { app_start_time, app_memory }; @@ -236,7 +236,7 @@ export function getDeviceContext(deviceOpt: DeviceContextOptions | true): Device // Hence, we only set boot time, if we get a valid uptime value. // @see https://github.com/getsentry/sentry-javascript/issues/5856 if (typeof uptime === 'number') { - device.boot_time = new Date(Date.now() - uptime * 1000).toISOString(); + device.boot_time = new Date(_INTERNAL_safeDateNow() - uptime * 1000).toISOString(); } device.arch = os.arch(); diff --git a/packages/node/.eslintrc.js b/packages/node/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/node/.eslintrc.js +++ b/packages/node/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/vercel-edge/.eslintrc.js b/packages/vercel-edge/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/vercel-edge/.eslintrc.js +++ b/packages/vercel-edge/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; From 61256efea5aa5a64aab2ec728c366c2e02c69365 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 6 Jan 2026 17:13:54 +0200 Subject: [PATCH 10/24] test: added e2e test --- .../app/metadata/page.tsx | 35 +++++++++++++++++++ .../tests/cacheComponents.spec.ts | 13 +++++++ 2 files changed, 48 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx new file mode 100644 index 000000000000..7bcdbd0474e0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/nextjs'; + +/** + * Tests generateMetadata function with cache components, this calls the propagation context to be set + * Which will generate and set a trace id in the propagation context, which should trigger the random API error if unpatched + * See: https://github.com/getsentry/sentry-javascript/issues/18392 + */ +export function generateMetadata() { + return { + title: 'Cache Components Metadata Test', + }; +} + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts index 9f7b0ca559be..60f92119e595 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -26,3 +26,16 @@ test('Should render suspense component', async ({ page }) => { expect(serverTx.spans?.filter(span => span.op === 'get.todos').length).toBeGreaterThan(0); await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); }); + +test('Should generate metadata', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/metadata'); + const serverTx = await serverTxPromise; + + expect(serverTx.spans?.filter(span => span.op === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + await expect(page).toHaveTitle('Cache Components Metadata Test'); +}); From 4caada8a2cbce5555efdf56174aa6ae7309dc528 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 6 Jan 2026 17:37:01 +0200 Subject: [PATCH 11/24] fix: use safe wrappers and ignore rule when not needed in next sdk --- .../wrapApiHandlerWithSentryVercelCrons.ts | 10 +++++----- packages/nextjs/src/config/polyfills/perf_hooks.js | 1 + packages/nextjs/src/config/withSentryConfig.ts | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts index c85bdc4f2ad3..8cd0c016d0fb 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts @@ -1,4 +1,4 @@ -import { captureCheckIn } from '@sentry/core'; +import { _INTERNAL_safeDateNow, captureCheckIn } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { VercelCronsConfig } from '../types'; @@ -57,14 +57,14 @@ export function wrapApiHandlerWithSentryVercelCrons { captureCheckIn({ checkInId, monitorSlug, status: 'error', - duration: Date.now() / 1000 - startTime, + duration: _INTERNAL_safeDateNow() / 1000 - startTime, }); }; @@ -82,7 +82,7 @@ export function wrapApiHandlerWithSentryVercelCrons { @@ -98,7 +98,7 @@ export function wrapApiHandlerWithSentryVercelCrons(nextConfig?: C, sentryBuildOptions: SentryBu */ function generateRandomTunnelRoute(): string { // Generate a random 8-character alphanumeric string + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis const randomString = Math.random().toString(36).substring(2, 10); return `/${randomString}`; } From d8ec196fc842c68a4db543f0d34563e16d63852a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 7 Jan 2026 13:02:35 +0200 Subject: [PATCH 12/24] test: added async metadata test --- .../app/metadata-async/page.tsx | 37 +++++++++++++++++++ .../tests/cacheComponents.spec.ts | 13 +++++++ 2 files changed, 50 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx new file mode 100644 index 000000000000..03201cdccf60 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/nextjs'; + +function fetchPost() { + return Promise.resolve({ id: '1', title: 'Post 1' }); +} + +export async function generateMetadata() { + const { id } = await fetchPost(); + const product = `Product: ${id}`; + + return { + title: product, + }; +} + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts index 60f92119e595..9a60ac59cd8f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -39,3 +39,16 @@ test('Should generate metadata', async ({ page }) => { await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); await expect(page).toHaveTitle('Cache Components Metadata Test'); }); + +test('Should generate metadata async', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/metadata-async'); + const serverTx = await serverTxPromise; + + expect(serverTx.spans?.filter(span => span.op === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + await expect(page).toHaveTitle('Product: 1'); +}); From e29378cfe94330ef555646665b237ba2d349b8f2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 7 Jan 2026 14:33:19 +0200 Subject: [PATCH 13/24] chore: bump size-limit --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 24772d8380f5..16d10962602b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -82,7 +82,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '85 KB', + limit: '85.5 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -243,7 +243,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '42 KB', + limit: '42.5 KB', }, // Node-Core SDK (ESM) { From 9bc80c29d586cadc186fa4f50bdf99cac50c9306 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 7 Jan 2026 16:21:37 +0200 Subject: [PATCH 14/24] fix: check for snapshot existence first --- packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts index c9136b285ed3..2275e0dfccec 100644 --- a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts +++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts @@ -13,10 +13,10 @@ export function prepareSafeIdGeneratorContext(): void { const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ; const als = getAsyncLocalStorage(); - if (!als) { + if (!als || typeof als.snapshot !== 'function') { DEBUG_BUILD && debug.warn( - '[@sentry/nextjs] No AsyncLocalStorage found in the runtime, skipping safe random ID generator context preparation, you may see some errors with Cache components.', + '[@sentry/nextjs] No AsyncLocalStorage found in the runtime or AsyncLocalStorage.snapshot() is not available, skipping safe random ID generator context preparation, you may see some errors with cache components.', ); return; } From dec6f70569f218ce6c22b5d27fc03915c7d8b0d6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 7 Jan 2026 20:07:00 +0200 Subject: [PATCH 15/24] refactor: revert all core changes --- packages/core/.eslintrc.js | 11 -- packages/core/src/client.ts | 3 +- packages/core/src/constants.ts | 1 - packages/core/src/index.ts | 8 +- .../integrations/mcp-server/correlation.ts | 3 +- packages/core/src/scope.ts | 5 +- packages/core/src/tracing/trace.ts | 3 +- packages/core/src/utils/misc.ts | 6 +- packages/core/src/utils/ratelimit.ts | 7 +- packages/core/src/utils/time.ts | 48 +++--- packages/core/src/utils/tracing.ts | 9 +- packages/core/test/lib/utils/time.test.ts | 25 +-- packages/eslint-plugin-sdk/src/index.js | 1 - .../src/rules/no-unsafe-random-apis.js | 147 ------------------ .../lib/rules/no-unsafe-random-apis.test.ts | 146 ----------------- packages/nextjs/.eslintrc.js | 9 -- .../wrapApiHandlerWithSentryVercelCrons.ts | 10 +- .../nextjs/src/config/polyfills/perf_hooks.js | 1 - .../nextjs/src/config/withSentryConfig.ts | 1 - packages/node-core/.eslintrc.js | 9 -- .../node-core/src/integrations/context.ts | 6 +- packages/node/.eslintrc.js | 9 -- packages/opentelemetry/.eslintrc.js | 11 -- packages/opentelemetry/src/sampler.ts | 3 +- packages/opentelemetry/src/spanExporter.ts | 9 +- packages/vercel-edge/.eslintrc.js | 9 -- 26 files changed, 67 insertions(+), 433 deletions(-) delete mode 100644 packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js delete mode 100644 packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index 5ce5d0f72cd2..5a021c016763 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -1,15 +1,4 @@ module.exports = { extends: ['../../.eslintrc.js'], ignorePatterns: ['rollup.npm.config.mjs'], - rules: { - '@sentry-internal/sdk/no-unsafe-random-apis': 'error', - }, - overrides: [ - { - files: ['test/**/*.ts', 'test/**/*.tsx'], - rules: { - '@sentry-internal/sdk/no-unsafe-random-apis': 'off', - }, - }, - ], }; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index a2a4166ae02a..aad363905a68 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -45,7 +45,6 @@ import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; -import { safeMathRandom } from './utils/safeRandomGeneratorRunner'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning } from './utils/spanUtils'; import { rejectedSyncPromise } from './utils/syncpromise'; @@ -1289,7 +1288,7 @@ export abstract class Client { // 0.0 === 0% events are sent // Sampling for transaction happens somewhere else const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); - if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { + if (isError && typeof parsedSampleRate === 'number' && Math.random() > parsedSampleRate) { this.recordDroppedEvent('sample_rate', 'error'); return rejectedSyncPromise( _makeDoNotSendEventError( diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 7fdc380faf0d..38475b857ace 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1,2 +1 @@ export const DEFAULT_ENVIRONMENT = 'production'; -export const DEV_ENVIRONMENT = 'development'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d15be512310b..5e2f37e20a72 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,7 +101,7 @@ export { headersToDict, httpHeadersToSpanAttributes, } from './utils/request'; -export { DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from './constants'; +export { DEFAULT_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; export { functionToStringIntegration } from './integrations/functiontostring'; // eslint-disable-next-line deprecation/deprecation @@ -514,9 +514,3 @@ export type { UnstableRollupPluginOptions, UnstableWebpackPluginOptions, } from './build-time-plugins/buildTimeOptionsBase'; -export { - runInRandomSafeContext as _INTERNAL_runInRandomSafeContext, - safeDateNow as _INTERNAL_safeDateNow, - safeMathRandom as _INTERNAL_safeMathRandom, - type RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, -} from './utils/safeRandomGeneratorRunner'; diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 044f7f443bdb..22517306c7cb 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -8,7 +8,6 @@ import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types-hoist/span'; -import { safeDateNow } from '../../utils/safeRandomGeneratorRunner'; import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes'; import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction'; import { buildServerAttributesFromInfo, extractSessionDataFromInitializeResponse } from './sessionExtraction'; @@ -47,7 +46,7 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI spanMap.set(requestId, { span, method, - startTime: safeDateNow(), + startTime: Date.now(), }); } diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index b923fdeb5d04..0639cdb845f1 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -22,7 +22,6 @@ import { isPlainObject } from './utils/is'; import { merge } from './utils/merge'; import { uuid4 } from './utils/misc'; import { generateTraceId } from './utils/propagationContext'; -import { safeMathRandom } from './utils/safeRandomGeneratorRunner'; import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope'; import { truncate } from './utils/string'; import { dateTimestampInSeconds } from './utils/time'; @@ -169,7 +168,7 @@ export class Scope { this._sdkProcessingMetadata = {}; this._propagationContext = { traceId: generateTraceId(), - sampleRand: safeMathRandom(), + sampleRand: Math.random(), }; } @@ -551,7 +550,7 @@ export class Scope { this._session = undefined; _setSpanForScope(this, undefined); this._attachments = []; - this.setPropagationContext({ traceId: generateTraceId(), sampleRand: safeMathRandom() }); + this.setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() }); this._notifyScopeListeners(); return this; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 411bbdcbf5b3..b147bb92fa63 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -17,7 +17,6 @@ import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { parseSampleRate } from '../utils/parseSampleRate'; import { generateTraceId } from '../utils/propagationContext'; -import { safeMathRandom } from '../utils/safeRandomGeneratorRunner'; import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope'; import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tracing'; @@ -294,7 +293,7 @@ export function startNewTrace(callback: () => T): T { return withScope(scope => { scope.setPropagationContext({ traceId: generateTraceId(), - sampleRand: safeMathRandom(), + sampleRand: Math.random(), }); DEBUG_BUILD && debug.log(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); return withActiveSpan(null, callback); diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index da3801d4c593..69cd217345b8 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -3,7 +3,6 @@ import type { Exception } from '../types-hoist/exception'; import type { Mechanism } from '../types-hoist/mechanism'; import type { StackFrame } from '../types-hoist/stackframe'; import { addNonEnumerableProperty } from './object'; -import { runInRandomSafeContext, safeMathRandom } from './safeRandomGeneratorRunner'; import { snipLine } from './string'; import { GLOBAL_OBJ } from './worldwide'; @@ -25,7 +24,7 @@ function getCrypto(): CryptoInternal | undefined { let emptyUuid: string | undefined; function getRandomByte(): number { - return safeMathRandom() * 16; + return Math.random() * 16; } /** @@ -36,8 +35,7 @@ function getRandomByte(): number { export function uuid4(crypto = getCrypto()): string { try { if (crypto?.randomUUID) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return runInRandomSafeContext(() => crypto.randomUUID!().replace(/-/g, '')); + return crypto.randomUUID().replace(/-/g, ''); } } catch { // some runtimes can crash invoking crypto diff --git a/packages/core/src/utils/ratelimit.ts b/packages/core/src/utils/ratelimit.ts index 8c724fe3892b..4cb8cb9d07a5 100644 --- a/packages/core/src/utils/ratelimit.ts +++ b/packages/core/src/utils/ratelimit.ts @@ -1,6 +1,5 @@ import type { DataCategory } from '../types-hoist/datacategory'; import type { TransportMakeRequestResponse } from '../types-hoist/transport'; -import { safeDateNow } from './safeRandomGeneratorRunner'; // Intentionally keeping the key broad, as we don't know for sure what rate limit headers get returned from backend export type RateLimits = Record; @@ -13,7 +12,7 @@ export const DEFAULT_RETRY_AFTER = 60 * 1000; // 60 seconds * @param now current unix timestamp * */ -export function parseRetryAfterHeader(header: string, now: number = safeDateNow()): number { +export function parseRetryAfterHeader(header: string, now: number = Date.now()): number { const headerDelay = parseInt(`${header}`, 10); if (!isNaN(headerDelay)) { return headerDelay * 1000; @@ -41,7 +40,7 @@ export function disabledUntil(limits: RateLimits, dataCategory: DataCategory): n /** * Checks if a category is rate limited */ -export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = safeDateNow()): boolean { +export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = Date.now()): boolean { return disabledUntil(limits, dataCategory) > now; } @@ -53,7 +52,7 @@ export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, no export function updateRateLimits( limits: RateLimits, { statusCode, headers }: TransportMakeRequestResponse, - now: number = safeDateNow(), + now: number = Date.now(), ): RateLimits { const updatedRateLimits: RateLimits = { ...limits, diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index d0d6a5acdec2..73ff34a30cc5 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -1,4 +1,3 @@ -import { runInRandomSafeContext } from './safeRandomGeneratorRunner'; import { GLOBAL_OBJ } from './worldwide'; const ONE_SECOND_IN_MS = 1000; @@ -22,7 +21,7 @@ interface Performance { * Returns a timestamp in seconds since the UNIX epoch using the Date API. */ export function dateTimestampInSeconds(): number { - return runInRandomSafeContext(() => Date.now() / ONE_SECOND_IN_MS); + return Date.now() / ONE_SECOND_IN_MS; } /** @@ -51,7 +50,7 @@ function createUnixTimestampInSecondsFunc(): () => number { // See: https://github.com/mdn/content/issues/4713 // See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6 return () => { - return runInRandomSafeContext(() => (timeOrigin + performance.now()) / ONE_SECOND_IN_MS); + return (timeOrigin + performance.now()) / ONE_SECOND_IN_MS; }; } @@ -79,14 +78,12 @@ let cachedTimeOrigin: number | null | undefined = null; /** * Gets the time origin and the mode used to determine it. - * - * Unfortunately browsers may report an inaccurate time origin data, through either performance.timeOrigin or - * performance.timing.navigationStart, which results in poor results in performance data. We only treat time origin - * data as reliable if they are within a reasonable threshold of the current time. - * * TODO: move to `@sentry/browser-utils` package. */ function getBrowserTimeOrigin(): number | undefined { + // Unfortunately browsers may report an inaccurate time origin data, through either performance.timeOrigin or + // performance.timing.navigationStart, which results in poor results in performance data. We only treat time origin + // data as reliable if they are within a reasonable threshold of the current time. const { performance } = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; if (!performance?.now) { return undefined; @@ -96,13 +93,11 @@ function getBrowserTimeOrigin(): number | undefined { const performanceNow = performance.now(); const dateNow = Date.now(); - const timeOrigin = performance.timeOrigin; - if (typeof timeOrigin === 'number') { - const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow); - if (timeOriginDelta < threshold) { - return timeOrigin; - } - } + // if timeOrigin isn't available set delta to threshold so it isn't used + const timeOriginDelta = performance.timeOrigin + ? Math.abs(performance.timeOrigin + performanceNow - dateNow) + : threshold; + const timeOriginIsReliable = timeOriginDelta < threshold; // TODO: Remove all code related to `performance.timing.navigationStart` once we drop support for Safari 14. // `performance.timeSince` is available in Safari 15. @@ -115,16 +110,23 @@ function getBrowserTimeOrigin(): number | undefined { // Date API. // eslint-disable-next-line deprecation/deprecation const navigationStart = performance.timing?.navigationStart; - if (typeof navigationStart === 'number') { - const navigationStartDelta = Math.abs(navigationStart + performanceNow - dateNow); - if (navigationStartDelta < threshold) { - return navigationStart; - } + const hasNavigationStart = typeof navigationStart === 'number'; + // if navigationStart isn't available set delta to threshold so it isn't used + const navigationStartDelta = hasNavigationStart ? Math.abs(navigationStart + performanceNow - dateNow) : threshold; + const navigationStartIsReliable = navigationStartDelta < threshold; + + // TODO: Since timeOrigin explicitly replaces navigationStart, we should probably remove the navigationStartIsReliable check. + if (timeOriginIsReliable && timeOriginDelta <= navigationStartDelta) { + return performance.timeOrigin; + } + + if (navigationStartIsReliable) { + return navigationStart; } - // Either both timeOrigin and navigationStart are skewed or neither is available, fallback to subtracting - // `performance.now()` from `Date.now()`. - return dateNow - performanceNow; + // TODO: We should probably fall back to Date.now() - performance.now(), since this is still more accurate than just Date.now() (?) + // Either both timeOrigin and navigationStart are skewed or neither is available, fallback to Date. + return dateNow; } /** diff --git a/packages/core/src/utils/tracing.ts b/packages/core/src/utils/tracing.ts index 76f34bdca51f..aa5a15153674 100644 --- a/packages/core/src/utils/tracing.ts +++ b/packages/core/src/utils/tracing.ts @@ -7,7 +7,6 @@ import { baggageHeaderToDynamicSamplingContext } from './baggage'; import { extractOrgIdFromClient } from './dsn'; import { parseSampleRate } from './parseSampleRate'; import { generateSpanId, generateTraceId } from './propagationContext'; -import { safeMathRandom } from './safeRandomGeneratorRunner'; // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- RegExp is used for readability here export const TRACEPARENT_REGEXP = new RegExp( @@ -66,7 +65,7 @@ export function propagationContextFromHeaders( if (!traceparentData?.traceId) { return { traceId: generateTraceId(), - sampleRand: safeMathRandom(), + sampleRand: Math.random(), }; } @@ -134,12 +133,12 @@ function getSampleRandFromTraceparentAndDsc( if (parsedSampleRate && traceparentData?.parentSampled !== undefined) { return traceparentData.parentSampled ? // Returns a sample rand with positive sampling decision [0, sampleRate) - safeMathRandom() * parsedSampleRate + Math.random() * parsedSampleRate : // Returns a sample rand with negative sampling decision [sampleRate, 1) - parsedSampleRate + safeMathRandom() * (1 - parsedSampleRate); + parsedSampleRate + Math.random() * (1 - parsedSampleRate); } else { // If nothing applies, return a random sample rand. - return safeMathRandom(); + return Math.random(); } } diff --git a/packages/core/test/lib/utils/time.test.ts b/packages/core/test/lib/utils/time.test.ts index a1d537df5862..f5b6cc93fc35 100644 --- a/packages/core/test/lib/utils/time.test.ts +++ b/packages/core/test/lib/utils/time.test.ts @@ -27,12 +27,12 @@ describe('browserPerformanceTimeOrigin', () => { vi.unstubAllGlobals(); }); - it('returns `Date.now() - performance.now()` if `performance.timeOrigin` is not reliable', async () => { + it('returns `Date.now()` if `performance.timeOrigin` is not reliable', async () => { const currentTimeMs = 1767778040866; const unreliableTime = currentTimeMs - RELIABLE_THRESHOLD_MS - 2_000; - const timeSincePageloadMs = 1_234.56789; + const timeSincePageloadMs = 1_234.789; vi.useFakeTimers(); vi.setSystemTime(new Date(currentTimeMs)); @@ -46,36 +46,39 @@ describe('browserPerformanceTimeOrigin', () => { }); const timeOrigin = await getFreshPerformanceTimeOrigin(); - expect(timeOrigin).toBe(currentTimeMs - timeSincePageloadMs); + expect(timeOrigin).toBe(1767778040866); vi.useRealTimers(); vi.unstubAllGlobals(); }); - it('returns `Date.now() - performance.now()` if neither `performance.timeOrigin` nor `performance.timing.navigationStart` are available', async () => { - const currentTimeMs = 1767778040866; + it('returns `performance.timing.navigationStart` if `performance.timeOrigin` is not available', async () => { + const currentTimeMs = 1767778040870; - const timeSincePageloadMs = 1_234.56789; + const navigationStartMs = currentTimeMs - 2_000; + + const timeSincePageloadMs = 1_234.789; vi.useFakeTimers(); vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { timeOrigin: undefined, timing: { - navigationStart: undefined, + navigationStart: navigationStartMs, }, now: () => timeSincePageloadMs, }); const timeOrigin = await getFreshPerformanceTimeOrigin(); - expect(timeOrigin).toBe(currentTimeMs - timeSincePageloadMs); + expect(timeOrigin).toBe(navigationStartMs); vi.useRealTimers(); vi.unstubAllGlobals(); }); - it('returns `performance.timing.navigationStart` if `performance.timeOrigin` is not available', async () => { - const currentTimeMs = 1767778040870; + it('returns `performance.timing.navigationStart` if `performance.timeOrigin` is less reliable', async () => { + const currentTimeMs = 1767778040874; const navigationStartMs = currentTimeMs - 2_000; @@ -85,7 +88,7 @@ describe('browserPerformanceTimeOrigin', () => { vi.setSystemTime(new Date(currentTimeMs)); vi.stubGlobal('performance', { - timeOrigin: undefined, + timeOrigin: navigationStartMs - 1, timing: { navigationStart: navigationStartMs, }, diff --git a/packages/eslint-plugin-sdk/src/index.js b/packages/eslint-plugin-sdk/src/index.js index c23a1afcd373..24cc9c4cc00c 100644 --- a/packages/eslint-plugin-sdk/src/index.js +++ b/packages/eslint-plugin-sdk/src/index.js @@ -15,6 +15,5 @@ module.exports = { 'no-regexp-constructor': require('./rules/no-regexp-constructor'), 'no-focused-tests': require('./rules/no-focused-tests'), 'no-skipped-tests': require('./rules/no-skipped-tests'), - 'no-unsafe-random-apis': require('./rules/no-unsafe-random-apis'), }, }; diff --git a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js deleted file mode 100644 index 3b112bacf84b..000000000000 --- a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js +++ /dev/null @@ -1,147 +0,0 @@ -'use strict'; - -/** - * @fileoverview Rule to enforce wrapping random/time APIs with runInRandomSafeContext - * - * This rule detects uses of APIs that generate random values or time-based values - * and ensures they are wrapped with `runInRandomSafeContext()` to ensure safe - * random number generation in certain contexts (e.g., React Server Components with caching). - */ - -// APIs that should be wrapped with runInRandomSafeContext, with their specific messages -const UNSAFE_MEMBER_CALLS = [ - { - object: 'Date', - property: 'now', - messageId: 'unsafeDateNow', - }, - { - object: 'Math', - property: 'random', - messageId: 'unsafeMathRandom', - }, - { - object: 'performance', - property: 'now', - messageId: 'unsafePerformanceNow', - }, - { - object: 'crypto', - property: 'randomUUID', - messageId: 'unsafeCryptoRandomUUID', - }, - { - object: 'crypto', - property: 'getRandomValues', - messageId: 'unsafeCryptoGetRandomValues', - }, -]; - -module.exports = { - meta: { - type: 'problem', - docs: { - description: - 'Enforce wrapping random/time APIs (Date.now, Math.random, performance.now, crypto.randomUUID) with runInRandomSafeContext', - category: 'Best Practices', - recommended: true, - }, - fixable: null, - schema: [], - messages: { - unsafeDateNow: - '`Date.now()` should be replaced with `safeDateNow()` from `@sentry/core` to ensure safe time value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', - unsafeMathRandom: - '`Math.random()` should be replaced with `safeMathRandom()` from `@sentry/core` to ensure safe random value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', - unsafePerformanceNow: - '`performance.now()` should be wrapped with `runInRandomSafeContext()` to ensure safe time value generation. Use: `runInRandomSafeContext(() => performance.now())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', - unsafeCryptoRandomUUID: - '`crypto.randomUUID()` should be wrapped with `runInRandomSafeContext()` to ensure safe random value generation. Use: `runInRandomSafeContext(() => crypto.randomUUID())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', - unsafeCryptoGetRandomValues: - '`crypto.getRandomValues()` should be wrapped with `runInRandomSafeContext()` to ensure safe random value generation. Use: `runInRandomSafeContext(() => crypto.getRandomValues(...))`. You can disable this rule with an eslint-disable comment if this usage is intentional.', - }, - }, - create: function (context) { - /** - * Check if a node is inside a runInRandomSafeContext call - */ - function isInsideRunInRandomSafeContext(node) { - let current = node.parent; - - while (current) { - // Check if we're inside a callback passed to runInRandomSafeContext - if ( - current.type === 'CallExpression' && - current.callee.type === 'Identifier' && - current.callee.name === 'runInRandomSafeContext' - ) { - return true; - } - - // Also check for arrow functions or regular functions passed to runInRandomSafeContext - if ( - (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') && - current.parent?.type === 'CallExpression' && - current.parent.callee.type === 'Identifier' && - current.parent.callee.name === 'runInRandomSafeContext' - ) { - return true; - } - - current = current.parent; - } - - return false; - } - - /** - * Check if a node is inside the safeRandomGeneratorRunner.ts file (the definition file) - */ - function isInSafeRandomGeneratorRunner(_node) { - const filename = context.getFilename(); - return filename.includes('safeRandomGeneratorRunner'); - } - - return { - CallExpression(node) { - // Skip if we're in the safeRandomGeneratorRunner.ts file itself - if (isInSafeRandomGeneratorRunner(node)) { - return; - } - - // Check for member expression calls like Date.now(), Math.random(), etc. - if (node.callee.type === 'MemberExpression') { - const callee = node.callee; - - // Get the object name (e.g., 'Date', 'Math', 'performance', 'crypto') - let objectName = null; - if (callee.object.type === 'Identifier') { - objectName = callee.object.name; - } - - // Get the property name (e.g., 'now', 'random', 'randomUUID') - let propertyName = null; - if (callee.property.type === 'Identifier') { - propertyName = callee.property.name; - } else if (callee.computed && callee.property.type === 'Literal') { - propertyName = callee.property.value; - } - - if (!objectName || !propertyName) { - return; - } - - // Check if this is one of the unsafe APIs - const unsafeApi = UNSAFE_MEMBER_CALLS.find(api => api.object === objectName && api.property === propertyName); - - if (unsafeApi && !isInsideRunInRandomSafeContext(node)) { - context.report({ - node, - messageId: unsafeApi.messageId, - }); - } - } - }, - }; - }, -}; diff --git a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts deleted file mode 100644 index a16b85c597b6..000000000000 --- a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { RuleTester } from 'eslint'; -import { describe, test } from 'vitest'; -// @ts-expect-error untyped module -import rule from '../../../src/rules/no-unsafe-random-apis'; - -describe('no-unsafe-random-apis', () => { - test('ruleTester', () => { - const ruleTester = new RuleTester({ - parserOptions: { - ecmaVersion: 2020, - }, - }); - - ruleTester.run('no-unsafe-random-apis', rule, { - valid: [ - // Wrapped with runInRandomSafeContext - arrow function - { - code: 'runInRandomSafeContext(() => Date.now())', - }, - { - code: 'runInRandomSafeContext(() => Math.random())', - }, - { - code: 'runInRandomSafeContext(() => performance.now())', - }, - { - code: 'runInRandomSafeContext(() => crypto.randomUUID())', - }, - { - code: 'runInRandomSafeContext(() => crypto.getRandomValues(new Uint8Array(16)))', - }, - // Wrapped with runInRandomSafeContext - regular function - { - code: 'runInRandomSafeContext(function() { return Date.now(); })', - }, - // Nested inside runInRandomSafeContext - { - code: 'runInRandomSafeContext(() => { const x = Date.now(); return x + Math.random(); })', - }, - // Expression inside runInRandomSafeContext - { - code: 'runInRandomSafeContext(() => Date.now() / 1000)', - }, - // Other unrelated calls should be fine - { - code: 'const x = someObject.now()', - }, - { - code: 'const x = Date.parse("2021-01-01")', - }, - { - code: 'const x = Math.floor(5.5)', - }, - { - code: 'const x = performance.mark("test")', - }, - ], - invalid: [ - // Direct Date.now() calls - { - code: 'const time = Date.now()', - errors: [ - { - messageId: 'unsafeDateNow', - }, - ], - }, - // Direct Math.random() calls - { - code: 'const random = Math.random()', - errors: [ - { - messageId: 'unsafeMathRandom', - }, - ], - }, - // Direct performance.now() calls - { - code: 'const perf = performance.now()', - errors: [ - { - messageId: 'unsafePerformanceNow', - }, - ], - }, - // Direct crypto.randomUUID() calls - { - code: 'const uuid = crypto.randomUUID()', - errors: [ - { - messageId: 'unsafeCryptoRandomUUID', - }, - ], - }, - // Direct crypto.getRandomValues() calls - { - code: 'const bytes = crypto.getRandomValues(new Uint8Array(16))', - errors: [ - { - messageId: 'unsafeCryptoGetRandomValues', - }, - ], - }, - // Inside a function but not wrapped - { - code: 'function getTime() { return Date.now(); }', - errors: [ - { - messageId: 'unsafeDateNow', - }, - ], - }, - // Inside an arrow function but not wrapped with runInRandomSafeContext - { - code: 'const getTime = () => Date.now()', - errors: [ - { - messageId: 'unsafeDateNow', - }, - ], - }, - // Inside someOtherWrapper - { - code: 'someOtherWrapper(() => Date.now())', - errors: [ - { - messageId: 'unsafeDateNow', - }, - ], - }, - // Multiple violations - { - code: 'const a = Date.now(); const b = Math.random();', - errors: [ - { - messageId: 'unsafeDateNow', - }, - { - messageId: 'unsafeMathRandom', - }, - ], - }, - ], - }); - }); -}); diff --git a/packages/nextjs/.eslintrc.js b/packages/nextjs/.eslintrc.js index 4a5bdd17795e..1f0ae547d4e0 100644 --- a/packages/nextjs/.eslintrc.js +++ b/packages/nextjs/.eslintrc.js @@ -7,9 +7,6 @@ module.exports = { jsx: true, }, extends: ['../../.eslintrc.js'], - rules: { - '@sentry-internal/sdk/no-unsafe-random-apis': 'error', - }, overrides: [ { files: ['scripts/**/*.ts'], @@ -30,11 +27,5 @@ module.exports = { globalThis: 'readonly', }, }, - { - files: ['test/**/*.ts', 'test/**/*.tsx'], - rules: { - '@sentry-internal/sdk/no-unsafe-random-apis': 'off', - }, - }, ], }; diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts index 8cd0c016d0fb..c85bdc4f2ad3 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts @@ -1,4 +1,4 @@ -import { _INTERNAL_safeDateNow, captureCheckIn } from '@sentry/core'; +import { captureCheckIn } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { VercelCronsConfig } from '../types'; @@ -57,14 +57,14 @@ export function wrapApiHandlerWithSentryVercelCrons { captureCheckIn({ checkInId, monitorSlug, status: 'error', - duration: _INTERNAL_safeDateNow() / 1000 - startTime, + duration: Date.now() / 1000 - startTime, }); }; @@ -82,7 +82,7 @@ export function wrapApiHandlerWithSentryVercelCrons { @@ -98,7 +98,7 @@ export function wrapApiHandlerWithSentryVercelCrons(nextConfig?: C, sentryBuildOptions: SentryBu */ function generateRandomTunnelRoute(): string { // Generate a random 8-character alphanumeric string - // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis const randomString = Math.random().toString(36).substring(2, 10); return `/${randomString}`; } diff --git a/packages/node-core/.eslintrc.js b/packages/node-core/.eslintrc.js index 073e587833b6..6da218bd8641 100644 --- a/packages/node-core/.eslintrc.js +++ b/packages/node-core/.eslintrc.js @@ -5,14 +5,5 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', - '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, - overrides: [ - { - files: ['test/**/*.ts', 'test/**/*.tsx'], - rules: { - '@sentry-internal/sdk/no-unsafe-random-apis': 'off', - }, - }, - ], }; diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index c0bc1be1ffd9..cad8a1c4a443 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -15,7 +15,7 @@ import type { IntegrationFn, OsContext, } from '@sentry/core'; -import { _INTERNAL_safeDateNow, defineIntegration } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; export const readFileAsync = promisify(readFile); export const readDirAsync = promisify(readdir); @@ -204,7 +204,7 @@ function getCultureContext(): CultureContext | undefined { */ export function getAppContext(): AppContext { const app_memory = process.memoryUsage().rss; - const app_start_time = new Date(_INTERNAL_safeDateNow() - process.uptime() * 1000).toISOString(); + const app_start_time = new Date(Date.now() - process.uptime() * 1000).toISOString(); // https://nodejs.org/api/process.html#processavailablememory const appContext: AppContext = { app_start_time, app_memory }; @@ -236,7 +236,7 @@ export function getDeviceContext(deviceOpt: DeviceContextOptions | true): Device // Hence, we only set boot time, if we get a valid uptime value. // @see https://github.com/getsentry/sentry-javascript/issues/5856 if (typeof uptime === 'number') { - device.boot_time = new Date(_INTERNAL_safeDateNow() - uptime * 1000).toISOString(); + device.boot_time = new Date(Date.now() - uptime * 1000).toISOString(); } device.arch = os.arch(); diff --git a/packages/node/.eslintrc.js b/packages/node/.eslintrc.js index 073e587833b6..6da218bd8641 100644 --- a/packages/node/.eslintrc.js +++ b/packages/node/.eslintrc.js @@ -5,14 +5,5 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', - '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, - overrides: [ - { - files: ['test/**/*.ts', 'test/**/*.tsx'], - rules: { - '@sentry-internal/sdk/no-unsafe-random-apis': 'off', - }, - }, - ], }; diff --git a/packages/opentelemetry/.eslintrc.js b/packages/opentelemetry/.eslintrc.js index 4b5e6310c8ee..fdb9952bae52 100644 --- a/packages/opentelemetry/.eslintrc.js +++ b/packages/opentelemetry/.eslintrc.js @@ -3,15 +3,4 @@ module.exports = { node: true, }, extends: ['../../.eslintrc.js'], - rules: { - '@sentry-internal/sdk/no-unsafe-random-apis': 'error', - }, - overrides: [ - { - files: ['test/**/*.ts', 'test/**/*.tsx'], - rules: { - '@sentry-internal/sdk/no-unsafe-random-apis': 'off', - }, - }, - ], }; diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 7f7edd441612..e06fe51bfd2a 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -12,7 +12,6 @@ import { } from '@opentelemetry/semantic-conventions'; import type { Client, SpanAttributes } from '@sentry/core'; import { - _INTERNAL_safeMathRandom, baggageHeaderToDynamicSamplingContext, debug, hasSpansEnabled, @@ -122,7 +121,7 @@ export class SentrySampler implements Sampler { const dscString = parentContext?.traceState ? parentContext.traceState.get(SENTRY_TRACE_STATE_DSC) : undefined; const dsc = dscString ? baggageHeaderToDynamicSamplingContext(dscString) : undefined; - const sampleRand = parseSampleRate(dsc?.sample_rand) ?? _INTERNAL_safeMathRandom(); + const sampleRand = parseSampleRate(dsc?.sample_rand) ?? Math.random(); const [sampled, sampleRate, localSampleRateWasApplied] = sampleSpan( options, diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index f02df1d9d56c..ea85641387a5 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -12,7 +12,6 @@ import type { TransactionSource, } from '@sentry/core'; import { - _INTERNAL_safeDateNow, captureEvent, convertSpanLinksForEnvelope, debounce, @@ -83,7 +82,7 @@ export class SentrySpanExporter { }) { this._finishedSpanBucketSize = options?.timeout || DEFAULT_TIMEOUT; this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined); - this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); + this._lastCleanupTimestampInS = Math.floor(Date.now() / 1000); this._spansToBucketEntry = new WeakMap(); this._sentSpans = new Map(); this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 }); @@ -94,7 +93,7 @@ export class SentrySpanExporter { * This is called by the span processor whenever a span is ended. */ public export(span: ReadableSpan): void { - const currentTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); + const currentTimestampInS = Math.floor(Date.now() / 1000); if (this._lastCleanupTimestampInS !== currentTimestampInS) { let droppedSpanCount = 0; @@ -147,7 +146,7 @@ export class SentrySpanExporter { `SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`, ); - const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000; + const expirationDate = Date.now() + DEFAULT_TIMEOUT * 1000; for (const span of sentSpans) { this._sentSpans.set(span.spanContext().spanId, expirationDate); @@ -227,7 +226,7 @@ export class SentrySpanExporter { /** Remove "expired" span id entries from the _sentSpans cache. */ private _flushSentSpanCache(): void { - const currentTimestamp = _INTERNAL_safeDateNow(); + const currentTimestamp = Date.now(); // Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297 for (const [spanId, expirationTime] of this._sentSpans.entries()) { if (expirationTime <= currentTimestamp) { diff --git a/packages/vercel-edge/.eslintrc.js b/packages/vercel-edge/.eslintrc.js index 073e587833b6..6da218bd8641 100644 --- a/packages/vercel-edge/.eslintrc.js +++ b/packages/vercel-edge/.eslintrc.js @@ -5,14 +5,5 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', - '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, - overrides: [ - { - files: ['test/**/*.ts', 'test/**/*.tsx'], - rules: { - '@sentry-internal/sdk/no-unsafe-random-apis': 'off', - }, - }, - ], }; From 6a5d8589567b2db392de93b8f7f2edfea3807701 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 7 Jan 2026 20:26:40 +0200 Subject: [PATCH 16/24] feat: update patching API name --- packages/core/src/index.ts | 4 ++++ ...GeneratorRunner.ts => randomSafeContext.ts} | 18 +----------------- .../server/prepareSafeIdGeneratorContext.ts | 8 ++++++-- 3 files changed, 11 insertions(+), 19 deletions(-) rename packages/core/src/utils/{safeRandomGeneratorRunner.ts => randomSafeContext.ts} (60%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e2f37e20a72..3f69d4410cd3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -514,3 +514,7 @@ export type { UnstableRollupPluginOptions, UnstableWebpackPluginOptions, } from './build-time-plugins/buildTimeOptionsBase'; +export { + withRandomSafeContext as _INTERNAL_withRandomSafeContext, + type RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, +} from './utils/randomSafeContext'; diff --git a/packages/core/src/utils/safeRandomGeneratorRunner.ts b/packages/core/src/utils/randomSafeContext.ts similarity index 60% rename from packages/core/src/utils/safeRandomGeneratorRunner.ts rename to packages/core/src/utils/randomSafeContext.ts index 871beac4fccb..38f2fef69eea 100644 --- a/packages/core/src/utils/safeRandomGeneratorRunner.ts +++ b/packages/core/src/utils/randomSafeContext.ts @@ -7,7 +7,7 @@ let RESOLVED_RUNNER: RandomSafeContextRunner | undefined; /** * Simple wrapper that allows SDKs to *secretly* set context wrapper to generate safe random IDs in cache components contexts */ -export function runInRandomSafeContext(cb: () => T): T { +export function withRandomSafeContext(cb: () => T): T { // Skips future symbol lookups if we've already resolved the runner once if (RESOLVED_RUNNER) { return RESOLVED_RUNNER(cb); @@ -23,19 +23,3 @@ export function runInRandomSafeContext(cb: () => T): T { return globalWithSymbol[sym](cb); } - -/** - * Returns the current date and time wrapped in a safe context runner. - * @returns number The current date and time. - */ -export function safeDateNow(): number { - return runInRandomSafeContext(() => Date.now()); -} - -/** - * Returns a random number between 0 and 1 wrapped in a safe context runner. - * @returns number A random number between 0 and 1. - */ -export function safeMathRandom(): number { - return runInRandomSafeContext(() => Math.random()); -} diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts index 2275e0dfccec..bd262eb736e1 100644 --- a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts +++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts @@ -1,4 +1,8 @@ -import { type _INTERNAL_RandomSafeContextRunner as RandomSafeContextRunner, debug, GLOBAL_OBJ } from '@sentry/core'; +import { + type _INTERNAL_RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, + debug, + GLOBAL_OBJ, +} from '@sentry/core'; import { DEBUG_BUILD } from '../common/debug-build'; // Inline AsyncLocalStorage interface from current types @@ -11,7 +15,7 @@ type OriginalAsyncLocalStorage = typeof AsyncLocalStorage; */ export function prepareSafeIdGeneratorContext(): void { const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); - const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ; + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: _INTERNAL_RandomSafeContextRunner } = GLOBAL_OBJ; const als = getAsyncLocalStorage(); if (!als || typeof als.snapshot !== 'function') { DEBUG_BUILD && From 8a49853cd56814adc5502b630d13bb1ee30e52df Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 7 Jan 2026 20:33:51 +0200 Subject: [PATCH 17/24] fix: only patch the needed functions --- packages/core/src/scope.ts | 10 +++++++--- packages/core/src/tracing/sentrySpan.ts | 7 ++++--- packages/core/src/utils/tracing.ts | 5 +++-- packages/opentelemetry/src/spanExporter.ts | 7 ++++--- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 0639cdb845f1..35043f71ff8f 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -22,6 +22,7 @@ import { isPlainObject } from './utils/is'; import { merge } from './utils/merge'; import { uuid4 } from './utils/misc'; import { generateTraceId } from './utils/propagationContext'; +import { withRandomSafeContext } from './utils/randomSafeContext'; import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope'; import { truncate } from './utils/string'; import { dateTimestampInSeconds } from './utils/time'; @@ -167,8 +168,8 @@ export class Scope { this._contexts = {}; this._sdkProcessingMetadata = {}; this._propagationContext = { - traceId: generateTraceId(), - sampleRand: Math.random(), + traceId: withRandomSafeContext(generateTraceId), + sampleRand: withRandomSafeContext(() => Math.random()), }; } @@ -550,7 +551,10 @@ export class Scope { this._session = undefined; _setSpanForScope(this, undefined); this._attachments = []; - this.setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() }); + this.setPropagationContext({ + traceId: withRandomSafeContext(generateTraceId), + sampleRand: withRandomSafeContext(() => Math.random()), + }); this._notifyScopeListeners(); return this; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 9bd98b9741c6..fc35beab2c99 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -26,6 +26,7 @@ import type { SpanStatus } from '../types-hoist/spanStatus'; import type { TimedEvent } from '../types-hoist/timedEvent'; import { debug } from '../utils/debug-logger'; import { generateSpanId, generateTraceId } from '../utils/propagationContext'; +import { withRandomSafeContext } from '../utils/randomSafeContext'; import { convertSpanLinksForEnvelope, getRootSpan, @@ -76,9 +77,9 @@ export class SentrySpan implements Span { * @hidden */ public constructor(spanContext: SentrySpanArguments = {}) { - this._traceId = spanContext.traceId || generateTraceId(); - this._spanId = spanContext.spanId || generateSpanId(); - this._startTime = spanContext.startTimestamp || timestampInSeconds(); + this._traceId = spanContext.traceId || withRandomSafeContext(generateTraceId); + this._spanId = spanContext.spanId || withRandomSafeContext(generateSpanId); + this._startTime = spanContext.startTimestamp || withRandomSafeContext(timestampInSeconds); this._links = spanContext.links; this._attributes = {}; diff --git a/packages/core/src/utils/tracing.ts b/packages/core/src/utils/tracing.ts index aa5a15153674..3c1f419bf0a6 100644 --- a/packages/core/src/utils/tracing.ts +++ b/packages/core/src/utils/tracing.ts @@ -7,6 +7,7 @@ import { baggageHeaderToDynamicSamplingContext } from './baggage'; import { extractOrgIdFromClient } from './dsn'; import { parseSampleRate } from './parseSampleRate'; import { generateSpanId, generateTraceId } from './propagationContext'; +import { withRandomSafeContext } from './randomSafeContext'; // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- RegExp is used for readability here export const TRACEPARENT_REGEXP = new RegExp( @@ -64,8 +65,8 @@ export function propagationContextFromHeaders( if (!traceparentData?.traceId) { return { - traceId: generateTraceId(), - sampleRand: Math.random(), + traceId: withRandomSafeContext(generateTraceId), + sampleRand: withRandomSafeContext(() => Math.random()), }; } diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index ea85641387a5..ded61b227e76 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -12,6 +12,7 @@ import type { TransactionSource, } from '@sentry/core'; import { + _INTERNAL_withRandomSafeContext, captureEvent, convertSpanLinksForEnvelope, debounce, @@ -93,7 +94,7 @@ export class SentrySpanExporter { * This is called by the span processor whenever a span is ended. */ public export(span: ReadableSpan): void { - const currentTimestampInS = Math.floor(Date.now() / 1000); + const currentTimestampInS = _INTERNAL_withRandomSafeContext(() => Math.floor(Date.now() / 1000)); if (this._lastCleanupTimestampInS !== currentTimestampInS) { let droppedSpanCount = 0; @@ -146,7 +147,7 @@ export class SentrySpanExporter { `SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`, ); - const expirationDate = Date.now() + DEFAULT_TIMEOUT * 1000; + const expirationDate = _INTERNAL_withRandomSafeContext(() => Date.now()) + DEFAULT_TIMEOUT * 1000; for (const span of sentSpans) { this._sentSpans.set(span.spanContext().spanId, expirationDate); @@ -226,7 +227,7 @@ export class SentrySpanExporter { /** Remove "expired" span id entries from the _sentSpans cache. */ private _flushSentSpanCache(): void { - const currentTimestamp = Date.now(); + const currentTimestamp = _INTERNAL_withRandomSafeContext(() => Date.now()); // Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297 for (const [spanId, expirationTime] of this._sentSpans.entries()) { if (expirationTime <= currentTimestamp) { From 7de9d83ebdc9fb284f5e22da7bd18db7847a3ea6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 7 Jan 2026 21:55:44 +0200 Subject: [PATCH 18/24] fix: bad reverts --- packages/core/src/constants.ts | 1 + packages/core/src/index.ts | 2 +- packages/core/src/utils/time.ts | 43 +++++++++++------------ packages/core/test/lib/utils/time.test.ts | 25 ++++++------- 4 files changed, 33 insertions(+), 38 deletions(-) diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 38475b857ace..7fdc380faf0d 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1 +1,2 @@ export const DEFAULT_ENVIRONMENT = 'production'; +export const DEV_ENVIRONMENT = 'development'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3f69d4410cd3..ebd44a768130 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,7 +101,7 @@ export { headersToDict, httpHeadersToSpanAttributes, } from './utils/request'; -export { DEFAULT_ENVIRONMENT } from './constants'; +export { DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; export { functionToStringIntegration } from './integrations/functiontostring'; // eslint-disable-next-line deprecation/deprecation diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index 73ff34a30cc5..ecaca1ea9e9b 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -78,12 +78,14 @@ let cachedTimeOrigin: number | null | undefined = null; /** * Gets the time origin and the mode used to determine it. + * + * Unfortunately browsers may report an inaccurate time origin data, through either performance.timeOrigin or + * performance.timing.navigationStart, which results in poor results in performance data. We only treat time origin + * data as reliable if they are within a reasonable threshold of the current time. + * * TODO: move to `@sentry/browser-utils` package. */ function getBrowserTimeOrigin(): number | undefined { - // Unfortunately browsers may report an inaccurate time origin data, through either performance.timeOrigin or - // performance.timing.navigationStart, which results in poor results in performance data. We only treat time origin - // data as reliable if they are within a reasonable threshold of the current time. const { performance } = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; if (!performance?.now) { return undefined; @@ -93,11 +95,13 @@ function getBrowserTimeOrigin(): number | undefined { const performanceNow = performance.now(); const dateNow = Date.now(); - // if timeOrigin isn't available set delta to threshold so it isn't used - const timeOriginDelta = performance.timeOrigin - ? Math.abs(performance.timeOrigin + performanceNow - dateNow) - : threshold; - const timeOriginIsReliable = timeOriginDelta < threshold; + const timeOrigin = performance.timeOrigin; + if (typeof timeOrigin === 'number') { + const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow); + if (timeOriginDelta < threshold) { + return timeOrigin; + } + } // TODO: Remove all code related to `performance.timing.navigationStart` once we drop support for Safari 14. // `performance.timeSince` is available in Safari 15. @@ -110,23 +114,16 @@ function getBrowserTimeOrigin(): number | undefined { // Date API. // eslint-disable-next-line deprecation/deprecation const navigationStart = performance.timing?.navigationStart; - const hasNavigationStart = typeof navigationStart === 'number'; - // if navigationStart isn't available set delta to threshold so it isn't used - const navigationStartDelta = hasNavigationStart ? Math.abs(navigationStart + performanceNow - dateNow) : threshold; - const navigationStartIsReliable = navigationStartDelta < threshold; - - // TODO: Since timeOrigin explicitly replaces navigationStart, we should probably remove the navigationStartIsReliable check. - if (timeOriginIsReliable && timeOriginDelta <= navigationStartDelta) { - return performance.timeOrigin; - } - - if (navigationStartIsReliable) { - return navigationStart; + if (typeof navigationStart === 'number') { + const navigationStartDelta = Math.abs(navigationStart + performanceNow - dateNow); + if (navigationStartDelta < threshold) { + return navigationStart; + } } - // TODO: We should probably fall back to Date.now() - performance.now(), since this is still more accurate than just Date.now() (?) - // Either both timeOrigin and navigationStart are skewed or neither is available, fallback to Date. - return dateNow; + // Either both timeOrigin and navigationStart are skewed or neither is available, fallback to subtracting + // `performance.now()` from `Date.now()`. + return dateNow - performanceNow; } /** diff --git a/packages/core/test/lib/utils/time.test.ts b/packages/core/test/lib/utils/time.test.ts index f5b6cc93fc35..a1d537df5862 100644 --- a/packages/core/test/lib/utils/time.test.ts +++ b/packages/core/test/lib/utils/time.test.ts @@ -27,12 +27,12 @@ describe('browserPerformanceTimeOrigin', () => { vi.unstubAllGlobals(); }); - it('returns `Date.now()` if `performance.timeOrigin` is not reliable', async () => { + it('returns `Date.now() - performance.now()` if `performance.timeOrigin` is not reliable', async () => { const currentTimeMs = 1767778040866; const unreliableTime = currentTimeMs - RELIABLE_THRESHOLD_MS - 2_000; - const timeSincePageloadMs = 1_234.789; + const timeSincePageloadMs = 1_234.56789; vi.useFakeTimers(); vi.setSystemTime(new Date(currentTimeMs)); @@ -46,39 +46,36 @@ describe('browserPerformanceTimeOrigin', () => { }); const timeOrigin = await getFreshPerformanceTimeOrigin(); - expect(timeOrigin).toBe(1767778040866); + expect(timeOrigin).toBe(currentTimeMs - timeSincePageloadMs); vi.useRealTimers(); vi.unstubAllGlobals(); }); - it('returns `performance.timing.navigationStart` if `performance.timeOrigin` is not available', async () => { - const currentTimeMs = 1767778040870; - - const navigationStartMs = currentTimeMs - 2_000; + it('returns `Date.now() - performance.now()` if neither `performance.timeOrigin` nor `performance.timing.navigationStart` are available', async () => { + const currentTimeMs = 1767778040866; - const timeSincePageloadMs = 1_234.789; + const timeSincePageloadMs = 1_234.56789; vi.useFakeTimers(); vi.setSystemTime(new Date(currentTimeMs)); - vi.stubGlobal('performance', { timeOrigin: undefined, timing: { - navigationStart: navigationStartMs, + navigationStart: undefined, }, now: () => timeSincePageloadMs, }); const timeOrigin = await getFreshPerformanceTimeOrigin(); - expect(timeOrigin).toBe(navigationStartMs); + expect(timeOrigin).toBe(currentTimeMs - timeSincePageloadMs); vi.useRealTimers(); vi.unstubAllGlobals(); }); - it('returns `performance.timing.navigationStart` if `performance.timeOrigin` is less reliable', async () => { - const currentTimeMs = 1767778040874; + it('returns `performance.timing.navigationStart` if `performance.timeOrigin` is not available', async () => { + const currentTimeMs = 1767778040870; const navigationStartMs = currentTimeMs - 2_000; @@ -88,7 +85,7 @@ describe('browserPerformanceTimeOrigin', () => { vi.setSystemTime(new Date(currentTimeMs)); vi.stubGlobal('performance', { - timeOrigin: navigationStartMs - 1, + timeOrigin: undefined, timing: { navigationStart: navigationStartMs, }, From c3c993f7859d240f03f9fc59e0ae8b6e34d2ce72 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Jan 2026 14:06:10 +0200 Subject: [PATCH 19/24] fix: size-limit --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 16d10962602b..215a40d1bf17 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -261,7 +261,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '162 KB', + limit: '162.5 KB', }, { name: '@sentry/node - without tracing', From 2240f9d4033826a5df2654cc3d9fc9650d3ce9ce Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Jan 2026 18:47:03 +0200 Subject: [PATCH 20/24] chore: bring back the eslint rule --- packages/core/.eslintrc.js | 11 ++ packages/eslint-plugin-sdk/src/index.js | 1 + .../src/rules/no-unsafe-random-apis.js | 147 ++++++++++++++++++ .../lib/rules/no-unsafe-random-apis.test.ts | 146 +++++++++++++++++ packages/nextjs/.eslintrc.js | 9 ++ packages/node-core/.eslintrc.js | 9 ++ packages/node/.eslintrc.js | 9 ++ packages/opentelemetry/.eslintrc.js | 11 ++ packages/vercel-edge/.eslintrc.js | 9 ++ 9 files changed, 352 insertions(+) create mode 100644 packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js create mode 100644 packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index 5a021c016763..5ce5d0f72cd2 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -1,4 +1,15 @@ module.exports = { extends: ['../../.eslintrc.js'], ignorePatterns: ['rollup.npm.config.mjs'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/eslint-plugin-sdk/src/index.js b/packages/eslint-plugin-sdk/src/index.js index 24cc9c4cc00c..c23a1afcd373 100644 --- a/packages/eslint-plugin-sdk/src/index.js +++ b/packages/eslint-plugin-sdk/src/index.js @@ -15,5 +15,6 @@ module.exports = { 'no-regexp-constructor': require('./rules/no-regexp-constructor'), 'no-focused-tests': require('./rules/no-focused-tests'), 'no-skipped-tests': require('./rules/no-skipped-tests'), + 'no-unsafe-random-apis': require('./rules/no-unsafe-random-apis'), }, }; diff --git a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js new file mode 100644 index 000000000000..8a9a27795481 --- /dev/null +++ b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js @@ -0,0 +1,147 @@ +'use strict'; + +/** + * @fileoverview Rule to enforce wrapping random/time APIs with withRandomSafeContext + * + * This rule detects uses of APIs that generate random values or time-based values + * and ensures they are wrapped with `withRandomSafeContext()` to ensure safe + * random number generation in certain contexts (e.g., React Server Components with caching). + */ + +// APIs that should be wrapped with withRandomSafeContext, with their specific messages +const UNSAFE_MEMBER_CALLS = [ + { + object: 'Date', + property: 'now', + messageId: 'unsafeDateNow', + }, + { + object: 'Math', + property: 'random', + messageId: 'unsafeMathRandom', + }, + { + object: 'performance', + property: 'now', + messageId: 'unsafePerformanceNow', + }, + { + object: 'crypto', + property: 'randomUUID', + messageId: 'unsafeCryptoRandomUUID', + }, + { + object: 'crypto', + property: 'getRandomValues', + messageId: 'unsafeCryptoGetRandomValues', + }, +]; + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Enforce wrapping random/time APIs (Date.now, Math.random, performance.now, crypto.randomUUID) with withRandomSafeContext', + category: 'Best Practices', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + unsafeDateNow: + '`Date.now()` should be replaced with `safeDateNow()` from `@sentry/core` to ensure safe time value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeMathRandom: + '`Math.random()` should be replaced with `safeMathRandom()` from `@sentry/core` to ensure safe random value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafePerformanceNow: + '`performance.now()` should be wrapped with `withRandomSafeContext()` to ensure safe time value generation. Use: `withRandomSafeContext(() => performance.now())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeCryptoRandomUUID: + '`crypto.randomUUID()` should be wrapped with `withRandomSafeContext()` to ensure safe random value generation. Use: `withRandomSafeContext(() => crypto.randomUUID())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeCryptoGetRandomValues: + '`crypto.getRandomValues()` should be wrapped with `withRandomSafeContext()` to ensure safe random value generation. Use: `withRandomSafeContext(() => crypto.getRandomValues(...))`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + }, + }, + create: function (context) { + /** + * Check if a node is inside a withRandomSafeContext call + */ + function isInsidewithRandomSafeContext(node) { + let current = node.parent; + + while (current) { + // Check if we're inside a callback passed to withRandomSafeContext + if ( + current.type === 'CallExpression' && + current.callee.type === 'Identifier' && + current.callee.name === 'withRandomSafeContext' + ) { + return true; + } + + // Also check for arrow functions or regular functions passed to withRandomSafeContext + if ( + (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') && + current.parent?.type === 'CallExpression' && + current.parent.callee.type === 'Identifier' && + current.parent.callee.name === 'withRandomSafeContext' + ) { + return true; + } + + current = current.parent; + } + + return false; + } + + /** + * Check if a node is inside the safeRandomGeneratorRunner.ts file (the definition file) + */ + function isInSafeRandomGeneratorRunner(_node) { + const filename = context.getFilename(); + return filename.includes('safeRandomGeneratorRunner'); + } + + return { + CallExpression(node) { + // Skip if we're in the safeRandomGeneratorRunner.ts file itself + if (isInSafeRandomGeneratorRunner(node)) { + return; + } + + // Check for member expression calls like Date.now(), Math.random(), etc. + if (node.callee.type === 'MemberExpression') { + const callee = node.callee; + + // Get the object name (e.g., 'Date', 'Math', 'performance', 'crypto') + let objectName = null; + if (callee.object.type === 'Identifier') { + objectName = callee.object.name; + } + + // Get the property name (e.g., 'now', 'random', 'randomUUID') + let propertyName = null; + if (callee.property.type === 'Identifier') { + propertyName = callee.property.name; + } else if (callee.computed && callee.property.type === 'Literal') { + propertyName = callee.property.value; + } + + if (!objectName || !propertyName) { + return; + } + + // Check if this is one of the unsafe APIs + const unsafeApi = UNSAFE_MEMBER_CALLS.find(api => api.object === objectName && api.property === propertyName); + + if (unsafeApi && !isInsidewithRandomSafeContext(node)) { + context.report({ + node, + messageId: unsafeApi.messageId, + }); + } + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts new file mode 100644 index 000000000000..e145336d6c3e --- /dev/null +++ b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts @@ -0,0 +1,146 @@ +import { RuleTester } from 'eslint'; +import { describe, test } from 'vitest'; +// @ts-expect-error untyped module +import rule from '../../../src/rules/no-unsafe-random-apis'; + +describe('no-unsafe-random-apis', () => { + test('ruleTester', () => { + const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + }, + }); + + ruleTester.run('no-unsafe-random-apis', rule, { + valid: [ + // Wrapped with withRandomSafeContext - arrow function + { + code: 'withRandomSafeContext(() => Date.now())', + }, + { + code: 'withRandomSafeContext(() => Math.random())', + }, + { + code: 'withRandomSafeContext(() => performance.now())', + }, + { + code: 'withRandomSafeContext(() => crypto.randomUUID())', + }, + { + code: 'withRandomSafeContext(() => crypto.getRandomValues(new Uint8Array(16)))', + }, + // Wrapped with withRandomSafeContext - regular function + { + code: 'withRandomSafeContext(function() { return Date.now(); })', + }, + // Nested inside withRandomSafeContext + { + code: 'withRandomSafeContext(() => { const x = Date.now(); return x + Math.random(); })', + }, + // Expression inside withRandomSafeContext + { + code: 'withRandomSafeContext(() => Date.now() / 1000)', + }, + // Other unrelated calls should be fine + { + code: 'const x = someObject.now()', + }, + { + code: 'const x = Date.parse("2021-01-01")', + }, + { + code: 'const x = Math.floor(5.5)', + }, + { + code: 'const x = performance.mark("test")', + }, + ], + invalid: [ + // Direct Date.now() calls + { + code: 'const time = Date.now()', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Direct Math.random() calls + { + code: 'const random = Math.random()', + errors: [ + { + messageId: 'unsafeMathRandom', + }, + ], + }, + // Direct performance.now() calls + { + code: 'const perf = performance.now()', + errors: [ + { + messageId: 'unsafePerformanceNow', + }, + ], + }, + // Direct crypto.randomUUID() calls + { + code: 'const uuid = crypto.randomUUID()', + errors: [ + { + messageId: 'unsafeCryptoRandomUUID', + }, + ], + }, + // Direct crypto.getRandomValues() calls + { + code: 'const bytes = crypto.getRandomValues(new Uint8Array(16))', + errors: [ + { + messageId: 'unsafeCryptoGetRandomValues', + }, + ], + }, + // Inside a function but not wrapped + { + code: 'function getTime() { return Date.now(); }', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Inside an arrow function but not wrapped with withRandomSafeContext + { + code: 'const getTime = () => Date.now()', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Inside someOtherWrapper + { + code: 'someOtherWrapper(() => Date.now())', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Multiple violations + { + code: 'const a = Date.now(); const b = Math.random();', + errors: [ + { + messageId: 'unsafeDateNow', + }, + { + messageId: 'unsafeMathRandom', + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/nextjs/.eslintrc.js b/packages/nextjs/.eslintrc.js index 1f0ae547d4e0..4a5bdd17795e 100644 --- a/packages/nextjs/.eslintrc.js +++ b/packages/nextjs/.eslintrc.js @@ -7,6 +7,9 @@ module.exports = { jsx: true, }, extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, overrides: [ { files: ['scripts/**/*.ts'], @@ -27,5 +30,11 @@ module.exports = { globalThis: 'readonly', }, }, + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, ], }; diff --git a/packages/node-core/.eslintrc.js b/packages/node-core/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/node-core/.eslintrc.js +++ b/packages/node-core/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/node/.eslintrc.js b/packages/node/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/node/.eslintrc.js +++ b/packages/node/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/opentelemetry/.eslintrc.js b/packages/opentelemetry/.eslintrc.js index fdb9952bae52..4b5e6310c8ee 100644 --- a/packages/opentelemetry/.eslintrc.js +++ b/packages/opentelemetry/.eslintrc.js @@ -3,4 +3,15 @@ module.exports = { node: true, }, extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/vercel-edge/.eslintrc.js b/packages/vercel-edge/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/vercel-edge/.eslintrc.js +++ b/packages/vercel-edge/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; From bd3ad829f05b3f1cd1b241de31ace1b8e3def1ef Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Jan 2026 19:14:25 +0200 Subject: [PATCH 21/24] fix: re-patch all random API calls --- packages/core/src/client.ts | 3 ++- packages/core/src/index.ts | 2 ++ .../src/integrations/mcp-server/correlation.ts | 1 + packages/core/src/scope.ts | 10 +++++----- packages/core/src/tracing/sentrySpan.ts | 7 +++---- packages/core/src/tracing/trace.ts | 3 ++- packages/core/src/utils/misc.ts | 6 ++++-- packages/core/src/utils/randomSafeContext.ts | 16 ++++++++++++++++ packages/core/src/utils/ratelimit.ts | 7 ++++--- packages/core/src/utils/time.ts | 9 +++++---- packages/core/src/utils/tracing.ts | 12 ++++++------ packages/opentelemetry/src/spanExporter.ts | 10 +++++----- 12 files changed, 55 insertions(+), 31 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index aad363905a68..d41f28b09eb7 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import { _INTERNAL_safeMathRandom } from '.'; import { getEnvelopeEndpointWithUrlEncodedAuth } from './api'; import { DEFAULT_ENVIRONMENT } from './constants'; import { getCurrentScope, getIsolationScope, getTraceContextFromScope } from './currentScopes'; @@ -1288,7 +1289,7 @@ export abstract class Client { // 0.0 === 0% events are sent // Sampling for transaction happens somewhere else const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); - if (isError && typeof parsedSampleRate === 'number' && Math.random() > parsedSampleRate) { + if (isError && typeof parsedSampleRate === 'number' && _INTERNAL_safeMathRandom() > parsedSampleRate) { this.recordDroppedEvent('sample_rate', 'error'); return rejectedSyncPromise( _makeDoNotSendEventError( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ebd44a768130..e74d67866f24 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -517,4 +517,6 @@ export type { export { withRandomSafeContext as _INTERNAL_withRandomSafeContext, type RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, + safeMathRandom as _INTERNAL_safeMathRandom, + safeDateNow as _INTERNAL_safeDateNow, } from './utils/randomSafeContext'; diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 22517306c7cb..3567ec382cdf 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -46,6 +46,7 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI spanMap.set(requestId, { span, method, + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis startTime: Date.now(), }); } diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 35043f71ff8f..3d65149facb1 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -22,7 +22,7 @@ import { isPlainObject } from './utils/is'; import { merge } from './utils/merge'; import { uuid4 } from './utils/misc'; import { generateTraceId } from './utils/propagationContext'; -import { withRandomSafeContext } from './utils/randomSafeContext'; +import { safeMathRandom } from './utils/randomSafeContext'; import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope'; import { truncate } from './utils/string'; import { dateTimestampInSeconds } from './utils/time'; @@ -168,8 +168,8 @@ export class Scope { this._contexts = {}; this._sdkProcessingMetadata = {}; this._propagationContext = { - traceId: withRandomSafeContext(generateTraceId), - sampleRand: withRandomSafeContext(() => Math.random()), + traceId: generateTraceId(), + sampleRand: safeMathRandom(), }; } @@ -552,8 +552,8 @@ export class Scope { _setSpanForScope(this, undefined); this._attachments = []; this.setPropagationContext({ - traceId: withRandomSafeContext(generateTraceId), - sampleRand: withRandomSafeContext(() => Math.random()), + traceId: generateTraceId(), + sampleRand: safeMathRandom(), }); this._notifyScopeListeners(); diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index fc35beab2c99..9bd98b9741c6 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -26,7 +26,6 @@ import type { SpanStatus } from '../types-hoist/spanStatus'; import type { TimedEvent } from '../types-hoist/timedEvent'; import { debug } from '../utils/debug-logger'; import { generateSpanId, generateTraceId } from '../utils/propagationContext'; -import { withRandomSafeContext } from '../utils/randomSafeContext'; import { convertSpanLinksForEnvelope, getRootSpan, @@ -77,9 +76,9 @@ export class SentrySpan implements Span { * @hidden */ public constructor(spanContext: SentrySpanArguments = {}) { - this._traceId = spanContext.traceId || withRandomSafeContext(generateTraceId); - this._spanId = spanContext.spanId || withRandomSafeContext(generateSpanId); - this._startTime = spanContext.startTimestamp || withRandomSafeContext(timestampInSeconds); + this._traceId = spanContext.traceId || generateTraceId(); + this._spanId = spanContext.spanId || generateSpanId(); + this._startTime = spanContext.startTimestamp || timestampInSeconds(); this._links = spanContext.links; this._attributes = {}; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index b147bb92fa63..28a5bccd4147 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -17,6 +17,7 @@ import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { parseSampleRate } from '../utils/parseSampleRate'; import { generateTraceId } from '../utils/propagationContext'; +import { safeMathRandom } from '../utils/randomSafeContext'; import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope'; import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tracing'; @@ -293,7 +294,7 @@ export function startNewTrace(callback: () => T): T { return withScope(scope => { scope.setPropagationContext({ traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }); DEBUG_BUILD && debug.log(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); return withActiveSpan(null, callback); diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index 69cd217345b8..86ddd52b05c3 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -3,6 +3,7 @@ import type { Exception } from '../types-hoist/exception'; import type { Mechanism } from '../types-hoist/mechanism'; import type { StackFrame } from '../types-hoist/stackframe'; import { addNonEnumerableProperty } from './object'; +import { safeMathRandom, withRandomSafeContext } from './randomSafeContext'; import { snipLine } from './string'; import { GLOBAL_OBJ } from './worldwide'; @@ -24,7 +25,7 @@ function getCrypto(): CryptoInternal | undefined { let emptyUuid: string | undefined; function getRandomByte(): number { - return Math.random() * 16; + return safeMathRandom() * 16; } /** @@ -35,7 +36,8 @@ function getRandomByte(): number { export function uuid4(crypto = getCrypto()): string { try { if (crypto?.randomUUID) { - return crypto.randomUUID().replace(/-/g, ''); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return withRandomSafeContext(() => crypto.randomUUID!()).replace(/-/g, ''); } } catch { // some runtimes can crash invoking crypto diff --git a/packages/core/src/utils/randomSafeContext.ts b/packages/core/src/utils/randomSafeContext.ts index 38f2fef69eea..6b13afbf0903 100644 --- a/packages/core/src/utils/randomSafeContext.ts +++ b/packages/core/src/utils/randomSafeContext.ts @@ -23,3 +23,19 @@ export function withRandomSafeContext(cb: () => T): T { return globalWithSymbol[sym](cb); } + +/** + * Identical to Math.random() but wrapped in withRandomSafeContext + * to ensure safe random number generation in certain contexts (e.g., Next.js Cache Components). + */ +export function safeMathRandom(): number { + return withRandomSafeContext(() => Math.random()); +} + +/** + * Identical to performance.now() but wrapped in withRandomSafeContext + * to ensure safe time value generation in certain contexts (e.g., Next.js Cache Components). + */ +export function safeDateNow(): number { + return withRandomSafeContext(() => Date.now()); +} diff --git a/packages/core/src/utils/ratelimit.ts b/packages/core/src/utils/ratelimit.ts index 4cb8cb9d07a5..606969d88858 100644 --- a/packages/core/src/utils/ratelimit.ts +++ b/packages/core/src/utils/ratelimit.ts @@ -1,5 +1,6 @@ import type { DataCategory } from '../types-hoist/datacategory'; import type { TransportMakeRequestResponse } from '../types-hoist/transport'; +import { safeDateNow } from './randomSafeContext'; // Intentionally keeping the key broad, as we don't know for sure what rate limit headers get returned from backend export type RateLimits = Record; @@ -12,7 +13,7 @@ export const DEFAULT_RETRY_AFTER = 60 * 1000; // 60 seconds * @param now current unix timestamp * */ -export function parseRetryAfterHeader(header: string, now: number = Date.now()): number { +export function parseRetryAfterHeader(header: string, now: number = safeDateNow()): number { const headerDelay = parseInt(`${header}`, 10); if (!isNaN(headerDelay)) { return headerDelay * 1000; @@ -40,7 +41,7 @@ export function disabledUntil(limits: RateLimits, dataCategory: DataCategory): n /** * Checks if a category is rate limited */ -export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = Date.now()): boolean { +export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = safeDateNow()): boolean { return disabledUntil(limits, dataCategory) > now; } @@ -52,7 +53,7 @@ export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, no export function updateRateLimits( limits: RateLimits, { statusCode, headers }: TransportMakeRequestResponse, - now: number = Date.now(), + now: number = safeDateNow(), ): RateLimits { const updatedRateLimits: RateLimits = { ...limits, diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index ecaca1ea9e9b..10a5103b2fc1 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -1,3 +1,4 @@ +import { safeDateNow, withRandomSafeContext } from './randomSafeContext'; import { GLOBAL_OBJ } from './worldwide'; const ONE_SECOND_IN_MS = 1000; @@ -21,7 +22,7 @@ interface Performance { * Returns a timestamp in seconds since the UNIX epoch using the Date API. */ export function dateTimestampInSeconds(): number { - return Date.now() / ONE_SECOND_IN_MS; + return safeDateNow() / ONE_SECOND_IN_MS; } /** @@ -50,7 +51,7 @@ function createUnixTimestampInSecondsFunc(): () => number { // See: https://github.com/mdn/content/issues/4713 // See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6 return () => { - return (timeOrigin + performance.now()) / ONE_SECOND_IN_MS; + return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS; }; } @@ -92,8 +93,8 @@ function getBrowserTimeOrigin(): number | undefined { } const threshold = 300_000; // 5 minutes in milliseconds - const performanceNow = performance.now(); - const dateNow = Date.now(); + const performanceNow = withRandomSafeContext(() => performance.now()); + const dateNow = safeDateNow(); const timeOrigin = performance.timeOrigin; if (typeof timeOrigin === 'number') { diff --git a/packages/core/src/utils/tracing.ts b/packages/core/src/utils/tracing.ts index 3c1f419bf0a6..25e3295118f8 100644 --- a/packages/core/src/utils/tracing.ts +++ b/packages/core/src/utils/tracing.ts @@ -7,7 +7,7 @@ import { baggageHeaderToDynamicSamplingContext } from './baggage'; import { extractOrgIdFromClient } from './dsn'; import { parseSampleRate } from './parseSampleRate'; import { generateSpanId, generateTraceId } from './propagationContext'; -import { withRandomSafeContext } from './randomSafeContext'; +import { safeMathRandom } from './randomSafeContext'; // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- RegExp is used for readability here export const TRACEPARENT_REGEXP = new RegExp( @@ -65,8 +65,8 @@ export function propagationContextFromHeaders( if (!traceparentData?.traceId) { return { - traceId: withRandomSafeContext(generateTraceId), - sampleRand: withRandomSafeContext(() => Math.random()), + traceId: generateTraceId(), + sampleRand: safeMathRandom(), }; } @@ -134,12 +134,12 @@ function getSampleRandFromTraceparentAndDsc( if (parsedSampleRate && traceparentData?.parentSampled !== undefined) { return traceparentData.parentSampled ? // Returns a sample rand with positive sampling decision [0, sampleRate) - Math.random() * parsedSampleRate + safeMathRandom() * parsedSampleRate : // Returns a sample rand with negative sampling decision [sampleRate, 1) - parsedSampleRate + Math.random() * (1 - parsedSampleRate); + parsedSampleRate + safeMathRandom() * (1 - parsedSampleRate); } else { // If nothing applies, return a random sample rand. - return Math.random(); + return safeMathRandom(); } } diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index ded61b227e76..f02df1d9d56c 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -12,7 +12,7 @@ import type { TransactionSource, } from '@sentry/core'; import { - _INTERNAL_withRandomSafeContext, + _INTERNAL_safeDateNow, captureEvent, convertSpanLinksForEnvelope, debounce, @@ -83,7 +83,7 @@ export class SentrySpanExporter { }) { this._finishedSpanBucketSize = options?.timeout || DEFAULT_TIMEOUT; this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined); - this._lastCleanupTimestampInS = Math.floor(Date.now() / 1000); + this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); this._spansToBucketEntry = new WeakMap(); this._sentSpans = new Map(); this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 }); @@ -94,7 +94,7 @@ export class SentrySpanExporter { * This is called by the span processor whenever a span is ended. */ public export(span: ReadableSpan): void { - const currentTimestampInS = _INTERNAL_withRandomSafeContext(() => Math.floor(Date.now() / 1000)); + const currentTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); if (this._lastCleanupTimestampInS !== currentTimestampInS) { let droppedSpanCount = 0; @@ -147,7 +147,7 @@ export class SentrySpanExporter { `SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`, ); - const expirationDate = _INTERNAL_withRandomSafeContext(() => Date.now()) + DEFAULT_TIMEOUT * 1000; + const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000; for (const span of sentSpans) { this._sentSpans.set(span.spanContext().spanId, expirationDate); @@ -227,7 +227,7 @@ export class SentrySpanExporter { /** Remove "expired" span id entries from the _sentSpans cache. */ private _flushSentSpanCache(): void { - const currentTimestamp = _INTERNAL_withRandomSafeContext(() => Date.now()); + const currentTimestamp = _INTERNAL_safeDateNow(); // Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297 for (const [spanId, expirationTime] of this._sentSpans.entries()) { if (expirationTime <= currentTimestamp) { From 66bed30d14d985bca0c9dedd5d1108d6b3112ac5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Jan 2026 19:20:18 +0200 Subject: [PATCH 22/24] fix: lint rules --- packages/core/src/utils/randomSafeContext.ts | 2 +- .../wrapApiHandlerWithSentryVercelCrons.ts | 10 +++++----- packages/nextjs/src/config/polyfills/perf_hooks.js | 1 + packages/nextjs/src/config/withSentryConfig.ts | 1 + packages/node-core/src/integrations/context.ts | 2 ++ 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/src/utils/randomSafeContext.ts b/packages/core/src/utils/randomSafeContext.ts index 6b13afbf0903..66cffaf1b2b0 100644 --- a/packages/core/src/utils/randomSafeContext.ts +++ b/packages/core/src/utils/randomSafeContext.ts @@ -33,7 +33,7 @@ export function safeMathRandom(): number { } /** - * Identical to performance.now() but wrapped in withRandomSafeContext + * Identical to Date.now() but wrapped in withRandomSafeContext * to ensure safe time value generation in certain contexts (e.g., Next.js Cache Components). */ export function safeDateNow(): number { diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts index c85bdc4f2ad3..8cd0c016d0fb 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts @@ -1,4 +1,4 @@ -import { captureCheckIn } from '@sentry/core'; +import { _INTERNAL_safeDateNow, captureCheckIn } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { VercelCronsConfig } from '../types'; @@ -57,14 +57,14 @@ export function wrapApiHandlerWithSentryVercelCrons { captureCheckIn({ checkInId, monitorSlug, status: 'error', - duration: Date.now() / 1000 - startTime, + duration: _INTERNAL_safeDateNow() / 1000 - startTime, }); }; @@ -82,7 +82,7 @@ export function wrapApiHandlerWithSentryVercelCrons { @@ -98,7 +98,7 @@ export function wrapApiHandlerWithSentryVercelCrons(nextConfig?: C, sentryBuildOptions: SentryBu */ function generateRandomTunnelRoute(): string { // Generate a random 8-character alphanumeric string + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis const randomString = Math.random().toString(36).substring(2, 10); return `/${randomString}`; } diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index cad8a1c4a443..6584640935ee 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -204,6 +204,7 @@ function getCultureContext(): CultureContext | undefined { */ export function getAppContext(): AppContext { const app_memory = process.memoryUsage().rss; + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis const app_start_time = new Date(Date.now() - process.uptime() * 1000).toISOString(); // https://nodejs.org/api/process.html#processavailablememory const appContext: AppContext = { app_start_time, app_memory }; @@ -236,6 +237,7 @@ export function getDeviceContext(deviceOpt: DeviceContextOptions | true): Device // Hence, we only set boot time, if we get a valid uptime value. // @see https://github.com/getsentry/sentry-javascript/issues/5856 if (typeof uptime === 'number') { + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis device.boot_time = new Date(Date.now() - uptime * 1000).toISOString(); } From 0c25ceb7eba462bd72fef299a632af9f767ed584 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Jan 2026 19:48:45 +0200 Subject: [PATCH 23/24] feat: optimize lookup speed by doing it once --- packages/core/src/utils/randomSafeContext.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/core/src/utils/randomSafeContext.ts b/packages/core/src/utils/randomSafeContext.ts index 66cffaf1b2b0..ce4bf5a8f16d 100644 --- a/packages/core/src/utils/randomSafeContext.ts +++ b/packages/core/src/utils/randomSafeContext.ts @@ -2,26 +2,28 @@ import { GLOBAL_OBJ } from './worldwide'; export type RandomSafeContextRunner = (callback: () => T) => T; -let RESOLVED_RUNNER: RandomSafeContextRunner | undefined; +// undefined = not yet resolved, null = no runner found, function = runner found +let RESOLVED_RUNNER: RandomSafeContextRunner | null | undefined; /** * Simple wrapper that allows SDKs to *secretly* set context wrapper to generate safe random IDs in cache components contexts */ export function withRandomSafeContext(cb: () => T): T { - // Skips future symbol lookups if we've already resolved the runner once - if (RESOLVED_RUNNER) { - return RESOLVED_RUNNER(cb); + // Skips future symbol lookups if we've already resolved (or attempted to resolve) the runner once + if (RESOLVED_RUNNER !== undefined) { + return RESOLVED_RUNNER ? RESOLVED_RUNNER(cb) : cb(); } const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ; - if (!(sym in globalWithSymbol) || typeof globalWithSymbol[sym] !== 'function') { - return cb(); - } - RESOLVED_RUNNER = globalWithSymbol[sym]; + if (sym in globalWithSymbol && typeof globalWithSymbol[sym] === 'function') { + RESOLVED_RUNNER = globalWithSymbol[sym]; + return RESOLVED_RUNNER(cb); + } - return globalWithSymbol[sym](cb); + RESOLVED_RUNNER = null; + return cb(); } /** From 0805c12dcb6d57a487d89f1dd099d5e73f825c35 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 8 Jan 2026 20:01:35 +0200 Subject: [PATCH 24/24] fix: lint --- packages/core/src/client.ts | 4 ++-- packages/opentelemetry/src/sampler.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index d41f28b09eb7..56b382a2860e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,5 +1,4 @@ /* eslint-disable max-lines */ -import { _INTERNAL_safeMathRandom } from '.'; import { getEnvelopeEndpointWithUrlEncodedAuth } from './api'; import { DEFAULT_ENVIRONMENT } from './constants'; import { getCurrentScope, getIsolationScope, getTraceContextFromScope } from './currentScopes'; @@ -46,6 +45,7 @@ import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; +import { safeMathRandom } from './utils/randomSafeContext'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning } from './utils/spanUtils'; import { rejectedSyncPromise } from './utils/syncpromise'; @@ -1289,7 +1289,7 @@ export abstract class Client { // 0.0 === 0% events are sent // Sampling for transaction happens somewhere else const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); - if (isError && typeof parsedSampleRate === 'number' && _INTERNAL_safeMathRandom() > parsedSampleRate) { + if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { this.recordDroppedEvent('sample_rate', 'error'); return rejectedSyncPromise( _makeDoNotSendEventError( diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index e06fe51bfd2a..7f7edd441612 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -12,6 +12,7 @@ import { } from '@opentelemetry/semantic-conventions'; import type { Client, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_safeMathRandom, baggageHeaderToDynamicSamplingContext, debug, hasSpansEnabled, @@ -121,7 +122,7 @@ export class SentrySampler implements Sampler { const dscString = parentContext?.traceState ? parentContext.traceState.get(SENTRY_TRACE_STATE_DSC) : undefined; const dsc = dscString ? baggageHeaderToDynamicSamplingContext(dscString) : undefined; - const sampleRand = parseSampleRate(dsc?.sample_rand) ?? Math.random(); + const sampleRand = parseSampleRate(dsc?.sample_rand) ?? _INTERNAL_safeMathRandom(); const [sampled, sampleRate, localSampleRateWasApplied] = sampleSpan( options,