Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ next-env.d.ts

# Sentry Config File
.env.sentry-build-plugin

# OpenNext build output
/.open-next/
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Suspense } from 'react';

export default function Page({ params }: { params: Promise<{ id: string }> }) {
return (
<Suspense fallback={<p>Loading...</p>}>
<Result params={params} />
</Suspense>
);
}

async function Result({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <p id="result">{`prefetched ${id}`}</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';

export default defineCloudflareConfig({});
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@
"test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack",
"test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack",
"test:assert": "pnpm test:prod && pnpm test:dev",
"test:assert-webpack": "TEST_BUNDLER=webpack pnpm test:prod && pnpm test:dev-webpack"
"test:assert-webpack": "TEST_BUNDLER=webpack pnpm test:prod && pnpm test:dev-webpack",
"test:build:cloudflare": "pnpm install && opennextjs-cloudflare build",
"start:cloudflare": "opennextjs-cloudflare preview --var \"E2E_TEST_DSN:$E2E_TEST_DSN\""
},
"dependencies": {
"@opennextjs/cloudflare": "^1.20.12",
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
"import-in-the-middle": "^1",
Expand All @@ -41,7 +44,8 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "^16",
"typescript": "^5"
"typescript": "^5",
"wrangler": "^4.148.0"
},
"sentryTest": {
"runner": "namespace-profile-sentry-javascript",
Expand All @@ -51,6 +55,14 @@
"label": "nextjs-16-cacheComponents (webpack)",
"assert-command": "pnpm test:assert-webpack"
}
],
"optionalVariants": [
{
"runtime": "cloudflare",
"build-command": "pnpm test:build:cloudflare",
"assert-command": "pnpm test:prod",
"label": "nextjs-16-cacheComponents (cloudflare)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
import { getPlaywrightConfig, getRuntime } from '@sentry-internal/test-utils';
const testEnv = process.env.TEST_ENV;

if (!testEnv) {
throw new Error('No test env defined');
}

const productionStartCommands = {
node: 'pnpm next start -p 3030',
cloudflare: 'pnpm start:cloudflare --port 3030',
};

const getStartCommand = () => {
if (testEnv === 'development-webpack') {
return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs';
Expand All @@ -15,7 +20,7 @@ const getStartCommand = () => {
}

if (testEnv === 'production') {
return 'pnpm next start -p 3030';
return productionStartCommands[getRuntime()];
}

throw new Error(`Unknown test env: ${testEnv}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
allowBuilds:
esbuild: true
unrs-resolver: true
workerd: true
minimumReleaseAge: 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';

const REQUEST_COUNT = 12;

// Next.js aborts a Cache Components prerender when randomness is generated while it runs. On Cloudflare Workers the
// SDK used to generate it inside the prerender of another request once prefetches overlapped, which turned their
// responses into the one byte partial marker `~` (https://github.com/getsentry/sentry-javascript/issues/25245).
test('overlapping runtime prefetch requests render their pages', async ({ request }) => {
// `Next-Router-Prefetch: 2` is what the Next.js router sends for a runtime prefetch, which Next.js answers with a
// prerender at request time.
const responses = await Promise.all(
Array.from({ length: REQUEST_COUNT }, (_, index) =>
request.get(`/prefetch-overlap/overlap-${index + 1}`, { headers: { RSC: '1', 'Next-Router-Prefetch': '2' } }),
),
);

for (const [index, response] of responses.entries()) {
expect(response.ok()).toBe(true);
expect(await response.text()).toContain(`prefetched overlap-${index + 1}`);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Sentry from '@sentry/nextjs/cloudflare';
// @ts-ignore the file exists only after `opennextjs-cloudflare build`
import handler from './.open-next/worker.js';

// @ts-ignore the file exists only after `opennextjs-cloudflare build`
export { DOQueueHandler, DOShardedTagCache, BucketCachePurge } from './.open-next/worker.js';

export default Sentry.withSentry(
(env: { E2E_TEST_DSN: string }) => ({
// The specs of this app assert transaction envelopes, like `sentry.server.config.ts`. On Workers the request
// transaction belongs to this client, so with span streaming here they would never see one.
traceLifecycle: 'static',
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: env.E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
}),
handler,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "nextjs-16-cachecomponents",
"main": "./worker.ts",
// From 2025-06-16 on, workerd binds an `AsyncLocalStorage` snapshot to the request that took it, which
// tests/prefetchOverlap.spec.ts relies on.
"compatibility_date": "2026-02-19",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets",
},
"services": [
{
// Self-reference service binding, the service name must match the worker name
// see https://opennext.js.org/cloudflare/caching
"binding": "WORKER_SELF_REFERENCE",
"service": "nextjs-16-cachecomponents",
},
],
}
5 changes: 5 additions & 0 deletions packages/nextjs/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'
import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors';
import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd';
import { NextSentryPropagator } from '../server/nextSentryPropagator';
import { prepareSafeIdGeneratorContext } from '../server/prepareSafeIdGeneratorContext';
import { getGlobalTracerProvider, markPrerendersOnTracerProvider } from '../server/prerenderContext';
import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from '../server/serverSpanHooks';
import { nextjsUseCacheIntegration } from '../server/useCacheInstrumentation';

export * from '@sentry/cloudflare';

// At isolate startup, so the copy of `@sentry/core` in the Worker bundle finds the runner on its first ID. A snapshot
// taken here is not bound to a request, unlike one taken while OpenNext loads Next.js inside the first request.
prepareSafeIdGeneratorContext();

class NextjsCloudflarePropagator extends NextSentryPropagator {
/** @inheritDoc */
public extract(ctx: Context, carrier: unknown, getter: TextMapGetter): Context {
Expand Down
54 changes: 51 additions & 3 deletions packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import { DEBUG_BUILD } from '../common/debug-build';
// Avoids conflict with resolving it from getBuiltinModule
type OriginalAsyncLocalStorage = typeof AsyncLocalStorage;

interface WorkUnitAsyncStorage {
exit<T>(callback: () => T): T;
}

// Next.js >= 16.4 anchors its async storages to `globalThis` under `Symbol.for('@next/<name>@<version>')`
// (`next/src/server/app-render/async-local-storage.ts`).
const NEXT_WORK_UNIT_ASYNC_STORAGE_KEY_PREFIX = '@next/work-unit-async-storage@';

/**
* 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
Expand All @@ -13,19 +21,31 @@ export function prepareSafeIdGeneratorContext(): void {
const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__');
const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ;

// Get initial snapshot - if unavailable, don't set up the wrapper at all
let workUnitAsyncStorage = getNextWorkUnitAsyncStorage();

// Without a snapshot or the Next.js storage there is nothing to run the callback in, so don't set up the wrapper
const initialSnapshot = getAsyncLocalStorageSnapshot();
if (!initialSnapshot) {
if (!initialSnapshot && !workUnitAsyncStorage) {
return;
}

// 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;
let cachedSnapshot = initialSnapshot;

globalWithSymbol[sym] = <T>(callback: () => T): T => {
// Next.js only flags sync IO while its work unit store is active, so exiting that store is enough and works in
// any request. A snapshot is only safe when taken outside of a render and, on Cloudflare Workers, only in the
// request that took it (`bind_asynclocalstorage_snapshot_to_request`).
if (workUnitAsyncStorage) {
return workUnitAsyncStorage.exit(callback);
}
if (!cachedSnapshot) {
return callback();
}

try {
return cachedSnapshot(callback);
} catch (error) {
Expand All @@ -34,6 +54,11 @@ export function prepareSafeIdGeneratorContext(): void {
throw error;
}

workUnitAsyncStorage = getNextWorkUnitAsyncStorage();
if (workUnitAsyncStorage) {
return workUnitAsyncStorage.exit(callback);
}

// Snapshot likely stale, try to get a fresh one and retry
const freshSnapshot = getAsyncLocalStorageSnapshot();
// No snapshot available, fall back to direct execution
Expand Down Expand Up @@ -61,6 +86,29 @@ export function prepareSafeIdGeneratorContext(): void {
DEBUG_BUILD && debug.log('[@sentry/nextjs] Prepared safe random ID generator context');
}

function getNextWorkUnitAsyncStorage(): WorkUnitAsyncStorage | undefined {
const globalWithStorages = GLOBAL_OBJ as typeof GLOBAL_OBJ & Record<symbol, unknown>;

for (const key of Object.getOwnPropertySymbols(globalWithStorages)) {
if (!key.description?.startsWith(NEXT_WORK_UNIT_ASYNC_STORAGE_KEY_PREFIX)) {
continue;
}
const storage = globalWithStorages[key] as WorkUnitAsyncStorage | undefined;
if (typeof storage?.exit !== 'function') {
continue;
}
try {
// Where no AsyncLocalStorage exists, Next.js registers a `FakeAsyncLocalStorage` whose `exit` always throws
storage.exit(() => undefined);
return storage;
} catch {
// not usable
}
}

return undefined;
}

function getAsyncLocalStorage(): OriginalAsyncLocalStorage | undefined {
// May exist in the Next.js runtime globals
// Doesn't exist in some of our tests
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import '../../src/cloudflare';

const RUNNER_KEY = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__');

describe('@sentry/nextjs/cloudflare', () => {
it('registers the safe random ID runner when the module loads', () => {
const runner = (globalThis as Record<symbol, unknown>)[RUNNER_KEY] as (<T>(callback: () => T) => T) | undefined;
expect(runner).toBeTypeOf('function');
expect(runner!(() => 'ok')).toBe('ok');
});
});
Loading
Loading