From 75319a1c852b82b9aea69b81cf78ed80d049e4d4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 29 Jan 2026 14:07:16 +0100 Subject: [PATCH 1/4] wip --- .../app/api/test-error/route.ts | 8 ++ .../app/api/test-success/route.ts | 7 ++ .../tests/cloudflare-runtime.test.ts | 62 +++++++++++++++ packages/nextjs/src/edge/index.ts | 16 +++- packages/nextjs/src/server/index.ts | 22 +++++- packages/nextjs/test/serverSdk.test.ts | 75 ++++++++++++++++++- packages/node-core/src/sdk/client.ts | 3 +- packages/node-core/src/types.ts | 7 ++ packages/node-core/test/sdk/client.test.ts | 13 ++++ packages/vercel-edge/src/client.ts | 4 +- packages/vercel-edge/src/types.ts | 8 ++ 11 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-error/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-success/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-error/route.ts new file mode 100644 index 000000000000..01d35550b5ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-error/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + throw new Error('This is a test error from an API route'); + return NextResponse.json({ success: false }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-success/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-success/route.ts new file mode 100644 index 000000000000..26d85db4ac28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-success/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ success: true }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts new file mode 100644 index 000000000000..9e998ffb5ae6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('Cloudflare Runtime', () => { + test('Should report cloudflare as the runtime in API route transactions', async ({ request }) => { + const transactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { + console.log('transactionEvent', transactionEvent.transaction); + return transactionEvent?.transaction === 'GET /api/test-success'; + }); + + const response = await request.get('/api/test-success'); + expect(await response.json()).toStrictEqual({ success: true }); + + const transaction = await transactionPromise; + + expect(transaction.contexts?.runtime).toEqual({ + name: 'cloudflare', + }); + }); + + test('Should report cloudflare as the runtime in API route error events', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => + value.value?.includes('This is a test error from an API route'), + ); + }); + + // This will throw an error + request.get('/api/test-error').catch(() => { + // Expected to fail + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.contexts?.runtime).toEqual({ + name: 'cloudflare', + }); + }); + + test('Should include cloudflare in the SDK metadata for API routes', async ({ request }) => { + const transactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { + return transactionEvent?.transaction === 'GET /api/test-success'; + }); + + const response = await request.get('/api/test-success'); + expect(await response.json()).toStrictEqual({ success: true }); + + const transaction = await transactionPromise; + + // The SDK info should include cloudflare in the packages + expect(transaction.sdk?.packages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'npm:@sentry/nextjs', + }), + expect.objectContaining({ + name: 'npm:@sentry/cloudflare', + }), + ]), + ); + }); +}); diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 9fa05c94e978..3dd74a03c43e 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -28,7 +28,7 @@ import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attribu import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; -import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; +import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; @@ -73,13 +73,23 @@ export function init(options: VercelEdgeOptions = {}): void { customDefaultIntegrations.push(distDirRewriteFramesIntegration({ distDirName })); } - const opts = { + // Detect if running on OpenNext/Cloudflare + const isRunningOnCloudflare = isCloudflareWaitUntilAvailable(); + + const opts: VercelEdgeOptions = { defaultIntegrations: customDefaultIntegrations, release: process.env._sentryRelease || globalWithInjectedValues._sentryRelease, ...options, + // Override runtime to 'cloudflare' when running on OpenNext/Cloudflare + ...(isRunningOnCloudflare && { runtime: { name: 'cloudflare' } }), }; - applySdkMetadata(opts, 'nextjs', ['nextjs', 'vercel-edge']); + // Use appropriate SDK metadata based on the runtime environment + if (isRunningOnCloudflare) { + applySdkMetadata(opts, 'nextjs', ['nextjs', 'cloudflare']); + } else { + applySdkMetadata(opts, 'nextjs', ['nextjs', 'vercel-edge']); + } const client = vercelEdgeInit(opts); diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 91d1dd65ca06..5821412a4576 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -31,6 +31,7 @@ import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, } from '../common/span-attributes-with-logic-attached'; import { isBuild } from '../common/utils/isBuild'; +import { isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { handleOnSpanStart } from './handleOnSpanStart'; @@ -91,6 +92,18 @@ export function showReportDialog(): void { return; } +/** + * Returns the runtime configuration for the SDK based on the environment. + * When running on OpenNext/Cloudflare, returns cloudflare runtime config. + */ +function getCloudflareRuntimeConfig(): { runtime: { name: string } } | undefined { + if (isCloudflareWaitUntilAvailable()) { + // todo: add version information? + return { runtime: { name: 'cloudflare' } }; + } + return undefined; +} + /** Inits the Sentry NextJS SDK on node. */ export function init(options: NodeOptions): NodeClient | undefined { prepareSafeIdGeneratorContext(); @@ -128,11 +141,16 @@ export function init(options: NodeOptions): NodeClient | undefined { customDefaultIntegrations.push(distDirRewriteFramesIntegration({ distDirName })); } + // Detect if running on OpenNext/Cloudflare and get runtime config + const cloudflareConfig = getCloudflareRuntimeConfig(); + const opts: NodeOptions = { environment: process.env.SENTRY_ENVIRONMENT || getVercelEnv(false) || process.env.NODE_ENV, release: process.env._sentryRelease || globalWithInjectedValues._sentryRelease, defaultIntegrations: customDefaultIntegrations, ...options, + // Override runtime to 'cloudflare' when running on OpenNext/Cloudflare + ...cloudflareConfig, }; if (DEBUG_BUILD && opts.debug) { @@ -146,9 +164,11 @@ export function init(options: NodeOptions): NodeClient | undefined { return; } - applySdkMetadata(opts, 'nextjs', ['nextjs', 'node']); + // Use appropriate SDK metadata based on the runtime environment + applySdkMetadata(opts, 'nextjs', ['nextjs', cloudflareConfig ? 'cloudflare' : 'node']); const client = nodeInit(opts); + client?.on('beforeSampling', ({ spanAttributes }, samplingDecision) => { // There are situations where the Next.js Node.js server forwards requests for the Edge Runtime server (e.g. in // middleware) and this causes spans for Sentry ingest requests to be created. These are not exempt from our tracing diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 8ea0b060155e..b6a2d5b0766b 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -2,7 +2,7 @@ import type { Integration } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; import { getCurrentScope } from '@sentry/node'; import * as SentryNode from '@sentry/node'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { init } from '../src/server'; // normally this is set as part of the build process, so mock it here @@ -115,4 +115,77 @@ describe('Server init()', () => { it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); + + describe('OpenNext/Cloudflare runtime detection', () => { + const cloudflareContextSymbol = Symbol.for('__cloudflare-context__'); + + beforeEach(() => { + // Reset the global scope to allow re-initialization + SentryNode.getGlobalScope().clear(); + SentryNode.getIsolationScope().clear(); + SentryNode.getCurrentScope().clear(); + SentryNode.getCurrentScope().setClient(undefined); + }); + + afterEach(() => { + // Clean up the cloudflare context + delete (GLOBAL_OBJ as Record)[cloudflareContextSymbol]; + }); + + it('sets cloudflare runtime when OpenNext context is available', () => { + // Mock the OpenNext Cloudflare context + (GLOBAL_OBJ as Record)[cloudflareContextSymbol] = { + ctx: { + waitUntil: vi.fn(), + }, + }; + + init({}); + + expect(nodeInit).toHaveBeenLastCalledWith( + expect.objectContaining({ + runtime: { name: 'cloudflare' }, + }), + ); + }); + + it('sets cloudflare in SDK metadata when OpenNext context is available', () => { + // Mock the OpenNext Cloudflare context + (GLOBAL_OBJ as Record)[cloudflareContextSymbol] = { + ctx: { + waitUntil: vi.fn(), + }, + }; + + init({}); + + expect(nodeInit).toHaveBeenLastCalledWith( + expect.objectContaining({ + _metadata: expect.objectContaining({ + sdk: expect.objectContaining({ + name: 'sentry.javascript.nextjs', + packages: expect.arrayContaining([ + expect.objectContaining({ + name: 'npm:@sentry/nextjs', + }), + expect.objectContaining({ + name: 'npm:@sentry/cloudflare', + }), + ]), + }), + }), + }), + ); + }); + + it('does not set cloudflare runtime when OpenNext context is not available', () => { + init({}); + + expect(nodeInit).toHaveBeenLastCalledWith( + expect.not.objectContaining({ + runtime: { name: 'cloudflare' }, + }), + ); + }); + }); }); diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts index 1e783ee24b80..80a233aa3954 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -38,7 +38,8 @@ export class NodeClient extends ServerRuntimeClient { const clientOptions: ServerRuntimeClientOptions = { ...options, platform: 'node', - runtime: { name: 'node', version: global.process.version }, + // Use provided runtime or default to 'node' with current process version + runtime: options.runtime || { name: 'node', version: global.process.version }, serverName, }; diff --git a/packages/node-core/src/types.ts b/packages/node-core/src/types.ts index ee94322089b9..174eb039a8c7 100644 --- a/packages/node-core/src/types.ts +++ b/packages/node-core/src/types.ts @@ -38,6 +38,13 @@ export interface OpenTelemetryServerRuntimeOptions extends ServerRuntimeOptions * Extends the common WinterTC options with OpenTelemetry support shared with Bun and other server-side SDKs. */ export interface BaseNodeOptions extends OpenTelemetryServerRuntimeOptions { + /** + * Override the runtime name reported in events. + * Defaults to 'node' with the current process version if not specified. + * + * @hidden This is primarily used internally to support platforms like Next on OpenNext/Cloudflare. + */ + runtime?: { name: string; version?: string }; /** * Sets profiling sample rate when @sentry/profiling-node is installed * diff --git a/packages/node-core/test/sdk/client.test.ts b/packages/node-core/test/sdk/client.test.ts index 0bcef2669095..6420191ffb0c 100644 --- a/packages/node-core/test/sdk/client.test.ts +++ b/packages/node-core/test/sdk/client.test.ts @@ -99,6 +99,19 @@ describe('NodeClient', () => { }); }); + test('uses custom runtime when provided in options', () => { + const options = getDefaultNodeClientOptions({ runtime: { name: 'cloudflare' } }); + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint, currentScope, isolationScope); + + expect(event.contexts?.runtime).toEqual({ + name: 'cloudflare', + }); + }); + test('adds server name to event when value passed in options', () => { const options = getDefaultNodeClientOptions({ serverName: 'foo' }); const client = new NodeClient(options); diff --git a/packages/vercel-edge/src/client.ts b/packages/vercel-edge/src/client.ts index a34d1b36f09c..ab7a4e938e96 100644 --- a/packages/vercel-edge/src/client.ts +++ b/packages/vercel-edge/src/client.ts @@ -27,8 +27,8 @@ export class VercelEdgeClient extends ServerRuntimeClient Date: Fri, 30 Jan 2026 16:33:50 +0100 Subject: [PATCH 2/4] fix(nextjs): Simplify cloudflare runtime e2e tests to use error events Server-side transactions are not reliably available on Cloudflare Workers, so the tests now verify the cloudflare runtime and SDK metadata using error events instead. Co-Authored-By: Claude Opus 4.5 --- .../tests/cloudflare-runtime.test.ts | 31 ++----------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts index 9e998ffb5ae6..1ce279e1b3e2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts @@ -1,23 +1,7 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError } from '@sentry-internal/test-utils'; test.describe('Cloudflare Runtime', () => { - test('Should report cloudflare as the runtime in API route transactions', async ({ request }) => { - const transactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { - console.log('transactionEvent', transactionEvent.transaction); - return transactionEvent?.transaction === 'GET /api/test-success'; - }); - - const response = await request.get('/api/test-success'); - expect(await response.json()).toStrictEqual({ success: true }); - - const transaction = await transactionPromise; - - expect(transaction.contexts?.runtime).toEqual({ - name: 'cloudflare', - }); - }); - test('Should report cloudflare as the runtime in API route error events', async ({ request }) => { const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => { return !!errorEvent?.exception?.values?.some(value => @@ -35,20 +19,9 @@ test.describe('Cloudflare Runtime', () => { expect(errorEvent.contexts?.runtime).toEqual({ name: 'cloudflare', }); - }); - - test('Should include cloudflare in the SDK metadata for API routes', async ({ request }) => { - const transactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'GET /api/test-success'; - }); - - const response = await request.get('/api/test-success'); - expect(await response.json()).toStrictEqual({ success: true }); - - const transaction = await transactionPromise; // The SDK info should include cloudflare in the packages - expect(transaction.sdk?.packages).toEqual( + expect(errorEvent.sdk?.packages).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'npm:@sentry/nextjs', From d14ae1940a45307aee3f8e5da1b8a810c1082096 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 30 Jan 2026 16:39:56 +0100 Subject: [PATCH 3/4] use error for testing --- .../nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts index 1ce279e1b3e2..cba53fa1970d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts @@ -9,7 +9,6 @@ test.describe('Cloudflare Runtime', () => { ); }); - // This will throw an error request.get('/api/test-error').catch(() => { // Expected to fail }); From cfd825e6bc63d108b8fee214e1f06ff703c25132 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 30 Jan 2026 16:58:23 +0100 Subject: [PATCH 4/4] lint --- packages/nextjs/test/serverSdk.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index b6a2d5b0766b..1aa20a5f8295 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -129,12 +129,13 @@ describe('Server init()', () => { afterEach(() => { // Clean up the cloudflare context - delete (GLOBAL_OBJ as Record)[cloudflareContextSymbol]; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (GLOBAL_OBJ as unknown as Record)[cloudflareContextSymbol]; }); it('sets cloudflare runtime when OpenNext context is available', () => { // Mock the OpenNext Cloudflare context - (GLOBAL_OBJ as Record)[cloudflareContextSymbol] = { + (GLOBAL_OBJ as unknown as Record)[cloudflareContextSymbol] = { ctx: { waitUntil: vi.fn(), }, @@ -151,7 +152,7 @@ describe('Server init()', () => { it('sets cloudflare in SDK metadata when OpenNext context is available', () => { // Mock the OpenNext Cloudflare context - (GLOBAL_OBJ as Record)[cloudflareContextSymbol] = { + (GLOBAL_OBJ as unknown as Record)[cloudflareContextSymbol] = { ctx: { waitUntil: vi.fn(), },