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-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..271f13600536 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -81,6 +81,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, +); + /** * The unique set of package names instrumented by `SENTRY_INSTRUMENTATIONS` * merged with any caller-provided `instrumentations` (e.g. `['mysql']`). diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index ae5899cafdb4..73fc5167b86f 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,51 @@ 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('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); + + // 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); + }); + + 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', () => { + const registrationOnlyModules = [ + ...new Set( + SENTRY_INSTRUMENTATIONS.filter(c => c.transform === MODULE_REGISTRATION_TRANSFORM).map(c => c.module.name), + ), + ].sort(); + + // 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 + // 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)).toBe(true); + }); +});