From 84145c7b23244f8324d168f745df33e05c593a7a Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:11:44 +0200 Subject: [PATCH 01/14] feat(server-utils): Warn when the orchestrion runtime hook was bundled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@sentry/node`'s `init()` installs a runtime module-transform hook from `@sentry/server-utils/orchestrion/register`, which drives a vendored code transformer (meriyah/astring/source-map) and is designed to run from `node_modules`. If a downstream bundler inlines and tree-shakes `@sentry/server-utils`, that transformer is stripped to empty objects, so at runtime `parse`/`generate` are `undefined` and the first module the hook tries to transform throws `TypeError: parse is not a function` — deep in the loader, once per module, and only when `debug: true` (otherwise it fails silently). Detect this once, up front: run a throwaway in-memory transform over a synthetic snippet before installing any hook. A healthy build returns normally; a tree-shaken one throws a `TypeError`. On detection, emit a single, always-on, actionable warning (via `consoleSandbox`, deduped on a global marker) and skip installing hooks that can't work, instead of letting the cryptic per-module error surface. The existing registration `catch` is likewise upgraded to an always-on warning. All of this lives inside `registerDiagnosticsChannelInjection`, so it tree-shakes away with the whole block when `bundleSizeOptimizations.excludeChannelInjection` sets `__SENTRY_CHANNEL_INJECTION__` to `false`. Ref #23664 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/utils/worldwide.ts | 8 ++ .../src/orchestrion/runtime/register.ts | 81 +++++++++++++++++-- .../test/orchestrion/register.test.ts | 65 +++++++++++++++ 3 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 packages/server-utils/test/orchestrion/register.test.ts diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 78f31d194911..ca88dd225db6 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -77,6 +77,14 @@ export type InternalGlobal = { * `init()` and instantiates them. */ integrations?: Map Integration>; + /** + * Set once `registerDiagnosticsChannelInjection()` has run but could not + * install the runtime module hooks — most commonly because + * `@sentry/server-utils` was bundled into the app (which strips its vendored + * code transformer) or the Node runtime lacks the required module-hook API. + * Dedupes the one-time warning and short-circuits repeat calls. + */ + runtimeUnavailable?: boolean; }; } & Carrier; diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 10865145b605..f42a4dfb1891 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,6 +1,7 @@ -import { debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core'; +import { consoleSandbox, debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; +import { create } from '@apm-js-collab/code-transformer'; import { SENTRY_INSTRUMENTATIONS } from '../config'; import type { register } from 'node:module'; import ModulePatch from '@apm-js-collab/tracing-hooks'; @@ -12,6 +13,9 @@ type NodeModule = { register?: typeof register; }; +// Surfaced in the always-on warnings below so users can find the fix. +const BUNDLING_DOCS_URL = 'https://docs.sentry.io/platforms/javascript/guides/node/troubleshooting/'; + /** `Module.registerHooks` only became stable in Node 24.13 / 25.1. */ function hasStableSyncModuleHooks(isDeno: boolean): boolean { // The minimum supported Deno (2.8.3) always has stable sync module hooks. @@ -23,6 +27,52 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13); } +/** + * Detect whether the vendored code-transformer chain (meriyah/astring/source-map, bundled into this + * package) survived downstream bundling. + * + * This package ships the transformer inline and is meant to run from `node_modules` (external). When + * an app bundler instead inlines `@sentry/server-utils` and tree-shakes it, those vendored deps are + * stripped to empty objects, so `parse`/`generate` become `undefined` and the FIRST module the hook + * tries to transform throws `TypeError: parse is not a function` — deep in the loader, once per + * module, only visible with `debug: true`. Running one throwaway in-memory transform up front turns + * that into a single, actionable, always-on warning (see `warnRuntimeUnavailable`). A healthy build + * returns normally; a tree-shaken one throws a `TypeError`. + */ +function isTransformerTreeShaken(): boolean { + try { + create( + [ + { + channelName: 'probe', + module: { name: '@sentry/orchestrion-probe', versionRange: '*', filePath: 'probe.js' }, + functionQuery: { className: 'C', methodName: 'm', kind: 'Async' }, + }, + ], + 'node:diagnostics_channel', + ) + .getTransformer('@sentry/orchestrion-probe', '0.0.0', 'probe.js') + ?.transform('class C { async m(x) { return x; } }', 'esm'); + return false; + } catch (error) { + // Tree-shaken: `parse`/`generate`/`create` are `undefined` → TypeError. A healthy build either + // succeeds or throws a domain `Error` (e.g. "Failed to find injection points"), never a TypeError. + return error instanceof TypeError; + } +} + +/** + * Emit a single, always-on warning that runtime channel injection is disabled, with the actionable + * fix. Unlike `debug.warn` (gated behind `debug: true`), this reaches every user — otherwise the + * SDK silently records no channel-based spans. Deduped via a global marker so repeat calls (e.g. + * `init()` plus `--import`) warn at most once. + */ +function warnRuntimeUnavailable(message: string): void { + consoleSandbox(() => { + GLOBAL_OBJ.console?.warn(`[Sentry] ${message} See ${BUNDLING_DOCS_URL}`); + }); +} + /** * Synchronously register the diagnostics-channel injection module hooks. * @@ -36,7 +86,23 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { * the channel-based integrations subscribe to. */ export function registerDiagnosticsChannelInjection(): void { - if (GLOBAL_OBJ?.__SENTRY_ORCHESTRION__?.runtime) { + const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); + + // Already hooked, or we already ran and found runtime injection unavailable (and warned once). + if (marker.runtime || marker.runtimeUnavailable) { + return; + } + + // A downstream bundler that inlined + tree-shook this package strips the vendored transformer, so + // every runtime transform would throw a cryptic `TypeError` deep in the loader. Detect that once, + // warn actionably, and don't install hooks that can't work. + if (isTransformerTreeShaken()) { + marker.runtimeUnavailable = true; + warnRuntimeUnavailable( + '`@sentry/server-utils` was bundled into your application, so diagnostics-channel ' + + 'auto-instrumentation is disabled. Keep `@sentry/server-utils` external in your server bundle, ' + + 'or use the Sentry bundler plugin for build-time instrumentation.', + ); return; } @@ -102,17 +168,18 @@ export function registerDiagnosticsChannelInjection(): void { new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); debug.log('Registered diagnostics-channel injection via Module.register()'); } else { + marker.runtimeUnavailable = true; debug.warn('No available Node API to register diagnostics-channel injection hooks; skipping.'); return; } } catch (error) { - debug.warn( - 'Failed to register diagnostics-channel injection hooks; channel-based integrations will not record spans.', - error, + marker.runtimeUnavailable = true; + warnRuntimeUnavailable( + 'Failed to register diagnostics-channel injection hooks, so channel-based integrations will not record spans.', ); + debug.warn('Diagnostics-channel injection registration error:', error); return; } - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; + marker.runtime = marker.runtime || []; } diff --git a/packages/server-utils/test/orchestrion/register.test.ts b/packages/server-utils/test/orchestrion/register.test.ts new file mode 100644 index 000000000000..ec2d82fcafcd --- /dev/null +++ b/packages/server-utils/test/orchestrion/register.test.ts @@ -0,0 +1,65 @@ +import type * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Simulate the vendored code-transformer chain. A tree-shaken build (this package bundled into an +// app and stripped) throws a `TypeError` from `create(...).getTransformer(...).transform(...)`; a +// healthy build does not. See `isTransformerTreeShaken` in `runtime/register.ts`. +const createMock = vi.fn(); +vi.mock('@apm-js-collab/code-transformer', () => ({ + create: (...args: unknown[]) => createMock(...args), +})); + +// Neutralise `consoleSandbox` (it swaps in the pristine console during its callback, which would +// bypass a spy) so we can assert the always-on warning directly. +vi.mock('@sentry/core', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, consoleSandbox: (cb: () => unknown) => cb() }; +}); + +import { GLOBAL_OBJ } from '@sentry/core'; +import { registerDiagnosticsChannelInjection } from '../../src/orchestrion/runtime/register'; + +describe('registerDiagnosticsChannelInjection - bundled/tree-shaken detection', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + createMock.mockReset(); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + warnSpy.mockRestore(); + }); + + it('warns once and disables runtime injection when the transformer was tree-shaken', () => { + // A tree-shaken chain: `parse`/`generate` are `undefined`, so a transform throws a TypeError. + createMock.mockImplementation(() => { + throw new TypeError('parse is not a function'); + }); + + registerDiagnosticsChannelInjection(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('was bundled into your application')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('docs.sentry.io')); + // Marked unavailable, and NOT marked as runtime-hooked (hooks were never installed). + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtimeUnavailable).toBe(true); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toBeUndefined(); + }); + + it('does not warn again on subsequent calls (deduped)', () => { + createMock.mockImplementation(() => { + throw new TypeError('parse is not a function'); + }); + + registerDiagnosticsChannelInjection(); + registerDiagnosticsChannelInjection(); + registerDiagnosticsChannelInjection(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + // The probe runs only on the first call; the marker short-circuits the rest. + expect(createMock).toHaveBeenCalledTimes(1); + }); +}); From aa07b3d1daeb5545ad29697061541d2576122af1 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:11:44 +0200 Subject: [PATCH 02/14] feat(server-utils): Keep @sentry/node external in the vite orchestrion plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime hook (reached via `@sentry/node`) must stay external so it resolves from `node_modules`; bundling it strips the transformer and breaks the `Module.register` self-reference. `@sentry/node` is a different package from the `@sentry/server-utils` barrel the plugin force-bundles (`ssr.noExternal`), so the vite plugin now also adds `@sentry/node` to `ssr.external`. Explicit `ssr.external` entries win over `noExternal`, so this holds even against a preset that sets `ssr.noExternal: true` — verified with a real vite SSR build. This covers the vite-based frameworks (SvelteKit, Astro, React Router, TanStack); the nitro/rollup frameworks (Nuxt, SolidStart) rely on the runtime warning above, with a nitro-level externalization guard as a follow-up. Ref #23664 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/orchestrion/bundler/vite.ts | 20 +++++++++++++++++-- .../test/orchestrion/bundler.test.ts | 11 ++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 5160bf70ea55..36569e51f366 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -83,7 +83,7 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { // calls never land in a browser (`client`) bundle (where they'd throw `X is not a function`). return environment.config.consumer === 'server'; }, - config(): { ssr: { noExternal: string[] } } { + config(): { ssr: { noExternal: string[]; external: string[] } } { // Force-bundle every instrumented package so the code transform actually // sees its source. Vite externalizes dependencies in SSR builds by // default, leaving them as bare `require()`/`import` calls resolved from @@ -99,8 +99,24 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { // ESM entry — a link-time crash at server startup. Bundling sidesteps // external ESM/CJS interop on both Vite majors, and the ESM barrel // tree-shakes to just the helper and the factories actually referenced. + // + // Conversely, `@sentry/node` must stay EXTERNAL. Its `init()` installs the + // runtime diagnostics-channel hook via `@sentry/server-utils/orchestrion/ + // register`, which loads the vendored code transformer and, on older Node, + // `Module.register`s a hook module by a self-referential specifier that + // only resolves from the package's real `node_modules` location. Bundling + // `@sentry/node` therefore strips the transformer (tree-shaking) AND breaks + // that self-reference. It's a different package from the `@sentry/server- + // utils` barrel above, so listing it here is not a package-granularity + // conflict; explicit `ssr.external` entries also win over `noExternal`, so + // this holds even against a preset that would otherwise inline it. A + // matching runtime warning in `orchestrion/register` covers bundlers this + // plugin can't reach. return { - ssr: { noExternal: [...instrumentedModuleNames(options.instrumentations), '@sentry/server-utils'] }, + ssr: { + noExternal: [...instrumentedModuleNames(options.instrumentations), '@sentry/server-utils'], + external: ['@sentry/node'], + }, }; }, configResolved(config: ResolvedConfig): void { diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index 1c1cd1c32977..c92021cb7ad9 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -198,6 +198,17 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(config.ssr.noExternal).toContain('mysql'); }); + it('keeps @sentry/node external so the runtime diagnostics-channel hook is never bundled', () => { + const plugin = vitePlugin(); + const config = (plugin.config as () => { ssr: { external: string[] } })(); + + // Bundling @sentry/node would strip the vendored transformer and break the + // `Module.register` self-reference in `orchestrion/register`. Explicit + // `ssr.external` entries win over `noExternal`, so this holds even when a + // preset sets `ssr.noExternal: true`. + expect(config.ssr.external).toContain('@sentry/node'); + }); + it('gates the transform on the ssr flag (Vite 5 ignores applyToEnvironment)', () => { const plugin = vitePlugin(); const transform = plugin.transform as ( From 05b1e778ec7e91132a7be7798f56192c6ee27939 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:11:44 +0200 Subject: [PATCH 03/14] docs(node): Document keeping @sentry/server-utils external when bundling Add a "Bundling your server" note to the Node README (and a Nuxt troubleshoot note) explaining that the runtime instrumentation hook must stay external, and pointing to the build-time bundler-plugin instrumentation as the alternative. Ref #23664 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/node/README.md | 14 ++++++++++++++ packages/nuxt/README.md | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/packages/node/README.md b/packages/node/README.md index 6471538fb4f0..51158d866a77 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -72,6 +72,20 @@ If it is not possible for you to pass the `--import` flag to the Node.js binary, NODE_OPTIONS="--import ./instrument.mjs" npm run start ``` +### Bundling your server + +`@sentry/node` installs its automatic (diagnostics-channel) instrumentation through a runtime module +hook that ships in `@sentry/server-utils` and is designed to run from `node_modules`. If you bundle +your server (esbuild, webpack, rollup, or a framework that bundles the server output), keep +`@sentry/server-utils` **external** — do not inline it into the bundle. Bundling it strips its +internal code transformer, which silently disables auto-instrumentation (`@sentry/node` will warn at +startup when it detects this). + +Most setups don't bundle the SDK. If you do, either mark `@sentry/server-utils` as external in your +bundler config, or use the build-time instrumentation from the Sentry bundler plugins instead +(`@sentry/node/esbuild`, `@sentry/node/webpack`, `@sentry/node/vite`, `@sentry/node/rollup`), which +inject the instrumentation into your bundled dependencies at build time. + ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index b7978c288ffd..13fe27528588 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -28,4 +28,9 @@ functionality related to Nuxt. ## Troubleshoot +If your server-side auto-instrumentation stops recording spans after bundling (e.g. certain Nitro +presets), make sure `@sentry/server-utils` is kept **external** in the Nitro/server build rather than +inlined — its runtime module hook must resolve from `node_modules`. `@sentry/node` logs a warning at +startup when it detects it was bundled. + If you encounter any issues with error tracking or integrations, refer to the official [Sentry Nuxt SDK documentation](https://docs.sentry.io/platforms/javascript/guides/nuxt/). If the documentation does not provide the necessary information, consider opening an issue on GitHub. From 757c84a1e94bbdedaa7c8a2ddad5171c4d97a9e3 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:27:20 +0200 Subject: [PATCH 04/14] fix(server-utils): Don't force @sentry/node external in the vite plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forcing `@sentry/node` into `ssr.external` broke Cloudflare/worker builds: the shared vite orchestrion plugin also runs under `@cloudflare/vite-plugin` (and frameworks deploying to workerd), where `@sentry/node` is unused and setting `resolve.external` on a worker environment is rejected outright — and the worker environment is even named `ssr`, so there's no reliable node-vs-worker discriminator in the `config()` hook. Vite already externalizes `@sentry/node` for node SSR by default anyway, and the runtime probe in `orchestrion/register` covers the cases where it does get bundled, so drop the forced externalization. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/orchestrion/bundler/vite.ts | 26 +++++++------------ .../test/orchestrion/bundler.test.ts | 11 -------- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 36569e51f366..e9ff403c00b1 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -83,7 +83,7 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { // calls never land in a browser (`client`) bundle (where they'd throw `X is not a function`). return environment.config.consumer === 'server'; }, - config(): { ssr: { noExternal: string[]; external: string[] } } { + config(): { ssr: { noExternal: string[] } } { // Force-bundle every instrumented package so the code transform actually // sees its source. Vite externalizes dependencies in SSR builds by // default, leaving them as bare `require()`/`import` calls resolved from @@ -100,23 +100,15 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { // external ESM/CJS interop on both Vite majors, and the ESM barrel // tree-shakes to just the helper and the factories actually referenced. // - // Conversely, `@sentry/node` must stay EXTERNAL. Its `init()` installs the - // runtime diagnostics-channel hook via `@sentry/server-utils/orchestrion/ - // register`, which loads the vendored code transformer and, on older Node, - // `Module.register`s a hook module by a self-referential specifier that - // only resolves from the package's real `node_modules` location. Bundling - // `@sentry/node` therefore strips the transformer (tree-shaking) AND breaks - // that self-reference. It's a different package from the `@sentry/server- - // utils` barrel above, so listing it here is not a package-granularity - // conflict; explicit `ssr.external` entries also win over `noExternal`, so - // this holds even against a preset that would otherwise inline it. A - // matching runtime warning in `orchestrion/register` covers bundlers this - // plugin can't reach. + // Note: we deliberately do NOT force `@sentry/node` into `ssr.external` + // here. Vite already externalizes it for node SSR by default (so the + // runtime hook resolves from `node_modules`), and this same plugin also + // runs in worker builds (`@sentry/cloudflare`, frameworks on + // `@cloudflare/vite-plugin`) where `@sentry/node` is unused and setting + // `resolve.external` is rejected outright. The runtime probe in + // `orchestrion/register` covers the cases where it does get bundled. return { - ssr: { - noExternal: [...instrumentedModuleNames(options.instrumentations), '@sentry/server-utils'], - external: ['@sentry/node'], - }, + ssr: { noExternal: [...instrumentedModuleNames(options.instrumentations), '@sentry/server-utils'] }, }; }, configResolved(config: ResolvedConfig): void { diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index c92021cb7ad9..1c1cd1c32977 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -198,17 +198,6 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(config.ssr.noExternal).toContain('mysql'); }); - it('keeps @sentry/node external so the runtime diagnostics-channel hook is never bundled', () => { - const plugin = vitePlugin(); - const config = (plugin.config as () => { ssr: { external: string[] } })(); - - // Bundling @sentry/node would strip the vendored transformer and break the - // `Module.register` self-reference in `orchestrion/register`. Explicit - // `ssr.external` entries win over `noExternal`, so this holds even when a - // preset sets `ssr.noExternal: true`. - expect(config.ssr.external).toContain('@sentry/node'); - }); - it('gates the transform on the ssr flag (Vite 5 ignores applyToEnvironment)', () => { const plugin = vitePlugin(); const transform = plugin.transform as ( From 15406097115e6e76fe7ef20586ecad9c73c83ef9 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:27:20 +0200 Subject: [PATCH 05/14] feat(server-utils): Stay quiet when build-time instrumentation covers a bundled hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If `@sentry/server-utils` was bundled AND the build-time bundler plugin ran (a defined `__SENTRY_ORCHESTRION__.bundler` Set), instrumentation is already injected at build time and the runtime hook is redundant — a supported setup. In that case downgrade the "bundled" message to a debug log instead of an always-on warning. The always-on warning now fires only when nothing instrumented the app (bundled and no build-time plugin). Also corrects the Node README: bundling doesn't disable auto-instrumentation when the build-time plugin is used. Ref #23664 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/node/README.md | 25 +++++++++++-------- .../src/orchestrion/runtime/register.ts | 25 +++++++++++++------ .../test/orchestrion/register.test.ts | 15 +++++++++++ 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index 51158d866a77..a2a32c69ab9f 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -75,16 +75,21 @@ NODE_OPTIONS="--import ./instrument.mjs" npm run start ### Bundling your server `@sentry/node` installs its automatic (diagnostics-channel) instrumentation through a runtime module -hook that ships in `@sentry/server-utils` and is designed to run from `node_modules`. If you bundle -your server (esbuild, webpack, rollup, or a framework that bundles the server output), keep -`@sentry/server-utils` **external** — do not inline it into the bundle. Bundling it strips its -internal code transformer, which silently disables auto-instrumentation (`@sentry/node` will warn at -startup when it detects this). - -Most setups don't bundle the SDK. If you do, either mark `@sentry/server-utils` as external in your -bundler config, or use the build-time instrumentation from the Sentry bundler plugins instead -(`@sentry/node/esbuild`, `@sentry/node/webpack`, `@sentry/node/vite`, `@sentry/node/rollup`), which -inject the instrumentation into your bundled dependencies at build time. +hook that ships in `@sentry/server-utils` and is designed to run from `node_modules`. There are two +supported ways to keep auto-instrumentation working when you bundle your server: + +1. **Keep `@sentry/server-utils` external** (do not inline it into the bundle) so the runtime hook + loads from `node_modules`. Most bundlers externalize `node_modules` for a Node target by default; + if yours inlines everything, mark `@sentry/server-utils` as external explicitly. +2. **Instrument at build time** with the Sentry bundler plugins (`@sentry/node/esbuild`, + `@sentry/node/webpack`, `@sentry/node/vite`, `@sentry/node/rollup`), which inject the + instrumentation into your bundled dependencies during the build. In this mode the runtime hook is + not needed. + +If you bundle `@sentry/server-utils` **and** don't use the build-time plugin, its internal code +transformer is stripped and runtime auto-instrumentation is disabled — `@sentry/node` warns at +startup when it detects this. (When the build-time plugin is used, there is no warning, since +instrumentation is already in place.) ## Links diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index f42a4dfb1891..e8b919ca67c3 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -94,15 +94,26 @@ export function registerDiagnosticsChannelInjection(): void { } // A downstream bundler that inlined + tree-shook this package strips the vendored transformer, so - // every runtime transform would throw a cryptic `TypeError` deep in the loader. Detect that once, - // warn actionably, and don't install hooks that can't work. + // every runtime transform would throw a cryptic `TypeError` deep in the loader. Detect that once + // and don't install hooks that can't work. if (isTransformerTreeShaken()) { marker.runtimeUnavailable = true; - warnRuntimeUnavailable( - '`@sentry/server-utils` was bundled into your application, so diagnostics-channel ' + - 'auto-instrumentation is disabled. Keep `@sentry/server-utils` external in your server bundle, ' + - 'or use the Sentry bundler plugin for build-time instrumentation.', - ); + // If the build-time bundler plugin ran (a defined `bundler` marker Set, set by its entry banner), + // instrumentation was already injected at build time and the runtime hook is redundant — this is + // an expected, supported setup, so stay quiet (debug-only). Otherwise nothing is instrumented, so + // surface an always-on, actionable warning. + if (marker.bundler instanceof Set) { + debug.log( + 'Runtime diagnostics-channel injection is disabled because `@sentry/server-utils` was bundled; ' + + 'build-time instrumentation is active, so this is expected.', + ); + } else { + warnRuntimeUnavailable( + '`@sentry/server-utils` was bundled into your application, so diagnostics-channel ' + + 'auto-instrumentation is disabled. Keep `@sentry/server-utils` external in your server bundle, ' + + 'or use the Sentry bundler plugin for build-time instrumentation.', + ); + } return; } diff --git a/packages/server-utils/test/orchestrion/register.test.ts b/packages/server-utils/test/orchestrion/register.test.ts index ec2d82fcafcd..b21e0227d9ae 100644 --- a/packages/server-utils/test/orchestrion/register.test.ts +++ b/packages/server-utils/test/orchestrion/register.test.ts @@ -49,6 +49,21 @@ describe('registerDiagnosticsChannelInjection - bundled/tree-shaken detection', expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toBeUndefined(); }); + it('does not warn when build-time instrumentation is active (bundler marker present)', () => { + createMock.mockImplementation(() => { + throw new TypeError('parse is not a function'); + }); + // A defined `bundler` Set signals the build-time plugin ran, so the runtime hook is redundant. + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set() }; + + registerDiagnosticsChannelInjection(); + + // No user-facing warning — this is an expected, supported setup. + expect(warnSpy).not.toHaveBeenCalled(); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtimeUnavailable).toBe(true); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toBeUndefined(); + }); + it('does not warn again on subsequent calls (deduped)', () => { createMock.mockImplementation(() => { throw new TypeError('parse is not a function'); From be4d02d36afc8dbc4e63dac41578d2a97f0c184b Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:45:29 +0200 Subject: [PATCH 06/14] better comment --- packages/server-utils/src/orchestrion/bundler/vite.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index e9ff403c00b1..5160bf70ea55 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -99,14 +99,6 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { // ESM entry — a link-time crash at server startup. Bundling sidesteps // external ESM/CJS interop on both Vite majors, and the ESM barrel // tree-shakes to just the helper and the factories actually referenced. - // - // Note: we deliberately do NOT force `@sentry/node` into `ssr.external` - // here. Vite already externalizes it for node SSR by default (so the - // runtime hook resolves from `node_modules`), and this same plugin also - // runs in worker builds (`@sentry/cloudflare`, frameworks on - // `@cloudflare/vite-plugin`) where `@sentry/node` is unused and setting - // `resolve.external` is rejected outright. The runtime probe in - // `orchestrion/register` covers the cases where it does get bundled. return { ssr: { noExternal: [...instrumentedModuleNames(options.instrumentations), '@sentry/server-utils'] }, }; From 1b98ab119554bfaa95ab567ecdb9e379ad25ffa0 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:51:54 +0200 Subject: [PATCH 07/14] just use console --- packages/server-utils/src/orchestrion/runtime/register.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index e8b919ca67c3..086c6bd3662f 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -69,7 +69,8 @@ function isTransformerTreeShaken(): boolean { */ function warnRuntimeUnavailable(message: string): void { consoleSandbox(() => { - GLOBAL_OBJ.console?.warn(`[Sentry] ${message} See ${BUNDLING_DOCS_URL}`); + // oxlint-disable-next-line no-console + console.warn(`[Sentry] ${message} See ${BUNDLING_DOCS_URL}`); }); } From 80a494bef33400d6b7adffe67dc4069a4474125f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 27 Aug 2026 12:03:52 -0400 Subject: [PATCH 08/14] fix(server-utils): Resolve vendored meriyah and astring to their ESM builds The code transformer reaches both through `require()`, so node-resolve picked their CJS builds and `@rollup/plugin-commonjs` emitted each as an empty `_virtual/.js` proxy populated from a separate module through a bare side-effect import. Downstream bundlers delete that import, leaving `parse` and `generate` undefined at runtime. Re-resolving the specifier without the `require` condition picks each package's ESM build instead, which the transformer then binds by value. It also drops the duplicate meriyah copy the build was shipping, ~324 kB per format. --- packages/server-utils/rollup.npm.config.mjs | 27 ++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 6b7c30b5a292..639e80a28ba8 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -53,6 +53,31 @@ const debugNodeAlias = { }, }; +// `@apm-js-collab/code-transformer` reaches meriyah and astring through `require()`, so node-resolve +// picks each package's CJS build and `@rollup/plugin-commonjs` emits it as an empty +// `_virtual/.js` proxy that a *separate* module fills in through cross-module property writes +// (`meriyah.parse = parse`), reachable only via a bare side-effect import. Downstream tree-shakers +// delete that import, so `parse`/`generate` are `undefined` and every instrumented module load +// throws (https://github.com/getsentry/sentry-javascript/issues/23664). +// +// Both packages also publish an ESM build. Re-resolving the bare specifier *without* the `require` +// condition that `@rollup/plugin-commonjs` asks for picks that build instead, and the transformer +// then binds `parse`/`generate` through a plain value import that no tree-shaker can drop. It also +// deduplicates meriyah: `src/orchestrion/bundler/moduleInjectedTransform.ts` already imports it as +// ESM, so before this the build shipped meriyah's CJS *and* ESM copy, ~324 kB each. +// +// source-map (0.6.1) publishes no ESM build and so keeps the fragile shape. That is what the +// `sideEffects` allowlist in this package's `package.json` covers. +const esmVendorAlias = { + name: 'esm-vendor-alias', + resolveId: { + order: 'pre', + handler(source, importer) { + return source === 'meriyah' || source === 'astring' ? this.resolve(source, importer, { skipSelf: true }) : null; + }, + }, +}; + // Bundling files from the repo-root `node_modules` moves rollup's common source ancestor up to the // repo root, so `preserveModules` names our own files `packages/server-utils/src/...` — strip that // prefix to keep the `build/cjs/index.js` layout the `exports` map points at. And npm never packs @@ -123,7 +148,7 @@ export default [ 'src/orchestrion/bundler/esbuild.ts', ], packageSpecificConfig: { - plugins: [debugNodeAlias, commonJSPlugin, thirdPartyLicensePlugin], + plugins: [debugNodeAlias, esmVendorAlias, commonJSPlugin, thirdPartyLicensePlugin], output: { // set exports to 'named' or 'auto' so that rollup doesn't warn exports: 'named', From 486cab5e541293dfb20d5d5dba56c2d086b888f2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 27 Aug 2026 12:03:57 -0400 Subject: [PATCH 09/14] fix(server-utils): Keep the vendored source-map from being tree-shaken away source-map 0.6.1 ships no ESM build, so it keeps the empty-proxy shape that a downstream bundler strips. A `sideEffects` allowlist pins it, but the value that actually governs the ESM build is the generated `build/esm/package.json`, which is nearer to those files than the package's own. Copying the root list verbatim would leave every glob pointing at a path that cannot match, so re-anchor the entries that belong to the output directory and drop the rest. --- .../rollup-utils/plugins/make-esm-plugin.mjs | 30 +++++++++++++++++-- packages/server-utils/package.json | 5 +++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/dev-packages/rollup-utils/plugins/make-esm-plugin.mjs b/dev-packages/rollup-utils/plugins/make-esm-plugin.mjs index ad18856c011a..7c6c2ac0306d 100644 --- a/dev-packages/rollup-utils/plugins/make-esm-plugin.mjs +++ b/dev-packages/rollup-utils/plugins/make-esm-plugin.mjs @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import path from 'node:path'; /** * Outputs a package.json file with {type: module} in the root of the output directory so that Node @@ -7,14 +8,17 @@ import fs from 'node:fs'; export function makePackageNodeEsm() { return { name: 'make-package-node-esm', - async generateBundle() { + async generateBundle(options) { // We need to keep the `sideEffects` value from the original package.json, // as e.g. webpack seems to depend on this // without this, tree shaking does not work as expected const packageJSONPath = (await this.resolve('package.json')).id; const packageJSON = JSON.parse(fs.readFileSync(packageJSONPath, 'utf-8')); - const sideEffects = packageJSON.sideEffects; + const sideEffects = scopeSideEffectsToOutputDir( + packageJSON.sideEffects, + path.relative(path.dirname(packageJSONPath), options.dir), + ); // For module federation we need to keep the version of the package const version = packageJSON.version; @@ -32,3 +36,25 @@ export function makePackageNodeEsm() { }, }; } + +/** + * Bundlers resolve `sideEffects` globs against the *nearest* package.json, and the file we emit here + * is nearer than the package's own for everything under the output directory. So a path list written + * relative to the package root (`./build/esm/vendored/foo/**`) would silently match nothing once it + * lands here. Re-anchor the entries that point into this output directory and drop the rest, which + * belong to sibling outputs (`./build/cjs/...`) still covered by the package's own package.json. + * + * A boolean `sideEffects` needs none of this and is passed through untouched. + * + * @param {boolean | string[] | undefined} sideEffects The package's own `sideEffects` value. + * @param {string} outputDir The output directory, relative to the package root. + */ +function scopeSideEffectsToOutputDir(sideEffects, outputDir) { + if (!Array.isArray(sideEffects)) { + return sideEffects; + } + + const prefix = `./${outputDir}/`; + + return sideEffects.filter(entry => entry.startsWith(prefix)).map(entry => `./${entry.slice(prefix.length)}`); +} diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index d95d222c5f3c..138cd0a0de1f 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -127,7 +127,10 @@ "volta": { "extends": "../../package.json" }, - "sideEffects": false, + "sideEffects": [ + "./build/esm/vendored/source-map/**", + "./build/cjs/vendored/source-map/**" + ], "nx": { "targets": { "build:transpile": { From 91d44015d76a5f924c040a4f3b5b45472252abc1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 27 Aug 2026 12:04:18 -0400 Subject: [PATCH 10/14] fix(server-utils): Read the Node module API through its default export webpack compiles a `node:module` external to `createRequire(...)('node:module')` and wraps the result in a synthetic namespace. Because `node:module` exports a function, that wrapper carries only `default`, so `registerHooks` and `register` both read as undefined and a webpack-bundled SDK gave up with "no available Node API" before installing any hook. Node's own ESM namespace exposes the same object under `default`, and the CJS build has no `default` at all, so preferring it covers every shape. --- .../server-utils/src/orchestrion/runtime/register.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 086c6bd3662f..79ccb776b071 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -123,7 +123,14 @@ export function registerDiagnosticsChannelInjection(): void { // `Module.registerHooks` / `Module.register` are newer than the @types/node // we build against, hence the cast. - const mod = Module as NodeModule; + // + // Prefer `default`: webpack compiles a `node:module` external to `createRequire(…)('node:module')` + // and wraps it in a synthetic namespace, and because `node:module` exports a *function* that + // wrapper only carries `default`. Reading `registerHooks`/`register` off the namespace then yields + // `undefined` and a webpack-bundled SDK falls through to "no available Node API". Node's own ESM + // namespace exposes `default` too (the same object), and the CJS build has no `default` at all, so + // this covers every shape. + const mod = ((Module as { default?: NodeModule }).default ?? Module) as NodeModule; setDiagnosticsHook(({ moduleName, error }): void => { if (error) { From f5aa6777bfc82c44bd3085f773d9e3fd0b8e6731 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 27 Aug 2026 12:04:23 -0400 Subject: [PATCH 11/14] test(server-utils): Cover the transformer surviving a downstream re-bundle Re-bundles the built `orchestrion/register` entry the way a downstream bundler does, honouring `sideEffects`, then runs the result in a child process and asserts it installs hooks rather than reporting the transformer unavailable. --- .../test/orchestrion/treeshaking.test.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/server-utils/test/orchestrion/treeshaking.test.ts diff --git a/packages/server-utils/test/orchestrion/treeshaking.test.ts b/packages/server-utils/test/orchestrion/treeshaking.test.ts new file mode 100644 index 000000000000..be21c3cb5971 --- /dev/null +++ b/packages/server-utils/test/orchestrion/treeshaking.test.ts @@ -0,0 +1,110 @@ +import { execFileSync, execSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import { rollup } from 'rollup'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +// `@sentry/server-utils/orchestrion/register` (the runtime entry `Sentry.init()` calls) pulls in the +// vendored orchestrion transformer chain (`@apm-js-collab/code-transformer` -> meriyah, esquery, +// astring, source-map). Under `preserveModules`, `@rollup/plugin-commonjs` emits a named-export CJS +// dep as an empty proxy object (`var meriyah = {}`) that a *separate* module populates via +// cross-module property writes (`meriyah.parse = parse`), reachable only through a bare side-effect +// import. A downstream bundler that inlines this package drops those "unused" writes, leaving the +// proxy empty, so `parse`/`generate`/the SourceMap constructors are `undefined` and every +// instrumented module load throws deep inside the loader. +// See https://github.com/getsentry/sentry-javascript/issues/23664. +// +// meriyah and astring avoid the shape entirely because the build resolves them to their ESM builds +// (see `esmVendorAlias` in rollup.npm.config.mjs); source-map ships no ESM build, so it stays fragile +// and is instead pinned by the `sideEffects` allowlist in this package's package.json. This test +// re-bundles the built entry the way a downstream bundler does, honouring `sideEffects`, and asserts +// the result still installs working hooks. + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(__dirname, '../..'); +const registerEntry = join(packageRoot, 'build/esm/orchestrion/runtime/register.js'); + +let tmpDir: string; +let bundlePath: string; +let reBundledCode: string; + +// The build and the re-bundle happen here, not in a test body, so the whole (potentially slow) job +// runs under one generous timeout. The nx build cache is Node-version-scoped, so on Node versions +// other than the one the CI build job ran on, `build/` is absent and gets built here. +beforeAll(async () => { + // The vendored chain only exists after this package's rollup build, so the test operates on + // `build/esm`; build on demand when it is missing. + if (!existsSync(registerEntry)) { + execSync('yarn build:transpile', { cwd: packageRoot, stdio: 'inherit' }); + } + + // Inside the package root, not the OS temp dir: the Node < 24.13 registration path resolves + // `@sentry/server-utils/orchestrion/hook` against the *bundle's* location, which only works from + // somewhere the installed package is reachable. + tmpDir = mkdtempSync(join(packageRoot, '.treeshake-')); + + const entryPath = join(tmpDir, 'entry.mjs'); + writeFileSync( + entryPath, + [ + `import { registerDiagnosticsChannelInjection } from ${JSON.stringify(registerEntry)};`, + 'export { registerDiagnosticsChannelInjection };', + ].join('\n'), + ); + + const bundle = await rollup({ + input: entryPath, + plugins: [nodeResolve()], + external: id => id === '@sentry/core' || id.startsWith('node:') || builtinModules.includes(id), + onwarn: () => { + /* the vendored graph has benign circular deps; keep the test output quiet */ + }, + }); + const { output } = await bundle.generate({ format: 'esm' }); + await bundle.close(); + + reBundledCode = output[0].code; + bundlePath = join(tmpDir, 'bundle.mjs'); + writeFileSync(bundlePath, reBundledCode); +}, 180_000); + +afterAll(() => { + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +describe('the vendored orchestrion transformer survives downstream tree-shaking', () => { + it('installs working hooks from a re-bundled register entry', () => { + // Run out-of-process: `registerDiagnosticsChannelInjection()` installs module hooks, which we do + // not want in the vitest worker. It probes the transformer before installing anything and marks + // `runtimeUnavailable` when the chain came back tree-shaken, so that flag is the assertion. + const stdout = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + [ + `import { registerDiagnosticsChannelInjection } from ${JSON.stringify(bundlePath)};`, + 'registerDiagnosticsChannelInjection();', + 'console.log(JSON.stringify(globalThis.__SENTRY_ORCHESTRION__));', + ].join('\n'), + ], + { cwd: packageRoot, encoding: 'utf-8' }, + ); + + expect(JSON.parse(stdout.trim())).toEqual({ runtime: [] }); + }); + + it('keeps every vendored transformer dependency in the re-bundled output', () => { + // Named so a failure says which dependency the bundler dropped. + expect(reBundledCode, 'meriyah').toContain('function parseSource('); + expect(reBundledCode, 'astring').toContain('EXPRESSIONS_PRECEDENCE'); + expect(reBundledCode, 'esquery').toContain('esquery'); + expect(reBundledCode, 'source-map').toMatch(/sourceMap\.SourceMapConsumer =/); + expect(reBundledCode, 'source-map').toMatch(/sourceMap\.SourceMapGenerator =/); + }); +}); From ccfa84722c9b3fb8371e92c0d8df2a7294136d7d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 27 Aug 2026 12:04:23 -0400 Subject: [PATCH 12/14] test(e2e): Assert a webpack-bundled server records channel spans The app only grepped for a config string, which survives tree-shaking even when the code transformer has been stripped to an empty object. It now keeps graphql external, runs the bundle, and asserts channel-based graphql spans arrive. --- .../node-orchestrion-webpack/assert.mjs | 35 +++++++++++++++++-- .../node-orchestrion-webpack/build.mjs | 5 +++ .../node-orchestrion-webpack/package.json | 5 +-- .../node-orchestrion-webpack/src/app.mjs | 14 ++++++-- .../node-orchestrion-webpack/src/entry.mjs | 26 ++++++++++++-- 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs index e4178c60573c..a2d7c615e5cd 100644 --- a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs @@ -1,10 +1,19 @@ /** - * Asserts the orchestrion subtree is bundled by default. Channel-based (orchestrion - * diagnostics-channel) instrumentation is the v11 default, so `Sentry.init()` pulls in the - * orchestrion code path unconditionally — there is no longer an opt-in to tree-shake it away. + * Asserts that a webpack-bundled server still gets orchestrion instrumentation. + * + * Two things are checked, and they fail for different reasons: + * + * 1. The orchestrion subtree is bundled at all. Channel-based (orchestrion diagnostics-channel) + * instrumentation is the v11 default, so `Sentry.init()` pulls in the orchestrion code path + * unconditionally, and there is no longer an opt-in to tree-shake it away. + * 2. The bundle, when run, actually records channel-based spans for an external dependency. The + * string check above passes even when the bundler has stripped the vendored code transformer to + * an empty object, which leaves auto-instrumentation dead and silent + * (https://github.com/getsentry/sentry-javascript/issues/23664). * * @module */ +import { execFileSync } from 'node:child_process'; import { readdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -23,6 +32,17 @@ function bundleText(name) { .join('\n'); } +function runBundle(name) { + const stdout = execFileSync('node', [join(__dirname, 'dist', name, 'main.mjs')], { encoding: 'utf-8' }); + const line = stdout.split('\n').find(l => l.startsWith('SENTRY_RESULT=')); + + if (!line) { + throw new Error(`${name} did not print SENTRY_RESULT (stdout: ${stdout})`); + } + + return JSON.parse(line.slice('SENTRY_RESULT='.length)); +} + let failed = false; function check(condition, message) { // eslint-disable-next-line no-console @@ -34,6 +54,15 @@ const app = bundleText('entry'); check(app.includes(MARKER), 'orchestrion is bundled by default when Sentry.init() runs'); +const { injected, spans } = runBundle('entry'); +const detail = `injected: ${JSON.stringify(injected)}, spans: ${JSON.stringify(spans)}`; + +check(injected.runtime.includes('graphql'), `the runtime hook injected channels into graphql (${detail})`); +check( + spans.some(span => span.origin === 'auto.graphql.diagnostic_channel'), + `the bundled app recorded channel-based graphql spans (${detail})`, +); + if (failed) { process.exit(1); } diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs index 08be7f25a103..ed8f392019aa 100644 --- a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs @@ -22,6 +22,11 @@ function build(name) { library: { type: 'module' }, chunkFormat: 'module', }, + // graphql is the module the runtime hook has to instrument, so it has to stay out of the + // bundle. Everything else, `@sentry/server-utils` included, is inlined: that is the setup + // where downstream tree-shaking used to silently strip the code transformer + // (https://github.com/getsentry/sentry-javascript/issues/23664). + externals: { graphql: 'import graphql' }, // Keep output readable; tree-shaking (module elimination via // `sideEffects: false`) happens regardless of minification, and // it's important to be able to debug when it messes up. diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json index 69dd20caf346..1a392eb3e236 100644 --- a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json @@ -1,6 +1,6 @@ { "name": "node-orchestrion-webpack", - "description": "ensure that orchestrion is not bundled inappropriately", + "description": "ensure that orchestrion is bundled and still instruments external dependencies", "version": "1.0.0", "private": true, "type": "module", @@ -11,7 +11,8 @@ }, "dependencies": { "@sentry/node": "file:../../packed/sentry-node-packed.tgz", - "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz" + "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", + "graphql": "16.14.2" }, "devDependencies": { "webpack": "5.107.2" diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs index e66db6685328..60dbd38404c5 100644 --- a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs @@ -1,2 +1,12 @@ -// eslint-disable-next-line no-console -console.log('this is the application'); +// Loaded *after* `Sentry.init()` so the runtime module hooks are already installed when graphql is +// compiled. graphql is deliberately left out of the bundle (see build.mjs): if it were bundled there +// would be no module load left for the hook to intercept and the assertion would prove nothing. +const { buildSchema, parse, execute } = await import('graphql'); + +const schema = buildSchema('type Query { hello: String }'); + +export async function runQuery() { + const document = parse('{ hello }'); + + await execute({ schema, document, rootValue: { hello: () => 'world' } }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/entry.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/entry.mjs index 5c03b545d672..91244ecb64c8 100644 --- a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/entry.mjs +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/entry.mjs @@ -1,9 +1,31 @@ import * as Sentry from '@sentry/node'; +const spans = []; + Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1, + // Nothing leaves the process: spans are collected here and the transport is a no-op, so the + // bundle runs offline against a fake DSN. + transport: () => ({ send: async () => ({}), flush: async () => true }), + beforeSendSpan(span) { + spans.push({ name: span.name, origin: span.attributes?.['sentry.origin'] }); + + return span; + }, }); -await import('./app.mjs'); +const { runQuery } = await import('./app.mjs'); + +await Sentry.startSpan({ name: 'graphql-work', op: 'test' }, runQuery); +await Sentry.flush(2000); + +const { runtime = [], bundler = [] } = globalThis.__SENTRY_ORCHESTRION__ ?? {}; + +// eslint-disable-next-line no-console +console.log( + `SENTRY_RESULT=${JSON.stringify({ + injected: { runtime, bundler: Array.isArray(bundler) ? bundler : [...bundler] }, + spans, + })}`, +); From 344ee5c1e410c45bb955d194eae9e79f3a950321 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 27 Aug 2026 12:04:28 -0400 Subject: [PATCH 13/14] chore: Account for the retained code transformer in size limits and docs Keeping the transformer alive costs ~53 kB gzip on every bundled Node entry, so the three affected size limits go up. The bundling notes lose the "keep it external" instruction, which no longer applies, and keep only the two cases that still need a choice. --- .size-limit.js | 6 +++--- packages/node/README.md | 30 ++++++++++++++++-------------- packages/nuxt/README.md | 8 +++++--- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index dfdbd49bfbe9..dcdfc5612dab 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '123 KB', + limit: '180 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -430,7 +430,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '92 KB', + limit: '145 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -473,7 +473,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '99 KB', + limit: '153 KB', disablePlugins: ['@size-limit/esbuild'], }, // Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output diff --git a/packages/node/README.md b/packages/node/README.md index a2a32c69ab9f..598a5ab27912 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -75,20 +75,22 @@ NODE_OPTIONS="--import ./instrument.mjs" npm run start ### Bundling your server `@sentry/node` installs its automatic (diagnostics-channel) instrumentation through a runtime module -hook that ships in `@sentry/server-utils` and is designed to run from `node_modules`. There are two -supported ways to keep auto-instrumentation working when you bundle your server: - -1. **Keep `@sentry/server-utils` external** (do not inline it into the bundle) so the runtime hook - loads from `node_modules`. Most bundlers externalize `node_modules` for a Node target by default; - if yours inlines everything, mark `@sentry/server-utils` as external explicitly. -2. **Instrument at build time** with the Sentry bundler plugins (`@sentry/node/esbuild`, - `@sentry/node/webpack`, `@sentry/node/vite`, `@sentry/node/rollup`), which inject the - instrumentation into your bundled dependencies during the build. In this mode the runtime hook is - not needed. - -If you bundle `@sentry/server-utils` **and** don't use the build-time plugin, its internal code -transformer is stripped and runtime auto-instrumentation is disabled — `@sentry/node` warns at -startup when it detects this. (When the build-time plugin is used, there is no warning, since +hook that ships in `@sentry/server-utils`. Bundling the SDK into your server is supported: the hook +keeps instrumenting the dependencies you leave external. + +Two setups still need a deliberate choice: + +- **You bundle your dependencies too.** Once a library is inlined there is no module load left for + the runtime hook to intercept, so instrument at build time with the Sentry bundler plugins + (`@sentry/node/esbuild`, `@sentry/node/webpack`, `@sentry/node/vite`, `@sentry/node/rollup`), which + inject the instrumentation into your bundled dependencies during the build. +- **You deploy the bundle without `node_modules` on Node.js older than 24.13.** Those versions + install the hook through an API that resolves `@sentry/server-utils` from disk, so it needs the + package to still be there. Either keep `@sentry/server-utils` external, or use the build-time + plugins above. + +`@sentry/node` warns at startup whenever it ends up without runtime instrumentation, so you do not +have to guess which case you are in. (When the build-time plugin is used, there is no warning, since instrumentation is already in place.) ## Links diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index 13fe27528588..91b16a0fe2a3 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -29,8 +29,10 @@ functionality related to Nuxt. ## Troubleshoot If your server-side auto-instrumentation stops recording spans after bundling (e.g. certain Nitro -presets), make sure `@sentry/server-utils` is kept **external** in the Nitro/server build rather than -inlined — its runtime module hook must resolve from `node_modules`. `@sentry/node` logs a warning at -startup when it detects it was bundled. +presets), check whether the libraries you expect spans for were inlined into the server bundle: once +a library is bundled there is no module load left for the runtime hook to intercept, and it has to be +instrumented at build time instead. `@sentry/node` logs a warning at startup whenever it ends up +without runtime instrumentation. See +[Bundling your server](https://github.com/getsentry/sentry-javascript/tree/master/packages/node#bundling-your-server). If you encounter any issues with error tracking or integrations, refer to the official [Sentry Nuxt SDK documentation](https://docs.sentry.io/platforms/javascript/guides/nuxt/). If the documentation does not provide the necessary information, consider opening an issue on GitHub. From 15dfdd974892c1bdd6684f8a4bb1c47c31b1fbea Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 27 Aug 2026 14:55:21 -0400 Subject: [PATCH 14/14] docs(node): Widen the Node < 24.13 bundling caveat The legacy registration path resolves against a path baked in at build time, so it also fails when a webpack bundle runs on a machine other than the one it was built on, not only when `node_modules` is absent. --- packages/node/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index 598a5ab27912..3f3f66a06c97 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -84,10 +84,12 @@ Two setups still need a deliberate choice: the runtime hook to intercept, so instrument at build time with the Sentry bundler plugins (`@sentry/node/esbuild`, `@sentry/node/webpack`, `@sentry/node/vite`, `@sentry/node/rollup`), which inject the instrumentation into your bundled dependencies during the build. -- **You deploy the bundle without `node_modules` on Node.js older than 24.13.** Those versions - install the hook through an API that resolves `@sentry/server-utils` from disk, so it needs the - package to still be there. Either keep `@sentry/server-utils` external, or use the build-time - plugins above. +- **You are on Node.js older than 24.13.** Those versions install the hook through an API that + resolves `@sentry/server-utils` from disk against a path baked into the bundle at build time. If + that path no longer resolves at runtime (a single-file deploy without `node_modules`, or a webpack + bundle run on a different machine than it was built on) the hook cannot install. Either keep + `@sentry/server-utils` external, or use the build-time plugins above. Node.js 24.13 and newer + install the hook entirely in-process and are unaffected. `@sentry/node` warns at startup whenever it ends up without runtime instrumentation, so you do not have to guess which case you are in. (When the build-time plugin is used, there is no warning, since