From 3adefaff0d05efe7da0ddd623a8cb5ba31804dfe Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 19 Jun 2026 14:48:37 +0200 Subject: [PATCH 1/2] ref(node): Refactor orchestrion config into separate files --- packages/server-utils/rollup.npm.config.mjs | 4 +- .../server-utils/src/orchestrion/channels.ts | 42 ++-- .../server-utils/src/orchestrion/config.ts | 215 ------------------ .../src/orchestrion/config/anthropic-ai.ts | 40 ++++ .../src/orchestrion/config/index.ts | 48 ++++ .../src/orchestrion/config/ioredis.ts | 32 +++ .../src/orchestrion/config/lru-memoizer.ts | 14 ++ .../src/orchestrion/config/mysql.ts | 13 ++ .../src/orchestrion/config/openai.ts | 37 +++ .../server-utils/src/orchestrion/config/pg.ts | 48 ++++ .../src/orchestrion/config/vercel-ai.ts | 58 +++++ .../vercel-ai-orchestrion-v6-subscriber.ts | 2 +- packages/server-utils/tsconfig.json | 2 +- 13 files changed, 309 insertions(+), 246 deletions(-) delete mode 100644 packages/server-utils/src/orchestrion/config.ts create mode 100644 packages/server-utils/src/orchestrion/config/anthropic-ai.ts create mode 100644 packages/server-utils/src/orchestrion/config/index.ts create mode 100644 packages/server-utils/src/orchestrion/config/ioredis.ts create mode 100644 packages/server-utils/src/orchestrion/config/lru-memoizer.ts create mode 100644 packages/server-utils/src/orchestrion/config/mysql.ts create mode 100644 packages/server-utils/src/orchestrion/config/openai.ts create mode 100644 packages/server-utils/src/orchestrion/config/pg.ts create mode 100644 packages/server-utils/src/orchestrion/config/vercel-ai.ts diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 21942c9340c8..ca2c6cf19486 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -19,7 +19,7 @@ export default [ ...orchestrionRuntimeHooks, ...makeNPMConfigVariants( makeBaseNPMConfig({ - // `src/orchestrion/config.ts` and `src/orchestrion/bundler/vite.ts` are + // `src/orchestrion/config/index.ts` and `src/orchestrion/bundler/vite.ts` are // loaded via dedicated subpath exports (`.../orchestrion/config`, // `.../orchestrion/vite`) — neither is reachable from `src/index.ts`, so we // list them as separate entrypoints to guarantee they end up in build/esm @@ -28,7 +28,7 @@ export default [ entrypoints: [ 'src/index.ts', 'src/orchestrion/index.ts', - 'src/orchestrion/config.ts', + 'src/orchestrion/config/index.ts', // `src/orchestrion/runtime/register.ts` backs the `./orchestrion/register` // subpath export; the Node SDK `require`s it synchronously from // `Sentry.init()` to install the channel-injection hooks. diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 55586ab6c147..b008aad618a2 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -1,3 +1,11 @@ +import { mysqlChannels } from './config/mysql'; +import { lruMemoizerChannels } from './config/lru-memoizer'; +import { ioredisChannels } from './config/ioredis'; +import { pgChannels } from './config/pg'; +import { openaiChannels } from './config/openai'; +import { anthropicAiChannels } from './config/anthropic-ai'; +import { vercelAiChannels } from './config/vercel-ai'; + /** * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. * @@ -12,33 +20,13 @@ * they don't drift apart and silently stop firing. */ export const CHANNELS = { - MYSQL_QUERY: 'orchestrion:mysql:query', - LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', - IOREDIS_COMMAND: 'orchestrion:ioredis:command', - IOREDIS_CONNECT: 'orchestrion:ioredis:connect', - PG_QUERY: 'orchestrion:pg:query', - PG_CONNECT: 'orchestrion:pg:connect', - PGPOOL_CONNECT: 'orchestrion:pg-pool:connect', - OPENAI_CHAT: 'orchestrion:openai:chat', - OPENAI_RESPONSES: 'orchestrion:openai:responses', - OPENAI_EMBEDDINGS: 'orchestrion:openai:embeddings', - OPENAI_CONVERSATIONS: 'orchestrion:openai:conversations', - ANTHROPIC_CHAT: 'orchestrion:@anthropic-ai/sdk:chat', - ANTHROPIC_MODELS: 'orchestrion:@anthropic-ai/sdk:models', - ANTHROPIC_MESSAGES_STREAM: 'orchestrion:@anthropic-ai/sdk:messages-stream', - // Vercel AI (`ai`) v6: orchestrion injects these so the same channel-based - // integration that consumes `ai`'s native `ai:telemetry` channel (v7) can - // also instrument v6. Each maps to a top-level function in `ai`'s bundle. - VERCEL_AI_GENERATE_TEXT: 'orchestrion:ai:generateText', - VERCEL_AI_STREAM_TEXT: 'orchestrion:ai:streamText', - VERCEL_AI_EMBED: 'orchestrion:ai:embed', - VERCEL_AI_EMBED_MANY: 'orchestrion:ai:embedMany', - VERCEL_AI_EXECUTE_TOOL_CALL: 'orchestrion:ai:executeToolCall', - // `resolveLanguageModel` is the single chokepoint every model call flows - // through; we wrap it to monkey-patch `doGenerate`/`doStream` on the returned - // model (the model-call site itself is an inline call with no injectable - // definition). - VERCEL_AI_RESOLVE_LANGUAGE_MODEL: 'orchestrion:ai:resolveLanguageModel', + ...mysqlChannels, + ...lruMemoizerChannels, + ...ioredisChannels, + ...pgChannels, + ...openaiChannels, + ...anthropicAiChannels, + ...vercelAiChannels, } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts deleted file mode 100644 index 355449ffb0fe..000000000000 --- a/packages/server-utils/src/orchestrion/config.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; - -/** - * The central list of channel injections orchestrion should perform. - * - * This module has NO side effects — it's the only thing both the runtime hook - * (`runtime/import-hook.mjs`) and the bundler plugins (`bundler/vite.ts`, …) - * import from. Adding a new instrumented method is one entry here plus one - * subscriber in `integrations//tracing-channel.ts`. - * - * `channelName` here is the unprefixed suffix; the actual diagnostics_channel - * name is `orchestrion:${module.name}:${channelName}` (see `channels.ts`). - */ -/** - * `ai` ships a single bundled entry per module system, so each instrumented - * function needs one config entry per file (the app loads whichever matches its - * module system). This expands a single target into both. - */ -function vercelAiV6Entries(channelName: string, functionName: string, kind: 'Async' | 'Sync'): InstrumentationConfig[] { - return ['dist/index.js', 'dist/index.mjs'].map(filePath => ({ - channelName, - module: { name: 'ai', versionRange: '>=6.0.0 <7.0.0', filePath }, - functionQuery: { functionName, kind }, - })); -} - -export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ - { - channelName: 'query', - module: { name: 'mysql', versionRange: '>=2.0.0 <3', filePath: 'lib/Connection.js' }, - // `Connection` in mysql v2 is a constructor function (NOT a class): - // `function Connection(options) { ... }` - // `Connection.prototype.query = function query(sql, values, cb) { ... }` - // orchestrion's `className`+`methodName` query only matches `class` declarations. - // The named function expression on the right-hand side of the prototype - // assignment is what we want — that's matched by `expressionName: 'query'`, - // which produces the esquery selector - // `AssignmentExpression[left.property.name="query"] > FunctionExpression[async]`. - // `Auto` so both `connection.query(sql, cb)` and `connection.query(sql)` - // (streamable, no callback) get channel events. The transform picks - // `wrapCallback` when the last arg is a function and `wrapPromise` - // otherwise — for mysql's no-callback path the latter publishes - // `start`/`end` synchronously around the original call and stores the - // returned `Query` emitter on `ctx.result`, which the integration uses to - // attach `'end'`/`'error'` listeners that finish the span. - functionQuery: { expressionName: 'query', kind: 'Auto' }, - }, - { - channelName: 'load', - // `>=2.1.0` only: the named `function memoizedFunction()` the selector targets exists from 2.1.0 - module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' }, - functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' }, - }, - // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) - ...['lib/redis.js', 'built/redis.js', 'built/redis/index.js'].flatMap((filePath): InstrumentationConfig[] => [ - { - channelName: 'command', - module: { name: 'ioredis', versionRange: '>=2.0.0 <5.0.0', filePath }, - functionQuery: { expressionName: 'sendCommand', kind: 'Async' }, - }, - { - channelName: 'connect', - module: { name: 'ioredis', versionRange: '>=2.0.0 <5.0.0', filePath }, - functionQuery: { expressionName: 'connect', kind: 'Async' }, - }, - ]), - { - channelName: 'command', - module: { name: 'ioredis', versionRange: '>=5.0.0 <5.11.0', filePath: 'built/Redis.js' }, - functionQuery: { className: 'Redis', methodName: 'sendCommand', kind: 'Async' }, - }, - { - channelName: 'connect', - module: { name: 'ioredis', versionRange: '>=5.0.0 <5.11.0', filePath: 'built/Redis.js' }, - functionQuery: { className: 'Redis', methodName: 'connect', kind: 'Async' }, - }, - // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, - // so `kind: 'Auto'` resolves to `wrapPromise`. openai ships dual CJS/ESM and the matcher compares - // `filePath` exactly, hence one entry per built file (`.js` for `require`, `.mjs` for `import`). - ...(['resources/chat/completions/completions.js', 'resources/chat/completions/completions.mjs'].map(filePath => ({ - channelName: 'chat', - module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, - functionQuery: { className: 'Completions', methodName: 'create', kind: 'Auto' as const }, - })) satisfies InstrumentationConfig[]), - // OpenAI responses API — same `create(body, options)` shape as chat completions. - ...(['resources/responses/responses.js', 'resources/responses/responses.mjs'].map(filePath => ({ - channelName: 'responses', - module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, - functionQuery: { className: 'Responses', methodName: 'create', kind: 'Auto' as const }, - })) satisfies InstrumentationConfig[]), - // OpenAI embeddings API — same `create(body, options)` shape as chat completions. - ...(['resources/embeddings.js', 'resources/embeddings.mjs'].map(filePath => ({ - channelName: 'embeddings', - module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, - functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Auto' as const }, - })) satisfies InstrumentationConfig[]), - // OpenAI conversations API — same `create(body, options)` shape as chat completions. - ...(['resources/conversations/conversations.js', 'resources/conversations/conversations.mjs'].map(filePath => ({ - channelName: 'conversations', - module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, - functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Auto' as const }, - })) satisfies InstrumentationConfig[]), - // `pg` (node-postgres). - // instruments `Client.prototype.query`/`connect` (both the JS and native - // clients) plus `pg-pool`'s `Pool.prototype.connect`. - // `Auto` covers the callback, promise, and streamable-`Submittable` - // call shapes (like mysql). - // `pg/lib/client.js` is `class Client { query() {...} connect() {...} }`, - // so `className`+`methodName` matches directly. - { - channelName: 'query', - module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/client.js' }, - functionQuery: { className: 'Client', methodName: 'query', kind: 'Auto' }, - }, - { - channelName: 'connect', - module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/client.js' }, - functionQuery: { className: 'Client', methodName: 'connect', kind: 'Auto' }, - }, - // The native client (`pg/lib/native/client.js`) is a constructor function, - // not a class. - // `Client.prototype.query = function (config, values, callback) {...}` - // so it needs `expressionName` (the mysql shape), publishing to the SAME - // `orchestrion:pg:query`/`:connect` channels as the JS client. - { - channelName: 'query', - module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/native/client.js' }, - functionQuery: { expressionName: 'query', kind: 'Auto' }, - }, - { - channelName: 'connect', - module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/native/client.js' }, - functionQuery: { expressionName: 'connect', kind: 'Auto' }, - }, - // `pg-pool` is `class Pool extends EventEmitter { connect(cb) {...} }`. - { - channelName: 'connect', - module: { name: 'pg-pool', versionRange: '>=2.0.0 <4', filePath: 'index.js' }, - functionQuery: { className: 'Pool', methodName: 'connect', kind: 'Auto' }, - }, - // One entry each for CJS/ESM - ...(['resources/messages/messages.js', 'resources/messages/messages.mjs'].flatMap(filePath => - (['create', 'countTokens'] as const).map(methodName => ({ - channelName: 'chat', - module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, - functionQuery: { className: 'Messages', methodName, kind: 'Auto' as const }, - })), - ) satisfies InstrumentationConfig[]), - ...(['resources/completions.js', 'resources/completions.mjs'].map(filePath => ({ - channelName: 'chat', - module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, - functionQuery: { className: 'Completions', methodName: 'create', kind: 'Auto' as const }, - })) satisfies InstrumentationConfig[]), - ...(['resources/beta/messages/messages.js', 'resources/beta/messages/messages.mjs'].map(filePath => ({ - channelName: 'chat', - module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, - functionQuery: { className: 'Messages', methodName: 'create', kind: 'Auto' as const }, - })) satisfies InstrumentationConfig[]), - ...(['resources/models.js', 'resources/models.mjs'].map(filePath => ({ - channelName: 'models', - module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, - functionQuery: { className: 'Models', methodName: 'retrieve', kind: 'Auto' as const }, - })) satisfies InstrumentationConfig[]), - // `messages.stream()` returns a synchronous emitter, not a promise, so `kind: 'Sync'` is required: - // `Auto`'s promise wrapper never publishes `end` for a non-thenable return, so the span would never end. - ...(['resources/messages/messages.js', 'resources/messages/messages.mjs'].map(filePath => ({ - channelName: 'messages-stream', - module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, - functionQuery: { className: 'Messages', methodName: 'stream', kind: 'Sync' as const }, - })) satisfies InstrumentationConfig[]), - // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting - // channels into the top-level entry points. `resolveLanguageModel` is wrapped - // not to span it, but so the subscriber can monkey-patch `doGenerate`/ - // `doStream` on the returned model (the only way to span the model call, - // which is an inline call with no injectable definition in `ai`). - // `streamText` returns its result synchronously (streaming is lazy), so it's - // `Sync`; the subscriber binds the span via `bindTracingChannelToSpan`, which - // ends it when the (synchronous) call returns. - ...vercelAiV6Entries('generateText', 'generateText', 'Async'), - ...vercelAiV6Entries('streamText', 'streamText', 'Sync'), - ...vercelAiV6Entries('embed', 'embed', 'Async'), - ...vercelAiV6Entries('embedMany', 'embedMany', 'Async'), - ...vercelAiV6Entries('executeToolCall', 'executeToolCall', 'Async'), - ...vercelAiV6Entries('resolveLanguageModel', 'resolveLanguageModel', 'Sync'), -]; - -/** - * The unique set of package names instrumented by `SENTRY_INSTRUMENTATIONS` - * (e.g. `['mysql']`). - * - * Bundler plugins MUST ensure these are actually bundled rather than - * externalized: an externalized dependency is resolved from `node_modules` at - * runtime and never passes through the code transform's `onLoad`, so its - * diagnostics_channel calls are silently never injected. - */ -export const INSTRUMENTED_MODULE_NAMES: string[] = Array.from(new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name))); - -/** - * Returns `external` with any instrumented packages removed, so a bundler that - * uses an "external" denylist (esbuild, Bun, Rollup) still bundles — and thus - * transforms — them. Matches an exact package name (`'mysql'`) or a subpath - * (`'mysql/lib/...'`); wildcard/other patterns are left untouched. `undefined` - * is returned unchanged. - * - * (Vite uses an `ssr.noExternal` allowlist instead, so it consumes - * `INSTRUMENTED_MODULE_NAMES` directly rather than this helper.) - */ -export function withoutInstrumentedExternals(external: readonly string[] | undefined): string[] | undefined { - if (!external) { - return undefined; - } - return external.filter( - entry => !INSTRUMENTED_MODULE_NAMES.some(name => entry === name || entry.startsWith(`${name}/`)), - ); -} diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts new file mode 100644 index 000000000000..e5032d88a209 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts @@ -0,0 +1,40 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const anthropicAiConfig = [ + // One entry each for CJS/ESM + ...['resources/messages/messages.js', 'resources/messages/messages.mjs'].flatMap(filePath => + (['create', 'countTokens'] as const).map(methodName => ({ + channelName: 'chat', + module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, + functionQuery: { className: 'Messages', methodName, kind: 'Auto' as const }, + })), + ), + ...['resources/completions.js', 'resources/completions.mjs'].map(filePath => ({ + channelName: 'chat', + module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, + functionQuery: { className: 'Completions', methodName: 'create', kind: 'Auto' as const }, + })), + ...['resources/beta/messages/messages.js', 'resources/beta/messages/messages.mjs'].map(filePath => ({ + channelName: 'chat', + module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, + functionQuery: { className: 'Messages', methodName: 'create', kind: 'Auto' as const }, + })), + ...['resources/models.js', 'resources/models.mjs'].map(filePath => ({ + channelName: 'models', + module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, + functionQuery: { className: 'Models', methodName: 'retrieve', kind: 'Auto' as const }, + })), + // `messages.stream()` returns a synchronous emitter, not a promise, so `kind: 'Sync'` is required: + // `Auto`'s promise wrapper never publishes `end` for a non-thenable return, so the span would never end. + ...['resources/messages/messages.js', 'resources/messages/messages.mjs'].map(filePath => ({ + channelName: 'messages-stream', + module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, + functionQuery: { className: 'Messages', methodName: 'stream', kind: 'Sync' as const }, + })), +] satisfies InstrumentationConfig[]; + +export const anthropicAiChannels = { + ANTHROPIC_CHAT: 'orchestrion:@anthropic-ai/sdk:chat', + ANTHROPIC_MODELS: 'orchestrion:@anthropic-ai/sdk:models', + ANTHROPIC_MESSAGES_STREAM: 'orchestrion:@anthropic-ai/sdk:messages-stream', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts new file mode 100644 index 000000000000..218cbf7e8875 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -0,0 +1,48 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { mysqlConfig } from './mysql'; +import { lruMemoizerConfig } from './lru-memoizer'; +import { ioredisConfig } from './ioredis'; +import { openaiConfig } from './openai'; +import { pgConfig } from './pg'; +import { anthropicAiConfig } from './anthropic-ai'; +import { vercelAiConfig } from './vercel-ai'; + +export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ + ...mysqlConfig, + ...lruMemoizerConfig, + ...ioredisConfig, + ...openaiConfig, + ...pgConfig, + ...anthropicAiConfig, + ...vercelAiConfig, +]; + +/** + * The unique set of package names instrumented by `SENTRY_INSTRUMENTATIONS` + * (e.g. `['mysql']`). + * + * Bundler plugins MUST ensure these are actually bundled rather than + * externalized: an externalized dependency is resolved from `node_modules` at + * runtime and never passes through the code transform's `onLoad`, so its + * diagnostics_channel calls are silently never injected. + */ +export const INSTRUMENTED_MODULE_NAMES: string[] = Array.from(new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name))); + +/** + * Returns `external` with any instrumented packages removed, so a bundler that + * uses an "external" denylist (esbuild, Bun, Rollup) still bundles — and thus + * transforms — them. Matches an exact package name (`'mysql'`) or a subpath + * (`'mysql/lib/...'`); wildcard/other patterns are left untouched. `undefined` + * is returned unchanged. + * + * (Vite uses an `ssr.noExternal` allowlist instead, so it consumes + * `INSTRUMENTED_MODULE_NAMES` directly rather than this helper.) + */ +export function withoutInstrumentedExternals(external: readonly string[] | undefined): string[] | undefined { + if (!external) { + return undefined; + } + return external.filter( + entry => !INSTRUMENTED_MODULE_NAMES.some(name => entry === name || entry.startsWith(`${name}/`)), + ); +} diff --git a/packages/server-utils/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts new file mode 100644 index 000000000000..f6d6392d72b8 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/ioredis.ts @@ -0,0 +1,32 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const ioredisConfig = [ + // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) + ...['lib/redis.js', 'built/redis.js', 'built/redis/index.js'].flatMap((filePath): InstrumentationConfig[] => [ + { + channelName: 'command', + module: { name: 'ioredis', versionRange: '>=2.0.0 <5.0.0', filePath }, + functionQuery: { expressionName: 'sendCommand', kind: 'Async' }, + }, + { + channelName: 'connect', + module: { name: 'ioredis', versionRange: '>=2.0.0 <5.0.0', filePath }, + functionQuery: { expressionName: 'connect', kind: 'Async' }, + }, + ]), + { + channelName: 'command', + module: { name: 'ioredis', versionRange: '>=5.0.0 <5.11.0', filePath: 'built/Redis.js' }, + functionQuery: { className: 'Redis', methodName: 'sendCommand', kind: 'Async' }, + }, + { + channelName: 'connect', + module: { name: 'ioredis', versionRange: '>=5.0.0 <5.11.0', filePath: 'built/Redis.js' }, + functionQuery: { className: 'Redis', methodName: 'connect', kind: 'Async' }, + }, +] satisfies InstrumentationConfig[]; + +export const ioredisChannels = { + IOREDIS_COMMAND: 'orchestrion:ioredis:command', + IOREDIS_CONNECT: 'orchestrion:ioredis:connect', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts new file mode 100644 index 000000000000..578317e612a8 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts @@ -0,0 +1,14 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const lruMemoizerConfig = [ + { + channelName: 'load', + // `>=2.1.0` only: the named `function memoizedFunction()` the selector targets exists from 2.1.0 + module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' }, + functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' }, + }, +] satisfies InstrumentationConfig[]; + +export const lruMemoizerChannels = { + LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/mysql.ts b/packages/server-utils/src/orchestrion/config/mysql.ts new file mode 100644 index 000000000000..589e384bd6a3 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/mysql.ts @@ -0,0 +1,13 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const mysqlConfig = [ + { + channelName: 'query', + module: { name: 'mysql', versionRange: '>=2.0.0 <3', filePath: 'lib/Connection.js' }, + functionQuery: { expressionName: 'query', kind: 'Auto' }, + }, +] satisfies InstrumentationConfig[]; + +export const mysqlChannels = { + MYSQL_QUERY: 'orchestrion:mysql:query', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts new file mode 100644 index 000000000000..e8091003fc58 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -0,0 +1,37 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const openaiConfig = [ + // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, + // so `kind: 'Auto'` resolves to `wrapPromise`. openai ships dual CJS/ESM and the matcher compares + // `filePath` exactly, hence one entry per built file (`.js` for `require`, `.mjs` for `import`). + ...['resources/chat/completions/completions.js', 'resources/chat/completions/completions.mjs'].map(filePath => ({ + channelName: 'chat', + module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, + functionQuery: { className: 'Completions', methodName: 'create', kind: 'Auto' as const }, + })), + // OpenAI responses API — same `create(body, options)` shape as chat completions. + ...['resources/responses/responses.js', 'resources/responses/responses.mjs'].map(filePath => ({ + channelName: 'responses', + module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, + functionQuery: { className: 'Responses', methodName: 'create', kind: 'Auto' as const }, + })), + // OpenAI embeddings API — same `create(body, options)` shape as chat completions. + ...['resources/embeddings.js', 'resources/embeddings.mjs'].map(filePath => ({ + channelName: 'embeddings', + module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, + functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Auto' as const }, + })), + // OpenAI conversations API — same `create(body, options)` shape as chat completions. + ...['resources/conversations/conversations.js', 'resources/conversations/conversations.mjs'].map(filePath => ({ + channelName: 'conversations', + module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, + functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Auto' as const }, + })), +] satisfies InstrumentationConfig[]; + +export const openaiChannels = { + OPENAI_CHAT: 'orchestrion:openai:chat', + OPENAI_RESPONSES: 'orchestrion:openai:responses', + OPENAI_EMBEDDINGS: 'orchestrion:openai:embeddings', + OPENAI_CONVERSATIONS: 'orchestrion:openai:conversations', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/pg.ts b/packages/server-utils/src/orchestrion/config/pg.ts new file mode 100644 index 000000000000..7bfa05a82661 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/pg.ts @@ -0,0 +1,48 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const pgConfig = [ + // `pg` (node-postgres). + // instruments `Client.prototype.query`/`connect` (both the JS and native + // clients) plus `pg-pool`'s `Pool.prototype.connect`. + // `Auto` covers the callback, promise, and streamable-`Submittable` + // call shapes (like mysql). + // `pg/lib/client.js` is `class Client { query() {...} connect() {...} }`, + // so `className`+`methodName` matches directly. + { + channelName: 'query', + module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/client.js' }, + functionQuery: { className: 'Client', methodName: 'query', kind: 'Auto' }, + }, + { + channelName: 'connect', + module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/client.js' }, + functionQuery: { className: 'Client', methodName: 'connect', kind: 'Auto' }, + }, + // The native client (`pg/lib/native/client.js`) is a constructor function, + // not a class. + // `Client.prototype.query = function (config, values, callback) {...}` + // so it needs `expressionName` (the mysql shape), publishing to the SAME + // `orchestrion:pg:query`/`:connect` channels as the JS client. + { + channelName: 'query', + module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/native/client.js' }, + functionQuery: { expressionName: 'query', kind: 'Auto' }, + }, + { + channelName: 'connect', + module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/native/client.js' }, + functionQuery: { expressionName: 'connect', kind: 'Auto' }, + }, + // `pg-pool` is `class Pool extends EventEmitter { connect(cb) {...} }`. + { + channelName: 'connect', + module: { name: 'pg-pool', versionRange: '>=2.0.0 <4', filePath: 'index.js' }, + functionQuery: { className: 'Pool', methodName: 'connect', kind: 'Auto' }, + }, +] satisfies InstrumentationConfig[]; + +export const pgChannels = { + PG_QUERY: 'orchestrion:pg:query', + PG_CONNECT: 'orchestrion:pg:connect', + PGPOOL_CONNECT: 'orchestrion:pg-pool:connect', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/vercel-ai.ts b/packages/server-utils/src/orchestrion/config/vercel-ai.ts new file mode 100644 index 000000000000..bf1be42796f8 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/vercel-ai.ts @@ -0,0 +1,58 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const vercelAiConfig = [ + // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting + // channels into the top-level entry points. `resolveLanguageModel` is wrapped + // not to span it, but so the subscriber can monkey-patch `doGenerate`/ + // `doStream` on the returned model (the only way to span the model call, + // which is an inline call with no injectable definition in `ai`). + // `streamText` returns its result synchronously (streaming is lazy), so it's + // `Sync`; the subscriber binds the span via `bindTracingChannelToSpan`, which + // ends it when the (synchronous) call returns. + ...vercelAiV6Entries('generateText', 'generateText', 'Async'), + ...vercelAiV6Entries('streamText', 'streamText', 'Sync'), + ...vercelAiV6Entries('embed', 'embed', 'Async'), + ...vercelAiV6Entries('embedMany', 'embedMany', 'Async'), + ...vercelAiV6Entries('executeToolCall', 'executeToolCall', 'Async'), + ...vercelAiV6Entries('resolveLanguageModel', 'resolveLanguageModel', 'Sync'), +] satisfies InstrumentationConfig[]; + +export const vercelAiChannels = { + // Vercel AI (`ai`) v6: orchestrion injects these so the same channel-based + // integration that consumes `ai`'s native `ai:telemetry` channel (v7) can + // also instrument v6. Each maps to a top-level function in `ai`'s bundle. + VERCEL_AI_GENERATE_TEXT: 'orchestrion:ai:generateText', + VERCEL_AI_STREAM_TEXT: 'orchestrion:ai:streamText', + VERCEL_AI_EMBED: 'orchestrion:ai:embed', + VERCEL_AI_EMBED_MANY: 'orchestrion:ai:embedMany', + VERCEL_AI_EXECUTE_TOOL_CALL: 'orchestrion:ai:executeToolCall', + // `resolveLanguageModel` is the single chokepoint every model call flows + // through; we wrap it to monkey-patch `doGenerate`/`doStream` on the returned + // model (the model-call site itself is an inline call with no injectable + // definition). + VERCEL_AI_RESOLVE_LANGUAGE_MODEL: 'orchestrion:ai:resolveLanguageModel', +} as const; + +/** + * The central list of channel injections orchestrion should perform. + * + * This module has NO side effects — it's the only thing both the runtime hook + * (`runtime/import-hook.mjs`) and the bundler plugins (`bundler/vite.ts`, …) + * import from. Adding a new instrumented method is one entry here plus one + * subscriber in `integrations//tracing-channel.ts`. + * + * `channelName` here is the unprefixed suffix; the actual diagnostics_channel + * name is `orchestrion:${module.name}:${channelName}` (see `channels.ts`). + */ +/** + * `ai` ships a single bundled entry per module system, so each instrumented + * function needs one config entry per file (the app loads whichever matches its + * module system). This expands a single target into both. + */ +function vercelAiV6Entries(channelName: string, functionName: string, kind: 'Async' | 'Sync'): InstrumentationConfig[] { + return ['dist/index.js', 'dist/index.mjs'].map(filePath => ({ + channelName, + module: { name: 'ai', versionRange: '>=6.0.0 <7.0.0', filePath }, + functionQuery: { functionName, kind }, + })); +} diff --git a/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-v6-subscriber.ts b/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-v6-subscriber.ts index 874057999504..0465df6d740a 100644 --- a/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-v6-subscriber.ts +++ b/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-v6-subscriber.ts @@ -19,7 +19,7 @@ import { * `ai` >= 7 publishes a normalized `ai:telemetry` tracing channel natively * (consumed by `subscribeVercelAiTracingChannel`). v6 has no such channel, so * orchestrion injects `orchestrion:ai:*` channels around the top-level - * functions (see `orchestrion/config.ts`). The injected channels carry only the + * functions (see `orchestrion/config/index.ts`). The injected channels carry only the * wrapped call's `{ arguments, result, error }` — NOT v7's normalized `event` * object — so this adapter reconstructs an equivalent {@link VercelAiChannelMessage} * from v6's argument/result shapes and delegates to the SAME span-building core diff --git a/packages/server-utils/tsconfig.json b/packages/server-utils/tsconfig.json index 5e5830ae75dc..5430d015af38 100644 --- a/packages/server-utils/tsconfig.json +++ b/packages/server-utils/tsconfig.json @@ -8,7 +8,7 @@ // `@sentry/server-utils/orchestrion/config`. If tsc picks it up, it // follows that subpath export back to `build/types/orchestrion/config.d.ts`, // treats the .d.ts as an input, and then collides with the .d.ts it wants to - // emit from `src/orchestrion/config.ts`. Excluding it keeps tsc focused on the + // emit from `src/orchestrion/config/index.ts`. Excluding it keeps tsc focused on the // .ts sources — rollup copies the file through to `build/orchestrion/` unchanged. "exclude": ["src/orchestrion/runtime/**/*.mjs", "src/orchestrion/runtime/**/*.cjs"] } From f1b04f540b2ed1ca2ce8918a53203f1250aa1b61 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 6 Jul 2026 08:24:28 +0200 Subject: [PATCH 2/2] fix exports --- packages/server-utils/package.json | 10 +++++----- packages/server-utils/tsconfig.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 350c7ce367ff..d42372b89ad0 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -28,9 +28,9 @@ "require": "./build/cjs/orchestrion/index.js" }, "./orchestrion/config": { - "types": "./build/types/orchestrion/config.d.ts", - "import": "./build/esm/orchestrion/config.js", - "require": "./build/cjs/orchestrion/config.js" + "types": "./build/types/orchestrion/config/index.d.ts", + "import": "./build/esm/orchestrion/config/index.js", + "require": "./build/cjs/orchestrion/config/index.js" }, "./orchestrion/register": { "types": "./build/types/orchestrion/runtime/register.d.ts", @@ -54,7 +54,7 @@ "build/types-ts3.8/orchestrion/index.d.ts" ], "orchestrion/config": [ - "build/types-ts3.8/orchestrion/config.d.ts" + "build/types-ts3.8/orchestrion/config/index.d.ts" ], "orchestrion/register": [ "build/types-ts3.8/orchestrion/runtime/register.d.ts" @@ -68,7 +68,7 @@ "build/types/orchestrion/index.d.ts" ], "orchestrion/config": [ - "build/types/orchestrion/config.d.ts" + "build/types/orchestrion/config/index.d.ts" ], "orchestrion/register": [ "build/types/orchestrion/runtime/register.d.ts" diff --git a/packages/server-utils/tsconfig.json b/packages/server-utils/tsconfig.json index 5430d015af38..72d1dd3759a1 100644 --- a/packages/server-utils/tsconfig.json +++ b/packages/server-utils/tsconfig.json @@ -6,7 +6,7 @@ "compilerOptions": {}, // The orchestrion runtime hook is a hand-written `.mjs` file that self-references // `@sentry/server-utils/orchestrion/config`. If tsc picks it up, it - // follows that subpath export back to `build/types/orchestrion/config.d.ts`, + // follows that subpath export back to `build/types/orchestrion/config/index.d.ts`, // treats the .d.ts as an input, and then collides with the .d.ts it wants to // emit from `src/orchestrion/config/index.ts`. Excluding it keeps tsc focused on the // .ts sources — rollup copies the file through to `build/orchestrion/` unchanged.