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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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