Skip to content

Commit 9df27e7

Browse files
authored
chore: Remove lazy loading for hook registration (#22443)
This PR simplifies the `register.ts` hook registration to remove all the lazy loading. Once the lazy loading was removed there were a number of other things required to get this actually passing all the e2e tests: - [x] Bundle the whole orchestrion runtime chain (`@apm-js-collab/*`, meriyah, esquery, …) into `@sentry/server-utils`' build (all now devDependencies). The dist only has relative requires, which removes two classes of breakage: - `require(esm)`: the CJS build is now genuine CJS — fixes AWS Lambda (`--no-experimental-require-module`) and the `Module.register` loader-thread crash on Node 22.15–24.12 - Build-time tracer vs runtime exports-map divergence on meriyah's `module-sync` condition (vercel/nft#603, nitrojs/nitro#4456) — no runtime package resolution left to get wrong - This also reduces the bundler impact of all these dependencies from +70kB to +20kB - [x] The `Module.register` async hook is now a self-referenced entrypoint of our own ESM build (`@sentry/server-utils/orchestrion/hook`), sharing the vendored chunks - [x] The webpack/Turbopack loader ships as a bundled entrypoint (`./orchestrion/webpack-loader`), resolved by self-reference since the `@apm-js-collab` packages are no longer installed - [x] Fix build-time Rollup ESM interop issues (Node builtins need `default` interop; default-only ESM deps need `requireReturnsDefault: 'auto'`) - [x] NextJS: force `@sentry/server-utils` external via absolute-path externals so `register.ts` stays in `node_modules` and its `Module.register` self-reference resolves under pnpm — also removes the `tracingHooksPath` workaround - [x] Bumps the `@sentry/node` size limit by ~40kb — the lazy loading previously hid this code from the size report - [x] #22513
1 parent 536eb3b commit 9df27e7

27 files changed

Lines changed: 627 additions & 214 deletions

.size-limit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,12 +390,12 @@ module.exports = [
390390
import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'),
391391
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
392392
gzip: true,
393-
limit: '154 KB',
393+
limit: '190 KB',
394394
disablePlugins: ['@size-limit/esbuild'],
395395
},
396396
{
397397
name: '@sentry/node/import (ESM hook with diagnostics-channel injection)',
398-
path: ['node_modules/@apm-js-collab/tracing-hooks/hook.mjs', 'packages/node/build/import-hook.mjs'],
398+
path: ['packages/server-utils/build/esm/orchestrion/runtime/hook.js', 'packages/node/build/import-hook.mjs'],
399399
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
400400
gzip: true,
401401
limit: '76 KB',

dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@
2727
},
2828
"pnpm": {
2929
"overrides": {
30-
"ofetch": "1.4.0",
31-
"@vercel/nft": "0.29.4"
30+
"ofetch": "1.4.0"
3231
}
3332
},
3433
"volta": {
Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { resolveOrchestrionRuntimeRequest } from '@sentry/server-utils/orchestrion/webpack';
2+
13
/**
24
* Instrumented packages verified (via e2e) to bundle correctly, removed from Sentry's own
35
* `serverExternalPackages` defaults so the build-time loader can transform them. Everything else
@@ -7,16 +9,55 @@
79
export const BUNDLE_SAFE_INSTRUMENTED_PACKAGES = ['ioredis'];
810

911
/**
10-
* The orchestrion runtime machinery must stay external — its parser breaks when bundled, which
11-
* silently disables the runtime module hook.
12+
* `@sentry/server-utils` (where `register.ts` and the bundled orchestrion runtime ship) must stay
13+
* external: `register.ts` passes its own `__filename`/`import.meta.url` as the `parentURL` for
14+
* `Module.register('@sentry/server-utils/orchestrion/hook.mjs', …)`, so that self-reference only
15+
* resolves while the code still lives at its real `node_modules` location. Bundled into an app
16+
* server chunk instead, the specifier would have to resolve from the chunk's output location,
17+
* which fails under isolated installs (pnpm) where the package is a transitive dependency.
18+
*
19+
* (The `@apm-js-collab/*` packages no longer appear here: they are bundled into
20+
* `@sentry/server-utils`' build, so no import of them exists at runtime.)
1221
*/
13-
export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [
14-
'@apm-js-collab/tracing-hooks',
15-
'@apm-js-collab/code-transformer',
16-
];
22+
export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = ['@sentry/server-utils'];
1723

1824
/** Remove the given packages from a `serverExternalPackages` list. */
1925
export function filterInstrumentedExternals(externals: string[], packagesToBundle: string[]): string[] {
2026
const set = new Set(packagesToBundle);
2127
return externals.filter(name => !set.has(name));
2228
}
29+
30+
/**
31+
* A webpack `externals` array entry that keeps {@link ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES} truly
32+
* external by resolving each request to an absolute path at build time and emitting a
33+
* `commonjs <absolute path>` external.
34+
*
35+
* Listing the packages in `serverExternalPackages` is not enough: Next.js only externalizes a
36+
* package when its bare specifier also resolves from the project root (`resolveExternal`'s
37+
* base-resolve check in `next/dist/build/handle-externals.js`) — otherwise the
38+
* `require('<bare specifier>')` it emits into the chunk would dangle at runtime, so Next silently
39+
* bundles the package instead. Under isolated installs (pnpm) the package is a transitive
40+
* dependency that never resolves from the project root, so the orchestrion runtime ended up
41+
* compiled into the server chunk — breaking the `Module.register` self-reference described on
42+
* {@link ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES}. Absolute paths sidestep all of this — webpack
43+
* emits `require('/abs/path/…')`, which loads the real files from `node_modules` no matter where
44+
* the chunk lives.
45+
*
46+
* Must be placed *before* Next's own externals handler in the `externals` array: webpack calls
47+
* array entries in order and stops at the first one that returns a result.
48+
*/
49+
export async function externalizeOrchestrionRuntimePackages({
50+
request,
51+
}: {
52+
request?: string;
53+
}): Promise<string | undefined> {
54+
if (
55+
!request ||
56+
!ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES.some(pkg => request === pkg || request.startsWith(`${pkg}/`))
57+
) {
58+
return undefined;
59+
}
60+
61+
const resolved = resolveOrchestrionRuntimeRequest(request);
62+
return resolved ? `commonjs ${resolved}` : undefined;
63+
}

packages/nextjs/src/config/webpack.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as fs from 'fs';
66
import { createRequire } from 'module';
77
import * as path from 'path';
88
import type { VercelCronsConfig } from '../common/types';
9+
import { externalizeOrchestrionRuntimePackages } from './diagnosticsChannelInjection';
910
import { getBuildPluginOptions, normalizePathForGlob } from './getBuildPluginOptions';
1011
import type { RouteManifest } from './manifest/types';
1112
// Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our
@@ -435,6 +436,7 @@ export function constructWebpackConfigFunction({
435436
// Orchestrion code-transform loader — Node server runtime only, never the edge compilation
436437
if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) {
437438
newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance);
439+
prependOrchestrionRuntimeExternals(newConfig);
438440
}
439441

440442
return newConfig;
@@ -873,6 +875,23 @@ function addOtelWarningIgnoreRule(newConfig: WebpackConfigObjectWithModuleRules)
873875
}
874876
}
875877

878+
/**
879+
* Prepends {@link externalizeOrchestrionRuntimePackages} to `newConfig.externals`, ahead of
880+
* Next.js's own externals handler, so the orchestrion runtime packages stay external even where
881+
* `serverExternalPackages` can't keep them so. See that function's docs for why this is necessary.
882+
*/
883+
function prependOrchestrionRuntimeExternals(newConfig: WebpackConfigObjectWithModuleRules): void {
884+
const existingExternals = newConfig.externals;
885+
886+
if (Array.isArray(existingExternals)) {
887+
existingExternals.unshift(externalizeOrchestrionRuntimePackages);
888+
} else if (existingExternals === undefined) {
889+
newConfig.externals = [externalizeOrchestrionRuntimePackages];
890+
} else {
891+
newConfig.externals = [externalizeOrchestrionRuntimePackages, existingExternals];
892+
}
893+
}
894+
876895
function addEdgeRuntimePolyfills(newConfig: WebpackConfigObjectWithModuleRules, buildContext: BuildContext): void {
877896
// Use ProvidePlugin to inject performance global only when accessed
878897
newConfig.plugins = newConfig.plugins || [];

packages/nextjs/src/config/withSentryConfig/buildTime.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as childProcess from 'child_process';
22
import * as fs from 'fs';
33
import * as path from 'path';
4-
import { getTracingHooksDirectory } from '@sentry/server-utils/orchestrion/webpack';
54
import type { NextConfigObject, SentryBuildOptions } from '../types';
65

76
/**
@@ -54,9 +53,6 @@ export function setUpBuildTimeVariables(
5453
// Marker read by the server SDK to warn if the runtime opt-in call is missing.
5554
if (userSentryOptions._experimental?.useDiagnosticsChannelInjection) {
5655
buildTimeVariables._sentryUseDiagnosticsChannelInjection = 'true';
57-
// Resolved here (where the SDK is a real on-disk package) and inlined, because the runtime
58-
// module hook can't resolve the bare specifier from a bundled server chunk under pnpm.
59-
buildTimeVariables._sentryOrchestrionTracingHooksDir = getTracingHooksDirectory();
6056
}
6157

6258
if (basePath) {

packages/nextjs/src/server/index.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,23 +48,15 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
4848
_sentryRewriteFramesDistDir?: string;
4949
_sentryRelease?: string;
5050
_sentryUseDiagnosticsChannelInjection?: string;
51-
_sentryOrchestrionTracingHooksDir?: string;
5251
};
5352

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

7062
// Call at module level so `next build` prerender workers still register the runner without `init`

packages/nextjs/test/config/diagnosticsChannelInjection.test.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { existsSync } from 'node:fs';
2+
import { isAbsolute } from 'node:path';
13
import { describe, expect, it } from 'vitest';
24
import {
35
BUNDLE_SAFE_INSTRUMENTED_PACKAGES,
6+
externalizeOrchestrionRuntimePackages,
47
filterInstrumentedExternals,
58
} from '../../src/config/diagnosticsChannelInjection';
69
import { setUpBuildTimeVariables } from '../../src/config/withSentryConfig/buildTime';
@@ -33,8 +36,7 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () =>
3336
expect(externals).toContain('pg');
3437
expect(externals).toContain('pg-pool');
3538
// The orchestrion machinery must be external for the runtime hook to work.
36-
expect(externals).toContain('@apm-js-collab/tracing-hooks');
37-
expect(externals).toContain('@apm-js-collab/code-transformer');
39+
expect(externals).toContain('@sentry/server-utils');
3840
});
3941

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

56+
describe('externalizeOrchestrionRuntimePackages', () => {
57+
it.each(['@sentry/server-utils', '@sentry/server-utils/orchestrion', '@sentry/server-utils/orchestrion/register'])(
58+
'externalizes %s as an absolute-path commonjs require',
59+
async request => {
60+
const external = await externalizeOrchestrionRuntimePackages({ request });
61+
62+
expect(external).toMatch(/^commonjs /);
63+
const resolvedPath = external!.slice('commonjs '.length);
64+
expect(isAbsolute(resolvedPath)).toBe(true);
65+
expect(existsSync(resolvedPath)).toBe(true);
66+
},
67+
);
68+
69+
it('ignores the bundled @apm-js-collab packages — no import of them exists in the dist anymore', async () => {
70+
await expect(
71+
externalizeOrchestrionRuntimePackages({ request: '@apm-js-collab/tracing-hooks' }),
72+
).resolves.toBeUndefined();
73+
});
74+
75+
it('resolves @sentry/server-utils subpaths to the CJS build, since the emitted external is a require()', async () => {
76+
const external = await externalizeOrchestrionRuntimePackages({
77+
request: '@sentry/server-utils/orchestrion/register',
78+
});
79+
80+
expect(external).toMatch(/[/\\]cjs[/\\]/);
81+
});
82+
83+
it('ignores unrelated requests so later externals handlers still run', async () => {
84+
await expect(externalizeOrchestrionRuntimePackages({ request: 'some-other-package' })).resolves.toBeUndefined();
85+
// Prefix matching must not leak beyond a package-name boundary.
86+
await expect(
87+
externalizeOrchestrionRuntimePackages({ request: '@sentry/server-utils-extras' }),
88+
).resolves.toBeUndefined();
89+
await expect(externalizeOrchestrionRuntimePackages({})).resolves.toBeUndefined();
90+
});
91+
});
92+
5493
describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => {
5594
it('injects the flag marker and the tracing-hooks location', () => {
5695
const nextConfig: NextConfigObject = {};
5796
setUpBuildTimeVariables(nextConfig, { _experimental: { useDiagnosticsChannelInjection: true } }, undefined);
5897

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

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

70107
expect(nextConfig.env).not.toHaveProperty('_sentryUseDiagnosticsChannelInjection');
71-
expect(nextConfig.env).not.toHaveProperty('_sentryOrchestrionTracingHooksDir');
72108
});
73109
});

packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import {
1616
} from '../fixtures';
1717
import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils';
1818

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

@@ -842,4 +845,45 @@ describe('constructWebpackConfigFunction()', () => {
842845
expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined();
843846
});
844847
});
848+
849+
describe('orchestrion runtime externals', () => {
850+
it('prepends an externals handler that resolves runtime packages to absolute paths when diagnostics-channel injection is enabled', async () => {
851+
const finalWebpackConfig = await materializeFinalWebpackConfig({
852+
exportedNextConfig,
853+
incomingWebpackConfig: serverWebpackConfig,
854+
incomingWebpackBuildContext: serverBuildContext,
855+
sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } },
856+
});
857+
858+
const externals = finalWebpackConfig.externals as ((data: { request?: string }) => Promise<string | undefined>)[];
859+
860+
expect(Array.isArray(externals)).toBe(true);
861+
await expect(externals[0]({ request: '@sentry/server-utils/orchestrion/register' })).resolves.toMatch(
862+
/^commonjs ([/\\]|[A-Za-z]:).*register\.js$/,
863+
);
864+
await expect(externals[0]({ request: 'some-other-package' })).resolves.toBeUndefined();
865+
});
866+
867+
it('does not touch `externals` when diagnostics-channel injection is not enabled', async () => {
868+
const finalWebpackConfig = await materializeFinalWebpackConfig({
869+
exportedNextConfig,
870+
incomingWebpackConfig: serverWebpackConfig,
871+
incomingWebpackBuildContext: serverBuildContext,
872+
sentryBuildTimeOptions: {},
873+
});
874+
875+
expect(finalWebpackConfig.externals).toBeUndefined();
876+
});
877+
878+
it('does not touch `externals` on the edge build', async () => {
879+
const finalWebpackConfig = await materializeFinalWebpackConfig({
880+
exportedNextConfig,
881+
incomingWebpackConfig: serverWebpackConfig,
882+
incomingWebpackBuildContext: edgeBuildContext,
883+
sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } },
884+
});
885+
886+
expect(finalWebpackConfig.externals).toBeUndefined();
887+
});
888+
});
845889
});

packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
redisChannelIntegration,
55
detectOrchestrionSetup,
66
} from '@sentry/server-utils/orchestrion';
7-
import type { RegisterDiagnosticsChannelInjectionOptions } from '@sentry/server-utils/orchestrion/register';
87
import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register';
98
import { cacheResponseHook } from '../integrations/tracing/redis/cache';
109
import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection';
@@ -46,12 +45,7 @@ export function diagnosticsChannelInjectionIntegrations(): typeof channelIntegra
4645
*
4746
* @experimental May change or be removed in any release.
4847
*/
49-
export function experimentalUseDiagnosticsChannelInjection(
50-
// Forwarded to `registerDiagnosticsChannelInjection()`; framework SDKs whose bundlers compile
51-
// the SDK into the app (e.g. `@sentry/nextjs`) use it to point the runtime module hook at the
52-
// tracing-hooks package location resolved at build time. Plain Node apps don't need it.
53-
options?: RegisterDiagnosticsChannelInjectionOptions,
54-
): void {
48+
export function experimentalUseDiagnosticsChannelInjection(): void {
5549
setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => {
5650
// These channel integrations 1:1 replace the OTel integration of the
5751
// same name. Framework SDKs that own their own channel listener
@@ -71,7 +65,7 @@ export function experimentalUseDiagnosticsChannelInjection(
7165
redisChannelIntegration({ responseHook: cacheResponseHook }),
7266
],
7367
replacedOtelIntegrationNames,
74-
register: () => registerDiagnosticsChannelInjection(options),
68+
register: () => registerDiagnosticsChannelInjection(),
7569
detect: detectOrchestrionSetup,
7670
};
7771
});

packages/server-utils/package.json

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,20 @@
5252
"import": "./build/esm/orchestrion/bundler/webpack.js",
5353
"require": "./build/cjs/orchestrion/bundler/webpack.js"
5454
},
55+
"./orchestrion/webpack-loader": {
56+
"import": "./build/esm/orchestrion/bundler/webpack-loader.js",
57+
"require": "./build/cjs/orchestrion/bundler/webpack-loader.js"
58+
},
5559
"./orchestrion/esbuild": {
5660
"types": "./build/types/orchestrion/bundler/esbuild.d.ts",
5761
"import": "./build/esm/orchestrion/bundler/esbuild.js",
5862
"require": "./build/cjs/orchestrion/bundler/esbuild.js"
5963
},
6064
"./orchestrion/import-hook": {
6165
"import": "./build/orchestrion/import-hook.mjs"
66+
},
67+
"./orchestrion/hook": {
68+
"import": "./build/esm/orchestrion/runtime/hook.js"
6269
}
6370
},
6471
"typesVersions": {
@@ -90,14 +97,14 @@
9097
"access": "public"
9198
},
9299
"dependencies": {
93-
"@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1",
94-
"@apm-js-collab/tracing-hooks": "^0.13.0",
95100
"@sentry/conventions": "^0.16.0",
96-
"@sentry/core": "10.67.0",
97-
"meriyah": "^6.1.4"
101+
"@sentry/core": "10.67.0"
98102
},
99103
"devDependencies": {
104+
"@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1",
105+
"@apm-js-collab/tracing-hooks": "^0.13.0",
100106
"@types/node": "^18.19.1",
107+
"meriyah": "^6.1.4",
101108
"vite": "^6.4.3"
102109
},
103110
"scripts": {

0 commit comments

Comments
 (0)