Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 63 additions & 13 deletions packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
type _INTERNAL_RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner,
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';

// Inline AsyncLocalStorage interface from current types
Expand All @@ -15,17 +11,53 @@ type OriginalAsyncLocalStorage = typeof AsyncLocalStorage;
*/
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.',
);
const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ;

// Get initial snapshot - if unavailable, don't set up the wrapper at all
const initialSnapshot = getAsyncLocalStorageSnapshot();
if (!initialSnapshot) {
return;
}

globalWithSymbol[sym] = als.snapshot();
// We store a wrapper function instead of the raw snapshot because in serverless
// environments (e.g., Cloudflare Workers), the snapshot is bound to the request
// context it was created in. Once that request ends, the snapshot becomes invalid.
// The wrapper catches this and creates a fresh snapshot for the current request context.
let cachedSnapshot: RandomSafeContextRunner = initialSnapshot;

globalWithSymbol[sym] = <T>(callback: () => T): T => {
try {
return cachedSnapshot(callback);
} catch (error) {
// Only handle AsyncLocalStorage-related errors, rethrow others
if (!isAsyncLocalStorageError(error)) {
throw error;
}

// Snapshot likely stale, try to get a fresh one and retry
Copy link
Member

Choose a reason for hiding this comment

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

How can the snapshot get stale?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Perhaps it's not the best way to describe this, but this was the closest analogy. This is what I found while debugging, when we set the ALS snapshot on startup:

  • if cold start it gets initialised again and it works as expected, at that point the snapshot isn't set, so the callbacks execute normally.
  • if warm start, the global already exists, so some random APIs might run before the init takes effect. It will run with the previous snapshot that was set which would be in a different request context.

Or that's what I think is happening.

const freshSnapshot = getAsyncLocalStorageSnapshot();
// No snapshot available, fall back to direct execution
if (!freshSnapshot) {
return callback();
}

// Update the cached snapshot
cachedSnapshot = freshSnapshot;

// Retry the callback with the fresh snapshot
try {
return cachedSnapshot(callback);
} catch (retryError) {
// Only fall back for AsyncLocalStorage errors, rethrow others
if (!isAsyncLocalStorageError(retryError)) {
throw retryError;
}
// If fresh snapshot also fails with ALS error, fall back to direct execution
return callback();
}
}
};

DEBUG_BUILD && debug.log('[@sentry/nextjs] Prepared safe random ID generator context');
}

Expand All @@ -47,3 +79,21 @@ function getAsyncLocalStorage(): OriginalAsyncLocalStorage | undefined {

return undefined;
}

function getAsyncLocalStorageSnapshot(): RandomSafeContextRunner | undefined {
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 undefined;
}

return als.snapshot();
}

function isAsyncLocalStorageError(error: unknown): boolean {
return error instanceof Error && error.message.includes('AsyncLocalStorage');
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The error detection logic is too broad and may incorrectly catch errors that merely mention "AsyncLocalStorage" in their message but aren't the specific ALS binding error that needs to be handled. Consider checking for the exact error message: "Cannot call this AsyncLocalStorage bound function outside of the request in which it was created." This will prevent false positives where unrelated errors that happen to mention AsyncLocalStorage in their message would incorrectly trigger the retry/fallback logic.

Suggested change
return error instanceof Error && error.message.includes('AsyncLocalStorage');
return (
error instanceof Error &&
error.message ===
'Cannot call this AsyncLocalStorage bound function outside of the request in which it was created.'
);

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Exact error matching is flimsy IMO, also I am not sure if it would be the exact same error in each serverless environment.

Copy link
Member

Choose a reason for hiding this comment

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

Also not sure what would be the best call here, I think just AsyncLocalStorage is fine when thrown at this point

}
Loading