diff --git a/.size-limit.js b/.size-limit.js index 4ff4c7d05f7f..2bf1fc905e94 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -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'], }, { diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index cf8b26f31771..9dc1a24992fa 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -9,10 +9,19 @@ 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. */ @@ -20,3 +29,33 @@ export function filterInstrumentedExternals(externals: string[], packagesToBundl 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 { + return request && ESM_ONLY_ORCHESTRION_SPECIFIERS.includes(request) ? `commonjs ${request}` : undefined; +} diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 08c874250d96..5e99c18d4e5b 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -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 @@ -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; @@ -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 || []; diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index 93ec6e42e243..b799c9becfaa 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -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'; /** @@ -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) { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index ee0d2346c4f2..203b0f348a97 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -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` diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts index 881da6a3caf6..1382d7f21b55 100644 --- a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -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'; @@ -51,6 +52,19 @@ 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 = {}; @@ -58,8 +72,6 @@ describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => { 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/), }); }); @@ -68,6 +80,5 @@ describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => { setUpBuildTimeVariables(nextConfig, {}, undefined); expect(nextConfig.env).not.toHaveProperty('_sentryUseDiagnosticsChannelInjection'); - expect(nextConfig.env).not.toHaveProperty('_sentryOrchestrionTracingHooksDir'); }); }); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index b822495d1a67..1fc90a3b10a2 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -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)[]; + + 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(); + }); + }); }); diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 1fce9b35372d..2472fba61c92 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -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'; @@ -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 @@ -71,7 +65,7 @@ export function experimentalUseDiagnosticsChannelInjection( redisChannelIntegration({ responseHook: cacheResponseHook }), ], replacedOtelIntegrationNames, - register: () => registerDiagnosticsChannelInjection(options), + register: () => registerDiagnosticsChannelInjection(), detect: detectOrchestrionSetup, }; }); diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index f1b3a19655a7..d45af68b35f1 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -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', }, }, }), diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 74c63f29633e..56d35f731cde 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -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'; @@ -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; diff --git a/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts new file mode 100644 index 000000000000..24a6b6a58a0f --- /dev/null +++ b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts @@ -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; +} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index bd559b2a6e48..ff12a7deb108 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,53 +1,27 @@ -import { debug, GLOBAL_OBJ } from '@sentry/core'; -import { createRequire } from 'node:module'; +import { debug, GLOBAL_OBJ, parseSemver } from '@sentry/core'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; -import { isMainThread, MessageChannel, parentPort } from 'node:worker_threads'; +import { isMainThread, parentPort } from 'node:worker_threads'; import { SENTRY_INSTRUMENTATIONS } from '../config'; import type { register } from 'node:module'; -import type { InstrumentationConfig } from '..'; - -type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; - -type TracingHooksSync = { - initialize: (opts: { instrumentations: InstrumentationConfig[] }) => void; - resolve: Function; - load: Function; -}; - -type TracingHooksDiagnostics = { - setDiagnosticsHook: (callback: (event: DiagnosticsEvent) => void) => void; -}; +import ModulePatch from '@apm-js-collab/tracing-hooks'; +import { initialize, load, resolve, createDiagnosticsPort } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'; +import { setDiagnosticsHook } from '@apm-js-collab/tracing-hooks/lib/diagnostics.js'; type NodeModule = { - registerHooks?: (options: unknown) => { deregister: () => void }; + registerHooks?: (options: { load: Function; resolve: Function }) => { deregister: () => void }; register?: typeof register; }; -export interface RegisterDiagnosticsChannelInjectionOptions { - /** - * Absolute directory of the `@apm-js-collab/tracing-hooks` package (forward slashes). - * - * Needed when SDK code is bundled into an app's server build: the default bare-specifier - * require then resolves from the emitted chunk, which fails under isolated installs (pnpm). - * Framework SDKs (e.g. `@sentry/nextjs`) resolve the package at build time and pass its - * location here; it is loaded through an opaque `createRequire` that bundlers can't trace. - */ - tracingHooksDir?: string; -} - /** `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. */ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolean { - const parseVersion = (v: string): number[] => v.split('.').map(n => parseInt(n, 10)); - const nodeVersion = parseVersion(process.versions.node ?? '0.0.0'); - const denoVersion = parseVersion(denoVersionString ?? '0.0.0'); - return ( - (nodeVersion[0] ?? 0) > 25 || - (nodeVersion[0] === 25 && (nodeVersion[1] ?? 0) >= 1) || - (nodeVersion[0] === 24 && (nodeVersion[1] ?? 0) >= 13) || - (denoVersion[0] ?? 0) > 2 || - (denoVersion[0] === 2 && (denoVersion[1] ?? 0) >= 8) - ); + if (denoVersionString) { + const { major = 0, minor = 0 } = parseSemver(denoVersionString); + return major > 2 || (major === 2 && minor >= 8); + } + + const { major = 0, minor = 0 } = parseSemver(process.versions.node ?? '0.0.0'); + return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13); } /** @@ -62,7 +36,7 @@ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolea * Libraries imported *after* this call publish the `tracingChannel` events that * the channel-based integrations subscribe to. */ -export function registerDiagnosticsChannelInjection(options?: RegisterDiagnosticsChannelInjectionOptions): void { +export function registerDiagnosticsChannelInjection(): void { // Skip Node's internal loader (hooks) threads, recognizable as the only threads without a // `parentPort`. Node re-runs `--require` preloads (though not `--import` ones) on the loader // thread it spawns for `Module.register()`, so this function runs there too — but that thread @@ -82,74 +56,27 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic const globalAny = globalThis as { Bun?: unknown; Deno?: { version?: { deno?: string } } }; const stableSyncHooks = hasStableSyncModuleHooks(globalAny.Deno?.version?.deno); - let thisModuleUrl: string; - /*! rollup-include-cjs-only */ - thisModuleUrl = pathToFileURL(__filename).href; - /*! rollup-include-cjs-only-end */ - /*! rollup-include-esm-only */ - thisModuleUrl = import.meta.url; - /*! rollup-include-esm-only-end */ - - // Default: bare specifiers via a plain (aliased) `require`, so bundlers see and resolve them - // like any other dependency. Override: with `tracingHooksDir`, absolute paths are loaded through - // `createRequire`, which bundlers leave as a true runtime require — they must not statically - // resolve these (Turbopack fails the build on an absolute request, and the machinery breaks when - // bundled anyway). `createRequire` rather than ignore-comments because webpack only honors - // `webpackIgnore` on `import()`, not `require()` (it compiles the call to a broken module stub). - let nodeRequire: (specifier: string) => unknown; - /*! rollup-include-cjs-only */ - nodeRequire = require; - /*! rollup-include-cjs-only-end */ - /*! rollup-include-esm-only */ - nodeRequire = createRequire(import.meta.url); - /*! rollup-include-esm-only-end */ - - const tracingHooksDir = options?.tracingHooksDir; - const requireFromHooksDir = tracingHooksDir ? createRequire(thisModuleUrl) : undefined; - // `Module.registerHooks` / `Module.register` are newer than the @types/node // we build against, hence the cast. const mod = Module as NodeModule; + setDiagnosticsHook(({ moduleName, error }): void => { + if (error) { + debug.warn(`[orchestrion] failed to inject diagnostics-channel into ${moduleName}:`, error); + } else { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); + } + }); + // runs both at `--import` time and (synchronously) inside `Sentry.init()`, // so an unguarded throw would either abort startup or make `init()` throw. // On any failure (e.g. dep resolution, `require(esm)` / Node-compat // incompatibility) we warn (DEBUG only) and continue without channel // injection try { - // `lib/diagnostics.js` is plain CJS, so unlike the ESM hook entry points it can be - // require()d on every supported Node version. It holds the hook state shared by - // everything that can transform a module on this thread (the sync ESM hooks and the - // `_compile` patch), so setting the hook once here covers both branches below. - const { setDiagnosticsHook } = ( - requireFromHooksDir - ? requireFromHooksDir(`${tracingHooksDir}/lib/diagnostics.js`) - : nodeRequire('@apm-js-collab/tracing-hooks/lib/diagnostics.js') - ) as TracingHooksDiagnostics; - - const onDiagnostics = ({ moduleName, error }: DiagnosticsEvent): void => { - if (error) { - debug.warn(`[orchestrion] failed to inject diagnostics-channel into ${moduleName}:`, error); - } else { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); - } - }; - - setDiagnosticsHook(onDiagnostics); - if (typeof mod.registerHooks === 'function' && stableSyncHooks) { - // Sync hooks cover CJS and ESM, no separate `_compile` patch needed. - // We require() this ESM module so that we can synchronously load it, - // including from a CommonJS Sentry build; all versions in - // stableSyncHooks support require(esm). - const { initialize, resolve, load } = ( - requireFromHooksDir - ? requireFromHooksDir(`${tracingHooksDir}/hook-sync.mjs`) - : nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') - ) as TracingHooksSync; - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); mod.registerHooks({ resolve, load }); debug.log('Registered diagnostics-channel injection via Module.registerHooks()'); @@ -157,26 +84,20 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 // path. Bun/Deno are excluded: they don't support this combination and // must use the stable `registerHooks` path above (or none at all). - // `Module.register` resolves ESM-style: a bare package specifier is resolved against - // `parentURL`, but a filesystem path (the `tracingHooksDir` override) is not a valid ESM - // specifier and must be passed as a file:// URL. - const hookSpecifier = tracingHooksDir - ? pathToFileURL(`${tracingHooksDir}/hook.mjs`).href - : '@apm-js-collab/tracing-hooks/hook.mjs'; - - // The `Module.register` hooks run on a loader thread with its own copy of - // `lib/diagnostics.js`, so the hook set above never fires there; the loader thread - // posts diagnostics back over a MessagePort instead. This replicates - // `createDiagnosticsPort` from hook.mjs, which is ESM and therefore not - // synchronously loadable on all Node versions that take this branch. - const { port1, port2 } = new MessageChannel(); - port1.on('message', onDiagnostics); - // The diagnostics channel must not keep the process alive. - port1.unref(); - mod.register(hookSpecifier, { - parentURL: thisModuleUrl, - data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort: port2 }, - transferList: [port2], + const diagnosticsPort = createDiagnosticsPort(); + + let parentURL: string; + /*! rollup-include-cjs-only */ + parentURL = pathToFileURL(__filename).href; + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + parentURL = import.meta.url; + /*! rollup-include-esm-only-end */ + + mod.register('@apm-js-collab/tracing-hooks/hook.mjs', { + parentURL, + data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort }, + transferList: [diagnosticsPort], }); // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM @@ -184,13 +105,6 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic // are resolved through the CJS machinery and never reach the ESM // register hook, so without this patch the file we want to instrument // loads untransformed. - const ModulePatch = ( - requireFromHooksDir && tracingHooksDir - ? requireFromHooksDir(tracingHooksDir) - : nodeRequire('@apm-js-collab/tracing-hooks') - ) as new (opts: { instrumentations: unknown }) => { - patch: () => void; - }; new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); debug.log('Registered diagnostics-channel injection via Module.register()'); } else { diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index f13209fba323..ddb909f085ea 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -1,5 +1,3 @@ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; import type { OnStartResult, PluginBuild } from 'esbuild'; import type { NormalizedInputOptions, PluginContext } from 'rollup'; import type { ResolvedConfig } from 'vite'; @@ -8,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { sentryOrchestrionPlugin as esbuildPlugin } from '../../src/orchestrion/bundler/esbuild'; import { sentryOrchestrionPlugin as rollupPlugin } from '../../src/orchestrion/bundler/rollup'; import { sentryOrchestrionPlugin as vitePlugin } from '../../src/orchestrion/bundler/vite'; -import { getTracingHooksDirectory, sentryOrchestrionWebpackPlugin } from '../../src/orchestrion/bundler/webpack'; +import { sentryOrchestrionWebpackPlugin } from '../../src/orchestrion/bundler/webpack'; // The upstream transform plugins are mocked so tests exercise only the hooks // added on top of them (the externalized-modules warnings). @@ -135,15 +133,3 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(runConfigResolved(undefined)).not.toHaveBeenCalled(); }); }); - -describe('getTracingHooksDirectory', () => { - it('returns the tracing-hooks package directory with the runtime hook entry points', () => { - const dir = getTracingHooksDirectory(); - - expect(dir).not.toContain('\\'); - // The runtime module hook loads these files by joining them onto the directory. - expect(existsSync(join(dir, 'hook-sync.mjs'))).toBe(true); - expect(existsSync(join(dir, 'hook.mjs'))).toBe(true); - expect(existsSync(join(dir, 'package.json'))).toBe(true); - }); -});