From 43866e7bde577da2800d01e96baab7f6742c48c8 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 16 Jul 2026 10:17:44 +0200 Subject: [PATCH 1/8] feat(node): Only setup orchestrion channel listeners when needed --- packages/bun/src/plugin.ts | 4 +- packages/core/src/client.ts | 16 ++++++ .../tracing-channel/express/index.ts | 23 +++++--- .../src/orchestrion/config/express.ts | 5 +- .../server-utils/src/orchestrion/detect.ts | 8 +++ .../src/orchestrion/instrumentation.ts | 53 +++++++++++++++++++ .../src/orchestrion/runtime/register.ts | 4 +- 7 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 packages/server-utils/src/orchestrion/instrumentation.ts diff --git a/packages/bun/src/plugin.ts b/packages/bun/src/plugin.ts index c0c9ac73fa79..f0dba86a54e6 100644 --- a/packages/bun/src/plugin.ts +++ b/packages/bun/src/plugin.ts @@ -44,7 +44,7 @@ import { } from '@sentry/server-utils/orchestrion/config'; const BUNDLER_MARKER_BANNER = - ';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=true;'; + ';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=[];'; // Minimal shape of Bun's `PluginBuilder` that we touch. Typed locally instead // of depending on `bun-types`, which would pull Bun's globals. @@ -57,7 +57,7 @@ interface BunPluginBuilder { * with the central `SENTRY_INSTRUMENTATIONS`. The plugin injects * `diagnostics_channel.tracingChannel` calls into the instrumented libraries as * `bun build` bundles them, and injects a banner that sets - * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` when the bundle boots + * `globalThis.__SENTRY_ORCHESTRION__.bundler = []` when the bundle boots * * Pass the result to `Bun.build({ plugins: [...] })`. * diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 87b62479d18e..97b1b00c10ff 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -927,6 +927,16 @@ export abstract class Client { */ public on(hook: 'stopUIProfiler', callback: () => void): () => void; + /** + * A hook that is called when the orchestrion runtime hook injects diagnostics + * channels into a module as it is loaded. + * + * The callback receives the name of the instrumented module. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'orchestrion.module-runtime-injected', callback: (moduleName: string) => void): () => void; + /** * Register a hook on this client. */ @@ -1188,6 +1198,12 @@ export abstract class Client { */ public emit(hook: 'stopUIProfiler'): void; + /** + * Emit a hook event when the orchestrion runtime hook injects diagnostics + * channels into a module as it is loaded. + */ + public emit(hook: 'orchestrion.module-runtime-injected', moduleName: string): void; + /** * Emit a hook that was previously registered via `on()`. */ diff --git a/packages/server-utils/src/integrations/tracing-channel/express/index.ts b/packages/server-utils/src/integrations/tracing-channel/express/index.ts index 2c7f19b4206a..37f66e24e217 100644 --- a/packages/server-utils/src/integrations/tracing-channel/express/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/express/index.ts @@ -3,24 +3,35 @@ import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; import type { ExpressIntegrationOptions } from './types'; import { instrumentExpress } from './instrumentation'; +import { expressModuleNames } from '../../../orchestrion/config/express'; +import { instrumentOrchestrion, instrumentOrchestrionLazy } from '../../../orchestrion/instrumentation'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Express' integration is omitted from the default set. const INTEGRATION_NAME = 'Express' as const; const _expressChannelIntegration = ((options: ExpressIntegrationOptions = {}) => { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return { + name: INTEGRATION_NAME, + }; + } + + const instrumentFn = () => { + instrumentExpress(options, diagnosticsChannel.tracingChannel); + }; + return { name: INTEGRATION_NAME, setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - waitForTracingChannelBinding(() => { - instrumentExpress(options, diagnosticsChannel.tracingChannel); + instrumentOrchestrion(expressModuleNames, instrumentFn); }); }, + setup(client) { + instrumentOrchestrionLazy(client, expressModuleNames, instrumentFn); + }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/orchestrion/config/express.ts b/packages/server-utils/src/orchestrion/config/express.ts index b7290dd2a7b4..8fbfbc303ff1 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const expressConfig = [ // Express funnels every middleware/route handler through a single method on @@ -54,7 +55,9 @@ export const expressConfig = [ module: { name: 'router', versionRange: '>=2.0.0 <3', filePath: 'index.js' }, functionQuery: { expressionName: 'use', kind: 'Sync' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const expressModuleNames = uniq(expressConfig.map(config => config.module.name)); export const expressChannels = { // Express v4 runs each layer's handler through `Layer.prototype.handle_request` diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index f6b7cb1c9e73..5a26a4504b87 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -50,3 +50,11 @@ export function detectOrchestrionSetup(): void { : '[Sentry] Bundler plugin did not run', ); } + +/** + * Get a list of all module names (e.g. express, @nestjs/core, ...) that have been injected by orchestrion, either at runtime or via a bundler plugin. + */ +export function getOrchestrionInjectedModules(): string[] { + const { runtime, bundler } = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ?? {}; + return [...(runtime ?? []), ...(bundler ?? [])]; +} diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts new file mode 100644 index 000000000000..051be4b1e533 --- /dev/null +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -0,0 +1,53 @@ +import type { Client } from '@sentry/core'; +import { addNonEnumerableProperty } from '@sentry/core'; +import { getOrchestrionInjectedModules } from './detect'; + +const INSTRUMENTATION_FN_SYMBOL = Symbol.for('InstrumentationFn'); +type InstrumentationFn = (() => void) & { [INSTRUMENTATION_FN_SYMBOL]?: boolean }; + +/** + * Instrument the provided callback to run when one of the provided module names has been orchestrion-injected already. + * Make sure to pass the same callback instance to this as is passed to `instrumentOrchestrionLazy`. + */ +export function instrumentOrchestrion(moduleNames: string[], callback: InstrumentationFn) { + if (hasBeenInjected(callback)) { + return; + } + + const modules = getOrchestrionInjectedModules(); + if (moduleNames.some(name => modules.includes(name))) { + callback(); + markAsInjected(callback); + } +} + +/** + * Run the provided instrumentation callback when one of the provided module names has been orchestrion-injected at runtime. + * This listens to the `orchestrion.module-runtime-injected` event and runs the callback when the event is emitted. + * Make sure to pass the same callback instance to this as is passed to `instrumentOrchestrion`. + */ +export function instrumentOrchestrionLazy(client: Client, moduleNames: string[], callback: InstrumentationFn) { + if (hasBeenInjected(callback)) { + return; + } + + const cleanup = client.on('orchestrion.module-runtime-injected', (moduleName: string) => { + if (moduleNames.includes(moduleName)) { + if (hasBeenInjected(callback)) { + cleanup(); + return; + } + callback(); + markAsInjected(callback); + cleanup(); + } + }); +} + +function hasBeenInjected(callback: InstrumentationFn) { + return callback[INSTRUMENTATION_FN_SYMBOL] ?? false; +} + +function markAsInjected(callback: InstrumentationFn) { + addNonEnumerableProperty(callback, INSTRUMENTATION_FN_SYMBOL, true); +} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 1e63c4c6ddeb..58579f46471a 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,4 +1,4 @@ -import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { debug, getClient, GLOBAL_OBJ } from '@sentry/core'; import { createRequire } from 'node:module'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; @@ -115,6 +115,8 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(event.moduleName); + + getClient()?.emit('orchestrion.module-runtime-injected', event.moduleName); }); initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); From ee28dc2014b67d292977459e3d1660384e2d8a31 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 16 Jul 2026 11:38:21 +0200 Subject: [PATCH 2/8] ref --- .../tracing-channel/express/index.ts | 23 +------ .../express/instrumentation.ts | 14 +---- .../src/orchestrion/instrumentation.ts | 60 ++++++++++++------- 3 files changed, 43 insertions(+), 54 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/express/index.ts b/packages/server-utils/src/integrations/tracing-channel/express/index.ts index 37f66e24e217..8ef81a5cabbf 100644 --- a/packages/server-utils/src/integrations/tracing-channel/express/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/express/index.ts @@ -1,36 +1,19 @@ -import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; import type { ExpressIntegrationOptions } from './types'; import { instrumentExpress } from './instrumentation'; import { expressModuleNames } from '../../../orchestrion/config/express'; -import { instrumentOrchestrion, instrumentOrchestrionLazy } from '../../../orchestrion/instrumentation'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Express' integration is omitted from the default set. const INTEGRATION_NAME = 'Express' as const; const _expressChannelIntegration = ((options: ExpressIntegrationOptions = {}) => { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return { - name: INTEGRATION_NAME, - }; - } - - const instrumentFn = () => { - instrumentExpress(options, diagnosticsChannel.tracingChannel); - }; - return { name: INTEGRATION_NAME, - setupOnce() { - waitForTracingChannelBinding(() => { - instrumentOrchestrion(expressModuleNames, instrumentFn); - }); - }, setup(client) { - instrumentOrchestrionLazy(client, expressModuleNames, instrumentFn); + invokeOrchestrionInstrumentation(client, expressModuleNames, instrumentExpress, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts index d3cfe0dc13ec..dcaf2f182d62 100644 --- a/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts +++ b/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts @@ -1,4 +1,4 @@ -import type * as diagnosticsChannel from 'node:diagnostics_channel'; +import * as diagnosticsChannel from 'node:diagnostics_channel'; import { HTTP_ROUTE } from '@sentry/conventions/attributes'; import type { Span } from '@sentry/core'; import { @@ -45,16 +45,8 @@ const ATTR_EXPRESS_TYPE = 'express.type'; const NOOP = (): void => {}; -let _isInstrumented = false; - -export function instrumentExpress( - options: ExpressIntegrationOptions, - tracingChannel: typeof diagnosticsChannel.tracingChannel, -): void { - if (_isInstrumented) { - return; - } - _isInstrumented = true; +export function instrumentExpress(options: ExpressIntegrationOptions): void { + const tracingChannel = diagnosticsChannel.tracingChannel; // Record each layer's registered path *pattern* as it is registered, so the // matched route can be reconstructed with its parameters intact at request diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts index 051be4b1e533..948b562961e3 100644 --- a/packages/server-utils/src/orchestrion/instrumentation.ts +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -1,44 +1,58 @@ import type { Client } from '@sentry/core'; -import { addNonEnumerableProperty } from '@sentry/core'; +import { addNonEnumerableProperty, waitForTracingChannelBinding } from '@sentry/core'; import { getOrchestrionInjectedModules } from './detect'; +import * as diagnosticsChannel from 'node:diagnostics_channel'; const INSTRUMENTATION_FN_SYMBOL = Symbol.for('InstrumentationFn'); -type InstrumentationFn = (() => void) & { [INSTRUMENTATION_FN_SYMBOL]?: boolean }; +// oxlint-disable-next-line typescript/no-explicit-any +type InstrumentationFn = ((...args: any[]) => void) & { [INSTRUMENTATION_FN_SYMBOL]?: boolean }; /** - * Instrument the provided callback to run when one of the provided module names has been orchestrion-injected already. - * Make sure to pass the same callback instance to this as is passed to `instrumentOrchestrionLazy`. + * Run the provided instrumentation callback when one of the provided module names is orchestrion-injected. + * If it is already injected, it will invoce the callback immediately (e.g. when build-time injection is used). + * If runtime injection is used, it may invoke the callback at a later point in time, when the injection actually happens. + * The callback will never be invoked more than once. */ -export function instrumentOrchestrion(moduleNames: string[], callback: InstrumentationFn) { +export function invokeOrchestrionInstrumentation( + client: Client, + moduleNames: string[], + callback: Callback, + args: Parameters, +) { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + // If already injected, skip if (hasBeenInjected(callback)) { return; } - const modules = getOrchestrionInjectedModules(); - if (moduleNames.some(name => modules.includes(name))) { - callback(); + const instrumentationFn = () => { markAsInjected(callback); - } -} -/** - * Run the provided instrumentation callback when one of the provided module names has been orchestrion-injected at runtime. - * This listens to the `orchestrion.module-runtime-injected` event and runs the callback when the event is emitted. - * Make sure to pass the same callback instance to this as is passed to `instrumentOrchestrion`. - */ -export function instrumentOrchestrionLazy(client: Client, moduleNames: string[], callback: InstrumentationFn) { - if (hasBeenInjected(callback)) { + waitForTracingChannelBinding(() => { + callback(...args); + }); + }; + + // First, check if the modules have been injected already + const modules = getOrchestrionInjectedModules(); + if (moduleNames.some(name => modules.includes(name))) { + instrumentationFn(); return; } + // Then, register a listener for the `orchestrion.module-runtime-injected` event const cleanup = client.on('orchestrion.module-runtime-injected', (moduleName: string) => { + if (hasBeenInjected(callback)) { + cleanup(); + return; + } + if (moduleNames.includes(moduleName)) { - if (hasBeenInjected(callback)) { - cleanup(); - return; - } - callback(); - markAsInjected(callback); + instrumentationFn(); cleanup(); } }); From 1bc33454c8c73c31f413291b56b15af5c39eb1d5 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 16 Jul 2026 13:02:33 +0200 Subject: [PATCH 3/8] migrate some stuff over --- .../integrations/tracing-channel/amqplib.ts | 37 +++---- .../integrations/tracing-channel/anthropic.ts | 54 +++++------ .../tracing-channel/fastify/index.ts | 5 +- .../tracing-channel/fastify/utils.ts | 2 +- .../tracing-channel/graphql/index.ts | 81 ++-------------- .../graphql/instrumentation.ts | 68 +++++++++++++ .../tracing-channel/kafkajs/index.ts | 96 ++----------------- .../kafkajs/instrumentation.ts | 78 +++++++++++++++ .../src/orchestrion/config/amqplib.ts | 5 +- .../src/orchestrion/config/anthropic-ai.ts | 5 +- .../src/orchestrion/config/graphql.ts | 5 +- .../src/orchestrion/config/kafkajs.ts | 5 +- 12 files changed, 214 insertions(+), 227 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/graphql/instrumentation.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/kafkajs/instrumentation.ts diff --git a/packages/server-utils/src/integrations/tracing-channel/amqplib.ts b/packages/server-utils/src/integrations/tracing-channel/amqplib.ts index a745ed88c9d2..419f657dfccb 100644 --- a/packages/server-utils/src/integrations/tracing-channel/amqplib.ts +++ b/packages/server-utils/src/integrations/tracing-channel/amqplib.ts @@ -3,7 +3,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { continueTrace, - debug, defineIntegration, getTraceData, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -11,7 +10,6 @@ import { SPAN_STATUS_ERROR, startInactiveSpan, timestampInSeconds, - waitForTracingChannelBinding, } from '@sentry/core'; // eslint-disable-next-line typescript/no-deprecated -- NET_PEER_* emitted alongside SERVER_* for backwards compatibility (TODO(v11): remove) import { @@ -27,9 +25,10 @@ import { SERVER_PORT, URL_FULL, } from '@sentry/conventions/attributes'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { amqplibModuleNames } from '../../orchestrion/config/amqplib'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Amqplib' integration is omitted from the default set. @@ -156,32 +155,20 @@ interface AmqpConnectContext { const NOOP = (): void => {}; -// Guards against subscribing to the amqplib channels more than once in a process. Core dedupes -// `setupOnce` by integration *name*, which is not enough here: the Deno SDK wraps this integration -// under a different name (`DenoAmqplib`) via `extendIntegration`, so adding both would otherwise run -// the subscribe logic twice and emit duplicate spans for every operation. -let subscribed = false; +function instrumentAmqplib(): void { + subscribeConnect(); + subscribePublish(); + subscribeConfirmPublish(); + subscribeConsume(); + subscribeDispatch(); + subscribeSettle(); +} const _amqplibChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel || subscribed) { - return; - } - subscribed = true; - - DEBUG_BUILD && debug.log('[orchestrion:amqplib] subscribing to amqplib tracing channels'); - - waitForTracingChannelBinding(() => { - subscribeConnect(); - subscribePublish(); - subscribeConfirmPublish(); - subscribeConsume(); - subscribeDispatch(); - subscribeSettle(); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, amqplibModuleNames, instrumentAmqplib, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/anthropic.ts b/packages/server-utils/src/integrations/tracing-channel/anthropic.ts index a6425437c7f7..68f2133b9321 100644 --- a/packages/server-utils/src/integrations/tracing-channel/anthropic.ts +++ b/packages/server-utils/src/integrations/tracing-channel/anthropic.ts @@ -4,7 +4,6 @@ import { _INTERNAL_shouldSkipAiProviderWrapping, addAnthropicRequestAttributes, addAnthropicResponseAttributes, - debug, defineIntegration, extractAnthropicRequestAttributes, GEN_AI_REQUEST_MODEL_ATTRIBUTE, @@ -14,11 +13,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, shouldEnableTruncation, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { anthropicAiModuleNames } from '../../orchestrion/config/anthropic-ai'; // Same name as the OTel integration by design: when enabled, the OTel 'Anthropic_AI' // integration is dropped from the default set (see the Node opt-in loader). @@ -47,39 +46,30 @@ interface AnthropicChannelContext { result?: unknown; } -let subscribed = false; +function instrumentAnthropic(options: AnthropicAiOptions): void { + for (const { channel, operation, methodPath, stream } of INSTRUMENTED_CHANNELS) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channel), + data => createGenAiSpan(data, operation, methodPath, options), + { + beforeSpanEnd: (span, data) => { + addAnthropicResponseAttributes( + span, + data.result as AnthropicAiResponse, + resolveAIRecordingOptions(options).recordOutputs, + ); + }, + deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, stream, options), + }, + ); + } +} const _anthropicChannelIntegration = ((options: AnthropicAiOptions = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - // tracingChannel is unavailable before Node 18.19 and prevent double-subscribe - if (!diagnosticsChannel.tracingChannel || subscribed) { - return; - } - subscribed = true; - - // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers - // after `setupOnce` runs, so wait for it before subscribing. - waitForTracingChannelBinding(() => { - for (const { channel, operation, methodPath, stream } of INSTRUMENTED_CHANNELS) { - DEBUG_BUILD && debug.log(`[orchestrion:anthropic] subscribing to channel "${channel}"`); - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(channel), - data => createGenAiSpan(data, operation, methodPath, options), - { - beforeSpanEnd: (span, data) => { - addAnthropicResponseAttributes( - span, - data.result as AnthropicAiResponse, - resolveAIRecordingOptions(options).recordOutputs, - ); - }, - deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, stream, options), - }, - ); - } - }); + setup(client) { + invokeOrchestrionInstrumentation(client, anthropicAiModuleNames, instrumentAnthropic, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/fastify/index.ts b/packages/server-utils/src/integrations/tracing-channel/fastify/index.ts index edab7d03804c..4bf64ae93eea 100644 --- a/packages/server-utils/src/integrations/tracing-channel/fastify/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/fastify/index.ts @@ -1,7 +1,6 @@ import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import type { FastifyIntegration, FastifyReply, FastifyRequest } from './types'; - import { instrumentFastify as _instrumentFastify } from './instrumentation'; import { defaultShouldHandleError, INTEGRATION_NAME } from './utils'; import { subscribeToFastifyErrorChannel, handleFastifyError as _handleFastifyError } from './errors'; @@ -76,9 +75,7 @@ const _fastifyIntegration = (({ shouldHandleError }: Partial = {}) => - _fastifyIntegration(options), -); +export const fastifyIntegration = defineIntegration(_fastifyIntegration); /** * @deprecated This export is deprecated and will not longer be exposed in the next major version. diff --git a/packages/server-utils/src/integrations/tracing-channel/fastify/utils.ts b/packages/server-utils/src/integrations/tracing-channel/fastify/utils.ts index 9b34135844df..e560590d9234 100644 --- a/packages/server-utils/src/integrations/tracing-channel/fastify/utils.ts +++ b/packages/server-utils/src/integrations/tracing-channel/fastify/utils.ts @@ -1,6 +1,6 @@ import type { FastifyReply, FastifyRequest } from './types'; -export const INTEGRATION_NAME = 'Fastify'; +export const INTEGRATION_NAME = 'Fastify' as const; /** * Default function to determine if an error should be sent to Sentry diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts index 2a3baa669e16..92f324d97b34 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts @@ -1,82 +1,20 @@ -import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; -import { DEBUG_BUILD } from '../../../debug-build'; +import { defineIntegration, extendIntegration } from '@sentry/core'; import { graphqlIntegration as graphqlNativeIntegration } from '../../../graphql'; import type { GraphqlDiagnosticChannelsOptions } from '../../../graphql/graphql-dc-subscriber'; -import { CHANNELS } from '../../../orchestrion/channels'; -import { bindTracingChannelToSpan } from '../../../tracing-channel'; -import { - finalizeExecuteSpan, - finalizeValidateSpan, - startExecuteSpan, - startParseSpan, - startValidateSpan, -} from './spans'; -import type { GraphqlResolvedConfig } from './types'; + +import { instrumentGraphql } from './instrumentation'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; +import { graphqlModuleNames } from '../../../orchestrion/config/graphql'; // Same name as the OTel/native integration by design, so enabling injection swaps this in for it. const INTEGRATION_NAME = 'Graphql' as const; -// The context orchestrion's transform attaches to each channel: `arguments` is the live args of the -// wrapped call, `result` the settled return value. -interface GraphqlChannelContext { - arguments: unknown[]; - self?: unknown; - result?: unknown; - error?: unknown; -} - -function getOptionsWithDefaults(options: GraphqlDiagnosticChannelsOptions): GraphqlResolvedConfig { - return { - ignoreResolveSpans: options.ignoreResolveSpans !== false, - ignoreTrivialResolveSpans: options.ignoreTrivialResolveSpans !== false, - useOperationNameForRootSpan: options.useOperationNameForRootSpan !== false, - }; -} - -/** - * Runs a span-building callback so a throw inside it can never break the user's graphql call: these - * run inside the `tracingChannel(...).trace*` machinery wrapping the real function (as the `getSpan` - * producer / `beforeSpanEnd` handler), where an unguarded throw would propagate into the traced call. - */ -function safe(fn: () => T): T | undefined { - try { - return fn(); - } catch (error) { - DEBUG_BUILD && debug.warn('[orchestrion:graphql] error building span', error); - return undefined; - } -} - const _graphqlChannelIntegration = ((options: GraphqlDiagnosticChannelsOptions = {}) => { - const config = getOptionsWithDefaults(options); - const getConfig = (): GraphqlResolvedConfig => config; - return { name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_PARSE), () => - safe(() => startParseSpan()), - ); - - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_VALIDATE), - data => safe(() => startValidateSpan(data.arguments[1])), - { beforeSpanEnd: (span, data) => void safe(() => finalizeValidateSpan(span, data.result)) }, - ); - - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_EXECUTE), - data => safe(() => startExecuteSpan(data.arguments, data.self, config, getConfig)), - { beforeSpanEnd: (span, data) => void safe(() => finalizeExecuteSpan(span, data.result)) }, - ); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, graphqlModuleNames, instrumentGraphql, [options]); }, }; }) satisfies IntegrationFn; @@ -102,8 +40,7 @@ export const graphqlChannelIntegration = defineIntegration(_graphqlChannelIntegr */ export const graphqlDiagnosticsChannelIntegration = (options?: GraphqlDiagnosticChannelsOptions) => { const orchestrion = graphqlChannelIntegration(options); - return extendIntegration(graphqlNativeIntegration(options), { - name: INTEGRATION_NAME, - setupOnce: () => orchestrion.setupOnce?.(), + return extendIntegration(orchestrion, { + ...graphqlNativeIntegration(options), }); }; diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/instrumentation.ts new file mode 100644 index 000000000000..b8c913d18b92 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/instrumentation.ts @@ -0,0 +1,68 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../../../debug-build'; +import type { GraphqlDiagnosticChannelsOptions } from '../../../graphql/graphql-dc-subscriber'; +import { CHANNELS } from '../../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../../tracing-channel'; +import { + finalizeExecuteSpan, + finalizeValidateSpan, + startExecuteSpan, + startParseSpan, + startValidateSpan, +} from './spans'; +import type { GraphqlResolvedConfig } from './types'; + +// The context orchestrion's transform attaches to each channel: `arguments` is the live args of the +// wrapped call, `result` the settled return value. +interface GraphqlChannelContext { + arguments: unknown[]; + self?: unknown; + result?: unknown; + error?: unknown; +} + +function getOptionsWithDefaults(options: GraphqlDiagnosticChannelsOptions): GraphqlResolvedConfig { + return { + ignoreResolveSpans: options.ignoreResolveSpans !== false, + ignoreTrivialResolveSpans: options.ignoreTrivialResolveSpans !== false, + useOperationNameForRootSpan: options.useOperationNameForRootSpan !== false, + }; +} + +/** + * Runs a span-building callback so a throw inside it can never break the user's graphql call: these + * run inside the `tracingChannel(...).trace*` machinery wrapping the real function (as the `getSpan` + * producer / `beforeSpanEnd` handler), where an unguarded throw would propagate into the traced call. + */ +function safe(fn: () => T): T | undefined { + try { + return fn(); + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:graphql] error building span', error); + return undefined; + } +} + +export function instrumentGraphql(options: GraphqlDiagnosticChannelsOptions = {}): void { + const config = getOptionsWithDefaults(options); + const getConfig = (): GraphqlResolvedConfig => config; + + const tracingChannel = diagnosticsChannel.tracingChannel; + + bindTracingChannelToSpan(tracingChannel(CHANNELS.GRAPHQL_PARSE), () => + safe(() => startParseSpan()), + ); + + bindTracingChannelToSpan( + tracingChannel(CHANNELS.GRAPHQL_VALIDATE), + data => safe(() => startValidateSpan(data.arguments[1])), + { beforeSpanEnd: (span, data) => void safe(() => finalizeValidateSpan(span, data.result)) }, + ); + + bindTracingChannelToSpan( + tracingChannel(CHANNELS.GRAPHQL_EXECUTE), + data => safe(() => startExecuteSpan(data.arguments, data.self, config, getConfig)), + { beforeSpanEnd: (span, data) => void safe(() => finalizeExecuteSpan(span, data.result)) }, + ); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/kafkajs/index.ts b/packages/server-utils/src/integrations/tracing-channel/kafkajs/index.ts index a1add0e544b3..35a2efd47a13 100644 --- a/packages/server-utils/src/integrations/tracing-channel/kafkajs/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/kafkajs/index.ts @@ -1,98 +1,16 @@ -import * as diagnosticsChannel from 'node:diagnostics_channel'; -import type { TracingChannelSubscribers } from 'node:diagnostics_channel'; -import type { IntegrationFn, Span } from '@sentry/core'; -import { debug, defineIntegration } from '@sentry/core'; -import { DEBUG_BUILD } from '../../../debug-build'; -import { CHANNELS } from '../../../orchestrion/channels'; -import { isWrappedConsumerCallback, wrapEachBatch, wrapEachMessage } from './consumer'; -import { applyErrorToSpans, startProducerSpan } from './spans'; -import type { ConsumerRunConfig, ProducerBatch } from './types'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; +import { instrumentKafkajs } from './instrumentation'; +import { kafkajsModuleNames } from '../../../orchestrion/config/kafkajs'; -// NOTE: this uses the same name as the OTel `Kafka` integration by design. When enabled, the OTel -// integration is omitted from the default set (see `experimentalUseDiagnosticsChannelInjection`). const INTEGRATION_NAME = 'Kafka' as const; -/** The tracing-channel context the transform attaches around `messageProducer.js`'s `sendBatch`. */ -interface SendBatchChannelContext { - // `arguments[0]` is the `{ topicMessages }` batch (kafkajs normalizes `send` into `sendBatch`). - arguments: [ProducerBatch?, ...unknown[]]; - error?: unknown; - // The producer spans opened at `start`, ended on `asyncEnd` (and marked errored on `error`). - _sentrySpans?: Span[]; -} - -/** The tracing-channel context the transform attaches around `consumer/index.js`'s `run`. */ -interface ConsumerRunChannelContext { - // `arguments[0]` is the `run(config)` config whose `eachMessage`/`eachBatch` we swap in place. - arguments: [ConsumerRunConfig?, ...unknown[]]; -} - -function subscribeToProducer(): void { - const channel = diagnosticsChannel.tracingChannel(CHANNELS.KAFKAJS_SEND_BATCH); - // Node types `subscribe` as requiring every lifecycle handler; runtime accepts a partial set, so we - // pass only the ones we use (matching `bindTracingChannelToSpan`'s handling in `tracing-channel.ts`). - const subscribers: Partial> = { - start(ctx) { - const spans: Span[] = []; - // `startProducerSpan` mutates each message's headers; doing it at `start` means the mutation - // reaches the real call, propagating the producer's trace to consumers. - (ctx.arguments[0]?.topicMessages ?? []).forEach(topicMessage => { - topicMessage.messages.forEach(message => { - spans.push(startProducerSpan(topicMessage.topic, message)); - }); - }); - ctx._sentrySpans = spans; - }, - error(ctx) { - if (ctx._sentrySpans) { - applyErrorToSpans(ctx._sentrySpans, ctx.error); - } - }, - asyncEnd(ctx) { - // `asyncEnd` fires on both success and failure; `error` (above) has already set the status. - ctx._sentrySpans?.forEach(span => span.end()); - }, - }; - channel.subscribe(subscribers as TracingChannelSubscribers); -} - -function subscribeToConsumer(): void { - const channel = diagnosticsChannel.tracingChannel(CHANNELS.KAFKAJS_CONSUMER_RUN); - const subscribers: Partial> = { - start(ctx) { - const config = ctx.arguments[0]; - if (!config || typeof config !== 'object') { - return; - } - // Swap the user callbacks for span-creating wrappers before `run` destructures its config. The - // `isWrappedConsumerCallback` guard keeps this idempotent: a config object reused across another - // `run` (or a second consumer) must not have its wrapper wrapped again, which would double spans. - if (typeof config.eachMessage === 'function' && !isWrappedConsumerCallback(config.eachMessage)) { - config.eachMessage = wrapEachMessage(config.eachMessage); - } - if (typeof config.eachBatch === 'function' && !isWrappedConsumerCallback(config.eachBatch)) { - config.eachBatch = wrapEachBatch(config.eachBatch); - } - }, - }; - channel.subscribe(subscribers as TracingChannelSubscribers); -} - const _kafkajsChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && - debug.log( - `[orchestrion:kafkajs] subscribing to channels "${CHANNELS.KAFKAJS_SEND_BATCH}", "${CHANNELS.KAFKAJS_CONSUMER_RUN}"`, - ); - - subscribeToProducer(); - subscribeToConsumer(); + setup(client) { + invokeOrchestrionInstrumentation(client, kafkajsModuleNames, instrumentKafkajs, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/kafkajs/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/kafkajs/instrumentation.ts new file mode 100644 index 000000000000..fdc209a2d0d4 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/kafkajs/instrumentation.ts @@ -0,0 +1,78 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { TracingChannelSubscribers } from 'node:diagnostics_channel'; +import type { Span } from '@sentry/core'; +import { CHANNELS } from '../../../orchestrion/channels'; +import { isWrappedConsumerCallback, wrapEachBatch, wrapEachMessage } from './consumer'; +import { applyErrorToSpans, startProducerSpan } from './spans'; +import type { ConsumerRunConfig, ProducerBatch } from './types'; + +/** The tracing-channel context the transform attaches around `messageProducer.js`'s `sendBatch`. */ +interface SendBatchChannelContext { + // `arguments[0]` is the `{ topicMessages }` batch (kafkajs normalizes `send` into `sendBatch`). + arguments: [ProducerBatch?, ...unknown[]]; + error?: unknown; + // The producer spans opened at `start`, ended on `asyncEnd` (and marked errored on `error`). + _sentrySpans?: Span[]; +} + +/** The tracing-channel context the transform attaches around `consumer/index.js`'s `run`. */ +interface ConsumerRunChannelContext { + // `arguments[0]` is the `run(config)` config whose `eachMessage`/`eachBatch` we swap in place. + arguments: [ConsumerRunConfig?, ...unknown[]]; +} + +function subscribeToProducer(): void { + const channel = diagnosticsChannel.tracingChannel(CHANNELS.KAFKAJS_SEND_BATCH); + // Node types `subscribe` as requiring every lifecycle handler; runtime accepts a partial set, so we + // pass only the ones we use (matching `bindTracingChannelToSpan`'s handling in `tracing-channel.ts`). + const subscribers: Partial> = { + start(ctx) { + const spans: Span[] = []; + // `startProducerSpan` mutates each message's headers; doing it at `start` means the mutation + // reaches the real call, propagating the producer's trace to consumers. + (ctx.arguments[0]?.topicMessages ?? []).forEach(topicMessage => { + topicMessage.messages.forEach(message => { + spans.push(startProducerSpan(topicMessage.topic, message)); + }); + }); + ctx._sentrySpans = spans; + }, + error(ctx) { + if (ctx._sentrySpans) { + applyErrorToSpans(ctx._sentrySpans, ctx.error); + } + }, + asyncEnd(ctx) { + // `asyncEnd` fires on both success and failure; `error` (above) has already set the status. + ctx._sentrySpans?.forEach(span => span.end()); + }, + }; + channel.subscribe(subscribers as TracingChannelSubscribers); +} + +function subscribeToConsumer(): void { + const channel = diagnosticsChannel.tracingChannel(CHANNELS.KAFKAJS_CONSUMER_RUN); + const subscribers: Partial> = { + start(ctx) { + const config = ctx.arguments[0]; + if (!config || typeof config !== 'object') { + return; + } + // Swap the user callbacks for span-creating wrappers before `run` destructures its config. The + // `isWrappedConsumerCallback` guard keeps this idempotent: a config object reused across another + // `run` (or a second consumer) must not have its wrapper wrapped again, which would double spans. + if (typeof config.eachMessage === 'function' && !isWrappedConsumerCallback(config.eachMessage)) { + config.eachMessage = wrapEachMessage(config.eachMessage); + } + if (typeof config.eachBatch === 'function' && !isWrappedConsumerCallback(config.eachBatch)) { + config.eachBatch = wrapEachBatch(config.eachBatch); + } + }, + }; + channel.subscribe(subscribers as TracingChannelSubscribers); +} + +export function instrumentKafkajs(): void { + subscribeToProducer(); + subscribeToConsumer(); +} diff --git a/packages/server-utils/src/orchestrion/config/amqplib.ts b/packages/server-utils/src/orchestrion/config/amqplib.ts index 75b5ced78cae..760ca81e217a 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; // `amqplib` splits its API across three files: // - `lib/channel_model.js` holds `class Channel` (publish/consume/ack/nack/reject/…) and @@ -73,7 +74,9 @@ export const amqplibConfig = [ module: { ...module, filePath: 'lib/connect.js' }, functionQuery: { functionName: 'connect', kind: 'Callback' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const amqplibModuleNames = uniq(amqplibConfig.map(config => config.module.name)); export const amqplibChannels = { AMQPLIB_PUBLISH: 'orchestrion:amqplib:publish', diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts index e5032d88a209..53f5a743d1c9 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const anthropicAiConfig = [ // One entry each for CJS/ESM @@ -31,7 +32,9 @@ export const anthropicAiConfig = [ module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, functionQuery: { className: 'Messages', methodName: 'stream', kind: 'Sync' as const }, })), -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const anthropicAiModuleNames = uniq(anthropicAiConfig.map(config => config.module.name)); export const anthropicAiChannels = { ANTHROPIC_CHAT: 'orchestrion:@anthropic-ai/sdk:chat', diff --git a/packages/server-utils/src/orchestrion/config/graphql.ts b/packages/server-utils/src/orchestrion/config/graphql.ts index be5a7809a0a5..0bff0f34f9cc 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; // `parse`/`validate`/`execute` are top-level named `function` declarations in graphql's compiled // files, stable across the supported majors, so `functionName` matches. `execute` returns @@ -19,7 +20,9 @@ export const graphqlConfig = [ module: { name: 'graphql', versionRange: '>=14.0.0 <17', filePath: 'execution/execute.js' }, functionQuery: { functionName: 'execute', kind: 'Auto' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const graphqlModuleNames = uniq(graphqlConfig.map(config => config.module.name)); export const graphqlChannels = { GRAPHQL_PARSE: 'orchestrion:graphql:parse', diff --git a/packages/server-utils/src/orchestrion/config/kafkajs.ts b/packages/server-utils/src/orchestrion/config/kafkajs.ts index e584ddd46299..37a60347fca4 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const kafkajsConfig = [ { @@ -19,7 +20,9 @@ export const kafkajsConfig = [ // `ctx.arguments` when invoking the original. functionQuery: { expressionName: 'run', kind: 'Async' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const kafkajsModuleNames = uniq(kafkajsConfig.map(config => config.module.name)); export const kafkajsChannels = { KAFKAJS_SEND_BATCH: 'orchestrion:kafkajs:send_batch', From f58c8a32f72ca763515030616102dfd8bdc7b397 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 16 Jul 2026 13:27:52 +0200 Subject: [PATCH 4/8] refactor all remaining --- .../tracing-channel/dataloader.ts | 32 +-- .../tracing-channel/generic-pool.ts | 18 +- .../tracing-channel/google-genai.ts | 60 ++--- .../src/integrations/tracing-channel/hapi.ts | 70 ------ .../tracing-channel/hapi/index.ts | 23 ++ .../tracing-channel/hapi/instrumentation.ts | 44 ++++ .../{hapi-types.ts => hapi/types.ts} | 0 .../{hapi-utils.ts => hapi/utils.ts} | 4 +- .../integrations/tracing-channel/ioredis.ts | 94 +++----- .../src/integrations/tracing-channel/knex.ts | 28 +-- .../tracing-channel/lru-memoizer.ts | 30 +-- .../src/integrations/tracing-channel/mysql.ts | 120 +++++---- .../integrations/tracing-channel/mysql2.ts | 14 +- .../integrations/tracing-channel/openai.ts | 48 ++-- .../tracing-channel/postgres-js.ts | 228 +++++++++--------- .../integrations/tracing-channel/postgres.ts | 42 ++-- .../src/integrations/tracing-channel/redis.ts | 45 ++-- .../integrations/tracing-channel/vercel-ai.ts | 19 +- .../server-utils/src/orchestrion/channels.ts | 4 - .../src/orchestrion/config/dataloader.ts | 5 +- .../src/orchestrion/config/generic-pool.ts | 5 +- .../src/orchestrion/config/google-genai.ts | 5 +- .../src/orchestrion/config/hapi.ts | 5 +- .../src/orchestrion/config/index.ts | 4 - .../src/orchestrion/config/ioredis.ts | 5 +- .../src/orchestrion/config/knex.ts | 5 +- .../src/orchestrion/config/lru-memoizer.ts | 5 +- .../src/orchestrion/config/mysql.ts | 5 +- .../src/orchestrion/config/mysql2.ts | 5 +- .../src/orchestrion/config/nestjs.ts | 5 +- .../src/orchestrion/config/openai.ts | 5 +- .../server-utils/src/orchestrion/config/pg.ts | 5 +- .../src/orchestrion/config/postgres.ts | 77 +++--- .../src/orchestrion/config/prisma.ts | 6 - .../src/orchestrion/config/react-router.ts | 6 - .../src/orchestrion/config/redis.ts | 5 +- .../src/orchestrion/config/vercel-ai.ts | 5 +- .../server-utils/src/orchestrion/index.ts | 2 +- .../vercel-ai-orchestrion-subscriber.ts | 14 +- 39 files changed, 509 insertions(+), 593 deletions(-) delete mode 100644 packages/server-utils/src/integrations/tracing-channel/hapi.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/hapi/index.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/hapi/instrumentation.ts rename packages/server-utils/src/integrations/tracing-channel/{hapi-types.ts => hapi/types.ts} (100%) rename packages/server-utils/src/integrations/tracing-channel/{hapi-utils.ts => hapi/utils.ts} (99%) delete mode 100644 packages/server-utils/src/orchestrion/config/prisma.ts delete mode 100644 packages/server-utils/src/orchestrion/config/react-router.ts diff --git a/packages/server-utils/src/integrations/tracing-channel/dataloader.ts b/packages/server-utils/src/integrations/tracing-channel/dataloader.ts index 80b998ab11e5..f00677d095ad 100644 --- a/packages/server-utils/src/integrations/tracing-channel/dataloader.ts +++ b/packages/server-utils/src/integrations/tracing-channel/dataloader.ts @@ -1,19 +1,18 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span, StartSpanOptions } from '@sentry/core'; import { - debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan, startSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import type { ChannelName } from '../../orchestrion/channels'; import { CHANNELS } from '../../orchestrion/channels'; import type { TracingChannelPayloadWithSpan } from '../../tracing-channel'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { dataloaderModuleNames } from '../../orchestrion/config/dataloader'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Dataloader' integration is omitted from the default set. @@ -78,25 +77,20 @@ function makeSpanOptions(loader: DataLoaderInstance | undefined, operation: Oper }; } +function instrumentDataloader(): void { + subscribeConstruct(); + subscribeLoad(); + subscribeSimpleOperation(CHANNELS.DATALOADER_LOAD_MANY, 'loadMany'); + subscribeSimpleOperation(CHANNELS.DATALOADER_PRIME, 'prime'); + subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR, 'clear'); + subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR_ALL, 'clearAll'); +} + const _dataloaderChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && debug.log('[orchestrion:dataloader] subscribing to dataloader tracing channels'); - - waitForTracingChannelBinding(() => { - subscribeConstruct(); - subscribeLoad(); - subscribeSimpleOperation(CHANNELS.DATALOADER_LOAD_MANY, 'loadMany'); - subscribeSimpleOperation(CHANNELS.DATALOADER_PRIME, 'prime'); - subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR, 'clear'); - subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR_ALL, 'clearAll'); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, dataloaderModuleNames, instrumentDataloader, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts b/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts index 576f0b4ca8e9..68c43a683716 100644 --- a/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts +++ b/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts @@ -1,13 +1,10 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { - defineIntegration, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startInactiveSpan, - waitForTracingChannelBinding, -} from '@sentry/core'; +import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { genericPoolModuleNames } from '../../orchestrion/config/generic-pool'; // Same name as the OTel integration by design — when enabled, the OTel // 'GenericPool' integration is omitted from the default set. @@ -20,13 +17,8 @@ interface GenericPoolAcquireContext { const _genericPoolChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => instrumentGenericPool()); + setup(client) { + invokeOrchestrionInstrumentation(client, genericPoolModuleNames, instrumentGenericPool, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/google-genai.ts b/packages/server-utils/src/integrations/tracing-channel/google-genai.ts index 312e4716cf61..bcbcefde42b9 100644 --- a/packages/server-utils/src/integrations/tracing-channel/google-genai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/google-genai.ts @@ -4,7 +4,6 @@ import { _INTERNAL_shouldSkipAiProviderWrapping, addGoogleGenAIRequestAttributes, addGoogleGenAIResponseAttributes, - debug, defineIntegration, extractGoogleGenAIRequestAttributes, GEN_AI_REQUEST_MODEL_ATTRIBUTE, @@ -15,11 +14,11 @@ import { shouldEnableTruncation, spanToJSON, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { googleGenAIModuleNames } from '../../orchestrion/config/google-genai'; // Same name as the OTel integration by design: when enabled, the OTel 'Google_GenAI' // integration is dropped from the default set (see the Node opt-in loader). @@ -44,42 +43,33 @@ interface GoogleGenAIChannelContext { result?: unknown; } -let subscribed = false; +function instrumentGoogleGenAI(options: GoogleGenAIOptions): void { + for (const { channel, operation } of INSTRUMENTED_CHANNELS) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channel), + data => createGenAiSpan(data, operation, options), + { + beforeSpanEnd: (span, data) => { + // Embeddings responses carry no content attributes. + if (operation !== 'embeddings') { + addGoogleGenAIResponseAttributes( + span, + data.result as GoogleGenAIResponse, + resolveAIRecordingOptions(options).recordOutputs, + ); + } + }, + deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), + }, + ); + } +} const _googleGenAIChannelIntegration = ((options: GoogleGenAIOptions = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe. - if (!diagnosticsChannel.tracingChannel || subscribed) { - return; - } - subscribed = true; - - // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers - // after `setupOnce` runs, so wait for it before subscribing. - waitForTracingChannelBinding(() => { - for (const { channel, operation } of INSTRUMENTED_CHANNELS) { - DEBUG_BUILD && debug.log(`[orchestrion:google-genai] subscribing to channel "${channel}"`); - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(channel), - data => createGenAiSpan(data, operation, options), - { - beforeSpanEnd: (span, data) => { - // Embeddings responses carry no content attributes. - if (operation !== 'embeddings') { - addGoogleGenAIResponseAttributes( - span, - data.result as GoogleGenAIResponse, - resolveAIRecordingOptions(options).recordOutputs, - ); - } - }, - deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), - }, - ); - } - }); + setup(client) { + invokeOrchestrionInstrumentation(client, googleGenAIModuleNames, instrumentGoogleGenAI, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi.ts b/packages/server-utils/src/integrations/tracing-channel/hapi.ts deleted file mode 100644 index 48a2d441873f..000000000000 --- a/packages/server-utils/src/integrations/tracing-channel/hapi.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as diagnosticsChannel from 'node:diagnostics_channel'; -import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; -import { CHANNELS } from '../../orchestrion/channels'; -import { wrapExtArguments, wrapRouteArguments } from './hapi-utils'; - -// NOTE: same name as the OTel integration by design — when enabled, the OTel -// 'Hapi' integration is omitted from the default set. -const INTEGRATION_NAME = 'Hapi' as const; - -/** - * The shape orchestrion's transform attaches to the `@hapi/hapi` route/ext - * tracing-channel `context` objects. - * - * `arguments` is the *live* args array passed to `server.route` / `server.ext`; - * we mutate it in place to swap handlers for span-creating proxies. `self` is - * the hapi server instance: the root server has `self.realm.plugin === undefined`, - * while a plugin's clone server exposes the registering plugin's name there. - */ -interface HapiChannelContext { - arguments: unknown[]; - self?: { realm?: { plugin?: string } }; -} - -const _hapiChannelIntegration = (() => { - return { - name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && - debug.log(`[orchestrion:hapi] subscribing to channels "${CHANNELS.HAPI_ROUTE}" / "${CHANNELS.HAPI_EXT}"`); - - // `subscribe` requires all five lifecycle hooks. We only act on `start`, - // which orchestrion fires synchronously with the live args array — that's - // the moment we mutate the handlers in place. - diagnosticsChannel.tracingChannel(CHANNELS.HAPI_ROUTE).subscribe({ - start(rawCtx) { - const ctx = rawCtx as HapiChannelContext; - wrapRouteArguments(ctx.arguments, ctx.self?.realm?.plugin); - }, - end() {}, - asyncStart() {}, - asyncEnd() {}, - error() {}, - }); - - diagnosticsChannel.tracingChannel(CHANNELS.HAPI_EXT).subscribe({ - start(rawCtx) { - const ctx = rawCtx as HapiChannelContext; - wrapExtArguments(ctx.arguments, ctx.self?.realm?.plugin); - }, - end() {}, - asyncStart() {}, - asyncEnd() {}, - error() {}, - }); - }, - }; -}) satisfies IntegrationFn; - -/** - * EXPERIMENTAL — orchestrion-driven hapi integration. Subscribes to the - * `orchestrion:@hapi/hapi:route` / `:ext` channels injected into `@hapi/hapi`'s - * `lib/server.js`. Requires the orchestrion runtime hook or bundler plugin. - */ -export const hapiChannelIntegration = defineIntegration(_hapiChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi/index.ts b/packages/server-utils/src/integrations/tracing-channel/hapi/index.ts new file mode 100644 index 000000000000..f6044080312b --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/hapi/index.ts @@ -0,0 +1,23 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; +import { hapiModuleNames } from '../../../orchestrion/config/hapi'; +import { instrumentHapi } from './instrumentation'; + +const INTEGRATION_NAME = 'Hapi' as const; + +const _hapiChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, hapiModuleNames, instrumentHapi, []); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven hapi integration. Subscribes to the + * `orchestrion:@hapi/hapi:route` / `:ext` channels injected into `@hapi/hapi`'s + * `lib/server.js`. Requires the orchestrion runtime hook or bundler plugin. + */ +export const hapiChannelIntegration = defineIntegration(_hapiChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/hapi/instrumentation.ts new file mode 100644 index 000000000000..6249e9305cff --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/hapi/instrumentation.ts @@ -0,0 +1,44 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { CHANNELS } from '../../../orchestrion/channels'; +import { wrapExtArguments, wrapRouteArguments } from './utils'; + +/** + * The shape orchestrion's transform attaches to the `@hapi/hapi` route/ext + * tracing-channel `context` objects. + * + * `arguments` is the *live* args array passed to `server.route` / `server.ext`; + * we mutate it in place to swap handlers for span-creating proxies. `self` is + * the hapi server instance: the root server has `self.realm.plugin === undefined`, + * while a plugin's clone server exposes the registering plugin's name there. + */ +interface HapiChannelContext { + arguments: unknown[]; + self?: { realm?: { plugin?: string } }; +} + +export function instrumentHapi() { + // `subscribe` requires all five lifecycle hooks. We only act on `start`, + // which orchestrion fires synchronously with the live args array — that's + // the moment we mutate the handlers in place. + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_ROUTE).subscribe({ + start(rawCtx) { + const ctx = rawCtx as HapiChannelContext; + wrapRouteArguments(ctx.arguments, ctx.self?.realm?.plugin); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); + + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_EXT).subscribe({ + start(rawCtx) { + const ctx = rawCtx as HapiChannelContext; + wrapExtArguments(ctx.arguments, ctx.self?.realm?.plugin); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi-types.ts b/packages/server-utils/src/integrations/tracing-channel/hapi/types.ts similarity index 100% rename from packages/server-utils/src/integrations/tracing-channel/hapi-types.ts rename to packages/server-utils/src/integrations/tracing-channel/hapi/types.ts diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi-utils.ts b/packages/server-utils/src/integrations/tracing-channel/hapi/utils.ts similarity index 99% rename from packages/server-utils/src/integrations/tracing-channel/hapi-utils.ts rename to packages/server-utils/src/integrations/tracing-channel/hapi/utils.ts index 82af2a11f196..e40411f78e90 100644 --- a/packages/server-utils/src/integrations/tracing-channel/hapi-utils.ts +++ b/packages/server-utils/src/integrations/tracing-channel/hapi/utils.ts @@ -25,10 +25,10 @@ import type { ServerRequestExtType, ServerRoute, ServerRouteOptions, -} from './hapi-types'; +} from './types'; // eslint-disable-next-line typescript/no-deprecated -- TODO(v11): Replace deprecated attributes import { HTTP_METHOD, HTTP_ROUTE } from '@sentry/conventions/attributes'; -import { AttributeNames, handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './hapi-types'; +import { AttributeNames, handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './types'; type SpanAttributes = Record; diff --git a/packages/server-utils/src/integrations/tracing-channel/ioredis.ts b/packages/server-utils/src/integrations/tracing-channel/ioredis.ts index 65d1fa69ac13..dd50a1798a1d 100644 --- a/packages/server-utils/src/integrations/tracing-channel/ioredis.ts +++ b/packages/server-utils/src/integrations/tracing-channel/ioredis.ts @@ -5,17 +5,12 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { DB_STATEMENT, DB_SYSTEM, NET_PEER_NAME, NET_PEER_PORT } from '@sentry/conventions/attributes'; import type { IntegrationFn, Span } from '@sentry/core'; -import { - debug, - defineIntegration, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startInactiveSpan, - waitForTracingChannelBinding, -} from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; +import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; import { defaultDbStatementSerializer } from '../../redis/redis-statement-serializer'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { ioredisModuleNames } from '../../orchestrion/config/ioredis'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // Distinct from the OTel `Redis` integration, which is composite (node-redis + // ioredis + the >=5.11.0 diagnostics_channel subscriber) and stays in the set; @@ -97,59 +92,50 @@ export function startIORedisCommandSpan(data: IORedisCommandContext): Span | und }); } -const _ioredisChannelIntegration = ((options: IORedisChannelIntegrationOptions = {}) => { +function instrumentIORedis(options: IORedisChannelIntegrationOptions) { const responseHook = options.responseHook; - return { - name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19. - if (!diagnosticsChannel.tracingChannel) { + const commandChannel = diagnosticsChannel.tracingChannel( + CHANNELS.IOREDIS_COMMAND, + ); + const connectChannel = diagnosticsChannel.tracingChannel( + CHANNELS.IOREDIS_CONNECT, + ); + + bindTracingChannelToSpan(commandChannel, startIORedisCommandSpan, { + // ioredis' `requireParentSpan` default: only create a span under an active span. + requiresParentSpan: true, + beforeSpanEnd(span, data) { + if ('error' in data || !responseHook) { return; } + const command = data.arguments?.[0] as RedisCommand | undefined; + if (command) { + runResponseHook(responseHook, span, command, data.result); + } + }, + }); - DEBUG_BUILD && - debug.log(`[orchestrion:ioredis] subscribing to "${CHANNELS.IOREDIS_COMMAND}"/"${CHANNELS.IOREDIS_CONNECT}"`); - - const commandChannel = diagnosticsChannel.tracingChannel( - CHANNELS.IOREDIS_COMMAND, - ); - const connectChannel = diagnosticsChannel.tracingChannel( - CHANNELS.IOREDIS_CONNECT, - ); - - // `bindTracingChannelToSpan` uses `bindStore`, which needs the async-context - // binding that `initOpenTelemetry()` registers after integration `setupOnce` — - // defer until it's available (matches the native redis diagnostics-channel subscriber). - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan(commandChannel, startIORedisCommandSpan, { - // ioredis' `requireParentSpan` default: only create a span under an active span. - requiresParentSpan: true, - beforeSpanEnd(span, data) { - if ('error' in data || !responseHook) { - return; - } - const command = data.arguments?.[0] as RedisCommand | undefined; - if (command) { - runResponseHook(responseHook, span, command, data.result); - } - }, - }); - - bindTracingChannelToSpan( - connectChannel, - data => { - const { host, port } = getConnectionOptions(data.self); - return startInactiveSpan({ - name: 'connect', - op: 'db', - attributes: { ...connectionAttributes(host, port), [DB_STATEMENT]: 'connect' }, - }); - }, - { requiresParentSpan: true }, - ); + bindTracingChannelToSpan( + connectChannel, + data => { + const { host, port } = getConnectionOptions(data.self); + return startInactiveSpan({ + name: 'connect', + op: 'db', + attributes: { ...connectionAttributes(host, port), [DB_STATEMENT]: 'connect' }, }); }, + { requiresParentSpan: true }, + ); +} + +const _ioredisChannelIntegration = ((options: IORedisChannelIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, ioredisModuleNames, instrumentIORedis, [options]); + }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/knex.ts b/packages/server-utils/src/integrations/tracing-channel/knex.ts index 68f08edbf6f1..404a8ed6e0b0 100644 --- a/packages/server-utils/src/integrations/tracing-channel/knex.ts +++ b/packages/server-utils/src/integrations/tracing-channel/knex.ts @@ -5,7 +5,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { - debug, defineIntegration, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -13,7 +12,6 @@ import { SPAN_STATUS_ERROR, startInactiveSpan, truncate, - waitForTracingChannelBinding, } from '@sentry/core'; import { DB_NAME, @@ -25,9 +23,10 @@ import { NET_PEER_PORT, NET_TRANSPORT, } from '@sentry/conventions/attributes'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { knexModuleNames } from '../../orchestrion/config/knex'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // NOTE: this uses the same name as the OTel integration by design. `@sentry/node`'s `knexIntegration` // picks this subscriber over the vendored OTel path when orchestrion injection is active. @@ -100,23 +99,18 @@ interface KnexBuilderChannelContext { result?: KnexBuilder; } +function instrumentKnex() { + subscribeBuilder(CHANNELS.KNEX_QUERY_BUILDER); + subscribeBuilder(CHANNELS.KNEX_SCHEMA_BUILDER); + subscribeBuilder(CHANNELS.KNEX_RAW); + subscribeQuery(); +} + const _knexChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && debug.log(`[orchestrion:knex] subscribing to channel "${CHANNELS.KNEX_QUERY}"`); - - waitForTracingChannelBinding(() => { - subscribeBuilder(CHANNELS.KNEX_QUERY_BUILDER); - subscribeBuilder(CHANNELS.KNEX_SCHEMA_BUILDER); - subscribeBuilder(CHANNELS.KNEX_RAW); - subscribeQuery(); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, knexModuleNames, instrumentKnex, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts index 982e5200b4ef..80b036e24bcb 100644 --- a/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts +++ b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts @@ -1,9 +1,10 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; +import { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { lruMemoizerModuleNames } from '../../orchestrion/config/lru-memoizer'; // Same name as the OTel integration by design — when enabled, the OTel // 'LruMemoizer' integration is omitted from the default set. @@ -13,24 +14,19 @@ interface LruMemoizerLoadContext { arguments: unknown[]; } +function instrumentLruMemoizer() { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD), + // We only want the helper's caller-context restore for the callback lru-memoizer fires from a detached `setImmediate`. + () => undefined, + ); +} + const _lruMemoizerChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && debug.log(`[orchestrion:lru-memoizer] subscribing to channel "${CHANNELS.LRU_MEMOIZER_LOAD}"`); - - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD), - // We only want the helper's caller-context restore for the callback lru-memoizer fires from a detached `setImmediate`. - () => undefined, - ); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, lruMemoizerModuleNames, instrumentLruMemoizer, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql.ts b/packages/server-utils/src/integrations/tracing-channel/mysql.ts index 9e96e23e3900..4a13d5eb00ba 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql.ts @@ -3,17 +3,16 @@ import type { IntegrationFn, Scope } from '@sentry/core'; import { isObjectLike, bindScopeToEmitter, - debug, defineIntegration, getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { mysqlModuleNames } from '../../orchestrion/config/mysql'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, OTel 'Mysql' integration is omitted from the default set. @@ -58,69 +57,64 @@ interface MysqlConnection { config?: MysqlConnectionConfig; } +function instrumentMysql() { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.MYSQL_QUERY), + data => { + const sql = extractSql(data.arguments[0]); + const { host, port, database, user } = getConnectionConfig(data.self); + const portNumber = typeof port === 'string' ? parseInt(port, 10) : port; + const portIsNumber = typeof portNumber === 'number' && !isNaN(portNumber); + + // For the streamed path: mysql emits the `Query` emitter's events from its socket data + // handler with the caller's context lost. `deferSpanEnd` replays this scope onto the emitter. + data._sentryCallerScope = getCurrentScope(); + + return startInactiveSpan({ + name: sql ?? 'mysql.query', + kind: SPAN_KIND.CLIENT, + op: 'db', + attributes: { + [ATTR_DB_SYSTEM]: 'mysql', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.orchestrion.mysql', + [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database), + ...(database ? { [ATTR_DB_NAME]: database } : {}), + ...(user ? { [ATTR_DB_USER]: user } : {}), + ...(sql ? { [ATTR_DB_STATEMENT]: sql } : {}), + ...(host ? { [ATTR_NET_PEER_NAME]: host } : {}), + ...(portIsNumber ? { [ATTR_NET_PEER_PORT]: portNumber } : {}), + }, + }); + }, + { + // No-callback `query(sql)` returns a streamable `Query` emitter as `result`; it settles on the + // emitter's `'end'`/`'error'`, not the channel, so defer ending to those. + deferSpanEnd({ data, end }) { + const result = data.result; + if (!result || typeof result !== 'object' || !hasOnMethod(result)) { + return false; + } + + // Replay the caller's scope so user listeners on the emitter nest under it, not a new trace. + const callerScope = data._sentryCallerScope; + if (callerScope) { + bindScopeToEmitter(result, callerScope); + } + + result.on('error', err => end(err)); + result.on('end', () => end()); + + return true; + }, + }, + ); +} + const _mysqlChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && debug.log(`[orchestrion:mysql] subscribing to channel "${CHANNELS.MYSQL_QUERY}"`); - - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.MYSQL_QUERY), - data => { - const sql = extractSql(data.arguments[0]); - const { host, port, database, user } = getConnectionConfig(data.self); - const portNumber = typeof port === 'string' ? parseInt(port, 10) : port; - const portIsNumber = typeof portNumber === 'number' && !isNaN(portNumber); - - // For the streamed path: mysql emits the `Query` emitter's events from its socket data - // handler with the caller's context lost. `deferSpanEnd` replays this scope onto the emitter. - data._sentryCallerScope = getCurrentScope(); - - return startInactiveSpan({ - name: sql ?? 'mysql.query', - kind: SPAN_KIND.CLIENT, - op: 'db', - attributes: { - [ATTR_DB_SYSTEM]: 'mysql', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.orchestrion.mysql', - [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database), - ...(database ? { [ATTR_DB_NAME]: database } : {}), - ...(user ? { [ATTR_DB_USER]: user } : {}), - ...(sql ? { [ATTR_DB_STATEMENT]: sql } : {}), - ...(host ? { [ATTR_NET_PEER_NAME]: host } : {}), - ...(portIsNumber ? { [ATTR_NET_PEER_PORT]: portNumber } : {}), - }, - }); - }, - { - // No-callback `query(sql)` returns a streamable `Query` emitter as `result`; it settles on the - // emitter's `'end'`/`'error'`, not the channel, so defer ending to those. - deferSpanEnd({ data, end }) { - const result = data.result; - if (!result || typeof result !== 'object' || !hasOnMethod(result)) { - return false; - } - - // Replay the caller's scope so user listeners on the emitter nest under it, not a new trace. - const callerScope = data._sentryCallerScope; - if (callerScope) { - bindScopeToEmitter(result, callerScope); - } - - result.on('error', err => end(err)); - result.on('end', () => end()); - - return true; - }, - }, - ); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, mysqlModuleNames, instrumentMysql, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts index 9e59f521d363..8e7dca3c595d 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -7,7 +7,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; import { subscribeMysql2DiagnosticChannels } from '../../mysql2/mysql2-dc-subscriber'; import type { ChannelName } from '../../orchestrion/channels'; @@ -21,6 +20,8 @@ import { NET_PEER_NAME, NET_PEER_PORT, } from '@sentry/conventions/attributes'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { mysql2ModuleNames } from '../../orchestrion/config/mysql2'; const INTEGRATION_NAME = 'Mysql2' as const; const ORIGIN = 'auto.db.orchestrion.mysql2'; @@ -137,15 +138,8 @@ function getConnectionAttributes(config: Mysql2ConnectionConfig | undefined): Sp const _mysql2ChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - instrumentMysql2(); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, mysql2ModuleNames, instrumentMysql2, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/openai.ts b/packages/server-utils/src/integrations/tracing-channel/openai.ts index e1498db89e43..f033120900c5 100644 --- a/packages/server-utils/src/integrations/tracing-channel/openai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/openai.ts @@ -4,7 +4,6 @@ import { _INTERNAL_shouldSkipAiProviderWrapping, addOpenAiRequestAttributes, addOpenAiResponseAttributes, - debug, defineIntegration, extractOpenAiRequestAttributes, instrumentOpenAiStream, @@ -12,11 +11,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, shouldEnableTruncation, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { openaiModuleNames } from '../../orchestrion/config/openai'; // Same name as the OTel integration by design: when enabled, the OTel 'OpenAI' // integration is dropped from the default set (see the Node opt-in loader). @@ -44,36 +43,27 @@ interface OpenAiChatChannelContext { result?: unknown; } -let subscribed = false; +function instrumentOpenAi(options: OpenAiOptions) { + for (const { channel, operation } of INSTRUMENTED_CHANNELS) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channel), + data => createGenAiSpan(data, operation, options), + { + beforeSpanEnd: (span, data) => { + addOpenAiResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); + }, + // Streaming: the result is a `Stream` consumed later, so instrument it and let it end the span. + deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), + }, + ); + } +} const _openaiChannelIntegration = ((options: OpenAiOptions = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe. - if (!diagnosticsChannel.tracingChannel || subscribed) { - return; - } - subscribed = true; - - // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers - // after `setupOnce` runs, so wait for it before subscribing. - waitForTracingChannelBinding(() => { - for (const { channel, operation } of INSTRUMENTED_CHANNELS) { - DEBUG_BUILD && debug.log(`[orchestrion:openai] subscribing to channel "${channel}"`); - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(channel), - data => createGenAiSpan(data, operation, options), - { - beforeSpanEnd: (span, data) => { - addOpenAiResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); - }, - // Streaming: the result is a `Stream` consumed later, so instrument it and let it end the span. - deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), - }, - ); - } - }); + setup(client) { + invokeOrchestrionInstrumentation(client, openaiModuleNames, instrumentOpenAi, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/postgres-js.ts b/packages/server-utils/src/integrations/tracing-channel/postgres-js.ts index 0a2440937643..4258498e4c86 100644 --- a/packages/server-utils/src/integrations/tracing-channel/postgres-js.ts +++ b/packages/server-utils/src/integrations/tracing-channel/postgres-js.ts @@ -13,11 +13,12 @@ import { SPAN_KIND, SPAN_STATUS_ERROR, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { postgresJsModuleNames } from '../../orchestrion/config/postgres'; // Same name as the OTel `PostgresJs` integration by design: when this is // enabled, the OTel integration of the same name is dropped from the default @@ -196,130 +197,123 @@ function wrapQuerySettlement(data: PostgresJsQueryContext, span: Span, sanitized } } -const _postgresJsChannelIntegration = ((options: PostgresJsChannelIntegrationOptions = {}) => { +function instrumentPostgresJs(options: PostgresJsChannelIntegrationOptions) { const { requireParentSpan, requestHook } = options; - return { - name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19. - if (!diagnosticsChannel.tracingChannel) { - return; + // Connection + execute are pure observers (no span, no async binding), so + // subscribe immediately — factory-time `Connection()` calls happen before + // `waitForTracingChannelBinding` resolves and must still be recorded. + diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_CONNECTION).subscribe({ + start: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + end: recordConnectionFromChannel, + }); + + // Per-connection attributes for queries reusing an already-open connection + // (`c.execute(q)`, `self === c`). `execute` is also called bare + // (`self === undefined`) for the first query on each connection, `fetchState` + // and `retry`; those miss here (the `connect` channel below covers the first + // user query, and the single-endpoint fallback covers the common case). + diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_EXECUTE).subscribe({ + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + start: attachConnectionAttributesFromChannel, + }); + + // The connection's `connect(query)` method (`self === c`, `arguments[0]` the + // query) fires when a fresh connection is opened for a query. That first query + // is later dispatched via a bare `execute` (no `self`), so this is where it + // gets its connection attributes in multi-endpoint apps. + diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_CONNECT).subscribe({ + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + start: attachConnectionAttributesFromChannel, + }); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_HANDLE), + data => { + const query = data.self; + if (!query) { + return undefined; } - DEBUG_BUILD && debug.log(`[orchestrion:postgresjs] subscribing to "${CHANNELS.POSTGRESJS_HANDLE}"`); - - // Connection + execute are pure observers (no span, no async binding), so - // subscribe immediately — factory-time `Connection()` calls happen before - // `waitForTracingChannelBinding` resolves and must still be recorded. - diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_CONNECTION).subscribe({ - start: NOOP, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, - end: recordConnectionFromChannel, - }); + // Opt out of re-entrant `handle()` calls (then/catch/finally re-invoke it, guarded by + // `executed`) and queries already wrapped by the portable `instrumentPostgresJsSql`. The + // parent-span requirement is applied via `requiresParentSpan` below. + if (query.executed === true || (query as Record)[QUERY_FROM_INSTRUMENTED_SQL]) { + return undefined; + } - // Per-connection attributes for queries reusing an already-open connection - // (`c.execute(q)`, `self === c`). `execute` is also called bare - // (`self === undefined`) for the first query on each connection, `fetchState` - // and `retry`; those miss here (the `connect` channel below covers the first - // user query, and the single-endpoint fallback covers the common case). - diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_EXECUTE).subscribe({ - end: NOOP, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, - start: attachConnectionAttributesFromChannel, + const fullQuery = _INTERNAL_reconstructPostgresQuery(query.strings); + const sanitizedSqlQuery = _INTERNAL_sanitizeSqlQuery(fullQuery); + + // `kind: CLIENT` matches the mysql/pg channel subscribers. + const span = startInactiveSpan({ + name: sanitizedSqlQuery || 'postgresjs.query', + op: 'db', + kind: SPAN_KIND.CLIENT, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [DB_SYSTEM_NAME]: 'postgres', + [DB_QUERY_TEXT]: sanitizedSqlQuery, + }, }); - // The connection's `connect(query)` method (`self === c`, `arguments[0]` the - // query) fires when a fresh connection is opened for a query. That first query - // is later dispatched via a bare `execute` (no `self`), so this is where it - // gets its connection attributes in multi-endpoint apps. - diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_CONNECT).subscribe({ - end: NOOP, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, - start: attachConnectionAttributesFromChannel, - }); + // Stash for the `execute`/`connect` channels to attach per-connection attributes. + (query as Record)[QUERY_SPAN] = span; - // The span-creating `handle` subscription needs the async-context binding - // that `initOpenTelemetry()` registers after integration setup. - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_HANDLE), - data => { - const query = data.self; - if (!query) { - return undefined; - } - - // Opt out of re-entrant `handle()` calls (then/catch/finally re-invoke it, guarded by - // `executed`) and queries already wrapped by the portable `instrumentPostgresJsSql`. The - // parent-span requirement is applied via `requiresParentSpan` below. - if (query.executed === true || (query as Record)[QUERY_FROM_INSTRUMENTED_SQL]) { - return undefined; - } - - const fullQuery = _INTERNAL_reconstructPostgresQuery(query.strings); - const sanitizedSqlQuery = _INTERNAL_sanitizeSqlQuery(fullQuery); - - // `kind: CLIENT` matches the mysql/pg channel subscribers. - const span = startInactiveSpan({ - name: sanitizedSqlQuery || 'postgresjs.query', - op: 'db', - kind: SPAN_KIND.CLIENT, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [DB_SYSTEM_NAME]: 'postgres', - [DB_QUERY_TEXT]: sanitizedSqlQuery, - }, - }); - - // Stash for the `execute`/`connect` channels to attach per-connection attributes. - (query as Record)[QUERY_SPAN] = span; - - // Single-endpoint fallback: resolve context now so `requestHook` has it - // and the first-query-per-connection (bare `execute`) path still gets attrs. - const context = resolveSingleEndpoint(); - if (context) { - setConnectionAttributes(span, query, context); - } - - if (requestHook) { - try { - requestHook(span, sanitizedSqlQuery, context); - } catch (e) { - span.setAttribute('sentry.hook.error', 'requestHook failed'); - DEBUG_BUILD && debug.error('[orchestrion:postgresjs] error in requestHook:', e); - } - } - - wrapQuerySettlement(data, span, sanitizedSqlQuery); - - return span; - }, - { - requiresParentSpan: requireParentSpan !== false, - deferSpanEnd({ data }) { - // `handle` is async: its promise settles on dispatch (asyncEnd), long - // before the query does. The resolve/reject wrappers own the ending. - if ((data as Record)[SPAN_ENDED]) { - return true; // wrappers already ended it - } - if ('error' in data) { - return false; // `handle()` itself threw; the error subscriber annotated the span, let the helper end it - } - // NOTE: for a cursor consumed as an async iterator, only the first batch - // reaches `handle` (the `executed` guard blocks the rest), so the span - // ends on the first batch — a pre-existing flaw kept for parity. - return true; // query in flight; the wrappers will end the span when it settles - }, - }, - ); - }); + // Single-endpoint fallback: resolve context now so `requestHook` has it + // and the first-query-per-connection (bare `execute`) path still gets attrs. + const context = resolveSingleEndpoint(); + if (context) { + setConnectionAttributes(span, query, context); + } + + if (requestHook) { + try { + requestHook(span, sanitizedSqlQuery, context); + } catch (e) { + span.setAttribute('sentry.hook.error', 'requestHook failed'); + DEBUG_BUILD && debug.error('[orchestrion:postgresjs] error in requestHook:', e); + } + } + + wrapQuerySettlement(data, span, sanitizedSqlQuery); + + return span; + }, + { + requiresParentSpan: requireParentSpan !== false, + deferSpanEnd({ data }) { + // `handle` is async: its promise settles on dispatch (asyncEnd), long + // before the query does. The resolve/reject wrappers own the ending. + if ((data as Record)[SPAN_ENDED]) { + return true; // wrappers already ended it + } + if ('error' in data) { + return false; // `handle()` itself threw; the error subscriber annotated the span, let the helper end it + } + // NOTE: for a cursor consumed as an async iterator, only the first batch + // reaches `handle` (the `executed` guard blocks the rest), so the span + // ends on the first batch — a pre-existing flaw kept for parity. + return true; // query in flight; the wrappers will end the span when it settles + }, + }, + ); +} + +const _postgresJsChannelIntegration = ((options: PostgresJsChannelIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, postgresJsModuleNames, instrumentPostgresJs, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/postgres.ts b/packages/server-utils/src/integrations/tracing-channel/postgres.ts index 9c69bdb5140b..85f53c4177fe 100644 --- a/packages/server-utils/src/integrations/tracing-channel/postgres.ts +++ b/packages/server-utils/src/integrations/tracing-channel/postgres.ts @@ -3,17 +3,16 @@ import type { IntegrationFn, Scope, SpanAttributes } from '@sentry/core'; import { isObjectLike, bindScopeToEmitter, - debug, defineIntegration, getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { pgModuleNames } from '../../orchestrion/config/pg'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Postgres' integration is omitted from the default set. @@ -79,28 +78,25 @@ interface PgPoolOptions extends PgConnectionParams { max?: number; } +function instrumentPg(options: { ignoreConnectSpans?: boolean }) { + // Query spans: `pg`/native `Client.prototype.query`. Only this channel can return a streamable + // `Submittable` result, so it's the only one that defers span-ending to the emitter (see below). + subscribeQueryLikeChannel(CHANNELS.PG_QUERY, querySpanOptions, { deferStreamedResult: true }); + + // Connect spans, gated by `ignoreConnectSpans` (same as OTel pg). + // `Client.prototype.connect` (pg + native) + // and `Pool.prototype.connect` (pg-pool). + if (!options.ignoreConnectSpans) { + subscribeQueryLikeChannel(CHANNELS.PG_CONNECT, connectSpanOptions); + subscribeQueryLikeChannel(CHANNELS.PGPOOL_CONNECT, poolConnectSpanOptions); + } +} + const _postgresChannelIntegration = ((options: { ignoreConnectSpans?: boolean } = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - // Query spans: `pg`/native `Client.prototype.query`. Only this channel can return a streamable - // `Submittable` result, so it's the only one that defers span-ending to the emitter (see below). - subscribeQueryLikeChannel(CHANNELS.PG_QUERY, querySpanOptions, { deferStreamedResult: true }); - - // Connect spans, gated by `ignoreConnectSpans` (same as OTel pg). - // `Client.prototype.connect` (pg + native) - // and `Pool.prototype.connect` (pg-pool). - if (!options.ignoreConnectSpans) { - subscribeQueryLikeChannel(CHANNELS.PG_CONNECT, connectSpanOptions); - subscribeQueryLikeChannel(CHANNELS.PGPOOL_CONNECT, poolConnectSpanOptions); - } - }); + setup(client) { + invokeOrchestrionInstrumentation(client, pgModuleNames, instrumentPg, [options]); }, }; }) satisfies IntegrationFn; @@ -116,8 +112,6 @@ function subscribeQueryLikeChannel( getSpanOptions: (ctx: PgChannelContext) => { name: string; op: string; attributes: SpanAttributes }, { deferStreamedResult = false }: { deferStreamedResult?: boolean } = {}, ): void { - DEBUG_BUILD && debug.log(`[orchestrion:pg] subscribing to channel "${channelName}"`); - bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(channelName), data => { diff --git a/packages/server-utils/src/integrations/tracing-channel/redis.ts b/packages/server-utils/src/integrations/tracing-channel/redis.ts index 6a1d5e4d779e..207c5e5b23bf 100644 --- a/packages/server-utils/src/integrations/tracing-channel/redis.ts +++ b/packages/server-utils/src/integrations/tracing-channel/redis.ts @@ -15,21 +15,20 @@ import { import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { isObjectLike, - debug, - defineIntegration, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, SPAN_STATUS_ERROR, startInactiveSpan, - waitForTracingChannelBinding, withActiveSpan, + defineIntegration, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { defaultDbStatementSerializer } from '../../redis/redis-statement-serializer'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { redisModuleNames } from '../../orchestrion/config/redis'; // A distinct name from the composite OTel `Redis` integration — they can't share one, and // `Redis` stays in the set for its native diagnostics_channel subscriber (node-redis >=5.12 / @@ -311,33 +310,25 @@ function bindNodeRedisBatchChannel(channelName: string, getOperation: (data: Com ); } -const _redisChannelIntegration = ((options: RedisChannelIntegrationOptions = {}) => { +function instrumentRedis(options: RedisChannelIntegrationOptions) { const responseHook = options.responseHook; + subscribeLegacyRedisCommand(responseHook); + bindNodeRedisCommandChannel(CHANNELS.NODE_REDIS_COMMAND, getSendCommandArgs, responseHook); + bindNodeRedisCommandChannel(CHANNELS.NODE_REDIS_EXECUTOR, getExecutorArgs, responseHook); + bindNodeRedisConnectChannel(); + bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_MULTI, () => 'MULTI'); + bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_PIPELINE, () => 'PIPELINE'); + bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_BATCH, data => + data.arguments?.[2] !== undefined ? 'MULTI' : 'PIPELINE', + ); +} + +const _redisChannelIntegration = ((options: RedisChannelIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && - debug.log(`[orchestrion:redis] subscribing to "${CHANNELS.REDIS_COMMAND}" and node-redis channels`); - - // redis v2-v3 uses a nested callback rather than `bindStore`, so it can be - // subscribed synchronously here. - subscribeLegacyRedisCommand(responseHook); - - waitForTracingChannelBinding(() => { - bindNodeRedisCommandChannel(CHANNELS.NODE_REDIS_COMMAND, getSendCommandArgs, responseHook); - bindNodeRedisCommandChannel(CHANNELS.NODE_REDIS_EXECUTOR, getExecutorArgs, responseHook); - bindNodeRedisConnectChannel(); - bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_MULTI, () => 'MULTI'); - bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_PIPELINE, () => 'PIPELINE'); - bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_BATCH, data => - data.arguments?.[2] !== undefined ? 'MULTI' : 'PIPELINE', - ); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, redisModuleNames, instrumentRedis, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/vercel-ai.ts b/packages/server-utils/src/integrations/tracing-channel/vercel-ai.ts index 72ef44bcbd86..1d40479eed01 100644 --- a/packages/server-utils/src/integrations/tracing-channel/vercel-ai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/vercel-ai.ts @@ -1,8 +1,9 @@ import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; import { vercelAiIntegration as baseVercelAiIntegration } from '../../vercel-ai'; -import * as dc from 'node:diagnostics_channel'; -import { subscribeVercelAiOrchestrionChannels } from '../../vercel-ai/vercel-ai-orchestrion-subscriber'; +import { instrumentVercelAi } from '../../vercel-ai/vercel-ai-orchestrion-subscriber'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { vercelAiModuleNames } from '../../orchestrion/config/vercel-ai'; type VercelAiOptions = Parameters[0]; @@ -13,21 +14,13 @@ type VercelAiOptions = Parameters[0]; // subscriber suppresses them at the source: it flips the wrapped call's // `experimental_telemetry.isEnabled` to `false`, so `ai` falls back to its // internal no-op tracer and never creates the native spans in the first place. -// See `subscribeVercelAiOrchestrionChannels`. const _vercelAiChannelIntegration = ((options: VercelAiOptions = {}) => { const parentIntegration = baseVercelAiIntegration(options); return extendIntegration(parentIntegration, { options, - setupOnce() { - // Bail if this is not available - if (!dc.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - subscribeVercelAiOrchestrionChannels(dc.tracingChannel, options); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, vercelAiModuleNames, instrumentVercelAi, [options]); }, }); }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 83413cfa0d34..7e4b7de3a6d8 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -21,8 +21,6 @@ import { nestjsChannels } from './config/nestjs'; import { openaiChannels } from './config/openai'; import { pgChannels } from './config/pg'; import { postgresJsChannels } from './config/postgres'; -import { prismaChannels } from './config/prisma'; -import { reactRouterChannels } from './config/react-router'; import { redisChannels } from './config/redis'; import { remixChannels } from './config/remix'; import { tediousChannels } from './config/tedious'; @@ -68,8 +66,6 @@ export const CHANNELS = { ...openaiChannels, ...pgChannels, ...postgresJsChannels, - ...prismaChannels, - ...reactRouterChannels, ...redisChannels, ...remixChannels, ...tediousChannels, diff --git a/packages/server-utils/src/orchestrion/config/dataloader.ts b/packages/server-utils/src/orchestrion/config/dataloader.ts index 5c479f9e000d..882c86d84d6b 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; // `dataloader` ships a single transpiled CommonJS `index.js`. Its class methods are emitted as // `_proto. = function () {}` (named function *expressions*), so they match on @@ -43,7 +44,9 @@ export const dataloaderConfig = [ module, functionQuery: { expressionName: 'clearAll', kind: 'Sync' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const dataloaderModuleNames = uniq(dataloaderConfig.map(config => config.module.name)); export const dataloaderChannels = { DATALOADER_CONSTRUCT: 'orchestrion:dataloader:construct', diff --git a/packages/server-utils/src/orchestrion/config/generic-pool.ts b/packages/server-utils/src/orchestrion/config/generic-pool.ts index 83866505162f..524f6128780e 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; // 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`. @@ -16,7 +17,9 @@ export const genericPoolConfig = [ module: { name: 'generic-pool', versionRange: '>=2.4.0 <3', filePath: 'lib/generic-pool.js' }, functionQuery: { expressionName: 'acquire', kind: 'Callback' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const genericPoolModuleNames = uniq(genericPoolConfig.map(config => config.module.name)); export const genericPoolChannels = { GENERIC_POOL_ACQUIRE: 'orchestrion:generic-pool:acquire', diff --git a/packages/server-utils/src/orchestrion/config/google-genai.ts b/packages/server-utils/src/orchestrion/config/google-genai.ts index d46854b88c76..242a405b9f11 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; // `@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` @@ -31,7 +32,9 @@ export const googleGenAiConfig = [ functionQuery: { className: 'Chat', methodName, kind: 'Auto' as const }, })), ), -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const googleGenAIModuleNames = uniq(googleGenAiConfig.map(config => config.module.name)); export const googleGenAiChannels = { GOOGLE_GENAI_GENERATE_CONTENT: 'orchestrion:@google/genai:generate-content', diff --git a/packages/server-utils/src/orchestrion/config/hapi.ts b/packages/server-utils/src/orchestrion/config/hapi.ts index 755161717c63..2144db8f7a97 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const hapiConfig = [ // hapi's `route`/`ext` live on an anonymous class (`internals.Server = class {}`), @@ -15,7 +16,9 @@ export const hapiConfig = [ module: { name: '@hapi/hapi', versionRange: '>=17.0.0 <22.0.0', filePath: 'lib/server.js' }, functionQuery: { methodName: 'ext', kind: 'Sync' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const hapiModuleNames = uniq(hapiConfig.map(config => config.module.name)); export const hapiChannels = { HAPI_ROUTE: 'orchestrion:@hapi/hapi:route', diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index d8f1c15bad6c..60bbf5c748cb 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -24,8 +24,6 @@ import { nestjsConfig } from './nestjs'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { postgresJsConfig } from './postgres'; -import { prismaConfig } from './prisma'; -import { reactRouterConfig } from './react-router'; import { redisConfig } from './redis'; import { remixConfig } from './remix'; import { tediousConfig } from './tedious'; @@ -64,8 +62,6 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...openaiConfig, ...pgConfig, ...postgresJsConfig, - ...prismaConfig, - ...reactRouterConfig, ...redisConfig, ...remixConfig, ...tediousConfig, diff --git a/packages/server-utils/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts index f6d6392d72b8..009a7674cf11 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const ioredisConfig = [ // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) @@ -24,7 +25,9 @@ export const ioredisConfig = [ module: { name: 'ioredis', versionRange: '>=5.0.0 <5.11.0', filePath: 'built/Redis.js' }, functionQuery: { className: 'Redis', methodName: 'connect', kind: 'Async' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const ioredisModuleNames = uniq(ioredisConfig.map(config => config.module.name)); export const ioredisChannels = { IOREDIS_COMMAND: 'orchestrion:ioredis:command', diff --git a/packages/server-utils/src/orchestrion/config/knex.ts b/packages/server-utils/src/orchestrion/config/knex.ts index 5e9ef9d7c7c0..5eb9e19e3f55 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; const MODULE_NAME = 'knex'; @@ -43,7 +44,9 @@ export const knexConfig: InstrumentationConfig[] = [ ...CLIENT_FILES.flatMap(({ filePath, versionRange }) => CLIENT_METHODS.map(methodName => clientMethod(methodName, filePath, versionRange)), ), -]; +] as const satisfies InstrumentationConfig[]; + +export const knexModuleNames = uniq(knexConfig.map(config => config.module.name)); export const knexChannels = { KNEX_QUERY: 'orchestrion:knex:query', diff --git a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts index 578317e612a8..721672c6b87f 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const lruMemoizerConfig = [ { @@ -7,7 +8,9 @@ export const lruMemoizerConfig = [ module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' }, functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const lruMemoizerModuleNames = uniq(lruMemoizerConfig.map(config => config.module.name)); export const lruMemoizerChannels = { LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', diff --git a/packages/server-utils/src/orchestrion/config/mysql.ts b/packages/server-utils/src/orchestrion/config/mysql.ts index 589e384bd6a3..34051c43f1ab 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const mysqlConfig = [ { @@ -6,7 +7,9 @@ export const mysqlConfig = [ module: { name: 'mysql', versionRange: '>=2.0.0 <3', filePath: 'lib/Connection.js' }, functionQuery: { expressionName: 'query', kind: 'Auto' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const mysqlModuleNames = uniq(mysqlConfig.map(config => config.module.name)); export const mysqlChannels = { MYSQL_QUERY: 'orchestrion:mysql:query', diff --git a/packages/server-utils/src/orchestrion/config/mysql2.ts b/packages/server-utils/src/orchestrion/config/mysql2.ts index 796361164756..832362c2775e 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; // Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection // prototype) to orchestrion channel injection. @@ -40,7 +41,9 @@ export const mysql2Config = [ module: { name: 'mysql2', versionRange: '>=3.11.5 <3.20.0', filePath: 'lib/base/connection.js' }, functionQuery: { className: 'BaseConnection', methodName: 'execute', kind: 'Callback' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const mysql2ModuleNames = uniq(mysql2Config.map(config => config.module.name)); export const mysql2Channels = { MYSQL2_QUERY: 'orchestrion:mysql2:query', diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index 70525177e4d3..8cd965c97d69 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 { FunctionKind, InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; /** * Wrap an instrumentation that targets nodes via a raw esquery selector @@ -128,7 +129,9 @@ export const nestjsConfig = [ }, functionQuery: { functionName: 'Processor', kind: 'Sync' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const nestjsModuleNames = uniq(nestjsConfig.map(config => config.module.name)); export const nestjsChannels = { NESTJS_APP_CREATION: 'orchestrion:@nestjs/core:nestFactoryCreate', diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index e8091003fc58..24916eff7703 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const openaiConfig = [ // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, @@ -27,7 +28,9 @@ export const openaiConfig = [ module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Auto' as const }, })), -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const openaiModuleNames = uniq(openaiConfig.map(config => config.module.name)); export const openaiChannels = { OPENAI_CHAT: 'orchestrion:openai:chat', diff --git a/packages/server-utils/src/orchestrion/config/pg.ts b/packages/server-utils/src/orchestrion/config/pg.ts index 7bfa05a82661..ad18f7d8faf7 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const pgConfig = [ // `pg` (node-postgres). @@ -39,7 +40,9 @@ export const pgConfig = [ module: { name: 'pg-pool', versionRange: '>=2.0.0 <4', filePath: 'index.js' }, functionQuery: { className: 'Pool', methodName: 'connect', kind: 'Auto' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const pgModuleNames = uniq(pgConfig.map(config => config.module.name)); export const pgChannels = { PG_QUERY: 'orchestrion:pg:query', diff --git a/packages/server-utils/src/orchestrion/config/postgres.ts b/packages/server-utils/src/orchestrion/config/postgres.ts index 7710a7132c6a..2eab903a24b5 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; // 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`). @@ -7,45 +8,47 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; // `cf/*` workerd build has no channel subscribers, see the integration). // Both builds share the same class/function shapes, so a single `flatMap` // over the two dirs emits one entry per (dir, target). -const postgresJsInstrumentationConfig = (dir: string): InstrumentationConfig[] => [ - // `Query.prototype.handle` (`class Query extends Promise`) is the single - // funnel every query passes through (`then`/`catch`/`finally`/`.execute()`/ - // `.forEach()`/cursor all call it), guarded by `this.executed`. `Async` - // because `handle` is `async`. - { - channelName: 'handle', - module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/query.js` }, - functionQuery: { className: 'Query', methodName: 'handle', kind: 'Async' }, - }, - // `function Connection(options, ...)` (default export of `connection.js`) - // returns the connection object; used to build the endpoint registry that - // resolves `server.address`/`server.port`/`db.namespace`. - { - channelName: 'connection', - module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, - functionQuery: { functionName: 'Connection', kind: 'Sync' }, - }, - // The nested `function execute(q)` inside `Connection`; the per-connection - // hook that attaches connection attributes to the query's span. - { - channelName: 'execute', - module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, - functionQuery: { functionName: 'execute', kind: 'Sync' }, - }, - // The connection object's `connect(query)` method. Matched by `methodName` - // (an object-literal method): `functionName` would hit the unrelated - // socket-level `async function connect()` in the same file. `self` is the - // connection object and `arguments[0]` the query, so the first query that - // opens a connection (dispatched via a bare `execute` with no `self`) still - // gets connection attributes in multi-endpoint apps. - { - channelName: 'connect', - module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, - functionQuery: { methodName: 'connect', kind: 'Sync' }, - }, -]; +const postgresJsInstrumentationConfig = (dir: string): InstrumentationConfig[] => + [ + // `Query.prototype.handle` (`class Query extends Promise`) is the single + // funnel every query passes through (`then`/`catch`/`finally`/`.execute()`/ + // `.forEach()`/cursor all call it), guarded by `this.executed`. `Async` + // because `handle` is `async`. + { + channelName: 'handle', + module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/query.js` }, + functionQuery: { className: 'Query', methodName: 'handle', kind: 'Async' }, + }, + // `function Connection(options, ...)` (default export of `connection.js`) + // returns the connection object; used to build the endpoint registry that + // resolves `server.address`/`server.port`/`db.namespace`. + { + channelName: 'connection', + module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, + functionQuery: { functionName: 'Connection', kind: 'Sync' }, + }, + // The nested `function execute(q)` inside `Connection`; the per-connection + // hook that attaches connection attributes to the query's span. + { + channelName: 'execute', + module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, + functionQuery: { functionName: 'execute', kind: 'Sync' }, + }, + // The connection object's `connect(query)` method. Matched by `methodName` + // (an object-literal method): `functionName` would hit the unrelated + // socket-level `async function connect()` in the same file. `self` is the + // connection object and `arguments[0]` the query, so the first query that + // opens a connection (dispatched via a bare `execute` with no `self`) still + // gets connection attributes in multi-endpoint apps. + { + channelName: 'connect', + module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, + functionQuery: { methodName: 'connect', kind: 'Sync' }, + }, + ] as const satisfies InstrumentationConfig[]; export const postgresJsConfig = ['src', 'cjs/src'].flatMap(postgresJsInstrumentationConfig); +export const postgresJsModuleNames = uniq(postgresJsConfig.map(config => config.module.name)); export const postgresJsChannels = { POSTGRESJS_HANDLE: 'orchestrion:postgres:handle', diff --git a/packages/server-utils/src/orchestrion/config/prisma.ts b/packages/server-utils/src/orchestrion/config/prisma.ts deleted file mode 100644 index d2b3f35ebbce..000000000000 --- a/packages/server-utils/src/orchestrion/config/prisma.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; - -// TODO: Stub for the `prisma` orchestrion integration (ports `@prisma/instrumentation`). -export const prismaConfig: InstrumentationConfig[] = []; - -export const prismaChannels = {} as const; diff --git a/packages/server-utils/src/orchestrion/config/react-router.ts b/packages/server-utils/src/orchestrion/config/react-router.ts deleted file mode 100644 index 50f45bc837c5..000000000000 --- a/packages/server-utils/src/orchestrion/config/react-router.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; - -// TODO: Stub for the `react-router` orchestrion integration (ports `ReactRouterInstrumentation`). -export const reactRouterConfig: InstrumentationConfig[] = []; - -export const reactRouterChannels = {} as const; diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts index c704eb43d08f..26a751599ac5 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const redisConfig = [ // redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an @@ -60,7 +61,9 @@ export const redisConfig = [ module: { name: '@redis/client', versionRange: '^1.0.0', filePath: 'dist/lib/client/index.js' }, functionQuery: { className: 'RedisClient', methodName: 'multiExecutor', kind: 'Async' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const redisModuleNames = uniq(redisConfig.map(config => config.module.name)); export const redisChannels = { REDIS_COMMAND: 'orchestrion:redis:command', diff --git a/packages/server-utils/src/orchestrion/config/vercel-ai.ts b/packages/server-utils/src/orchestrion/config/vercel-ai.ts index 082be1a135bc..e425af7bf5c1 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 '@apm-js-collab/code-transformer'; +import { uniq } from '@sentry/core'; export const vercelAiConfig = [ // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting @@ -22,7 +23,9 @@ export const vercelAiConfig = [ // The following entry is only present in v6 and later ...vercelAiEntries('>=6.0.0 <7.0.0', 'executeToolCall', 'executeToolCall', 'Async'), -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const vercelAiModuleNames = uniq(vercelAiConfig.map(config => config.module.name)); export const vercelAiChannels = { // Vercel AI (`ai`): orchestrion injects these so the same channel-based diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index cf0302556d6a..e68073eb7588 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -7,7 +7,7 @@ import { graphqlChannelIntegration, graphqlDiagnosticsChannelIntegration, } from '../integrations/tracing-channel/graphql'; -import { hapiChannelIntegration } from '../integrations/tracing-channel/hapi'; +import { hapiChannelIntegration } from '../integrations/tracing-channel/hapi/index'; import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis'; import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs'; import { knexChannelIntegration } from '../integrations/tracing-channel/knex'; diff --git a/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts b/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts index 4548ecc73b35..2516266b26b5 100644 --- a/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts +++ b/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { Span } from '@sentry/core'; import { debug, getActiveSpan, isObjectLike, SPAN_STATUS_ERROR, withActiveSpan } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; @@ -107,8 +108,6 @@ const recordingBySpan = new WeakMap>(); // telemetry object we replaced on a prior call — would be misread as user-disabled and lose its span. const suppressedTelemetry = new WeakSet(); -let subscribed = false; - /** * Subscribe the v6 orchestrion channel adapter. Safe to always call: inert on * `ai` >= 7 (those channels are never published) and when orchestrion injection @@ -118,15 +117,8 @@ let subscribed = false; * `subscribeVercelAiTracingChannel`); `options` pins the recording settings at * subscribe time so we never look the integration up per event. */ -export function subscribeVercelAiOrchestrionChannels( - tracingChannel: VercelAiTracingChannelFactory, - options: VercelAiChannelOptions = {}, -): void { - if (subscribed) { - return; - } - subscribed = true; - +export function instrumentVercelAi(options: VercelAiChannelOptions = {}): void { + const tracingChannel = diagnosticsChannel.tracingChannel; try { bindOperation(tracingChannel, CHANNELS.VERCEL_AI_GENERATE_TEXT, buildTextMessage('generateText'), options); bindOperation(tracingChannel, CHANNELS.VERCEL_AI_STREAM_TEXT, buildTextMessage('streamText'), options); From e2b7d8b4aca872291b40f8baf849ab379ef9e205 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 16 Jul 2026 13:31:40 +0200 Subject: [PATCH 5/8] fixes --- .../src/integrations/tracing-channel/graphql/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts index 92f324d97b34..70e82d6b5987 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts @@ -1,4 +1,4 @@ -import type { IntegrationFn } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/core'; import { defineIntegration, extendIntegration } from '@sentry/core'; import { graphqlIntegration as graphqlNativeIntegration } from '../../../graphql'; import type { GraphqlDiagnosticChannelsOptions } from '../../../graphql/graphql-dc-subscriber'; @@ -40,7 +40,6 @@ export const graphqlChannelIntegration = defineIntegration(_graphqlChannelIntegr */ export const graphqlDiagnosticsChannelIntegration = (options?: GraphqlDiagnosticChannelsOptions) => { const orchestrion = graphqlChannelIntegration(options); - return extendIntegration(orchestrion, { - ...graphqlNativeIntegration(options), - }); + const graphqlNative = graphqlNativeIntegration(options); + return extendIntegration(orchestrion, graphqlNative as Partial); }; From b734ee22cfc50dad2bc44fa582e5168045aae505 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 16 Jul 2026 13:42:49 +0200 Subject: [PATCH 6/8] fix --- .../src/integrations/tracing-channel/graphql/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts index 70e82d6b5987..5ef80494a276 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts @@ -1,4 +1,4 @@ -import type { Integration, IntegrationFn } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, extendIntegration } from '@sentry/core'; import { graphqlIntegration as graphqlNativeIntegration } from '../../../graphql'; import type { GraphqlDiagnosticChannelsOptions } from '../../../graphql/graphql-dc-subscriber'; @@ -41,5 +41,5 @@ export const graphqlChannelIntegration = defineIntegration(_graphqlChannelIntegr export const graphqlDiagnosticsChannelIntegration = (options?: GraphqlDiagnosticChannelsOptions) => { const orchestrion = graphqlChannelIntegration(options); const graphqlNative = graphqlNativeIntegration(options); - return extendIntegration(orchestrion, graphqlNative as Partial); + return extendIntegration(orchestrion, { ...graphqlNative }); }; From 03033bca50b25bf3057176caeb66960863b5036f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 16 Jul 2026 14:45:32 +0200 Subject: [PATCH 7/8] fix stuff --- .../src/orchestrion/runtime/register.ts | 62 +++- .../test/orchestrion/register.test.ts | 279 ++++++++++++++++++ 2 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 packages/server-utils/test/orchestrion/register.test.ts diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 58579f46471a..2d0d02aa7367 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -7,13 +7,43 @@ import { SENTRY_INSTRUMENTATIONS } from '../config'; import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import type { register } from 'node:module'; +/** The shape emitted for each module the code transform injects; `error` is only set on a failed transform. */ +type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; + type TracingHooksSync = { initialize: (opts: { instrumentations: InstrumentationConfig[] }) => void; resolve: Function; load: Function; - setDiagnosticsHook: (callback: (event: { url: string; moduleName: string; error: Error }) => void) => void; + setDiagnosticsHook: (callback: (event: DiagnosticsEvent) => void) => void; +}; + +/** + * The `@apm-js-collab/tracing-hooks` main-thread diagnostics singleton (`lib/diagnostics`). It is + * CJS, so it can be `require()`d synchronously on the `Module.register` path (Node < 24.13, where + * `require()`ing the `.mjs` hook entry isn't available). The CJS `_compile` patcher emits through + * this singleton, so `setDiagnosticsHook` here observes every CJS-loaded module the transform injects. + */ +type Diagnostics = { + setDiagnosticsHook: (callback: (event: DiagnosticsEvent) => void) => void; }; +/** + * Records an orchestrion-injected module: appends it to the global marker and notifies the active + * client so channel-subscriber integrations can lazily wire up when their module is actually loaded. + * A failed transform (`error` set) is not recorded — nothing will ever publish to its channels. + */ +function onModuleInjected(event: DiagnosticsEvent): void { + if (event.error) { + return; + } + + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(event.moduleName); + + getClient()?.emit('orchestrion.module-runtime-injected', event.moduleName); +} + type NodeModule = { registerHooks?: (options: unknown) => { deregister: () => void }; register?: typeof register; @@ -111,13 +141,7 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic : nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') ) as TracingHooksSync; - setDiagnosticsHook(event => { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(event.moduleName); - - getClient()?.emit('orchestrion.module-runtime-injected', event.moduleName); - }); + setDiagnosticsHook(onModuleInjected); initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); mod.registerHooks({ resolve, load }); @@ -126,6 +150,25 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 // path. Bun/Deno are excluded: they don't support this combination and // must use the stable `registerHooks` path above (or none at all). + + // Hook the `lib/diagnostics` singleton that the CJS `_compile` patcher (below) emits through, + // so `onModuleInjected` observes every CJS-loaded module the transform injects. It is CJS, so it + // can be `require()`d here even on Node versions where `require()`ing the `.mjs` hook entry + // isn't available. + // + // This covers CJS `require()`s only, which is every instrumented package in practice: they are + // CJS and are pulled in through `require` chains even from ESM apps. The ESM loader hook runs on + // a worker thread `Module.register` spawns; posting its diagnostics back would need a + // `transferList`ed MessagePort, but that worker re-runs this preload and re-registering the hook + // from it faults Node's loader, so it is deliberately not attempted here. On Node >= 24.13 the + // `registerHooks` branch above runs synchronously on this thread and covers ESM natively. + const { setDiagnosticsHook } = ( + requireFromHooksDir && tracingHooksDir + ? requireFromHooksDir(`${tracingHooksDir}/lib/diagnostics`) + : nodeRequire('@apm-js-collab/tracing-hooks/lib/diagnostics') + ) as Diagnostics; + setDiagnosticsHook(onModuleInjected); + // `Module.register` resolves ESM-style: a bare package specifier is resolved against // `parentURL`, but a filesystem path (the `tracingHooksDir` override) is not a valid ESM // specifier and must be passed as a file:// URL. @@ -141,7 +184,8 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic // file `import`s a CJS package, the package's internal `require()` calls // are resolved through the CJS machinery and never reach the ESM // register hook, so without this patch the file we want to instrument - // loads untransformed. + // loads untransformed. This side emits through the same main-thread + // `lib/diagnostics` singleton hooked above. const ModulePatch = ( requireFromHooksDir && tracingHooksDir ? requireFromHooksDir(tracingHooksDir) diff --git a/packages/server-utils/test/orchestrion/register.test.ts b/packages/server-utils/test/orchestrion/register.test.ts new file mode 100644 index 000000000000..fc6d3546fabe --- /dev/null +++ b/packages/server-utils/test/orchestrion/register.test.ts @@ -0,0 +1,279 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import type * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Shared, per-test-resettable state the `node:module` mock reads. `vi.hoisted` runs before the +// hoisted `vi.mock` factory, so the factory can close over it. +const state = vi.hoisted(() => { + return { + // Which of Node's module-hook APIs are available this test. + hasRegisterHooks: false, + hasRegister: false, + registerHooksMock: undefined as unknown as ReturnType, + registerMock: undefined as unknown as ReturnType, + // `nodeRequire(specifier)` results, keyed by specifier. A function value is thrown to simulate a + // require failure. + modules: {} as Record, + requireError: null as Error | null, + }; +}); + +const clientEmit = vi.fn(); + +vi.mock('node:module', () => { + const fakeRequire = (specifier: string): unknown => { + if (state.requireError) { + throw state.requireError; + } + if (!(specifier in state.modules)) { + throw new Error(`Cannot find module '${specifier}'`); + } + return state.modules[specifier]; + }; + + return { + createRequire: () => fakeRequire, + get registerHooks() { + return state.hasRegisterHooks ? state.registerHooksMock : undefined; + }, + get register() { + return state.hasRegister ? state.registerMock : undefined; + }, + }; +}); + +vi.mock('@sentry/core', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + getClient: () => ({ emit: clientEmit }), + }; +}); + +// Imported after the mocks are declared so it picks up the mocked `node:module`. +const { registerDiagnosticsChannelInjection } = await import('../../src/orchestrion/runtime/register'); + +/** Capture the `onModuleInjected` callback the code hands to `setDiagnosticsHook`. */ +let capturedDiagnosticsHook: ((event: { url: string; moduleName: string; error?: Error }) => void) | undefined; + +function makeSyncHooksModule(): Record { + return { + initialize: vi.fn(), + resolve: vi.fn(), + load: vi.fn(), + setDiagnosticsHook: vi.fn((cb: typeof capturedDiagnosticsHook) => { + capturedDiagnosticsHook = cb; + }), + }; +} + +function makeDiagnosticsModule(): Record { + return { + setDiagnosticsHook: vi.fn((cb: typeof capturedDiagnosticsHook) => { + capturedDiagnosticsHook = cb; + }), + }; +} + +function makeModulePatchModule(): unknown { + const patch = vi.fn(); + return class ModulePatch { + public patch = patch; + }; +} + +function setNodeVersion(version: string): void { + Object.defineProperty(process.versions, 'node', { value: version, configurable: true }); +} + +describe('registerDiagnosticsChannelInjection', () => { + const originalNodeVersion = process.versions.node; + + beforeEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + capturedDiagnosticsHook = undefined; + clientEmit.mockClear(); + state.hasRegisterHooks = false; + state.hasRegister = false; + state.registerHooksMock = vi.fn(); + state.registerMock = vi.fn(); + state.modules = {}; + state.requireError = null; + }); + + afterEach(() => { + setNodeVersion(originalNodeVersion); + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + describe('guard', () => { + it('returns early and does not register when runtime injection already ran', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['pg'] }; + state.hasRegisterHooks = true; + setNodeVersion('24.13.0'); + state.modules['@apm-js-collab/tracing-hooks/hook-sync.mjs'] = makeSyncHooksModule(); + + registerDiagnosticsChannelInjection(); + + expect(state.registerHooksMock).not.toHaveBeenCalled(); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime).toEqual(['pg']); + }); + }); + + describe('registerHooks path (Node >= 24.13)', () => { + beforeEach(() => { + setNodeVersion('24.13.0'); + state.hasRegisterHooks = true; + state.hasRegister = true; // registerHooks wins when both are available + state.modules['@apm-js-collab/tracing-hooks/hook-sync.mjs'] = makeSyncHooksModule(); + }); + + it('initializes, sets the diagnostics hook, and registers the sync hooks', () => { + const syncModule = state.modules['@apm-js-collab/tracing-hooks/hook-sync.mjs'] as Record< + string, + ReturnType + >; + + registerDiagnosticsChannelInjection(); + + expect(syncModule.setDiagnosticsHook).toHaveBeenCalledOnce(); + expect(syncModule.initialize).toHaveBeenCalledOnce(); + expect(state.registerHooksMock).toHaveBeenCalledOnce(); + expect(state.registerMock).not.toHaveBeenCalled(); + // Marker created with an empty runtime array = "hook registered, nothing injected yet". + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__).toEqual({ runtime: [] }); + }); + }); + + describe('Module.register path (Node 18.19–24.12)', () => { + beforeEach(() => { + setNodeVersion('20.19.5'); + state.hasRegister = true; + state.modules['@apm-js-collab/tracing-hooks/lib/diagnostics'] = makeDiagnosticsModule(); + state.modules['@apm-js-collab/tracing-hooks'] = makeModulePatchModule(); + }); + + it('sets the diagnostics hook, registers the ESM hook, and patches _compile', () => { + const diagnosticsModule = state.modules['@apm-js-collab/tracing-hooks/lib/diagnostics'] as Record< + string, + ReturnType + >; + + registerDiagnosticsChannelInjection(); + + expect(diagnosticsModule.setDiagnosticsHook).toHaveBeenCalledOnce(); + expect(state.registerMock).toHaveBeenCalledOnce(); + expect(state.registerHooksMock).not.toHaveBeenCalled(); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__).toEqual({ runtime: [] }); + }); + + it('registers the ESM hook without a diagnostics port (avoids the loader-worker fault)', () => { + registerDiagnosticsChannelInjection(); + + const [, options] = state.registerMock.mock.calls[0] as [string, { data: Record }]; + expect(options.data).not.toHaveProperty('diagnosticsPort'); + expect(options).not.toHaveProperty('transferList'); + }); + }); + + describe('tracingHooksDir override', () => { + it('loads the sync hooks from the provided directory (registerHooks path)', () => { + setNodeVersion('24.13.0'); + state.hasRegisterHooks = true; + const syncModule = makeSyncHooksModule(); + state.modules['/custom/hooks/hook-sync.mjs'] = syncModule; + + registerDiagnosticsChannelInjection({ tracingHooksDir: '/custom/hooks' }); + + expect(syncModule.setDiagnosticsHook as ReturnType).toHaveBeenCalledOnce(); + expect(state.registerHooksMock).toHaveBeenCalledOnce(); + }); + + it('loads diagnostics + the patcher from the provided directory and passes a file:// URL to register (Module.register path)', () => { + setNodeVersion('20.19.5'); + state.hasRegister = true; + state.modules['/custom/hooks/lib/diagnostics'] = makeDiagnosticsModule(); + state.modules['/custom/hooks'] = makeModulePatchModule(); + + registerDiagnosticsChannelInjection({ tracingHooksDir: '/custom/hooks' }); + + expect(state.registerMock).toHaveBeenCalledOnce(); + const [specifier] = state.registerMock.mock.calls[0] as [string]; + expect(specifier).toBe('file:///custom/hooks/hook.mjs'); + }); + }); + + describe('no available Node API', () => { + it('does not register or create the marker', () => { + setNodeVersion('20.19.5'); + // Neither registerHooks nor register available. + + registerDiagnosticsChannelInjection(); + + expect(state.registerHooksMock).not.toHaveBeenCalled(); + expect(state.registerMock).not.toHaveBeenCalled(); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__).toBeUndefined(); + }); + + it('skips the Module.register path on Bun/Deno even when register is available', () => { + setNodeVersion('20.19.5'); + state.hasRegister = true; + state.modules['@apm-js-collab/tracing-hooks/lib/diagnostics'] = makeDiagnosticsModule(); + (globalThis as { Deno?: unknown }).Deno = { version: { deno: '1.0.0' } }; + + try { + registerDiagnosticsChannelInjection(); + } finally { + delete (globalThis as { Deno?: unknown }).Deno; + } + + expect(state.registerMock).not.toHaveBeenCalled(); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__).toBeUndefined(); + }); + }); + + describe('failure handling', () => { + it('swallows a require failure and leaves the marker unset so a later attempt can retry', () => { + setNodeVersion('24.13.0'); + state.hasRegisterHooks = true; + state.requireError = new Error('dependency resolution failed'); + + expect(() => registerDiagnosticsChannelInjection()).not.toThrow(); + + expect(state.registerHooksMock).not.toHaveBeenCalled(); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__).toBeUndefined(); + }); + }); + + describe('onModuleInjected (the diagnostics hook callback)', () => { + beforeEach(() => { + setNodeVersion('24.13.0'); + state.hasRegisterHooks = true; + state.modules['@apm-js-collab/tracing-hooks/hook-sync.mjs'] = makeSyncHooksModule(); + registerDiagnosticsChannelInjection(); + }); + + it('records an injected module and notifies the client', () => { + capturedDiagnosticsHook?.({ url: 'file:///pg.js', moduleName: 'pg' }); + + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toEqual(['pg']); + expect(clientEmit).toHaveBeenCalledWith('orchestrion.module-runtime-injected', 'pg'); + }); + + it('appends multiple injected modules in order', () => { + capturedDiagnosticsHook?.({ url: 'file:///pg.js', moduleName: 'pg' }); + capturedDiagnosticsHook?.({ url: 'file:///express.js', moduleName: 'express' }); + + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toEqual(['pg', 'express']); + expect(clientEmit).toHaveBeenNthCalledWith(1, 'orchestrion.module-runtime-injected', 'pg'); + expect(clientEmit).toHaveBeenNthCalledWith(2, 'orchestrion.module-runtime-injected', 'express'); + }); + + it('ignores a failed transform (event with an error)', () => { + capturedDiagnosticsHook?.({ url: 'file:///pg.js', moduleName: 'pg', error: new Error('transform failed') }); + + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toEqual([]); + expect(clientEmit).not.toHaveBeenCalled(); + }); + }); +}); From acd44ec77fbde6dc0c23f7e898805d38e1e1d4c1 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 16 Jul 2026 14:46:31 +0200 Subject: [PATCH 8/8] fix bun --- .../bun-integration-tests/suites/orchestrion-mysql/test.ts | 2 +- .../bun-integration-tests/suites/orchestrion-postgres/test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts index d29903b76970..7344e37c221c 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts @@ -49,7 +49,7 @@ describe('orchestrion mysql instrumentation (Bun)', () => { // with the expected SQL expect(line).toContain('statement=SELECT 1 AS solution'); // injected banner ran at bundle boot - expect(line).toContain('"bundler":true'); + expect(line).toContain('"bundler":[]'); } finally { if (outfile) { rmSync(dirname(outfile), { recursive: true, force: true }); diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts index 89d6cfb49b19..c129f4e86f4e 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts @@ -54,7 +54,7 @@ describe('orchestrion pg instrumentation (Bun)', () => { // with the expected SQL expect(line).toContain('statement=SELECT 1 AS solution'); // injected banner ran at bundle boot - expect(line).toContain('"bundler":true'); + expect(line).toContain('"bundler":[]'); } finally { if (outfile) { rmSync(dirname(outfile), { recursive: true, force: true });