Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ module.exports = [
import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '154 KB',
limit: '230 KB',
disablePlugins: ['@size-limit/esbuild'],
},
{
Expand Down
39 changes: 39 additions & 0 deletions packages/nextjs/src/config/diagnosticsChannelInjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,53 @@ 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` (the package `register.ts` — the code that actually calls into
* `@apm-js-collab/tracing-hooks` — ships in) is included too: if it stays external, its own
* `__filename`/`import.meta.url` keep pointing at their real `node_modules` location, so its
* bare-specifier `require`/`import` of the (also-external) tracing-hooks packages resolve
* correctly. If `@sentry/server-utils` were bundled into an app server chunk instead, its code
* would be relocated away from `node_modules`, and those same specifiers would fail to resolve
* under isolated installs (pnpm).
*/
export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [
'@apm-js-collab/tracing-hooks',
'@apm-js-collab/code-transformer',
'@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));
}

/**
* `@apm-js-collab/tracing-hooks/hook-sync.mjs` is ESM-only with no CJS equivalent, but
* `@sentry/server-utils`'s `register.ts` must `require()` it synchronously at runtime (see that
* file). Next.js's webpack config refuses to compile any bare `require()` of an ESM-only package
* (`ESM packages (...) need to be imported`, thrown by `handleExternals` in `next/dist/build/handle-externals.js`)
* unless the app-wide `experimental.esmExternals: 'loose'` flag is set — which we don't want to force
* on every user, and which routes through a separate Next.js ESM-interop codepath that has its own
* `require(esm)` parent-URL misattribution bug at runtime.
*
* Being in `serverExternalPackages`/`ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES` above doesn't help here:
* Next's ESM guard throws before it even considers whether a package is externalized.
*/
export const ESM_ONLY_ORCHESTRION_SPECIFIERS = ['@apm-js-collab/tracing-hooks/hook-sync.mjs'];

/**
* A webpack `externals` array entry that externalizes {@link ESM_ONLY_ORCHESTRION_SPECIFIERS} as
* plain `commonjs` requires — the same declaration Next.js's own `handleExternals` would produce for
* a genuinely CJS package — so its ESM guard never sees (and never throws on) these specifiers.
*
* 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 externalizeEsmOnlyOrchestrionSpecifiers({
request,
}: {
request?: string;
}): Promise<string | undefined> {
return request && ESM_ONLY_ORCHESTRION_SPECIFIERS.includes(request) ? `commonjs ${request}` : 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 { externalizeEsmOnlyOrchestrionSpecifiers } 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);
prependEsmOnlyOrchestrionExternalsShim(newConfig);
}

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

/**
* Prepends {@link externalizeEsmOnlyOrchestrionSpecifiers} to `newConfig.externals`, ahead of
* Next.js's own externals handler, so its ESM guard never sees the orchestrion runtime's ESM-only
* specifiers. See that function's docs for why this is necessary.
*/
function prependEsmOnlyOrchestrionExternalsShim(newConfig: WebpackConfigObjectWithModuleRules): void {
const existingExternals = newConfig.externals;

if (Array.isArray(existingExternals)) {
existingExternals.unshift(externalizeEsmOnlyOrchestrionSpecifiers);
} else if (existingExternals === undefined) {
newConfig.externals = [externalizeEsmOnlyOrchestrionSpecifiers];
} else {
newConfig.externals = [externalizeEsmOnlyOrchestrionSpecifiers, 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
17 changes: 14 additions & 3 deletions packages/nextjs/test/config/diagnosticsChannelInjection.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
BUNDLE_SAFE_INSTRUMENTED_PACKAGES,
externalizeEsmOnlyOrchestrionSpecifiers,
filterInstrumentedExternals,
} from '../../src/config/diagnosticsChannelInjection';
import { setUpBuildTimeVariables } from '../../src/config/withSentryConfig/buildTime';
Expand Down Expand Up @@ -51,15 +52,26 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () =>
});
});

describe('externalizeEsmOnlyOrchestrionSpecifiers', () => {
it('externalizes the ESM-only orchestrion specifier as a plain commonjs require', async () => {
await expect(
externalizeEsmOnlyOrchestrionSpecifiers({ request: '@apm-js-collab/tracing-hooks/hook-sync.mjs' }),
).resolves.toBe('commonjs @apm-js-collab/tracing-hooks/hook-sync.mjs');
});

it('ignores unrelated requests so later externals handlers still run', async () => {
await expect(externalizeEsmOnlyOrchestrionSpecifiers({ request: 'some-other-package' })).resolves.toBeUndefined();
await expect(externalizeEsmOnlyOrchestrionSpecifiers({})).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 +80,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 @@ -842,4 +842,45 @@ describe('constructWebpackConfigFunction()', () => {
expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined();
});
});

describe('esm-only orchestrion externals shim', () => {
it('prepends the shim to `externals` 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: '@apm-js-collab/tracing-hooks/hook-sync.mjs' })).resolves.toBe(
'commonjs @apm-js-collab/tracing-hooks/hook-sync.mjs',
);
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
21 changes: 13 additions & 8 deletions packages/server-utils/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,19 @@ export default [
exports: 'named',
// set preserveModules to true because we don't want to bundle everything into one file.
preserveModules: true,
// `@apm-js-collab/code-transformer-bundler-plugins` ships CJS entries as bare
// `module.exports = fn` with no `__esModule`/`.default`. The repo default
// `interop: 'esModule'` assumes ESM-shaped externals and would dereference a nonexistent
// `.default`, so a default import compiles to `codeTransformer.default(...)` → "not a
// function". Use 'auto' for just these so Rollup emits its interop helper. Scoped here (not
// repo-wide) because 'auto' also turns `import * as x` into a copy, which breaks in-place
// monkey-patching that other packages (e.g. the OTel fs instrumentation) depend on.
interop: id => (id?.startsWith('@apm-js-collab/code-transformer-bundler-plugins') ? 'auto' : 'esModule'),
// `@apm-js-collab/code-transformer-bundler-plugins` and `@apm-js-collab/tracing-hooks`
// ship CJS entries as bare `module.exports = fn`/`= class` with no `__esModule`/`.default`.
// The repo default `interop: 'esModule'` assumes ESM-shaped externals and would dereference
// a nonexistent `.default`, so a default import compiles to `codeTransformer.default(...)` /
// `ModulePatch.default(...)` → "not a function"/"not a constructor". Use 'auto' for just
// these so Rollup emits its interop helper. Scoped here (not repo-wide) because 'auto' also
// turns `import * as x` into a copy, which breaks in-place monkey-patching that other
// packages (e.g. the OTel fs instrumentation) depend on.
interop: id =>
id?.startsWith('@apm-js-collab/code-transformer-bundler-plugins') ||
id?.startsWith('@apm-js-collab/tracing-hooks')
? 'auto'
: 'esModule',
},
},
}),
Expand Down
13 changes: 0 additions & 13 deletions packages/server-utils/src/orchestrion/bundler/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// separately because Turbopack can only take webpack loaders (via `turbopack.rules`), not plugins.

import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import type { Compiler } from 'webpack';
import type { InstrumentationConfig } from '..';
import { instrumentedModuleNames, SENTRY_INSTRUMENTATIONS } from '../config';
Expand Down Expand Up @@ -31,18 +30,6 @@ export function getOrchestrionLoaderPath(): string {
return getOrchestrionRequire().resolve('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader');
}

/**
* Absolute path to the `@apm-js-collab/tracing-hooks` package directory, resolved from this
* package's own dependency graph. SDKs inject it at build time so the runtime module hook can
* load the package even where the bare specifier doesn't resolve (bundled SDK code under
* isolated installs, e.g. pnpm).
*/
export function getTracingHooksDirectory(): string {
const packageJsonPath = getOrchestrionRequire().resolve('@apm-js-collab/tracing-hooks/package.json');
// This avoids any backslash-escaping concerns on Windows
return dirname(packageJsonPath).replace(/\\/g, '/');
}

/** The central instrumentation config, to pass as the loader's `instrumentations` option. */
export function getSentryInstrumentations(): InstrumentationConfig[] {
return SENTRY_INSTRUMENTATIONS;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Ambient declarations for `@apm-js-collab/tracing-hooks`, which ships no types of its own.

declare module '@apm-js-collab/tracing-hooks' {
type InstrumentationConfig = unknown;

type PatchConfig = { instrumentations: InstrumentationConfig[] };

/** Patches `Module.prototype._compile` to transform CJS modules as they load. */
export default class ModulePatch {
public constructor(config?: PatchConfig);
public patch(): void;
public unpatch(): void;
}
}

declare module '@apm-js-collab/tracing-hooks/lib/diagnostics.js' {
type DiagnosticsEvent = { url: string; moduleName: string; error?: Error };

export function setDiagnosticsHook(callback: (event: DiagnosticsEvent) => void): void;
export function emitDiagnostics(event: DiagnosticsEvent): void;
}

declare module '@apm-js-collab/tracing-hooks/hook-sync.mjs' {
import type { MessagePort } from 'node:worker_threads';
import type { InstrumentationConfig } from '@apm-js-collab/code-transformer';

type DiagnosticsEvent = { url: string; moduleName: string; error?: Error };
type InitializeData = { instrumentations?: InstrumentationConfig[]; diagnosticsPort?: MessagePort };

export function initialize(data?: InitializeData): void;
export function resolve(specifier: string, context: unknown, nextResolve: Function): unknown;
export function load(url: string, context: unknown, nextLoad: Function): unknown;
export function setDiagnosticsHook(callback: (event: DiagnosticsEvent) => void): void;
export function createDiagnosticsPort(): MessagePort;
}
Loading
Loading