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..cba53fa1970d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('Cloudflare Runtime', () => { + 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'), + ); + }); + + request.get('/api/test-error').catch(() => { + // Expected to fail + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.contexts?.runtime).toEqual({ + name: 'cloudflare', + }); + + // The SDK info should include cloudflare in the packages + expect(errorEvent.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..1aa20a5f8295 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,78 @@ 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 + // 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 unknown 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 unknown 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