diff --git a/.size-limit.js b/.size-limit.js
index 24772d8380f5..215a40d1bf17 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)
{
@@ -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',
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/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..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
@@ -26,3 +26,29 @@ 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');
+});
+
+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');
+});
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/core/src/client.ts b/packages/core/src/client.ts
index aad363905a68..56b382a2860e 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/randomSafeContext';
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/index.ts b/packages/core/src/index.ts
index e4b48f24de2f..e74d67866f24 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -514,3 +514,9 @@ export type {
UnstableRollupPluginOptions,
UnstableWebpackPluginOptions,
} from './build-time-plugins/buildTimeOptionsBase';
+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 0639cdb845f1..3d65149facb1 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/randomSafeContext';
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,10 @@ 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..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
new file mode 100644
index 000000000000..ce4bf5a8f16d
--- /dev/null
+++ b/packages/core/src/utils/randomSafeContext.ts
@@ -0,0 +1,43 @@
+import { GLOBAL_OBJ } from './worldwide';
+
+export type RandomSafeContextRunner = (callback: () => T) => T;
+
+// 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 (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') {
+ RESOLVED_RUNNER = globalWithSymbol[sym];
+ return RESOLVED_RUNNER(cb);
+ }
+
+ RESOLVED_RUNNER = null;
+ return 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 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 {
+ 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 aa5a15153674..25e3295118f8 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 './randomSafeContext';
// 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/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/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/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;
}
diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts
new file mode 100644
index 000000000000..bd262eb736e1
--- /dev/null
+++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts
@@ -0,0 +1,49 @@
+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
+// Avoids conflict with resolving it from getBuiltinModule
+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
+ */
+export function prepareSafeIdGeneratorContext(): void {
+ const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__');
+ const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: _INTERNAL_RandomSafeContextRunner } = GLOBAL_OBJ;
+ const als = getAsyncLocalStorage();
+ if (!als || typeof als.snapshot !== 'function') {
+ DEBUG_BUILD &&
+ debug.warn(
+ '[@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;
+ }
+
+ 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
+ // 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') ?? {};
+
+ return AsyncLocalStorage as OriginalAsyncLocalStorage;
+ }
+
+ return undefined;
+}
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..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();
}
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/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) {
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',
+ },
+ },
+ ],
};