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
@@ -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 });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';

export const dynamic = 'force-dynamic';

export async function GET() {
return NextResponse.json({ success: true });
}
Original file line number Diff line number Diff line change
@@ -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',
}),
]),
);
});
});
16 changes: 13 additions & 3 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

Expand Down
22 changes: 21 additions & 1 deletion packages/nextjs/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
76 changes: 75 additions & 1 deletion packages/nextjs/test/serverSdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<symbol, unknown>)[cloudflareContextSymbol];
});

it('sets cloudflare runtime when OpenNext context is available', () => {
// Mock the OpenNext Cloudflare context
(GLOBAL_OBJ as unknown as Record<symbol, unknown>)[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<symbol, unknown>)[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' },
}),
);
});
});
});
3 changes: 2 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
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,
};

Expand Down
7 changes: 7 additions & 0 deletions packages/node-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
13 changes: 13 additions & 0 deletions packages/node-core/test/sdk/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/vercel-edge/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export class VercelEdgeClient extends ServerRuntimeClient<VercelEdgeClientOption
const clientOptions: ServerRuntimeClientOptions = {
...options,
platform: 'javascript',
// TODO: Grab version information
runtime: { name: 'vercel-edge' },
// Use provided runtime or default to 'vercel-edge'
runtime: options.runtime || { name: 'vercel-edge' },
serverName: options.serverName || process.env.SENTRY_NAME,
};

Expand Down
8 changes: 8 additions & 0 deletions packages/vercel-edge/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export interface BaseVercelEdgeOptions {
/** Sets an optional server name (device name) */
serverName?: string;

/**
* Override the runtime name reported in events.
* Defaults to 'vercel-edge' if not specified.
*
* @hidden This is primarily used internally to support platforms like OpenNext/Cloudflare.
*/
runtime?: { name: string; version?: string };

/**
* Specify a custom VercelEdgeClient to be used. Must extend VercelEdgeClient!
* This is not a public, supported API, but used internally only.
Expand Down
Loading