From 6d20ecf5d86533a308b4a1f4500a183d8f6276eb Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 9 Sep 2026 12:05:12 +0200 Subject: [PATCH 1/5] fix(node): Skip registration-only instrumentations in the runtime loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration-only orchestrion configs (native-channel libraries — ai v7, ioredis, @redis/client, mysql2, mongoose) carry a custom transform wired into the bundler plugins only. The runtime loader (`@sentry/server-runtime-injection` `register`) has no custom transforms, so transforming these modules threw `TypeError: transform is not a function`, which the diagnostics callback misreported as the always-on "`@sentry/server-runtime-injection` was bundled ... loads uninstrumented" warning — even though the libraries are correctly instrumented via their native channel (`setupOnce` / `waitForTracingChannelBinding`). Exclude registration-only configs from the runtime instrumentation set (`SENTRY_RUNTIME_INSTRUMENTATIONS`). This is lossless: at runtime the snippet would only trigger a no-op subscription to `orchestrion:*` channels these versions never publish. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server-runtime-injection/src/register.ts | 8 ++--- .../src/orchestrion/config/index.ts | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/server-runtime-injection/src/register.ts b/packages/server-runtime-injection/src/register.ts index 7c437e2980c0..9d130ab41c53 100644 --- a/packages/server-runtime-injection/src/register.ts +++ b/packages/server-runtime-injection/src/register.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'; import * as Module from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { SENTRY_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config'; +import { SENTRY_RUNTIME_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config'; import type { register } from 'node:module'; import ModulePatch from '@apm-js-collab/tracing-hooks'; import { initialize, load, resolve, createDiagnosticsPort } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'; @@ -164,7 +164,7 @@ export function registerDiagnosticsChannelInjection(): void { // incompatibility) we warn and continue without channel injection. try { if (typeof mod.registerHooks === 'function' && stableSyncHooks) { - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); + initialize({ instrumentations: SENTRY_RUNTIME_INSTRUMENTATIONS }); mod.registerHooks({ resolve, load }); debug.log('Registered diagnostics-channel injection via Module.registerHooks()'); } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { @@ -223,7 +223,7 @@ export function registerDiagnosticsChannelInjection(): void { mod.register(hookSpecifier, { parentURL, - data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort }, + data: { instrumentations: SENTRY_RUNTIME_INSTRUMENTATIONS, diagnosticsPort }, transferList: [diagnosticsPort], }); @@ -232,7 +232,7 @@ export function registerDiagnosticsChannelInjection(): void { // are resolved through the CJS machinery and never reach the ESM // register hook, so without this patch the file we want to instrument // loads untransformed. - new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + new ModulePatch({ instrumentations: SENTRY_RUNTIME_INSTRUMENTATIONS }).patch(); debug.log('Registered diagnostics-channel injection via Module.register()'); } else { marker.runtimeUnavailable = true; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 1fec4fb2c5ad..f28ad60c56cb 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -35,6 +35,8 @@ import { vercelAiConfig } from './vercel-ai'; // Kept sorted alphabetically by module so concurrent additions insert at different // points rather than all appending to the end (fewer merge conflicts). +import { MODULE_REGISTRATION_TRANSFORM } from './registration-only'; + /** * The orchestrion code-transform configs. Every instrumentable library is here * so the transform is all-or-nothing: whenever orchestrion is enabled, all of @@ -81,6 +83,38 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...vercelAiConfig, ]; +/** + * The subset of {@link SENTRY_INSTRUMENTATIONS} the RUNTIME loader + * (`@sentry/server-runtime-injection`'s `register`, reached via `--import` or + * `Sentry.init()`) can actually apply. + * + * Registration-only configs (native-channel libraries such as `ai` v7, + * `ioredis`, `@redis/client`, `mysql2`, `mongoose`) carry the custom + * `MODULE_REGISTRATION_TRANSFORM` operator. That operator is wired into the + * BUNDLER plugins only (see `orchestrion/bundler/moduleInjectedTransform.ts`, + * applied via `bundler/options.ts`'s `customTransforms`); the runtime loader's + * `initialize()` receives no custom transforms. Attempting one of these at + * runtime therefore throws `TypeError: transform is not a function`, which the + * loader misreports as the always-on "`@sentry/server-runtime-injection` was + * bundled ... loads uninstrumented" warning even though nothing is wrong. + * + * Excluding them at runtime is correct, not just a way to silence the warning: + * these libraries publish their own tracing channels, and their integrations + * subscribe through `setupOnce()` / `waitForTracingChannelBinding`, + * independently of the module-injected snippet. That snippet only fires + * `orchestrion.module-injected`, which drives the `setup()` / + * `invokeOrchestrionInstrumentation` path; for a native-channel version that + * path subscribes to the injected `orchestrion:*` channels the library never + * publishes — a no-op. So running these at runtime would add no spans. The + * snippet earns its keep only on the BUNDLER path — notably bundler-only SDKs + * (e.g. `@sentry/cloudflare`) that discover a loaded module via that event to + * instantiate its integration factory. `@sentry/node` registers its + * integrations statically, so it does not need it. + */ +export const SENTRY_RUNTIME_INSTRUMENTATIONS: InstrumentationConfig[] = SENTRY_INSTRUMENTATIONS.filter( + config => config.transform !== MODULE_REGISTRATION_TRANSFORM, +); + /** * The unique set of package names instrumented by `SENTRY_INSTRUMENTATIONS` * merged with any caller-provided `instrumentations` (e.g. `['mysql']`). From 38f1c3ca1c5a9f0b652172029144d632c1a0a63b Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 9 Sep 2026 12:59:35 +0200 Subject: [PATCH 2/5] test(node): Cover registration-only runtime instrumentation exclusion - server-utils unit test: `SENTRY_RUNTIME_INSTRUMENTATIONS` drops every registration-only config and keeps the rest in order, and documents the affected native-channel modules (`@redis/client`, `ai`, `ioredis`, `mongoose`, `mysql2`), asserting the exclusion is per-config not per-module. - node-integration-test: with the runtime loader active, loading a native-channel library (`mysql2` >= 3.20) emits no "transform is not a function" / "server-runtime-injection was bundled" warning (`ensureNoErrorOutput`). This fails against the unfiltered set and passes with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../instrument.mjs | 9 ++++ .../scenario.mjs | 15 +++++++ .../orchestrion-registration-only/test.ts | 24 +++++++++++ .../test/orchestrion/config.test.ts | 42 +++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/scenario.mjs new file mode 100644 index 000000000000..aa9a5186fc82 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/scenario.mjs @@ -0,0 +1,15 @@ +// `mysql2` >= 3.20 has a registration-only orchestrion config (its tracing +// channels are native, so orchestrion doesn't wrap it — it only splices a +// module-registration snippet). Requiring `mysql2` loads its instrumented file +// (`lib/base/connection.js`), which drives the runtime module hook to transform +// it. +// +// The registration-only transform is wired into the bundler plugins only, so +// before the runtime instrumentation set excluded these configs, the runtime +// hook threw `TypeError: transform is not a function`, which the loader turned +// into an always-on "`@sentry/server-runtime-injection` was bundled ..." warning +// on stderr. `ensureNoErrorOutput` fails the test if that warning (or anything +// else) reaches stderr. +// +// A bare `import` is service-free — it never connects to a database. +await import('mysql2'); diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/test.ts b/dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/test.ts new file mode 100644 index 000000000000..6aa84f71fa74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-registration-only/test.ts @@ -0,0 +1,24 @@ +import * as path from 'path'; +import { afterAll, describe, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe('orchestrion registration-only modules at runtime', () => { + // A registration-only config (a native-channel library such as `mysql2` >= 3.20) + // carries the custom `MODULE_REGISTRATION_TRANSFORM`, which is wired into the + // bundler plugins only. The runtime loader excludes these + // (`SENTRY_RUNTIME_INSTRUMENTATIONS`), so loading such a module must not attempt + // an unavailable transform — which would surface as `TypeError: transform is + // not a function` and the always-on "`@sentry/server-runtime-injection` was + // bundled ..." warning on stderr. `ensureNoErrorOutput` fails on any stderr. + test('loads a native-channel library without the transformer-unavailable warning', async () => { + await createRunner(__dirname, 'scenario.mjs') + .withInstrument(path.join(__dirname, 'instrument.mjs')) + .ensureNoErrorOutput() + .start() + .completed(); + }); +}); diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index ae5899cafdb4..0110876d6f8d 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -4,9 +4,11 @@ import { INSTRUMENTED_MODULE_NAMES, instrumentedModuleNames, SENTRY_INSTRUMENTATIONS, + SENTRY_RUNTIME_INSTRUMENTATIONS, withoutInstrumentedExternals, } from '../../src/orchestrion/config'; import { CHANNEL_INTEGRATION_DEFINITIONS } from '../../src/orchestrion/config/channel-integration-definitions'; +import { MODULE_REGISTRATION_TRANSFORM } from '../../src/orchestrion/config/registration-only'; describe('orchestrion config — scoped @hapi/hapi module', () => { it('includes the scoped @hapi/hapi name in INSTRUMENTED_MODULE_NAMES', () => { @@ -55,3 +57,43 @@ describe('orchestrion config — custom instrumentations', () => { expect(withoutInstrumentedExternals(external)).toEqual(['react', 'my-lib']); }); }); + +describe('orchestrion config — SENTRY_RUNTIME_INSTRUMENTATIONS', () => { + // The runtime loader has no custom transforms, so a registration-only config + // (its `transform` is the bundler-only `MODULE_REGISTRATION_TRANSFORM`) throws + // `transform is not a function` there. These are excluded from the runtime set; + // the bundler keeps the full `SENTRY_INSTRUMENTATIONS`. + it('drops every registration-only config and keeps the rest in order', () => { + // The exclusion is only meaningful if there are registration-only configs to drop. + const registrationOnly = SENTRY_INSTRUMENTATIONS.filter(c => c.transform === MODULE_REGISTRATION_TRANSFORM); + expect(registrationOnly.length).toBeGreaterThan(0); + + // None survive into the runtime set... + expect(SENTRY_RUNTIME_INSTRUMENTATIONS.some(c => c.transform === MODULE_REGISTRATION_TRANSFORM)).toBe(false); + + // ...while every other config is preserved, unchanged and in order. + expect(SENTRY_RUNTIME_INSTRUMENTATIONS).toEqual( + SENTRY_INSTRUMENTATIONS.filter(c => c.transform !== MODULE_REGISTRATION_TRANSFORM), + ); + }); + + it('excludes only the native-channel modules, and only via their registration-only config', () => { + const registrationOnlyModules = [ + ...new Set( + SENTRY_INSTRUMENTATIONS.filter(c => c.transform === MODULE_REGISTRATION_TRANSFORM).map(c => c.module.name), + ), + ].sort(); + + // Documents which libraries carry a native-channel (registration-only) + // config today. Update deliberately when one is added or removed — it changes + // what the runtime loader skips. + expect(registrationOnlyModules).toEqual(['@redis/client', 'ai', 'ioredis', 'mongoose', 'mysql2']); + + // The exclusion is per-config, not per-module: a module with both a + // registration-only (native) config and older transform-based configs keeps + // the latter at runtime. `ai` (v7 native + v4–6 transforms) is one such case. + const runtimeAiConfigs = SENTRY_RUNTIME_INSTRUMENTATIONS.filter(c => c.module.name === 'ai'); + expect(runtimeAiConfigs.length).toBeGreaterThan(0); + expect(runtimeAiConfigs.every(c => c.transform !== MODULE_REGISTRATION_TRANSFORM)).toBe(true); + }); +}); From 0bf78f83fb6873575a4df014c93b967f19dfc13d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 9 Sep 2026 12:59:54 +0200 Subject: [PATCH 3/5] adjust test --- packages/server-utils/test/orchestrion/config.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index 0110876d6f8d..f49ded3ee4a5 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -84,10 +84,7 @@ describe('orchestrion config — SENTRY_RUNTIME_INSTRUMENTATIONS', () => { ), ].sort(); - // Documents which libraries carry a native-channel (registration-only) - // config today. Update deliberately when one is added or removed — it changes - // what the runtime loader skips. - expect(registrationOnlyModules).toEqual(['@redis/client', 'ai', 'ioredis', 'mongoose', 'mysql2']); + expect(registrationOnlyModules).toContain(['@redis/client', 'ai', 'ioredis', 'mongoose', 'mysql2']); // The exclusion is per-config, not per-module: a module with both a // registration-only (native) config and older transform-based configs keeps From 9ca2f1a0739ef659112e2bf8a59e4a7a0c4e1f8d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 9 Sep 2026 13:04:37 +0200 Subject: [PATCH 4/5] test(node): Assert registration-only modules as a subset, not exact `.toContain([...])` checks for the whole array as a single member, so it never matched. Use `arrayContaining` so the assertion documents the known native-channel modules without breaking when another such library is added. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server-utils/test/orchestrion/config.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index f49ded3ee4a5..891252dae9eb 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -84,7 +84,12 @@ describe('orchestrion config — SENTRY_RUNTIME_INSTRUMENTATIONS', () => { ), ].sort(); - expect(registrationOnlyModules).toContain(['@redis/client', 'ai', 'ioredis', 'mongoose', 'mysql2']); + // These native-channel libraries are known to use a registration-only config + // today. Asserted as a subset (not the exact set) so adding another such + // library does not break this test. + expect(registrationOnlyModules).toEqual( + expect.arrayContaining(['@redis/client', 'ai', 'ioredis', 'mongoose', 'mysql2']), + ); // The exclusion is per-config, not per-module: a module with both a // registration-only (native) config and older transform-based configs keeps From 4e66dcb05317e1baa8c89c4388d334e0d2fb7980 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 11 Sep 2026 09:41:37 +0200 Subject: [PATCH 5/5] pr feedback --- .../src/orchestrion/config/index.ts | 4 +-- .../test/orchestrion/config.test.ts | 28 +++++++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index f28ad60c56cb..271f13600536 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -35,8 +35,6 @@ import { vercelAiConfig } from './vercel-ai'; // Kept sorted alphabetically by module so concurrent additions insert at different // points rather than all appending to the end (fewer merge conflicts). -import { MODULE_REGISTRATION_TRANSFORM } from './registration-only'; - /** * The orchestrion code-transform configs. Every instrumentable library is here * so the transform is all-or-nothing: whenever orchestrion is enabled, all of @@ -112,7 +110,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ * integrations statically, so it does not need it. */ export const SENTRY_RUNTIME_INSTRUMENTATIONS: InstrumentationConfig[] = SENTRY_INSTRUMENTATIONS.filter( - config => config.transform !== MODULE_REGISTRATION_TRANSFORM, + config => !config.transform, ); /** diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index 891252dae9eb..73fc5167b86f 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -63,18 +63,24 @@ describe('orchestrion config — SENTRY_RUNTIME_INSTRUMENTATIONS', () => { // (its `transform` is the bundler-only `MODULE_REGISTRATION_TRANSFORM`) throws // `transform is not a function` there. These are excluded from the runtime set; // the bundler keeps the full `SENTRY_INSTRUMENTATIONS`. - it('drops every registration-only config and keeps the rest in order', () => { - // The exclusion is only meaningful if there are registration-only configs to drop. - const registrationOnly = SENTRY_INSTRUMENTATIONS.filter(c => c.transform === MODULE_REGISTRATION_TRANSFORM); - expect(registrationOnly.length).toBeGreaterThan(0); + it('only keeps configs the transform-less runtime loader can actually apply', () => { + // The filter is only meaningful if some configs carry a custom transform to drop. + expect(SENTRY_INSTRUMENTATIONS.some(c => c.transform)).toBe(true); - // None survive into the runtime set... - expect(SENTRY_RUNTIME_INSTRUMENTATIONS.some(c => c.transform === MODULE_REGISTRATION_TRANSFORM)).toBe(false); + // The invariant: the runtime loader registers no custom transforms, so any config + // carrying one (not just MODULE_REGISTRATION_TRANSFORM) throws there. Every runtime + // config must therefore be transform-less — this is what we need to hold even if a + // future feature adds a different named transform. + expect(SENTRY_RUNTIME_INSTRUMENTATIONS.every(c => !c.transform)).toBe(true); + }); - // ...while every other config is preserved, unchanged and in order. - expect(SENTRY_RUNTIME_INSTRUMENTATIONS).toEqual( - SENTRY_INSTRUMENTATIONS.filter(c => c.transform !== MODULE_REGISTRATION_TRANSFORM), - ); + it('keeps every transform-less config, dropping only the ones with a custom transform', () => { + // Nothing the runtime can apply is lost: each transform-less config from the full + // set survives (by reference), and the runtime set adds nothing extra. + for (const config of SENTRY_INSTRUMENTATIONS.filter(c => !c.transform)) { + expect(SENTRY_RUNTIME_INSTRUMENTATIONS).toContain(config); + } + expect(SENTRY_RUNTIME_INSTRUMENTATIONS).toHaveLength(SENTRY_INSTRUMENTATIONS.filter(c => !c.transform).length); }); it('excludes only the native-channel modules, and only via their registration-only config', () => { @@ -96,6 +102,6 @@ describe('orchestrion config — SENTRY_RUNTIME_INSTRUMENTATIONS', () => { // the latter at runtime. `ai` (v7 native + v4–6 transforms) is one such case. const runtimeAiConfigs = SENTRY_RUNTIME_INSTRUMENTATIONS.filter(c => c.module.name === 'ai'); expect(runtimeAiConfigs.length).toBeGreaterThan(0); - expect(runtimeAiConfigs.every(c => c.transform !== MODULE_REGISTRATION_TRANSFORM)).toBe(true); + expect(runtimeAiConfigs.every(c => !c.transform)).toBe(true); }); });