diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 42a7ffdfaec4..396c98d952a2 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -12,6 +12,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Integration } from '../types/integration'; import type { Carrier } from '../carrier'; import type { SdkSource } from './env'; @@ -63,6 +64,14 @@ export type InternalGlobal = { runtime?: string[]; /** Empty array signifies bundler plugin ran */ bundler?: string[]; + /** + * Channel-subscriber integration factories a bundler plugin's + * subscribe-injection stored here, keyed by export name (one per instrumented + * package actually bundled; the key dedupes packages split across several + * files). A bundler-only SDK (e.g. `@sentry/cloudflare`) reads these at + * `init()` and instantiates them. + */ + integrations?: Map Integration>; }; } & Carrier; diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index b38501033e57..e2eaf9768654 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -119,7 +119,8 @@ "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0" + "@sentry/core": "10.67.0", + "meriyah": "^6.1.4" }, "devDependencies": { "@types/node": "^18.19.1", diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index a3c194aae165..7776c878bbf3 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -1,5 +1,6 @@ import type { InstrumentationConfig, CustomTransform } from '..'; import { SENTRY_INSTRUMENTATIONS } from '../config'; +import { subscribeInjectionOptions } from './subscribeInjection'; import type { CodeTransformerPluginOptions } from '@apm-js-collab/code-transformer-bundler-plugins/core'; export type PluginOptions = { @@ -17,6 +18,26 @@ export type PluginOptions = { * Defaults to `true`. */ shouldInjectDiagnostics?: boolean; + /** + * Inject a small marker-push into each instrumented module that imports only + * that package's channel-subscriber factory and pushes it onto + * `globalThis.__SENTRY_ORCHESTRION__.integrations`. A bundler-only SDK reads + * the marker at `init()` and instantiates the collected factories, so every + * transformed package's subscriber is wired up with no runtime module hook. + * + * Because each site imports a single named factory, it tree-shakes: a bundle + * carries subscriber code only for the packages actually transformed into it. + * + * This is what lets a bundler-only SDK (e.g. `@sentry/cloudflare`, which runs + * in workerd where requires can't be monkey-patched) record channel spans, + * but it is bundler-agnostic: any orchestrion bundler plugin can enable it. + * Leave it off for SDKs that wire the integrations up through a static import + * instead (e.g. `@sentry/node`'s `experimentalUseDiagnosticsChannelInjection()`), + * so the subscribers aren't registered twice. + * + * Defaults to `false`. + */ + injectChannelSubscribers?: boolean; }; /** @@ -53,8 +74,14 @@ export function externalizedModulesWarning(externalizedModules: string[]): strin * visible to the runtime). */ export function orchestrionTransformOptions(options: PluginOptions): CodeTransformerPluginOptions { - const instrumentations = [...SENTRY_INSTRUMENTATIONS, ...(options.instrumentations || [])]; - const customTransforms = options.customTransforms; + const subscribeInjection = options.injectChannelSubscribers ? subscribeInjectionOptions() : undefined; + + const instrumentations = [ + ...SENTRY_INSTRUMENTATIONS, + ...(options.instrumentations || []), + ...(subscribeInjection?.instrumentations || []), + ]; + const customTransforms = { ...options.customTransforms, ...subscribeInjection?.customTransforms }; if (options.shouldInjectDiagnostics === false) { return { diff --git a/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts b/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts new file mode 100644 index 000000000000..9bb89257ab89 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts @@ -0,0 +1,87 @@ +import type { CustomTransform } from '@apm-js-collab/code-transformer'; +import { parse } from 'meriyah'; +import { SUBSCRIBE_INJECTIONS } from '../config'; +import { subscriberExportForModule } from '../config/channel-integration-definitions'; +import { SUBSCRIBE_TRANSFORM_NAME } from '../config/subscribe-injection'; +import type { PluginOptions } from './options'; + +// Tracks Program nodes we already injected into, so a package with several +// instrumented files (or several configs pointing at one file) is injected only +// once per file. A `WeakSet` keyed by the node avoids mutating the emitted AST. +const injectedPrograms = new WeakSet(); + +interface ProgramNode { + type: string; + body: Array<{ type: string; directive?: string }>; +} + +/** + * Snippet injected into each instrumented module. It imports ONLY that package's + * channel-subscriber factory (plus the `registerOrchestrionChannelIntegration` + * helper) from `@sentry/server-utils/orchestrion`, and hands both to the helper, + * which stores the factory on the global marker and live-registers it on any + * existing client (see that helper for the load-order and dedup rationale). + * + * Importing the single named factory (rather than a central dispatch that pulls + * in every subscriber) is what makes this tree-shake: a bundle carries only the + * subscriber code for packages actually transformed into it. The same + * "only-active-when-bundled" property the runtime module hook gives unbundled + * Node, but without a hook (workerd can't monkey-patch requires). The helper is + * generic (references no factory), so importing it alongside doesn't pull siblings. + */ +function subscribeSnippet(exportName: string, esm: boolean): string { + const importStmt = esm + ? `import { ${exportName}, registerOrchestrionChannelIntegration } from '@sentry/server-utils/orchestrion';` + : `const { ${exportName}, registerOrchestrionChannelIntegration } = require('@sentry/server-utils/orchestrion');`; + + return `${importStmt}\nregisterOrchestrionChannelIntegration(${JSON.stringify(exportName)}, ${exportName});`; +} + +/** + * The custom transform registered under {@link SUBSCRIBE_TRANSFORM_NAME}. It is + * invoked with the matched `Program` node and mutates it in place, splicing the + * marker-push snippet in after any `'use strict'` directive. + * + * `state` carries the matched config spread with `{ moduleType }`; the config's + * `channelName` carries the package name (see `toSubscribeInjections`), which + * maps to the subscriber's export name. + */ +const injectSubscribe: CustomTransform = (state, program) => { + const node = program as ProgramNode; + if (injectedPrograms.has(node)) { + return; + } + + const { moduleType, channelName } = state as { moduleType?: string; channelName?: string }; + const exportName = channelName ? subscriberExportForModule(channelName) : undefined; + if (!exportName) { + return; + } + + injectedPrograms.add(node); + + const statements = parse(subscribeSnippet(exportName, moduleType === 'esm'), { + module: moduleType === 'esm', + next: true, + }).body as ProgramNode['body']; + + const directiveIndex = node.body.findIndex(n => n.type === 'ExpressionStatement' && n.directive === 'use strict'); + node.body.splice(directiveIndex + 1, 0, ...statements); +}; + +/** + * The `instrumentations` + `customTransforms` a bundler plugin passes to + * {@link orchestrionTransformOptions} to enable the marker-push subscribe + * injection used by bundler-only SDKs (e.g. `@sentry/cloudflare`). + * + * The `SUBSCRIBE_INJECTIONS` configs ride alongside the real channel-publishing + * configs, and `injectSubscribe` runs on each matched module, so every + * transformed package self-registers its subscriber on the global marker + * without a runtime module hook. + */ +export function subscribeInjectionOptions(): Pick { + return { + instrumentations: SUBSCRIBE_INJECTIONS, + customTransforms: { [SUBSCRIBE_TRANSFORM_NAME]: injectSubscribe }, + }; +} diff --git a/packages/server-utils/src/orchestrion/config/amqplib.ts b/packages/server-utils/src/orchestrion/config/amqplib.ts index 5f2082859347..dea498f607c9 100644 --- a/packages/server-utils/src/orchestrion/config/amqplib.ts +++ b/packages/server-utils/src/orchestrion/config/amqplib.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // `amqplib` splits its API across three files: // - `lib/channel_model.js` holds `class Channel` (publish/consume/ack/nack/reject/…) and @@ -87,3 +88,5 @@ export const amqplibChannels = { AMQPLIB_NACK_ALL: 'orchestrion:amqplib:nackAll', AMQPLIB_CONNECT: 'orchestrion:amqplib:connect', } as const; + +export const amqplibSubscribeInjection = toSubscribeInjections(amqplibConfig); diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts index 0b5f0e0ecf10..202fd538eabc 100644 --- a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts +++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const anthropicAiConfig = [ // One entry each for CJS/ESM @@ -38,3 +39,5 @@ export const anthropicAiChannels = { ANTHROPIC_MODELS: 'orchestrion:@anthropic-ai/sdk:models', ANTHROPIC_MESSAGES_STREAM: 'orchestrion:@anthropic-ai/sdk:messages-stream', } as const; + +export const anthropicAiSubscribeInjection = toSubscribeInjections(anthropicAiConfig); diff --git a/packages/server-utils/src/orchestrion/config/aws-sdk.ts b/packages/server-utils/src/orchestrion/config/aws-sdk.ts index fdcc4d9e5682..d9e6bf35726d 100644 --- a/packages/server-utils/src/orchestrion/config/aws-sdk.ts +++ b/packages/server-utils/src/orchestrion/config/aws-sdk.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { toSubscribeInjections } from './subscribe-injection'; // The AWS SDK (v3) routes every command through the smithy `Client.prototype.send` method. Which // package hosts that `Client` class changed across versions, so we target all of them; only the one @@ -32,3 +33,5 @@ export const awsSdkChannels = { AWS_SMITHY_CLIENT_SEND: 'orchestrion:@smithy/smithy-client:send', AWS_SDK_SMITHY_CLIENT_SEND: 'orchestrion:@aws-sdk/smithy-client:send', } as const; + +export const awsSdkSubscribeInjection = toSubscribeInjections(awsSdkConfig); diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts new file mode 100644 index 000000000000..649cb816e837 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -0,0 +1,48 @@ +/** + * Build-time metadata mapping each instrumented package (orchestrion + * `module.name`) to the channel-subscriber integration that consumes the + * channels injected into it — by the `exportName` it is published under from + * `@sentry/server-utils/orchestrion`. + * + * Kept in a separate, factory-free module on purpose: the subscribe-injection + * transform (reachable from every orchestrion bundler plugin) reads this to + * generate the tiny snippet it injects into each instrumented file, and must + * not drag any subscriber code — or its `@sentry/core` span machinery — into + * the plugin's own build to do so. + * + * `exportName` must be a named export of `@sentry/server-utils/orchestrion`. + * `modules` must match `module.name` values in `SENTRY_INSTRUMENTATIONS` — e.g. + * `postgresChannelIntegration` covers both `pg` and `pg-pool`, and + * `redisChannelIntegration` both `redis` and `@redis/client`. + * + * `redis`, `ioredis` and `dataloader` are included even though they're not in + * the node SDK's `channelIntegrations` (they only partially replace an OTel + * integration there): in a bundler-only runtime like Cloudflare Workers there + * is no OTel integration to coordinate with, so subscribing whenever the + * package is bundled is unconditionally correct. + */ +export const CHANNEL_INTEGRATION_DEFINITIONS = [ + { exportName: 'postgresChannelIntegration', modules: ['pg', 'pg-pool'] }, + { exportName: 'postgresJsChannelIntegration', modules: ['postgres'] }, + { exportName: 'mysqlChannelIntegration', modules: ['mysql'] }, + { exportName: 'mysql2ChannelIntegration', modules: ['mysql2'] }, + { exportName: 'genericPoolChannelIntegration', modules: ['generic-pool'] }, + { exportName: 'lruMemoizerChannelIntegration', modules: ['lru-memoizer'] }, + { exportName: 'openaiChannelIntegration', modules: ['openai'] }, + { exportName: 'anthropicChannelIntegration', modules: ['@anthropic-ai/sdk'] }, + { exportName: 'googleGenAIChannelIntegration', modules: ['@google/genai'] }, + { exportName: 'vercelAiChannelIntegration', modules: ['ai'] }, + { exportName: 'amqplibChannelIntegration', modules: ['amqplib'] }, + { exportName: 'hapiChannelIntegration', modules: ['@hapi/hapi'] }, + { exportName: 'expressChannelIntegration', modules: ['express', 'router'] }, + { exportName: 'graphqlChannelIntegration', modules: ['graphql'] }, + { exportName: 'kafkajsChannelIntegration', modules: ['kafkajs'] }, + { exportName: 'redisChannelIntegration', modules: ['redis', '@redis/client'] }, + { exportName: 'ioredisChannelIntegration', modules: ['ioredis'] }, + { exportName: 'dataloaderChannelIntegration', modules: ['dataloader'] }, +] as const satisfies ReadonlyArray<{ exportName: string; modules: readonly string[] }>; + +/** Look up the subscriber export name for an instrumented package, if any. */ +export function subscriberExportForModule(moduleName: string): string | undefined { + return CHANNEL_INTEGRATION_DEFINITIONS.find(d => (d.modules as readonly string[]).includes(moduleName))?.exportName; +} diff --git a/packages/server-utils/src/orchestrion/config/dataloader.ts b/packages/server-utils/src/orchestrion/config/dataloader.ts index cd1f0879bdc3..58cc3b7ba621 100644 --- a/packages/server-utils/src/orchestrion/config/dataloader.ts +++ b/packages/server-utils/src/orchestrion/config/dataloader.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // `dataloader` ships a single transpiled CommonJS `index.js`. Its class methods are emitted as // `_proto. = function () {}` (named function *expressions*), so they match on @@ -53,3 +54,5 @@ export const dataloaderChannels = { DATALOADER_CLEAR: 'orchestrion:dataloader:clear', DATALOADER_CLEAR_ALL: 'orchestrion:dataloader:clearAll', } as const; + +export const dataloaderSubscribeInjection = toSubscribeInjections(dataloaderConfig); diff --git a/packages/server-utils/src/orchestrion/config/express.ts b/packages/server-utils/src/orchestrion/config/express.ts index 81fe89947e3d..0c349454184f 100644 --- a/packages/server-utils/src/orchestrion/config/express.ts +++ b/packages/server-utils/src/orchestrion/config/express.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const expressConfig = [ // Express funnels every middleware/route handler through a single method on @@ -72,3 +73,5 @@ export const expressChannels = { EXPRESS_REGISTER: 'orchestrion:express:register', ROUTER_REGISTER: 'orchestrion:router:register', } as const; + +export const expressSubscribeInjection = toSubscribeInjections(expressConfig); diff --git a/packages/server-utils/src/orchestrion/config/firebase.ts b/packages/server-utils/src/orchestrion/config/firebase.ts index fa8606eb1ff6..3bc9d9287274 100644 --- a/packages/server-utils/src/orchestrion/config/firebase.ts +++ b/packages/server-utils/src/orchestrion/config/firebase.ts @@ -1,6 +1,9 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // TODO: Stub for the `firebase` orchestrion integration (ports `FirebaseInstrumentation`). export const firebaseConfig: InstrumentationConfig[] = []; export const firebaseChannels = {} as const; + +export const firebaseSubscribeInjection = toSubscribeInjections(firebaseConfig); diff --git a/packages/server-utils/src/orchestrion/config/generic-pool.ts b/packages/server-utils/src/orchestrion/config/generic-pool.ts index 724451207ea2..cb3f2f64d524 100644 --- a/packages/server-utils/src/orchestrion/config/generic-pool.ts +++ b/packages/server-utils/src/orchestrion/config/generic-pool.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // Two shapes of `acquire`, both publishing to the same `orchestrion:generic-pool:acquire` channel: // - v3+: `class Pool { acquire(priority) }` returns a promise, so `kind: 'Auto'` resolves to `wrapPromise`. @@ -21,3 +22,5 @@ export const genericPoolConfig = [ export const genericPoolChannels = { GENERIC_POOL_ACQUIRE: 'orchestrion:generic-pool:acquire', } as const; + +export const genericPoolSubscribeInjection = toSubscribeInjections(genericPoolConfig); diff --git a/packages/server-utils/src/orchestrion/config/google-genai.ts b/packages/server-utils/src/orchestrion/config/google-genai.ts index 693e8ba039bc..8426f2306e0d 100644 --- a/packages/server-utils/src/orchestrion/config/google-genai.ts +++ b/packages/server-utils/src/orchestrion/config/google-genai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // `@google/genai` ships one bundled file per module format and the matcher compares `filePath` exactly, // so we list every file the `node` export condition resolves to across the supported range: `index.js` @@ -38,3 +39,5 @@ export const googleGenAiChannels = { GOOGLE_GENAI_EMBED_CONTENT: 'orchestrion:@google/genai:embed-content', GOOGLE_GENAI_CHAT: 'orchestrion:@google/genai:chat', } as const; + +export const googleGenAiSubscribeInjection = toSubscribeInjections(googleGenAiConfig); diff --git a/packages/server-utils/src/orchestrion/config/graphql.ts b/packages/server-utils/src/orchestrion/config/graphql.ts index 13257755934c..3517ea137626 100644 --- a/packages/server-utils/src/orchestrion/config/graphql.ts +++ b/packages/server-utils/src/orchestrion/config/graphql.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // `parse`/`validate`/`execute` are top-level named `function` declarations in graphql's compiled // files, stable across the supported majors, so `functionName` matches. `execute` returns @@ -26,3 +27,5 @@ export const graphqlChannels = { GRAPHQL_VALIDATE: 'orchestrion:graphql:validate', GRAPHQL_EXECUTE: 'orchestrion:graphql:execute', } as const; + +export const graphqlSubscribeInjection = toSubscribeInjections(graphqlConfig); diff --git a/packages/server-utils/src/orchestrion/config/hapi.ts b/packages/server-utils/src/orchestrion/config/hapi.ts index 7c11aac6911a..94752b564666 100644 --- a/packages/server-utils/src/orchestrion/config/hapi.ts +++ b/packages/server-utils/src/orchestrion/config/hapi.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const hapiConfig = [ // hapi's `route`/`ext` live on an anonymous class (`internals.Server = class {}`), @@ -21,3 +22,5 @@ export const hapiChannels = { HAPI_ROUTE: 'orchestrion:@hapi/hapi:route', HAPI_EXT: 'orchestrion:@hapi/hapi:ext', } as const; + +export const hapiSubscribeInjection = toSubscribeInjections(hapiConfig); diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index a7c3cd159b75..cc0522ac6b4e 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -1,35 +1,35 @@ import type { InstrumentationConfig } from '..'; import { uniq } from '@sentry/core'; -import { amqplibConfig } from './amqplib'; -import { anthropicAiConfig } from './anthropic-ai'; -import { awsSdkConfig } from './aws-sdk'; -import { dataloaderConfig } from './dataloader'; -import { expressConfig } from './express'; -import { firebaseConfig } from './firebase'; -import { genericPoolConfig } from './generic-pool'; -import { googleGenAiConfig } from './google-genai'; -import { graphqlConfig } from './graphql'; -import { hapiConfig } from './hapi'; -import { ioredisConfig } from './ioredis'; -import { kafkajsConfig } from './kafkajs'; -import { knexConfig } from './knex'; -import { koaConfig } from './koa'; -import { langchainConfig } from './langchain'; -import { langgraphConfig } from './langgraph'; -import { lruMemoizerConfig } from './lru-memoizer'; -import { mongodbConfig } from './mongodb'; -import { mongooseConfig } from './mongoose'; -import { mysql2Config } from './mysql2'; -import { mysqlConfig } from './mysql'; -import { nestjsConfig } from './nestjs'; -import { openaiConfig } from './openai'; -import { pgConfig } from './pg'; -import { postgresJsConfig } from './postgres'; -import { redisConfig } from './redis'; -import { remixConfig } from './remix'; -import { tediousConfig } from './tedious'; -import { vercelAiConfig } from './vercel-ai'; +import { awsSdkConfig, awsSdkSubscribeInjection } from './aws-sdk'; +import { amqplibConfig, amqplibSubscribeInjection } from './amqplib'; +import { anthropicAiConfig, anthropicAiSubscribeInjection } from './anthropic-ai'; +import { dataloaderConfig, dataloaderSubscribeInjection } from './dataloader'; +import { expressConfig, expressSubscribeInjection } from './express'; +import { firebaseConfig, firebaseSubscribeInjection } from './firebase'; +import { genericPoolConfig, genericPoolSubscribeInjection } from './generic-pool'; +import { googleGenAiConfig, googleGenAiSubscribeInjection } from './google-genai'; +import { graphqlConfig, graphqlSubscribeInjection } from './graphql'; +import { hapiConfig, hapiSubscribeInjection } from './hapi'; +import { ioredisConfig, ioredisSubscribeInjection } from './ioredis'; +import { kafkajsConfig, kafkajsSubscribeInjection } from './kafkajs'; +import { knexConfig, knexSubscribeInjection } from './knex'; +import { koaConfig, koaSubscribeInjection } from './koa'; +import { langchainConfig, langchainSubscribeInjection } from './langchain'; +import { langgraphConfig, langgraphSubscribeInjection } from './langgraph'; +import { lruMemoizerConfig, lruMemoizerSubscribeInjection } from './lru-memoizer'; +import { mongodbConfig, mongodbSubscribeInjection } from './mongodb'; +import { mongooseConfig, mongooseSubscribeInjection } from './mongoose'; +import { mysql2Config, mysql2SubscribeInjection } from './mysql2'; +import { mysqlConfig, mysqlSubscribeInjection } from './mysql'; +import { nestjsConfig, nestjsSubscribeInjection } from './nestjs'; +import { openaiConfig, openaiSubscribeInjection } from './openai'; +import { pgConfig, pgSubscribeInjection } from './pg'; +import { postgresJsConfig, postgresJsSubscribeInjection } from './postgres'; +import { redisConfig, redisSubscribeInjection } from './redis'; +import { remixConfig, remixSubscribeInjection } from './remix'; +import { tediousConfig, tediousSubscribeInjection } from './tedious'; +import { vercelAiConfig, vercelAiSubscribeInjection } 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). @@ -72,6 +72,49 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...vercelAiConfig, ]; +/** + * The `Program`-matching injection configs that make each instrumented file + * self-register its channel subscriber at load (used by bundler-only SDKs like + * `@sentry/cloudflare`). + * + * Deliberately separate from `SENTRY_INSTRUMENTATIONS`: these reference a custom + * transform that only the opted-in bundler plugin registers, so feeding them to + * the runtime `--import` hook (which can't register it) would make the + * code-transformer drop the whole file. Each library owns its own + * `*SubscribeInjection` (derived from its channel configs), collected here. + */ +export const SUBSCRIBE_INJECTIONS: InstrumentationConfig[] = [ + ...amqplibSubscribeInjection, + ...anthropicAiSubscribeInjection, + ...awsSdkSubscribeInjection, + ...dataloaderSubscribeInjection, + ...expressSubscribeInjection, + ...firebaseSubscribeInjection, + ...genericPoolSubscribeInjection, + ...googleGenAiSubscribeInjection, + ...graphqlSubscribeInjection, + ...hapiSubscribeInjection, + ...ioredisSubscribeInjection, + ...kafkajsSubscribeInjection, + ...knexSubscribeInjection, + ...koaSubscribeInjection, + ...langchainSubscribeInjection, + ...langgraphSubscribeInjection, + ...lruMemoizerSubscribeInjection, + ...mongodbSubscribeInjection, + ...mongooseSubscribeInjection, + ...mysql2SubscribeInjection, + ...mysqlSubscribeInjection, + ...nestjsSubscribeInjection, + ...openaiSubscribeInjection, + ...pgSubscribeInjection, + ...postgresJsSubscribeInjection, + ...redisSubscribeInjection, + ...remixSubscribeInjection, + ...tediousSubscribeInjection, + ...vercelAiSubscribeInjection, +]; + /** * 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/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts index 35d2d44cd79b..5755759c549b 100644 --- a/packages/server-utils/src/orchestrion/config/ioredis.ts +++ b/packages/server-utils/src/orchestrion/config/ioredis.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const ioredisConfig = [ // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) @@ -30,3 +31,5 @@ export const ioredisChannels = { IOREDIS_COMMAND: 'orchestrion:ioredis:command', IOREDIS_CONNECT: 'orchestrion:ioredis:connect', } as const; + +export const ioredisSubscribeInjection = toSubscribeInjections(ioredisConfig); diff --git a/packages/server-utils/src/orchestrion/config/kafkajs.ts b/packages/server-utils/src/orchestrion/config/kafkajs.ts index 0d653e0db0eb..0e42054b20b6 100644 --- a/packages/server-utils/src/orchestrion/config/kafkajs.ts +++ b/packages/server-utils/src/orchestrion/config/kafkajs.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const kafkajsConfig = [ { @@ -25,3 +26,5 @@ export const kafkajsChannels = { KAFKAJS_SEND_BATCH: 'orchestrion:kafkajs:send_batch', KAFKAJS_CONSUMER_RUN: 'orchestrion:kafkajs:consumer_run', } as const; + +export const kafkajsSubscribeInjection = toSubscribeInjections(kafkajsConfig); diff --git a/packages/server-utils/src/orchestrion/config/knex.ts b/packages/server-utils/src/orchestrion/config/knex.ts index 8c4c2d068563..aa407c5fc198 100644 --- a/packages/server-utils/src/orchestrion/config/knex.ts +++ b/packages/server-utils/src/orchestrion/config/knex.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; const MODULE_NAME = 'knex'; @@ -51,3 +52,5 @@ export const knexChannels = { KNEX_SCHEMA_BUILDER: 'orchestrion:knex:schemaBuilder', KNEX_RAW: 'orchestrion:knex:raw', } as const; + +export const knexSubscribeInjection = toSubscribeInjections(knexConfig); diff --git a/packages/server-utils/src/orchestrion/config/koa.ts b/packages/server-utils/src/orchestrion/config/koa.ts index b0d8ad516a37..8e4ddfab0fff 100644 --- a/packages/server-utils/src/orchestrion/config/koa.ts +++ b/packages/server-utils/src/orchestrion/config/koa.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { toSubscribeInjections } from './subscribe-injection'; export const koaConfig = [ { @@ -11,3 +12,5 @@ export const koaConfig = [ export const koaChannels = { KOA_USE: 'orchestrion:koa:use', } as const; + +export const koaSubscribeInjection = toSubscribeInjections(koaConfig); diff --git a/packages/server-utils/src/orchestrion/config/langchain.ts b/packages/server-utils/src/orchestrion/config/langchain.ts index df6247c91d1c..88a5addeb8c9 100644 --- a/packages/server-utils/src/orchestrion/config/langchain.ts +++ b/packages/server-utils/src/orchestrion/config/langchain.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // `@langchain/*` packages ship dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the // matcher compares `filePath` exactly, so each hook is declared once per built file. @@ -71,3 +72,5 @@ export const langchainChannels = { LANGCHAIN_CHAT_MODEL_INVOKE: 'orchestrion:@langchain/core:chatModelInvoke', LANGCHAIN_CHAT_MODEL_STREAM: 'orchestrion:@langchain/core:chatModelStream', } as const; + +export const langchainSubscribeInjection = toSubscribeInjections(langchainConfig); diff --git a/packages/server-utils/src/orchestrion/config/langgraph.ts b/packages/server-utils/src/orchestrion/config/langgraph.ts index 666e4ac3ef12..4daa0f8e3d34 100644 --- a/packages/server-utils/src/orchestrion/config/langgraph.ts +++ b/packages/server-utils/src/orchestrion/config/langgraph.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // `@langchain/langgraph` ships dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the // matcher compares `filePath` exactly, so each hook is declared once per built file. `StateGraph.compile` @@ -32,3 +33,5 @@ export const langgraphChannels = { LANGGRAPH_STATE_GRAPH_COMPILE: 'orchestrion:@langchain/langgraph:stateGraphCompile', LANGGRAPH_CREATE_REACT_AGENT: 'orchestrion:@langchain/langgraph:createReactAgent', } as const; + +export const langgraphSubscribeInjection = toSubscribeInjections(langgraphConfig); diff --git a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts index e186136d05d1..5193072808b9 100644 --- a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts +++ b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const lruMemoizerConfig = [ { @@ -12,3 +13,5 @@ export const lruMemoizerConfig = [ export const lruMemoizerChannels = { LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', } as const; + +export const lruMemoizerSubscribeInjection = toSubscribeInjections(lruMemoizerConfig); diff --git a/packages/server-utils/src/orchestrion/config/mongodb.ts b/packages/server-utils/src/orchestrion/config/mongodb.ts index ee44b79b0bba..45044530c5ef 100644 --- a/packages/server-utils/src/orchestrion/config/mongodb.ts +++ b/packages/server-utils/src/orchestrion/config/mongodb.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // The mongodb driver's command architecture changed across majors, mirrored in the vendored OTel // instrumentation's version bands: @@ -70,3 +71,5 @@ export const mongodbChannels = { MONGODB_V3_QUERY: 'orchestrion:mongodb:v3_query', MONGODB_V3_GET_MORE: 'orchestrion:mongodb:v3_get_more', } as const; + +export const mongodbSubscribeInjection = toSubscribeInjections(mongodbConfig); diff --git a/packages/server-utils/src/orchestrion/config/mongoose.ts b/packages/server-utils/src/orchestrion/config/mongoose.ts index efc952184ba1..4b7069c07d3a 100644 --- a/packages/server-utils/src/orchestrion/config/mongoose.ts +++ b/packages/server-utils/src/orchestrion/config/mongoose.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // mongoose >= 9.7.0 publishes via its own `node:diagnostics_channel` tracing channels (handled by // `subscribeMongooseDiagnosticChannels`), so this transform is gated to `< 9.7.0` to avoid emitting @@ -124,3 +125,5 @@ export const mongooseChannels = { export const MONGOOSE_CONTEXT_CAPTURE_CHANNELS: string[] = CONTEXT_CAPTURE_QUERY_METHODS.map( methodName => `orchestrion:mongoose:ctx_${methodName}`, ); + +export const mongooseSubscribeInjection = toSubscribeInjections(mongooseConfig); diff --git a/packages/server-utils/src/orchestrion/config/mysql.ts b/packages/server-utils/src/orchestrion/config/mysql.ts index c686f435a9f7..2a720b1fa189 100644 --- a/packages/server-utils/src/orchestrion/config/mysql.ts +++ b/packages/server-utils/src/orchestrion/config/mysql.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const mysqlConfig = [ { @@ -11,3 +12,5 @@ export const mysqlConfig = [ export const mysqlChannels = { MYSQL_QUERY: 'orchestrion:mysql:query', } as const; + +export const mysqlSubscribeInjection = toSubscribeInjections(mysqlConfig); diff --git a/packages/server-utils/src/orchestrion/config/mysql2.ts b/packages/server-utils/src/orchestrion/config/mysql2.ts index bac26e323053..f0d49bf530d4 100644 --- a/packages/server-utils/src/orchestrion/config/mysql2.ts +++ b/packages/server-utils/src/orchestrion/config/mysql2.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection // prototype) to orchestrion channel injection. @@ -46,3 +47,5 @@ export const mysql2Channels = { MYSQL2_QUERY: 'orchestrion:mysql2:query', MYSQL2_EXECUTE: 'orchestrion:mysql2:execute', } as const; + +export const mysql2SubscribeInjection = toSubscribeInjections(mysql2Config); diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index 44900addc39e..727eb6070a55 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; /** * Wrap an instrumentation that targets nodes via a raw esquery selector @@ -141,3 +142,5 @@ export const nestjsChannels = { NESTJS_ONEVENT: 'orchestrion:@nestjs/event-emitter:onEventDecorator', NESTJS_PROCESSOR: 'orchestrion:@nestjs/bullmq:processorDecorator', } as const; + +export const nestjsSubscribeInjection = toSubscribeInjections(nestjsConfig); diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index d29c12623e24..9055cf0bb1ab 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const openaiConfig = [ // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, @@ -35,3 +36,5 @@ export const openaiChannels = { OPENAI_CHAT: 'orchestrion:openai:chat', OPENAI_EMBEDDINGS: 'orchestrion:openai:embeddings', } as const; + +export const openaiSubscribeInjection = toSubscribeInjections(openaiConfig); diff --git a/packages/server-utils/src/orchestrion/config/pg.ts b/packages/server-utils/src/orchestrion/config/pg.ts index d000e423068a..9be137a02653 100644 --- a/packages/server-utils/src/orchestrion/config/pg.ts +++ b/packages/server-utils/src/orchestrion/config/pg.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const pgConfig = [ // `pg` (node-postgres). @@ -46,3 +47,5 @@ export const pgChannels = { PG_CONNECT: 'orchestrion:pg:connect', PGPOOL_CONNECT: 'orchestrion:pg-pool:connect', } as const; + +export const pgSubscribeInjection = toSubscribeInjections(pgConfig); diff --git a/packages/server-utils/src/orchestrion/config/postgres.ts b/packages/server-utils/src/orchestrion/config/postgres.ts index 245a939cd7e7..2af4ff5472f0 100644 --- a/packages/server-utils/src/orchestrion/config/postgres.ts +++ b/packages/server-utils/src/orchestrion/config/postgres.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // postgres.js (`postgres` npm package, v3.x). Named after the npm package; // `postgres` doesn't collide with `pg.ts` (that file instruments `pg`/`pg-pool`). @@ -53,3 +54,5 @@ export const postgresJsChannels = { POSTGRESJS_EXECUTE: 'orchestrion:postgres:execute', POSTGRESJS_CONNECT: 'orchestrion:postgres:connect', } as const; + +export const postgresJsSubscribeInjection = toSubscribeInjections(postgresJsConfig); diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts index 55db5e45c4e8..d156bb448002 100644 --- a/packages/server-utils/src/orchestrion/config/redis.ts +++ b/packages/server-utils/src/orchestrion/config/redis.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const redisConfig = [ // redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an @@ -71,3 +72,5 @@ export const redisChannels = { NODE_REDIS_PIPELINE: 'orchestrion:@redis/client:pipeline', NODE_REDIS_BATCH: 'orchestrion:@redis/client:batch', } as const; + +export const redisSubscribeInjection = toSubscribeInjections(redisConfig); diff --git a/packages/server-utils/src/orchestrion/config/remix.ts b/packages/server-utils/src/orchestrion/config/remix.ts index 893efa781e5a..cd2dbcf46b14 100644 --- a/packages/server-utils/src/orchestrion/config/remix.ts +++ b/packages/server-utils/src/orchestrion/config/remix.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // Four concepts, one channel each: // - `requestHandler` → the async handler returned by `createRequestHandler` (the server span) @@ -57,3 +58,5 @@ export const remixChannels = { REMIX_CALL_ROUTE_LOADER: 'orchestrion:@remix-run/server-runtime:callRouteLoader', REMIX_CALL_ROUTE_ACTION: 'orchestrion:@remix-run/server-runtime:callRouteAction', } as const; + +export const remixSubscribeInjection = toSubscribeInjections(remixConfig); diff --git a/packages/server-utils/src/orchestrion/config/subscribe-injection.ts b/packages/server-utils/src/orchestrion/config/subscribe-injection.ts new file mode 100644 index 000000000000..129fc637feaf --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/subscribe-injection.ts @@ -0,0 +1,54 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +/** + * Name shared by the `Program` injection configs (their `transform` field) and + * the custom transform registered on the bundler plugin (its `customTransforms` + * key). Any unique string works — it only has to match on both sides. + * + * Lives in this dependency-free leaf so the per-library config files can build + * their injection configs without importing the transform implementation (which + * pulls in `meriyah` and must never reach the runtime `--import` path). + */ +export const SUBSCRIBE_TRANSFORM_NAME = 'sentrySubscribeOrchestrionChannel'; + +/** + * Turn a library's channel-publishing configs into the `Program`-matching + * injection configs that make each instrumented file self-register its channel + * subscriber (via the {@link SUBSCRIBE_TRANSFORM_NAME} custom transform). + * + * Emits one injection per distinct instrumented file (deduped by module + * matcher), so the subscribe snippet lands in exactly the files that receive + * channels and inherits their precise version ranges. `channelName` carries the + * package name (not a real channel — nothing is wrapped here) so the transform + * can look up which subscriber to import. + * + * Co-located with each library's config (e.g. `mysqlSubscribeInjection`) but + * kept OUT of `SENTRY_INSTRUMENTATIONS`: the runtime `--import` hook consumes + * that list and can't register the custom transform, and an unregistered + * `transform` makes the code-transformer drop the whole file. They are + * aggregated separately into `SUBSCRIBE_INJECTIONS` and only handed to a bundler + * plugin that opts in (and registers the transform). + */ +export function toSubscribeInjections(configs: InstrumentationConfig[]): InstrumentationConfig[] { + const seen = new Set(); + const injections: InstrumentationConfig[] = []; + + for (const { module } of configs) { + const key = `${module.name}\0${module.versionRange}\0${String(module.filePath)}`; + + if (seen.has(key)) { + continue; + } + + seen.add(key); + + injections.push({ + channelName: module.name, + module, + astQuery: 'Program', + transform: SUBSCRIBE_TRANSFORM_NAME, + }); + } + + return injections; +} diff --git a/packages/server-utils/src/orchestrion/config/tedious.ts b/packages/server-utils/src/orchestrion/config/tedious.ts index c8ac5129e807..52d6ae195b6d 100644 --- a/packages/server-utils/src/orchestrion/config/tedious.ts +++ b/packages/server-utils/src/orchestrion/config/tedious.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; const MODULE_NAME = 'tedious'; @@ -29,3 +30,5 @@ export const tediousChannels = { TEDIOUS_PREPARE: 'orchestrion:tedious:prepare', TEDIOUS_EXECUTE: 'orchestrion:tedious:execute', } as const; + +export const tediousSubscribeInjection = toSubscribeInjections(tediousConfig); diff --git a/packages/server-utils/src/orchestrion/config/vercel-ai.ts b/packages/server-utils/src/orchestrion/config/vercel-ai.ts index 3c7d15fa0be7..b1874f7bac75 100644 --- a/packages/server-utils/src/orchestrion/config/vercel-ai.ts +++ b/packages/server-utils/src/orchestrion/config/vercel-ai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const vercelAiConfig = [ // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting @@ -68,3 +69,5 @@ function vercelAiEntries( functionQuery: { functionName, kind }, })); } + +export const vercelAiSubscribeInjection = toSubscribeInjections(vercelAiConfig); diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 1e2f15b1a422..061c1b3920d7 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -28,6 +28,10 @@ import { vercelAiChannelIntegration } from '../integrations/tracing-channel/verc import { expressChannelIntegration } from '../integrations/tracing-channel/express'; export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; +// The runtime target of the subscribe-injection snippet: instrumented modules +// import this to self-register their channel subscriber on the global marker +// (used by bundler-only SDKs). +export { registerOrchestrionChannelIntegration } from './registerChannelIntegration'; // The `@nestjs/*` channel names live here alongside their transform config; the // listener that subscribes to them lives in `@sentry/nestjs`, which imports this. export { nestjsChannels } from './config/nestjs'; diff --git a/packages/server-utils/src/orchestrion/registerChannelIntegration.ts b/packages/server-utils/src/orchestrion/registerChannelIntegration.ts new file mode 100644 index 000000000000..72f4a9a777fd --- /dev/null +++ b/packages/server-utils/src/orchestrion/registerChannelIntegration.ts @@ -0,0 +1,38 @@ +import type { Integration } from '@sentry/core'; +import { getClient, GLOBAL_OBJ } from '@sentry/core'; + +/** + * Register an orchestrion channel-subscriber integration from an instrumented + * module. This is the runtime target of the snippet the subscribe-injection + * transform splices into each transformed package (see + * `bundler/subscribeInjection.ts`), so a bundler-only SDK (e.g. + * `@sentry/cloudflare`, running in workerd where requires can't be + * monkey-patched) wires up subscribers with no runtime module hook. + * + * It does two things, covering the two disjoint timing cases: + * + * 1. Stores the factory on the global orchestrion marker under `name`, so a + * later `init()` (a fresh isolate, or a client created after this module + * loads) picks it up via `getDefaultIntegrations()`. + * 2. If a client already exists, registers the integration on it right away. + * This is what makes the mechanism robust against module load order: + * bundler-only SDKs call `init()` per request, but a package like `mysql` + * loads its instrumented file lazily on first use, i.e. AFTER that request's + * `init()` already snapshotted the marker. Without the live add, the first + * request that touches such a package would publish to a channel nobody + * subscribed to yet. + * + * `addIntegration` dedupes by integration name and only runs `setupOnce` once, + * so storing AND live-adding never double-subscribes. + * + * The marker is a `Map` keyed by `name` (the factory's export name) so a package + * split across several instrumented files (e.g. `pg`'s JS and native clients, or + * openai's per-resource `.js`/`.mjs` files) registers its one subscriber once, + * no matter how many of its files land in the bundle. `.set` on the shared key + * is idempotent. + */ +export function registerOrchestrionChannelIntegration(name: string, integrationFn: () => Integration): void { + const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); + (marker.integrations ??= new Map()).set(name, integrationFn); + getClient()?.addIntegration(integrationFn()); +} diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index 24a2accc036e..ea8499c8f6f4 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'; import { INSTRUMENTED_MODULE_NAMES, instrumentedModuleNames, + SENTRY_INSTRUMENTATIONS, + SUBSCRIBE_INJECTIONS, withoutInstrumentedExternals, } from '../../src/orchestrion/config'; @@ -19,6 +21,20 @@ describe('orchestrion config — scoped @hapi/hapi module', () => { }); }); +describe('orchestrion config — subscribe injection coverage', () => { + // Every instrumented library must contribute a subscribe injection so bundler-only SDKs + // self-register its subscriber. A literal `.length` check is wrong: `toSubscribeInjections` + // dedupes by (module, versionRange, filePath), so one library with many channel configs + // (e.g. redis) collapses to fewer injections. The invariant that must hold is at the + // module-name level — the set of instrumented modules and the set of injected modules match. + it('has a subscribe injection for every instrumented module and vice versa', () => { + const instrumentedModules = new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name)); + const injectedModules = new Set(SUBSCRIBE_INJECTIONS.map(i => i.module.name)); + + expect([...injectedModules].sort()).toEqual([...instrumentedModules].sort()); + }); +}); + describe('orchestrion config — custom instrumentations', () => { const customInstrumentation = { module: { name: 'my-lib' } } as InstrumentationConfig; diff --git a/packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts b/packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts new file mode 100644 index 000000000000..6c8af08a002b --- /dev/null +++ b/packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts @@ -0,0 +1,46 @@ +import type { Client, Integration } from '@sentry/core'; +import { getCurrentScope, GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerOrchestrionChannelIntegration } from '../../src/orchestrion/registerChannelIntegration'; + +describe('registerOrchestrionChannelIntegration', () => { + const factory = (name: string) => (): Integration => ({ name, setupOnce: () => undefined }); + + beforeEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + getCurrentScope().setClient(undefined); + }); + + afterEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + getCurrentScope().setClient(undefined); + }); + + it('stores the factory on the global marker keyed by its export name', () => { + const fn = factory('MyIntegration'); + registerOrchestrionChannelIntegration('myChannelIntegration', fn); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('myChannelIntegration')).toBe(fn); + }); + + it('keeps one entry per export name (a package split across files registers once)', () => { + registerOrchestrionChannelIntegration('myChannelIntegration', factory('MyIntegration')); + registerOrchestrionChannelIntegration('myChannelIntegration', factory('MyIntegration')); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.size).toBe(1); + }); + + it('live-registers the integration on an already-set client', () => { + const addIntegration = vi.fn(); + getCurrentScope().setClient({ addIntegration } as unknown as Client); + + registerOrchestrionChannelIntegration('myChannelIntegration', factory('MyIntegration')); + + expect(addIntegration).toHaveBeenCalledTimes(1); + expect(addIntegration.mock.calls[0]?.[0]).toMatchObject({ name: 'MyIntegration' }); + }); + + it('does not throw the live add when no client is set yet', () => { + expect(() => registerOrchestrionChannelIntegration('myChannelIntegration', factory('X'))).not.toThrow(); + // still stored for the next init() to pick up + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.has('myChannelIntegration')).toBe(true); + }); +}); diff --git a/packages/server-utils/test/orchestrion/subscribeInjection.test.ts b/packages/server-utils/test/orchestrion/subscribeInjection.test.ts new file mode 100644 index 000000000000..eaa94cf5173d --- /dev/null +++ b/packages/server-utils/test/orchestrion/subscribeInjection.test.ts @@ -0,0 +1,116 @@ +import { createCodeTransformer } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +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 { + CHANNEL_INTEGRATION_DEFINITIONS, + subscriberExportForModule, +} from '../../src/orchestrion/config/channel-integration-definitions'; +import { orchestrionTransformOptions } from '../../src/orchestrion/bundler/options'; + +// The code transformer reads the instrumented package's version from its +// on-disk `package.json`, so each test package needs a real directory. +function makePackage(root: string, name: string, version: string, type?: 'module' | 'commonjs'): void { + const dir = join(root, 'node_modules', name); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version, ...(type ? { type } : {}) })); +} + +describe('channel integration definitions', () => { + it('maps every module to a defined subscriber export', () => { + expect(subscriberExportForModule('mysql')).toBe('mysqlChannelIntegration'); + expect(subscriberExportForModule('pg')).toBe('postgresChannelIntegration'); + expect(subscriberExportForModule('pg-pool')).toBe('postgresChannelIntegration'); + expect(subscriberExportForModule('@redis/client')).toBe('redisChannelIntegration'); + expect(subscriberExportForModule('not-a-package')).toBeUndefined(); + }); + + it('references only real named exports of @sentry/server-utils/orchestrion', async () => { + const barrel = await import('../../src/orchestrion/index'); + for (const { exportName } of CHANNEL_INTEGRATION_DEFINITIONS) { + expect(typeof (barrel as Record)[exportName]).toBe('function'); + } + }); +}); + +describe('subscribe-injection transform option', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'orch-subscribe-')); + makePackage(root, 'mysql', '2.18.1', 'commonjs'); + makePackage(root, 'pg', '8.11.0', 'module'); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('adds Program injection configs and the custom transform only when opted in', () => { + const off = orchestrionTransformOptions({}); + expect(off.customTransforms).toEqual({}); + expect(off.instrumentations.some(i => i.astQuery === 'Program' && i.transform)).toBe(false); + + const on = orchestrionTransformOptions({ injectChannelSubscribers: true }); + expect(Object.keys(on.customTransforms || {})).toContain('sentrySubscribeOrchestrionChannel'); + expect(on.instrumentations.some(i => i.astQuery === 'Program' && i.transform)).toBe(true); + }); + + it('injects a CJS marker-push importing only that package factory, after "use strict"', () => { + const t = createCodeTransformer(orchestrionTransformOptions({ injectChannelSubscribers: true })); + const code = + "'use strict';\nfunction Connection(){}\nConnection.prototype.query = function query(sql, cb){ return cb(); };\n"; + const result = t.transform(code, join(root, 'node_modules/mysql/lib/Connection.js')); + + expect(result).not.toBeNull(); + expect(result!.code.split('\n')[0]).toContain("'use strict'"); + // Imports ONLY the mysql factory plus the generic helper, from a single require. + expect(result!.code).toMatch( + /const\s*\{\s*mysqlChannelIntegration,\s*registerOrchestrionChannelIntegration\s*\}\s*=\s*require\(["']@sentry\/server-utils\/orchestrion["']\)/, + ); + // The helper stores the factory on the marker AND live-registers it on an existing client, so a + // module that loads AFTER `init()` (mysql loads its instrumented file lazily) still subscribes + // for the in-flight request instead of only the next `init()`. + expect(result!.code).toContain( + 'registerOrchestrionChannelIntegration("mysqlChannelIntegration", mysqlChannelIntegration)', + ); + // No separate @sentry/core import at the injection site — the helper owns that. + expect(result!.code).not.toContain('@sentry/core'); + // It imports ONLY the mysql factory — no central dispatch pulling in others. + expect(result!.code).not.toContain('pgChannelIntegration'); + expect(result!.code).not.toContain('subscribeOrchestrionChannel'); + // The real channel-publishing transform still ran alongside the injection. + expect(result!.code).toContain('orchestrion:mysql:query'); + }); + + it('injects an ESM marker-push for an instrumented ESM module', () => { + const t = createCodeTransformer(orchestrionTransformOptions({ injectChannelSubscribers: true })); + const result = t.transform( + 'export class Client { query(){} connect(){} }\n', + join(root, 'node_modules/pg/lib/client.js'), + ); + + expect(result).not.toBeNull(); + expect(result!.code).toMatch( + /import\s*\{\s*postgresChannelIntegration,\s*registerOrchestrionChannelIntegration\s*\}\s*from\s*["']@sentry\/server-utils\/orchestrion["']/, + ); + expect(result!.code).not.toContain('@sentry/core'); + expect(result!.code).toContain( + 'registerOrchestrionChannelIntegration("postgresChannelIntegration", postgresChannelIntegration)', + ); + }); + + it('registers the factory at most once per file', () => { + const t = createCodeTransformer(orchestrionTransformOptions({ injectChannelSubscribers: true })); + // `pg`'s `lib/client.js` is matched by both the `query` and `connect` configs. + const result = t.transform( + 'export class Client { query(){} connect(){} }\n', + join(root, 'node_modules/pg/lib/client.js'), + ); + + const registrations = + result!.code.match(/registerOrchestrionChannelIntegration\("postgresChannelIntegration"/g) ?? []; + expect(registrations).toHaveLength(1); + }); +});