From 17976865aabd01ab2902e093262951e19902c0e7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:11:44 +0200 Subject: [PATCH 01/11] 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 77b537594c809c780042bf031ec5c70e6522a9b9 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:11:44 +0200 Subject: [PATCH 02/11] 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 34084c72b2051b2464b55cabb2f961220c50693d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:11:44 +0200 Subject: [PATCH 03/11] 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 14c8a999739c97b73252dc334538b0f4253271d2 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:27:20 +0200 Subject: [PATCH 04/11] 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 a3e6fe25c4507ce93ae216d755802ff70133cd7c Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:27:20 +0200 Subject: [PATCH 05/11] 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 f94b60f1102648d5e5525cda2a062b75c96e2fd4 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:45:29 +0200 Subject: [PATCH 06/11] 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 4140c0ad773925d5d7d2d3f4495bad567d3b9c9c Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:51:54 +0200 Subject: [PATCH 07/11] 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 e1f15a7caca64f0404664a9cc4b793d56856a7db Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Aug 2026 13:53:37 +0200 Subject: [PATCH 08/11] better comment --- packages/server-utils/src/orchestrion/runtime/register.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 086c6bd3662f..e486e4f49205 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -106,7 +106,7 @@ export function registerDiagnosticsChannelInjection(): void { 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.', + 'build-time instrumentation is active.', ); } else { warnRuntimeUnavailable( From da0de0d0e64352947b5f6382a9a5abda2844b790 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 28 Aug 2026 11:24:15 +0200 Subject: [PATCH 09/11] small fixes --- packages/server-utils/package.json | 1 + packages/server-utils/src/orchestrion/runtime/register.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index d95d222c5f3c..51360b990abf 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -99,6 +99,7 @@ "@sentry/core": "10.67.0" }, "devDependencies": { + "@apm-js-collab/code-transformer": "^0.18.1", "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4", "@apm-js-collab/tracing-hooks": "^0.13.0", "@types/node": "^18.19.1", diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index e486e4f49205..340bdaf066d4 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -64,8 +64,8 @@ function isTransformerTreeShaken(): boolean { /** * 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. + * SDK silently records no channel-based spans. Deduped via a a global marker (carrier.runtimeAvailable) + * so repeat calls (e.g. `init()` plus `--import`) warn at most once. */ function warnRuntimeUnavailable(message: string): void { consoleSandbox(() => { From 7bd9c469878d84848d58f7f5ad3e24b195c24388 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 28 Aug 2026 11:48:57 +0200 Subject: [PATCH 10/11] bump size limit --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index dfdbd49bfbe9..6ad5eb831484 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: '127 KB', disablePlugins: ['@size-limit/esbuild'], }, { From 2d039c7b50ea8d6f1b39923cf4179293785a8025 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 28 Aug 2026 11:50:06 +0200 Subject: [PATCH 11/11] fix test --- .../test/orchestrion/moduleInjectedTransform.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 581d9cf552fa..85ed0e6e11b6 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -3,6 +3,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import * as barrel from '../../src/index'; +import { SENTRY_INSTRUMENTATIONS } from '../../src/orchestrion/config'; import { CHANNEL_INTEGRATION_DEFINITIONS, subscriberExportForModule, @@ -28,17 +30,15 @@ describe('channel integration definitions', () => { expect(subscriberExportForModule('not-a-package')).toBeUndefined(); }); - it('references only real named exports of @sentry/server-utils', async () => { + it('references only real named exports of @sentry/server-utils', () => { // The injected snippet imports each factory from `@sentry/server-utils` // (the `DEFAULT_IMPORT_SPECIFIER`), so the export must exist on that entry. - const barrel = await import('../../src/index'); for (const { exportName } of CHANNEL_INTEGRATION_DEFINITIONS) { expect(typeof (barrel as Record)[exportName]).toBe('function'); } }); - it('covers every instrumented module that has a channel-subscriber integration', async () => { - const { SENTRY_INSTRUMENTATIONS } = await import('../../src/orchestrion/config'); + it('covers every instrumented module that has a channel-subscriber integration', () => { const configured = new Set(SENTRY_INSTRUMENTATIONS.map(c => c.module.name)); const defined = new Set(CHANNEL_INTEGRATION_DEFINITIONS.flatMap(d => d.modules as readonly string[]));