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
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -390,12 +390,12 @@ module.exports = [
import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '154 KB',
limit: '190 KB',
Comment thread
cursor[bot] marked this conversation as resolved.
disablePlugins: ['@size-limit/esbuild'],
},
{
name: '@sentry/node/import (ESM hook with diagnostics-channel injection)',
path: ['node_modules/@apm-js-collab/tracing-hooks/hook.mjs', 'packages/node/build/import-hook.mjs'],
path: ['packages/server-utils/build/esm/orchestrion/runtime/hook.js', 'packages/node/build/import-hook.mjs'],
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '76 KB',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@
},
"pnpm": {
"overrides": {
"ofetch": "1.4.0",
"@vercel/nft": "0.29.4"
"ofetch": "1.4.0"
}
},
"volta": {
Expand Down
53 changes: 47 additions & 6 deletions packages/nextjs/src/config/diagnosticsChannelInjection.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { resolveOrchestrionRuntimeRequest } from '@sentry/server-utils/orchestrion/webpack';

/**
* 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
Expand All @@ -7,16 +9,55 @@
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.
* `@sentry/server-utils` (where `register.ts` and the bundled orchestrion runtime ship) must stay
* external: `register.ts` passes its own `__filename`/`import.meta.url` as the `parentURL` for
* `Module.register('@sentry/server-utils/orchestrion/hook.mjs', …)`, so that self-reference only
* resolves while the code still lives at its real `node_modules` location. Bundled into an app
* server chunk instead, the specifier would have to resolve from the chunk's output location,
* which fails under isolated installs (pnpm) where the package is a transitive dependency.
*
* (The `@apm-js-collab/*` packages no longer appear here: they are bundled into
* `@sentry/server-utils`' build, so no import of them exists at runtime.)
*/
export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [
'@apm-js-collab/tracing-hooks',
'@apm-js-collab/code-transformer',
];
export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = ['@sentry/server-utils'];

/** 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));
}

/**
* A webpack `externals` array entry that keeps {@link ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES} truly
* external by resolving each request to an absolute path at build time and emitting a
* `commonjs <absolute path>` external.
*
* Listing the packages in `serverExternalPackages` is not enough: Next.js only externalizes a
* package when its bare specifier also resolves from the project root (`resolveExternal`'s
* base-resolve check in `next/dist/build/handle-externals.js`) — otherwise the
* `require('<bare specifier>')` it emits into the chunk would dangle at runtime, so Next silently
* bundles the package instead. Under isolated installs (pnpm) the package is a transitive
* dependency that never resolves from the project root, so the orchestrion runtime ended up
* compiled into the server chunk — breaking the `Module.register` self-reference described on
* {@link ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES}. Absolute paths sidestep all of this — webpack
* emits `require('/abs/path/…')`, which loads the real files from `node_modules` no matter where
* the chunk lives.
*
* Must be placed *before* Next's own externals handler in the `externals` array: webpack calls
* array entries in order and stops at the first one that returns a result.
*/
export async function externalizeOrchestrionRuntimePackages({
request,
}: {
request?: string;
}): Promise<string | undefined> {
if (
!request ||
!ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES.some(pkg => request === pkg || request.startsWith(`${pkg}/`))
) {
return undefined;
}

const resolved = resolveOrchestrionRuntimeRequest(request);
return resolved ? `commonjs ${resolved}` : undefined;
}
19 changes: 19 additions & 0 deletions packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as fs from 'fs';
import { createRequire } from 'module';
import * as path from 'path';
import type { VercelCronsConfig } from '../common/types';
import { externalizeOrchestrionRuntimePackages } from './diagnosticsChannelInjection';
import { getBuildPluginOptions, normalizePathForGlob } from './getBuildPluginOptions';
import type { RouteManifest } from './manifest/types';
// Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our
Expand Down Expand Up @@ -435,6 +436,7 @@ export function constructWebpackConfigFunction({
// Orchestrion code-transform loader — Node server runtime only, never the edge compilation
if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) {
newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance);
prependOrchestrionRuntimeExternals(newConfig);
}

return newConfig;
Expand Down Expand Up @@ -873,6 +875,23 @@ function addOtelWarningIgnoreRule(newConfig: WebpackConfigObjectWithModuleRules)
}
}

/**
* Prepends {@link externalizeOrchestrionRuntimePackages} to `newConfig.externals`, ahead of
* Next.js's own externals handler, so the orchestrion runtime packages stay external even where
* `serverExternalPackages` can't keep them so. See that function's docs for why this is necessary.
*/
function prependOrchestrionRuntimeExternals(newConfig: WebpackConfigObjectWithModuleRules): void {
const existingExternals = newConfig.externals;

if (Array.isArray(existingExternals)) {
existingExternals.unshift(externalizeOrchestrionRuntimePackages);
} else if (existingExternals === undefined) {
newConfig.externals = [externalizeOrchestrionRuntimePackages];
} else {
newConfig.externals = [externalizeOrchestrionRuntimePackages, existingExternals];
}
}

function addEdgeRuntimePolyfills(newConfig: WebpackConfigObjectWithModuleRules, buildContext: BuildContext): void {
// Use ProvidePlugin to inject performance global only when accessed
newConfig.plugins = newConfig.plugins || [];
Expand Down
4 changes: 0 additions & 4 deletions packages/nextjs/src/config/withSentryConfig/buildTime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
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 @@ -54,9 +53,6 @@ export function setUpBuildTimeVariables(
// 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) {
Expand Down
10 changes: 1 addition & 9 deletions packages/nextjs/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,23 +48,15 @@ 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);
nodeExperimentalUseDiagnosticsChannelInjection();
}

// Call at module level so `next build` prerender workers still register the runner without `init`
Expand Down
46 changes: 41 additions & 5 deletions packages/nextjs/test/config/diagnosticsChannelInjection.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { existsSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
BUNDLE_SAFE_INSTRUMENTED_PACKAGES,
externalizeOrchestrionRuntimePackages,
filterInstrumentedExternals,
} from '../../src/config/diagnosticsChannelInjection';
import { setUpBuildTimeVariables } from '../../src/config/withSentryConfig/buildTime';
Expand Down Expand Up @@ -33,8 +36,7 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () =>
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');
expect(externals).toContain('@sentry/server-utils');
});

it('respects user-provided externals even for bundle-safe packages', () => {
Expand All @@ -51,15 +53,50 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () =>
});
});

describe('externalizeOrchestrionRuntimePackages', () => {
it.each(['@sentry/server-utils', '@sentry/server-utils/orchestrion', '@sentry/server-utils/orchestrion/register'])(
'externalizes %s as an absolute-path commonjs require',
async request => {
const external = await externalizeOrchestrionRuntimePackages({ request });

expect(external).toMatch(/^commonjs /);
const resolvedPath = external!.slice('commonjs '.length);
expect(isAbsolute(resolvedPath)).toBe(true);
expect(existsSync(resolvedPath)).toBe(true);
},
);

it('ignores the bundled @apm-js-collab packages — no import of them exists in the dist anymore', async () => {
await expect(
externalizeOrchestrionRuntimePackages({ request: '@apm-js-collab/tracing-hooks' }),
).resolves.toBeUndefined();
});

it('resolves @sentry/server-utils subpaths to the CJS build, since the emitted external is a require()', async () => {
const external = await externalizeOrchestrionRuntimePackages({
request: '@sentry/server-utils/orchestrion/register',
});

expect(external).toMatch(/[/\\]cjs[/\\]/);
});

it('ignores unrelated requests so later externals handlers still run', async () => {
await expect(externalizeOrchestrionRuntimePackages({ request: 'some-other-package' })).resolves.toBeUndefined();
// Prefix matching must not leak beyond a package-name boundary.
await expect(
externalizeOrchestrionRuntimePackages({ request: '@sentry/server-utils-extras' }),
).resolves.toBeUndefined();
await expect(externalizeOrchestrionRuntimePackages({})).resolves.toBeUndefined();
});
});

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/),
});
});

Expand All @@ -68,6 +105,5 @@ describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => {
setUpBuildTimeVariables(nextConfig, {}, undefined);

expect(nextConfig.env).not.toHaveProperty('_sentryUseDiagnosticsChannelInjection');
expect(nextConfig.env).not.toHaveProperty('_sentryOrchestrionTracingHooksDir');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
} from '../fixtures';
import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils';

vi.mock('@sentry/server-utils/orchestrion/webpack', () => ({
// Only the plugin factory is stubbed — `resolveOrchestrionRuntimeRequest` must stay real because
// the externals handler under test uses it.
vi.mock('@sentry/server-utils/orchestrion/webpack', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
sentryOrchestrionWebpackPlugin: () => ({ _name: 'sentry-orchestrion-webpack-plugin' }),
}));

Expand Down Expand Up @@ -842,4 +845,45 @@ describe('constructWebpackConfigFunction()', () => {
expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined();
});
});

describe('orchestrion runtime externals', () => {
it('prepends an externals handler that resolves runtime packages to absolute paths when diagnostics-channel injection is enabled', async () => {
const finalWebpackConfig = await materializeFinalWebpackConfig({
exportedNextConfig,
incomingWebpackConfig: serverWebpackConfig,
incomingWebpackBuildContext: serverBuildContext,
sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } },
});

const externals = finalWebpackConfig.externals as ((data: { request?: string }) => Promise<string | undefined>)[];

expect(Array.isArray(externals)).toBe(true);
await expect(externals[0]({ request: '@sentry/server-utils/orchestrion/register' })).resolves.toMatch(
/^commonjs ([/\\]|[A-Za-z]:).*register\.js$/,
);
await expect(externals[0]({ request: 'some-other-package' })).resolves.toBeUndefined();
});

it('does not touch `externals` when diagnostics-channel injection is not enabled', async () => {
const finalWebpackConfig = await materializeFinalWebpackConfig({
exportedNextConfig,
incomingWebpackConfig: serverWebpackConfig,
incomingWebpackBuildContext: serverBuildContext,
sentryBuildTimeOptions: {},
});

expect(finalWebpackConfig.externals).toBeUndefined();
});

it('does not touch `externals` on the edge build', async () => {
const finalWebpackConfig = await materializeFinalWebpackConfig({
exportedNextConfig,
incomingWebpackConfig: serverWebpackConfig,
incomingWebpackBuildContext: edgeBuildContext,
sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } },
});

expect(finalWebpackConfig.externals).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
redisChannelIntegration,
detectOrchestrionSetup,
} from '@sentry/server-utils/orchestrion';
import type { RegisterDiagnosticsChannelInjectionOptions } from '@sentry/server-utils/orchestrion/register';
import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register';
import { cacheResponseHook } from '../integrations/tracing/redis/cache';
import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection';
Expand Down Expand Up @@ -46,12 +45,7 @@ export function diagnosticsChannelInjectionIntegrations(): typeof channelIntegra
*
* @experimental May change or be removed in any release.
*/
export function experimentalUseDiagnosticsChannelInjection(
// Forwarded to `registerDiagnosticsChannelInjection()`; framework SDKs whose bundlers compile
// the SDK into the app (e.g. `@sentry/nextjs`) use it to point the runtime module hook at the
// tracing-hooks package location resolved at build time. Plain Node apps don't need it.
options?: RegisterDiagnosticsChannelInjectionOptions,
): void {
export function experimentalUseDiagnosticsChannelInjection(): void {
setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => {
// These channel integrations 1:1 replace the OTel integration of the
// same name. Framework SDKs that own their own channel listener
Expand All @@ -71,7 +65,7 @@ export function experimentalUseDiagnosticsChannelInjection(
redisChannelIntegration({ responseHook: cacheResponseHook }),
],
replacedOtelIntegrationNames,
register: () => registerDiagnosticsChannelInjection(options),
register: () => registerDiagnosticsChannelInjection(),
detect: detectOrchestrionSetup,
};
});
Expand Down
15 changes: 11 additions & 4 deletions packages/server-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,20 @@
"import": "./build/esm/orchestrion/bundler/webpack.js",
"require": "./build/cjs/orchestrion/bundler/webpack.js"
},
"./orchestrion/webpack-loader": {
"import": "./build/esm/orchestrion/bundler/webpack-loader.js",
"require": "./build/cjs/orchestrion/bundler/webpack-loader.js"
},
"./orchestrion/esbuild": {
"types": "./build/types/orchestrion/bundler/esbuild.d.ts",
"import": "./build/esm/orchestrion/bundler/esbuild.js",
"require": "./build/cjs/orchestrion/bundler/esbuild.js"
},
"./orchestrion/import-hook": {
"import": "./build/orchestrion/import-hook.mjs"
},
"./orchestrion/hook": {
"import": "./build/esm/orchestrion/runtime/hook.js"
}
},
"typesVersions": {
Expand Down Expand Up @@ -90,14 +97,14 @@
"access": "public"
},
"dependencies": {
"@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1",
"@apm-js-collab/tracing-hooks": "^0.13.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.67.0",
"meriyah": "^6.1.4"
"@sentry/core": "10.67.0"
},
"devDependencies": {
"@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1",
"@apm-js-collab/tracing-hooks": "^0.13.0",
"@types/node": "^18.19.1",
"meriyah": "^6.1.4",
"vite": "^6.4.3"
},
"scripts": {
Expand Down
Loading
Loading