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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const NODE_EXPORTS_IGNORE = [
// factories for that same Node-runtime-only opt-in, so it isn't surfaced
// through the framework / serverless SDKs either.
'diagnosticsChannelInjectionIntegrations',
// Companion to the above two, same reasoning (Next.js re-exports it via `export * from '@sentry/node'`)
'isDiagnosticsChannelInjectionEnabled',
// Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration)
'_INTERNAL_normalizeCollectionInterval',
];
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"@sentry/node": "10.65.0",
"@sentry/opentelemetry": "10.65.0",
"@sentry/react": "10.65.0",
"@sentry/server-utils": "10.65.0",
"@sentry/vercel-edge": "10.65.0",
"@sentry/webpack-plugin": "^5.3.0",
"rollup": "^4.60.3",
Expand Down
22 changes: 22 additions & 0 deletions packages/nextjs/src/config/diagnosticsChannelInjection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Instrumented packages verified (via e2e) to bundle correctly, removed from Sentry's own
* `serverExternalPackages` defaults so the build-time loader can transform them. Everything else
* stays external and is instrumented by the runtime module hook instead. Deliberately an
* allowlist — bundling can break packages outright (e.g. `mysql` 2.x under Turbopack).
*/
export const BUNDLE_SAFE_INSTRUMENTED_PACKAGES = ['ioredis'];

/**
* The orchestrion runtime machinery must stay external — its parser breaks when bundled, which
* silently disables the runtime module hook.
*/
export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [
'@apm-js-collab/tracing-hooks',
'@apm-js-collab/code-transformer',
];

/** Remove the given packages from a `serverExternalPackages` list. */
export function filterInstrumentedExternals(externals: string[], packagesToBundle: string[]): string[] {
const set = new Set(packagesToBundle);
return externals.filter(name => !set.has(name));
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { debug } from '@sentry/core';
import * as path from 'path';
import { getOrchestrionLoaderPath, getSentryInstrumentations } from '@sentry/server-utils/orchestrion/webpack';
import type { VercelCronsConfig } from '../../common/types';
import type { RouteManifest } from '../manifest/types';
import type { NextConfigObject, SentryBuildOptions, TurbopackMatcherWithRule, TurbopackOptions } from '../types';
import type {
JSONValue,
NextConfigObject,
SentryBuildOptions,
TurbopackMatcherWithRule,
TurbopackOptions,
} from '../types';
import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util';
import { generateValueInjectionRules } from './generateValueInjectionRules';

Expand Down Expand Up @@ -106,9 +113,42 @@ export function constructTurbopackConfig({
});
}

newConfig.rules = maybeAddOrchestrionRule(newConfig.rules, userSentryOptions, nextJsVersion);

return newConfig;
}

/**
* Adds the orchestrion code-transform loader rule when diagnostics-channel injection is enabled.
*/
function maybeAddOrchestrionRule(
rules: TurbopackOptions['rules'],
userSentryOptions: SentryBuildOptions | undefined,
nextJsVersion: string | undefined,
): TurbopackOptions['rules'] {
if (
!userSentryOptions?._experimental?.useDiagnosticsChannelInjection ||
!nextJsVersion ||
!supportsTurbopackRuleCondition(nextJsVersion)
) {
return rules;
}

return safelyAddTurbopackRule(rules, {
matcher: '*.{js,mjs,cjs}',
rule: {
condition: 'node',
loaders: [
{
loader: getOrchestrionLoaderPath(),
// `instrumentations` is JSON-serializable
options: { instrumentations: getSentryInstrumentations() as unknown as JSONValue[] },
},
],
},
});
}

/**
* Safely add a Turbopack rule to the existing rules.
*
Expand Down
15 changes: 14 additions & 1 deletion packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type NextRewrite = {
destination: string;
};

interface WebpackPluginInstance {
export interface WebpackPluginInstance {
[index: string]: unknown;
apply: (compiler: unknown) => void;
}
Expand Down Expand Up @@ -765,6 +765,19 @@ export type SentryBuildOptions = {
enabled?: boolean;
ignoredComponents?: string[];
};
/**
* EXPERIMENTAL: Wire up orchestrion diagnostics-channel instrumentation at build time.
*
* When enabled, `withSentryConfig` injects the orchestrion code-transform loader for bundled
* server packages and keeps the remaining instrumented packages external so the runtime module
* hook picks them up. You must still call `Sentry.experimentalUseDiagnosticsChannelInjection()`
* in your server config to record spans.
*
* Turbopack support requires Next.js 16+; the webpack path works on earlier versions.
*
* @experimental May change or be removed in any release.
*/
useDiagnosticsChannelInjection?: boolean;
}>;

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import type {
WebpackConfigObject,
WebpackConfigObjectWithModuleRules,
WebpackEntryProperty,
WebpackPluginInstance,
} from './types';
import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack';
import { getNextjsVersion, getPackageModules } from './util';
import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils';

Expand Down Expand Up @@ -429,6 +431,11 @@ export function constructWebpackConfigFunction({
}),
);

// Orchestrion code-transform loader — Node server runtime only, never the edge compilation
Comment thread
andreiborza marked this conversation as resolved.
if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) {
newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as WebpackPluginInstance);
}
Comment thread
chargome marked this conversation as resolved.

return newConfig;
};
}
Expand Down
9 changes: 9 additions & 0 deletions packages/nextjs/src/config/withSentryConfig/buildTime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { getTracingHooksDirectory } from '@sentry/server-utils/orchestrion/webpack';
import type { NextConfigObject, SentryBuildOptions } from '../types';

/**
Expand Down Expand Up @@ -50,6 +51,14 @@ export function setUpBuildTimeVariables(
buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath;
}

// Marker read by the server SDK to warn if the runtime opt-in call is missing.
if (userSentryOptions._experimental?.useDiagnosticsChannelInjection) {
buildTimeVariables._sentryUseDiagnosticsChannelInjection = 'true';
// Resolved here (where the SDK is a real on-disk package) and inlined, because the runtime
// module hook can't resolve the bare specifier from a bundled server chunk under pnpm.
buildTimeVariables._sentryOrchestrionTracingHooksDir = getTracingHooksDirectory();
}

if (basePath) {
buildTimeVariables._sentryBasePath = basePath;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,11 @@ export function getFinalConfigObject(

maybeEnableTurbopackSourcemaps(incomingUserNextConfigObject, userSentryOptions, bundlerInfo);

const useDiagnosticsChannelInjection = userSentryOptions._experimental?.useDiagnosticsChannelInjection ?? false;

return {
...incomingUserNextConfigObject,
...getServerExternalPackagesPatch(incomingUserNextConfigObject, nextMajor),
...getServerExternalPackagesPatch(incomingUserNextConfigObject, nextMajor, useDiagnosticsChannelInjection),
...getWebpackPatch({
incomingUserNextConfigObject,
userSentryOptions,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
BUNDLE_SAFE_INSTRUMENTED_PACKAGES,
filterInstrumentedExternals,
ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES,
} from '../diagnosticsChannelInjection';
import { handleRunAfterProductionCompile } from '../handleRunAfterProductionCompile';
import type { RouteManifest } from '../manifest/types';
import { constructTurbopackConfig } from '../turbopack';
Expand Down Expand Up @@ -227,23 +232,32 @@ export function maybeEnableTurbopackSourcemaps(
export function getServerExternalPackagesPatch(
incomingUserNextConfigObject: NextConfigObject,
nextMajor: number | undefined,
useDiagnosticsChannelInjection = false,
): Partial<NextConfigObject> {
// Diagnostics-channel injection: only bundle-safe packages leave OUR defaults (→ build-time
// loader); everything else stays external (→ runtime module hook), including the orchestrion
// machinery itself, which breaks when bundled.
const mergeExternals = (userProvided: string[] | undefined): string[] => {
const defaults = useDiagnosticsChannelInjection
? filterInstrumentedExternals(DEFAULT_SERVER_EXTERNAL_PACKAGES, BUNDLE_SAFE_INSTRUMENTED_PACKAGES)
: DEFAULT_SERVER_EXTERNAL_PACKAGES;
return [
...(userProvided || []),
...defaults,
...(useDiagnosticsChannelInjection ? ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES : []),
];
};

if (nextMajor && nextMajor >= 15) {
return {
serverExternalPackages: [
...(incomingUserNextConfigObject.serverExternalPackages || []),
...DEFAULT_SERVER_EXTERNAL_PACKAGES,
],
};
return { serverExternalPackages: mergeExternals(incomingUserNextConfigObject.serverExternalPackages) };
}

return {
experimental: {
...incomingUserNextConfigObject.experimental,
serverComponentsExternalPackages: [
...(incomingUserNextConfigObject.experimental?.serverComponentsExternalPackages || []),
...DEFAULT_SERVER_EXTERNAL_PACKAGES,
],
serverComponentsExternalPackages: mergeExternals(
incomingUserNextConfigObject.experimental?.serverComponentsExternalPackages,
),
},
};
}
Expand Down
37 changes: 36 additions & 1 deletion packages/nextjs/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
} from '@sentry/core';
import type { NodeClient, NodeOptions } from '@sentry/node';
import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node';
import {
experimentalUseDiagnosticsChannelInjection as nodeExperimentalUseDiagnosticsChannelInjection,
getDefaultIntegrations,
httpIntegration,
init as nodeInit,
isDiagnosticsChannelInjectionEnabled,
} from '@sentry/node';
import { DEBUG_BUILD } from '../common/debug-build';
import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor';
import { getVercelEnv } from '../common/getVercelEnv';
Expand Down Expand Up @@ -41,8 +47,26 @@ export { startSpan, startSpanManual, startInactiveSpan } from '../common/utils/n
const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
_sentryRewriteFramesDistDir?: string;
_sentryRelease?: string;
_sentryUseDiagnosticsChannelInjection?: string;
_sentryOrchestrionTracingHooksDir?: string;
};

/**
* EXPERIMENTAL: Next.js-aware variant of `Sentry.experimentalUseDiagnosticsChannelInjection()`
* from `@sentry/node` (see its docs for behavior and caveats).
*
* Next.js bundles the SDK into the server build, from where the runtime module hook can't resolve
* the `@apm-js-collab/tracing-hooks` bare specifier under isolated installs (pnpm). This variant
* points the hook at the package location that `withSentryConfig` resolved at build time.
*
* @experimental May change or be removed in any release.
*/
export function experimentalUseDiagnosticsChannelInjection(): void {
const tracingHooksDir =
process.env._sentryOrchestrionTracingHooksDir || globalWithInjectedValues._sentryOrchestrionTracingHooksDir;
nodeExperimentalUseDiagnosticsChannelInjection(tracingHooksDir ? { tracingHooksDir } : undefined);
}

// Call at module level so `next build` prerender workers still register the runner without `init`
prepareSafeIdGeneratorContext();

Expand Down Expand Up @@ -138,6 +162,17 @@ export function init(options: NodeOptions): NodeClient | undefined {
customDefaultIntegrations.push(distDirRewriteFramesIntegration({ distDirName }));
}

// The build wired the orchestrion loader but the runtime opt-in is missing → no DB spans.
const useDiagnosticsChannelInjection =
process.env._sentryUseDiagnosticsChannelInjection || globalWithInjectedValues._sentryUseDiagnosticsChannelInjection;
if (DEBUG_BUILD && useDiagnosticsChannelInjection && !isDiagnosticsChannelInjectionEnabled()) {
debug.warn(
'[@sentry/nextjs] `useDiagnosticsChannelInjection` is enabled in `withSentryConfig`, but ' +
'`Sentry.experimentalUseDiagnosticsChannelInjection()` was not called before `Sentry.init()`. ' +
'Server DB spans will not be recorded.',
);
Comment thread
chargome marked this conversation as resolved.
}

// Detect if running on OpenNext/Cloudflare and get runtime config
const cloudflareConfig = getCloudflareRuntimeConfig();

Expand Down
73 changes: 73 additions & 0 deletions packages/nextjs/test/config/diagnosticsChannelInjection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import {
BUNDLE_SAFE_INSTRUMENTED_PACKAGES,
filterInstrumentedExternals,
} from '../../src/config/diagnosticsChannelInjection';
import { setUpBuildTimeVariables } from '../../src/config/withSentryConfig/buildTime';
import { getServerExternalPackagesPatch } from '../../src/config/withSentryConfig/getFinalConfigObjectBundlerUtils';
import type { NextConfigObject } from '../../src/config/types';

describe('filterInstrumentedExternals', () => {
it('removes the given packages, keeps the rest', () => {
expect(
filterInstrumentedExternals(['express', 'pg', 'pg-pool', 'ioredis', 'mongodb'], ['pg', 'pg-pool', 'ioredis']),
).toEqual(['express', 'mongodb']);
});

it('is a no-op with an empty bundle list', () => {
expect(filterInstrumentedExternals(['express', 'pg'], [])).toEqual(['express', 'pg']);
});
});

describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () => {
it('keeps everything external except the bundle-safe allowlist, and adds the runtime machinery', () => {
const patch = getServerExternalPackagesPatch({}, 16, true);
const externals = patch.serverExternalPackages ?? [];

// Only the verified bundle-safe packages leave the external list (→ build-time loader).
for (const name of BUNDLE_SAFE_INSTRUMENTED_PACKAGES) {
expect(externals).not.toContain(name);
}
// Other instrumented packages stay external (→ runtime module hook).
expect(externals).toContain('mysql');
expect(externals).toContain('pg');
expect(externals).toContain('pg-pool');
// The orchestrion machinery must be external for the runtime hook to work.
expect(externals).toContain('@apm-js-collab/tracing-hooks');
expect(externals).toContain('@apm-js-collab/code-transformer');
});

it('respects user-provided externals even for bundle-safe packages', () => {
const patch = getServerExternalPackagesPatch({ serverExternalPackages: ['ioredis'] }, 16, true);
expect(patch.serverExternalPackages).toContain('ioredis');
});

it('is unchanged with the flag off', () => {
const patch = getServerExternalPackagesPatch({}, 16, false);
const externals = patch.serverExternalPackages ?? [];
expect(externals).toContain('ioredis');
expect(externals).toContain('mysql');
expect(externals).not.toContain('@apm-js-collab/tracing-hooks');
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feat lacks integration or E2E

Medium Severity

This feature PR adds unit and webpack config tests but no integration or E2E test in the diff, though the description references separate e2e verification. Review guidelines expect at least one integration or E2E test for feat changes.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit a3a3595. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handled in #22080


describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => {
it('injects the flag marker and the tracing-hooks location', () => {
const nextConfig: NextConfigObject = {};
setUpBuildTimeVariables(nextConfig, { _experimental: { useDiagnosticsChannelInjection: true } }, undefined);

expect(nextConfig.env).toMatchObject({
_sentryUseDiagnosticsChannelInjection: 'true',
// The runtime module hook joins subpaths onto this, so it must be an absolute directory.
_sentryOrchestrionTracingHooksDir: expect.stringMatching(/@apm-js-collab[/+]tracing-hooks/),
});
});

it('injects neither with the flag off', () => {
const nextConfig: NextConfigObject = {};
setUpBuildTimeVariables(nextConfig, {}, undefined);

expect(nextConfig.env).not.toHaveProperty('_sentryUseDiagnosticsChannelInjection');
expect(nextConfig.env).not.toHaveProperty('_sentryOrchestrionTracingHooksDir');
});
});
Loading
Loading