Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6e1678a
chore(eslint): create a random id gen rule finder
logaretm Jan 5, 2026
708280b
feat: implement a safe random runner
logaretm Jan 5, 2026
de30928
fix: wrap all random APIs with the safe runner
logaretm Jan 5, 2026
9031948
fix: set the escape hatch in next sdk
logaretm Jan 5, 2026
fe8f4bc
fix: format
logaretm Jan 5, 2026
4b27d51
fix: also patch otel pkg
logaretm Jan 5, 2026
824d1b0
refactor: better comments and remove duplication
logaretm Jan 6, 2026
9f40228
feat: eslint rule message improvements
logaretm Jan 6, 2026
d029ed2
style: enable the rule in all next sdk dep graph
logaretm Jan 6, 2026
61256ef
test: added e2e test
logaretm Jan 6, 2026
4caada8
fix: use safe wrappers and ignore rule when not needed in next sdk
logaretm Jan 6, 2026
d8ec196
test: added async metadata test
logaretm Jan 7, 2026
e29378c
chore: bump size-limit
logaretm Jan 7, 2026
9bc80c2
fix: check for snapshot existence first
logaretm Jan 7, 2026
dec6f70
refactor: revert all core changes
logaretm Jan 7, 2026
6a5d858
feat: update patching API name
logaretm Jan 7, 2026
8a49853
fix: only patch the needed functions
logaretm Jan 7, 2026
7de9d83
fix: bad reverts
logaretm Jan 7, 2026
c3c993f
fix: size-limit
logaretm Jan 8, 2026
2240f9d
chore: bring back the eslint rule
logaretm Jan 8, 2026
bd3ad82
fix: re-patch all random API calls
logaretm Jan 8, 2026
66bed30
fix: lint rules
logaretm Jan 8, 2026
0c25ceb
feat: optimize lookup speed by doing it once
logaretm Jan 8, 2026
0805c12
fix: lint
logaretm Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -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)
{
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<>
<h1>This will be pre-rendered</h1>
<DynamicContent />
</>
);
}

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 <div id="todos-fetched">Todos fetched: {todos.length}</div>;
}
Original file line number Diff line number Diff line change
@@ -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() {
Copy link
Member

Choose a reason for hiding this comment

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

Could we expand this test case, or better duplicate it and run some async computation within generateMetadata?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sure thing, I will add some logic there

return {
title: 'Cache Components Metadata Test',
};
}

export default function Page() {
return (
<>
<h1>This will be pre-rendered</h1>
<DynamicContent />
</>
);
}

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 <div id="todos-fetched">Todos fetched: {todos.length}</div>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
11 changes: 11 additions & 0 deletions packages/core/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -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',
},
},
],
};
3 changes: 2 additions & 1 deletion packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1288,7 +1289,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
// 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(
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions packages/core/src/integrations/mcp-server/correlation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
}
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -168,7 +169,7 @@ export class Scope {
this._sdkProcessingMetadata = {};
this._propagationContext = {
traceId: generateTraceId(),
sampleRand: Math.random(),
sampleRand: safeMathRandom(),
};
}

Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/tracing/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -293,7 +294,7 @@ export function startNewTrace<T>(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);
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/utils/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,7 +25,7 @@ function getCrypto(): CryptoInternal | undefined {
let emptyUuid: string | undefined;

function getRandomByte(): number {
return Math.random() * 16;
return safeMathRandom() * 16;
}

/**
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/utils/randomSafeContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { GLOBAL_OBJ } from './worldwide';

export type RandomSafeContextRunner = <T>(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<T>(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());
}
7 changes: 4 additions & 3 deletions packages/core/src/utils/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/utils/time.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { safeDateNow, withRandomSafeContext } from './randomSafeContext';
import { GLOBAL_OBJ } from './worldwide';

const ONE_SECOND_IN_MS = 1000;
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
};
}

Expand Down Expand Up @@ -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') {
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/utils/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -65,7 +66,7 @@ export function propagationContextFromHeaders(
if (!traceparentData?.traceId) {
return {
traceId: generateTraceId(),
sampleRand: Math.random(),
sampleRand: safeMathRandom(),
};
}

Expand Down Expand Up @@ -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();
}
}

Expand Down
Loading
Loading