diff --git a/packages/node/src/integrations/tracing/dataloader/index.ts b/packages/node/src/integrations/tracing/dataloader/index.ts index 8ac7d3b6d36a..c6a04f5fa1c2 100644 --- a/packages/node/src/integrations/tracing/dataloader/index.ts +++ b/packages/node/src/integrations/tracing/dataloader/index.ts @@ -9,19 +9,24 @@ const INTEGRATION_NAME = 'Dataloader' as const; export const instrumentDataloader = generateInstrumentOnce(INTEGRATION_NAME, () => new DataloaderInstrumentation()); const _dataloaderIntegration = (() => { + // Decide in setup/setupOnce, not in the factory: the runtime channel injection runs inside `Sentry.init()`, + // after the integrations array has already been built, so `isOrchestrionInjected()` is only + // reliable by `setup`. When the diagnostics channels are injected (runtime hook or bundler + // plugin), subscribe to them (the channel integration needs the client to register its + // injection listener); otherwise fall back to the vendored OTel instrumentation. + return { name: INTEGRATION_NAME, setupOnce() { - // Decide here, not in the factory: the runtime channel injection runs inside `Sentry.init()`, - // after the integrations array has already been built, so `isOrchestrionInjected()` is only - // reliable by `setupOnce`. When the diagnostics channels are injected (runtime hook or bundler - // plugin), subscribe to them; otherwise fall back to the vendored OTel instrumentation. - if (isOrchestrionInjected()) { - dataloaderChannelIntegration().setupOnce?.(); - } else { + if (!isOrchestrionInjected()) { instrumentDataloader(); } }, + setup(client) { + if (isOrchestrionInjected()) { + dataloaderChannelIntegration().setup?.(client); + } + }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/knex/index.ts b/packages/node/src/integrations/tracing/knex/index.ts index e914bba0a841..36a65c17a6d9 100644 --- a/packages/node/src/integrations/tracing/knex/index.ts +++ b/packages/node/src/integrations/tracing/knex/index.ts @@ -9,18 +9,24 @@ const INTEGRATION_NAME = 'Knex' as const; export const instrumentKnex = generateInstrumentOnce(INTEGRATION_NAME, () => new KnexInstrumentation()); const _knexIntegration = (() => { + // Decide in setup/setupOnce, not in the factory: the runtime channel injection runs inside `Sentry.init()`, + // after the integrations array has already been built, so `isOrchestrionInjected()` is only + // reliable by `setup`. When the diagnostics channels are injected (runtime hook or bundler + // plugin), subscribe to them (the channel integration needs the client to register its + // injection listener); otherwise fall back to the vendored OTel instrumentation. + return { name: INTEGRATION_NAME, setupOnce() { - // Prefer the diagnostics-channel subscriber when orchestrion injected its channels; otherwise - // fall back to the vendored OTel instrumentation. `isOrchestrionInjected()` is only reliable by - // `setupOnce` (the runtime injection runs during `Sentry.init()`, after integrations are built). - if (isOrchestrionInjected()) { - knexChannelIntegration().setupOnce?.(); - } else { + if (!isOrchestrionInjected()) { instrumentKnex(); } }, + setup(client) { + if (isOrchestrionInjected()) { + knexChannelIntegration().setup?.(client); + } + }, }; }) satisfies IntegrationFn; 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/dataloader.ts b/packages/server-utils/src/integrations/tracing-channel/dataloader.ts index 80b998ab11e5..ae9c20cb4d16 100644 --- a/packages/server-utils/src/integrations/tracing-channel/dataloader.ts +++ b/packages/server-utils/src/integrations/tracing-channel/dataloader.ts @@ -1,22 +1,19 @@ 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. const INTEGRATION_NAME = 'Dataloader' as const; const MODULE_NAME = 'dataloader'; @@ -78,25 +75,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/express/index.ts b/packages/server-utils/src/integrations/tracing-channel/express/index.ts index 2c7f19b4206a..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,8 +1,9 @@ -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 { 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. @@ -11,15 +12,8 @@ const INTEGRATION_NAME = 'Express' as const; const _expressChannelIntegration = ((options: ExpressIntegrationOptions = {}) => { 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); - }); + setup(client) { + 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 19db7a815d14..eec5430a57bb 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/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/graphql/index.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts index 2a3baa669e16..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,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,6 @@ export const graphqlChannelIntegration = defineIntegration(_graphqlChannelIntegr */ export const graphqlDiagnosticsChannelIntegration = (options?: GraphqlDiagnosticChannelsOptions) => { const orchestrion = graphqlChannelIntegration(options); - return extendIntegration(graphqlNativeIntegration(options), { - name: INTEGRATION_NAME, - setupOnce: () => orchestrion.setupOnce?.(), - }); + const graphqlNative = graphqlNativeIntegration(options); + return extendIntegration(orchestrion, { ...graphqlNative }); }; 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/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/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/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/koa.ts b/packages/server-utils/src/integrations/tracing-channel/koa.ts index 982faee9765e..387b758718f3 100644 --- a/packages/server-utils/src/integrations/tracing-channel/koa.ts +++ b/packages/server-utils/src/integrations/tracing-channel/koa.ts @@ -16,6 +16,8 @@ import { import { CODE_FUNCTION_NAME, HTTP_ROUTE, KOA_NAME, KOA_TYPE } from '@sentry/conventions/attributes'; import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { koaModuleNames } from '../../orchestrion/config/koa'; // Same name as the OTel integration. When enabled, the OTel 'Koa' integration is omitted from the default set. const INTEGRATION_NAME = 'Koa' as const; @@ -32,11 +34,6 @@ type KoaLayerType = (typeof LAYER_TYPE)[keyof typeof LAYER_TYPE]; // multiple routes (mirrors the vendored OTel instrumentation's symbol). const kLayerPatched: unique symbol = Symbol('sentry.koa.layer-patched'); -// Core dedupes `setupOnce` by integration name, but the Deno SDK also runs this -// under the name `DenoKoa` (via `extendIntegration`), so guard against a second -// subscription here. -let subscribed = false; - type Next = () => Promise; interface KoaContext { @@ -76,28 +73,25 @@ export interface KoaChannelIntegrationOptions { ignoreLayersType?: Array<'middleware' | 'router'>; } -const _koaChannelIntegration = ((options: KoaChannelIntegrationOptions = {}) => { +function instrumentKoa(options: KoaChannelIntegrationOptions): void { const ignoreLayersType = options.ignoreLayersType ?? []; + diagnosticsChannel.tracingChannel(CHANNELS.KOA_USE).subscribe({ + start(rawCtx) { + handleUse(rawCtx as KoaUseContext, ignoreLayersType); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); +} + +const _koaChannelIntegration = ((options: KoaChannelIntegrationOptions = {}) => { 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:koa] subscribing to channel "${CHANNELS.KOA_USE}"`); - - diagnosticsChannel.tracingChannel(CHANNELS.KOA_USE).subscribe({ - start(rawCtx) { - handleUse(rawCtx as KoaUseContext, ignoreLayersType); - }, - end() {}, - asyncStart() {}, - asyncEnd() {}, - error() {}, - }); + setup(client) { + invokeOrchestrionInstrumentation(client, koaModuleNames, instrumentKoa, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/langchain.ts b/packages/server-utils/src/integrations/tracing-channel/langchain.ts index 008d52638241..17c15705a6a5 100644 --- a/packages/server-utils/src/integrations/tracing-channel/langchain.ts +++ b/packages/server-utils/src/integrations/tracing-channel/langchain.ts @@ -6,18 +6,16 @@ import { _INTERNAL_skipAiProviderWrapping, ANTHROPIC_AI_INTEGRATION_NAME, createLangChainCallbackHandler, - debug, defineIntegration, GOOGLE_GENAI_INTEGRATION_NAME, LANGCHAIN_INTEGRATION_NAME, OPENAI_INTEGRATION_NAME, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; -import { langchainEmbeddingsChannels } from '../../orchestrion/config/langchain'; +import { langchainEmbeddingsChannels, langchainModuleNames } from '../../orchestrion/config/langchain'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // Same name as the OTel integration by design: when enabled, the OTel 'LangChain' integration is // dropped from the default set (see the Node opt-in loader). @@ -38,8 +36,6 @@ interface EmbeddingsChannelContext { arguments: unknown[]; } -let subscribed = false; - // Registered lazily on the first LangChain call (not at `setupOnce`) so a direct provider call made // before any LangChain call still gets its own span — matches the OTel patch-on-import timing. It // also stops the underlying SDK from double-instrumenting embeddings, whose `embedQuery`/ @@ -48,57 +44,48 @@ function markProvidersSkipped(): void { _INTERNAL_skipAiProviderWrapping(SKIPPED_PROVIDERS); } +function instrumentLangChain(options: LangChainOptions): void { + // One stateful handler tracks spans across the whole run tree, just like the OTel path. + const sentryHandler = createLangChainCallbackHandler(options); + + // Chat models: inject the Sentry callback handler into the call options (arg 1). LangChain's own + // callback dispatch then creates the spans, exactly as in the OTel path, so no span is opened + // here — a `start` subscriber (which also makes orchestrion wrap the function) is enough. + const injectHandler = (message: unknown): void => { + markProvidersSkipped(); + + const args = (message as RunnableChannelContext).arguments; + if (!Array.isArray(args)) { + return; + } + + let callOptions = args[1] as Record | undefined; + if (!callOptions || typeof callOptions !== 'object' || Array.isArray(callOptions)) { + callOptions = {}; + args[1] = callOptions; + } + + callOptions.callbacks = _INTERNAL_mergeLangChainCallbackHandler(callOptions.callbacks, sentryHandler); + }; + + for (const channelName of [CHANNELS.LANGCHAIN_CHAT_MODEL_INVOKE, CHANNELS.LANGCHAIN_CHAT_MODEL_STREAM]) { + diagnosticsChannel.tracingChannel(channelName).start.subscribe(injectHandler); + } + + for (const channelName of langchainEmbeddingsChannels) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => createEmbeddingsSpan(data, options), + { captureError: () => ({ mechanism: { handled: false, type: 'auto.ai.langchain' } }) }, + ); + } +} + const _langChainChannelIntegration = ((options: LangChainOptions = {}) => { 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; - - // One stateful handler tracks spans across the whole run tree, just like the OTel path. - const sentryHandler = createLangChainCallbackHandler(options); - - // Chat models: inject the Sentry callback handler into the call options (arg 1). LangChain's own - // callback dispatch then creates the spans, exactly as in the OTel path, so no span is opened - // here — a `start` subscriber (which also makes orchestrion wrap the function) is enough. - const injectHandler = (message: unknown): void => { - markProvidersSkipped(); - - const args = (message as RunnableChannelContext).arguments; - if (!Array.isArray(args)) { - return; - } - - let callOptions = args[1] as Record | undefined; - if (!callOptions || typeof callOptions !== 'object' || Array.isArray(callOptions)) { - callOptions = {}; - args[1] = callOptions; - } - - callOptions.callbacks = _INTERNAL_mergeLangChainCallbackHandler(callOptions.callbacks, sentryHandler); - }; - - for (const channelName of [CHANNELS.LANGCHAIN_CHAT_MODEL_INVOKE, CHANNELS.LANGCHAIN_CHAT_MODEL_STREAM]) { - DEBUG_BUILD && debug.log(`[orchestrion:langchain] subscribing to channel "${channelName}"`); - diagnosticsChannel.tracingChannel(channelName).start.subscribe(injectHandler); - } - - // Embeddings don't use the callback system — the OTel path wraps the method in its own span, so - // do the same here. `bindTracingChannelToSpan` needs the async-context binding that - // `initOpenTelemetry()` registers after `setupOnce`, so wait for it before subscribing. - waitForTracingChannelBinding(() => { - for (const channelName of langchainEmbeddingsChannels) { - DEBUG_BUILD && debug.log(`[orchestrion:langchain] subscribing to channel "${channelName}"`); - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(channelName), - data => createEmbeddingsSpan(data, options), - { captureError: () => ({ mechanism: { handled: false, type: 'auto.ai.langchain' } }) }, - ); - } - }); + setup(client) { + invokeOrchestrionInstrumentation(client, langchainModuleNames, instrumentLangChain, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/langgraph.ts b/packages/server-utils/src/integrations/tracing-channel/langgraph.ts index c5e601c61d9d..3578ca76896c 100644 --- a/packages/server-utils/src/integrations/tracing-channel/langgraph.ts +++ b/packages/server-utils/src/integrations/tracing-channel/langgraph.ts @@ -11,12 +11,13 @@ import { LANGGRAPH_INTEGRATION_NAME, resolveAIRecordingOptions, startInactiveSpan, - waitForTracingChannelBinding, wrapToolsWithSpans, } 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 { langgraphModuleNames } from '../../orchestrion/config/langgraph'; // Same name as the OTel integration by design: when enabled, the OTel 'LangGraph' integration is // dropped from the default set (see the Node opt-in loader). @@ -32,90 +33,78 @@ interface CreateReactAgentChannelContext { result?: unknown; } -let subscribed = false; - // `createReactAgent` compiles a `StateGraph` internally; suppress the `create_agent` span for that // nested compile so a react agent gets a single `invoke_agent` span, matching the OTel path. let insideCreateReactAgent = false; -const _langGraphChannelIntegration = ((options: LangGraphOptions = {}) => { - 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; - - const resolvedOptions = resolveAIRecordingOptions(options); - const sentryHandler = createLangChainCallbackHandler(resolvedOptions); +function instrumentLangGraph(options: LangGraphOptions): void { + const resolvedOptions = resolveAIRecordingOptions(options); + const sentryHandler = createLangChainCallbackHandler(resolvedOptions); - // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers - // after `setupOnce` runs, so wait for it before subscribing. - waitForTracingChannelBinding(() => { - // StateGraph.compile → `create_agent` span, then wrap the returned graph's `invoke`. - DEBUG_BUILD && - debug.log(`[orchestrion:langgraph] subscribing to channel "${CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE}"`); - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE), - data => { - if (insideCreateReactAgent) { - return undefined; - } - const compileOptions = getFirstArgObject(data.arguments); - const name = typeof compileOptions?.name === 'string' ? compileOptions.name : undefined; + // StateGraph.compile → `create_agent` span, then wrap the returned graph's `invoke`. + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE), + data => { + if (insideCreateReactAgent) { + return undefined; + } + const compileOptions = getFirstArgObject(data.arguments); + const name = typeof compileOptions?.name === 'string' ? compileOptions.name : undefined; - return startInactiveSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(name)); - }, - { - beforeSpanEnd: (_span, data) => { - wrapCompiledGraphInvoke( - data.result, - getFirstArgObject(data.arguments) ?? {}, - resolvedOptions, - null, - sentryHandler, - ); - }, - }, + return startInactiveSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(name)); + }, + { + beforeSpanEnd: (_span, data) => { + wrapCompiledGraphInvoke( + data.result, + getFirstArgObject(data.arguments) ?? {}, + resolvedOptions, + null, + sentryHandler, ); + }, + }, + ); - // createReactAgent has no `create_agent` span of its own; it only wraps tools and the returned - // graph's `invoke`. Tools are wrapped at `start` (before the agent runs), invoke at `end`. - DEBUG_BUILD && - debug.log(`[orchestrion:langgraph] subscribing to channel "${CHANNELS.LANGGRAPH_CREATE_REACT_AGENT}"`); - const reactAgentChannel = diagnosticsChannel.tracingChannel( - CHANNELS.LANGGRAPH_CREATE_REACT_AGENT, - ); - reactAgentChannel.start.subscribe(message => { - // `createReactAgent` runs synchronously and compiles a `StateGraph` internally, so the flag - // must be on for the duration and off by `end`. It's set here (never in a branch that can - // throw) and cleared in both `end` and `error`, so it can neither stick on across calls nor - // stay off during this call's nested compile. Tool wrapping is guarded for the same reason. - insideCreateReactAgent = true; - try { - const { arguments: args } = message as CreateReactAgentChannelContext; - const params = getFirstArgObject(args); - if (params && Array.isArray(params.tools) && params.tools.length > 0) { - wrapToolsWithSpans(params.tools, resolvedOptions, extractAgentNameFromParams(args) ?? undefined); - } - } catch (error) { - DEBUG_BUILD && debug.error('[orchestrion:langgraph] failed to wrap createReactAgent tools', error); - } - }); - reactAgentChannel.end.subscribe(message => { - insideCreateReactAgent = false; - const { arguments: args, result } = message as CreateReactAgentChannelContext; - const agentName = extractAgentNameFromParams(args) ?? undefined; - const compileOptions = agentName ? { name: agentName } : {}; - wrapCompiledGraphInvoke(result, compileOptions, resolvedOptions, extractLLMFromParams(args), sentryHandler); - }); - // Make sure a thrown `createReactAgent` doesn't leave the suppression flag stuck on. - reactAgentChannel.error.subscribe(() => { - insideCreateReactAgent = false; - }); - }); + // createReactAgent has no `create_agent` span of its own; it only wraps tools and the returned + // graph's `invoke`. Tools are wrapped at `start` (before the agent runs), invoke at `end`. + const reactAgentChannel = diagnosticsChannel.tracingChannel( + CHANNELS.LANGGRAPH_CREATE_REACT_AGENT, + ); + reactAgentChannel.start.subscribe(message => { + // `createReactAgent` runs synchronously and compiles a `StateGraph` internally, so the flag + // must be on for the duration and off by `end`. It's set here (never in a branch that can + // throw) and cleared in both `end` and `error`, so it can neither stick on across calls nor + // stay off during this call's nested compile. Tool wrapping is guarded for the same reason. + insideCreateReactAgent = true; + try { + const { arguments: args } = message as CreateReactAgentChannelContext; + const params = getFirstArgObject(args); + if (params && Array.isArray(params.tools) && params.tools.length > 0) { + wrapToolsWithSpans(params.tools, resolvedOptions, extractAgentNameFromParams(args) ?? undefined); + } + } catch (error) { + DEBUG_BUILD && debug.error('[orchestrion:langgraph] failed to wrap createReactAgent tools', error); + } + }); + reactAgentChannel.end.subscribe(message => { + insideCreateReactAgent = false; + const { arguments: args, result } = message as CreateReactAgentChannelContext; + const agentName = extractAgentNameFromParams(args) ?? undefined; + const compileOptions = agentName ? { name: agentName } : {}; + wrapCompiledGraphInvoke(result, compileOptions, resolvedOptions, extractLLMFromParams(args), sentryHandler); + }); + // Make sure a thrown `createReactAgent` doesn't leave the suppression flag stuck on. + reactAgentChannel.error.subscribe(() => { + insideCreateReactAgent = false; + }); +} + +const _langGraphChannelIntegration = ((options: LangGraphOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, langgraphModuleNames, instrumentLangGraph, [options]); }, }; }) 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/mongodb.ts b/packages/server-utils/src/integrations/tracing-channel/mongodb.ts index 85899d910792..44cbb206ced6 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mongodb.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mongodb.ts @@ -1,6 +1,6 @@ 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 { MongodbNamespace, MongoV3Topology } from '../../mongodb/mongodb-span'; import { getV3CommandOperation, @@ -10,6 +10,8 @@ import { } from '../../mongodb/mongodb-span'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { mongodbModuleNames } from '../../orchestrion/config/mongodb'; const INTEGRATION_NAME = 'Mongo' as const; @@ -32,6 +34,12 @@ interface V3CallInfo { operation: string | undefined; } +function instrumentMongoDB(): void { + subscribeV4Command(); + subscribeV4Checkout(); + subscribeV3Wireprotocol(); +} + // Command doc first-keys whose span the `v3_command` channel must suppress. // The `insert`/`update`/`remove`/`query`/`getMore` functions call the shared // `command` function internally. Orchestrion transforms the `command` *source* @@ -49,16 +57,8 @@ const V3_DEDICATED_COMMANDS = new Set(['insert', 'update', 'delete', 'find', 'ge const _mongodbChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - subscribeV4Command(); - subscribeV4Checkout(); - subscribeV3Wireprotocol(); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, mongodbModuleNames, instrumentMongoDB, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/mongoose.ts b/packages/server-utils/src/integrations/tracing-channel/mongoose.ts index 4dc77d0224d0..a37620c8f183 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mongoose.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mongoose.ts @@ -1,16 +1,14 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span } from '@sentry/core'; -import { debug, defineIntegration, getActiveSpan, waitForTracingChannelBinding } from '@sentry/core'; -import { subscribeMongooseDiagnosticChannels } from '../../mongoose/mongoose-dc-subscriber'; +import { defineIntegration, extendIntegration, getActiveSpan } from '@sentry/core'; import type { MongooseLegacyCollection } from '../../mongoose/mongoose-legacy-span'; import { startMongooseLegacySpan } from '../../mongoose/mongoose-legacy-span'; import { CHANNELS } from '../../orchestrion/channels'; -import { MONGOOSE_CONTEXT_CAPTURE_CHANNELS } from '../../orchestrion/config/mongoose'; -import { DEBUG_BUILD } from '../../debug-build'; +import { MONGOOSE_CONTEXT_CAPTURE_CHANNELS, mongooseModuleNames } from '../../orchestrion/config/mongoose'; import type { SentryTracingChannel } from '../../tracing-channel'; import { bindTracingChannelToSpan } from '../../tracing-channel'; - -const INTEGRATION_NAME = 'Mongoose' as const; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { mongooseIntegration } from '../../mongoose'; // Origin distinguishes the orchestrion path from the OTel/IITM path // (`auto.db.otel.mongoose`) and the native diagnostics_channel path @@ -56,38 +54,16 @@ interface MongooseChannelContext { // mutating mongoose's own objects. const STORED_PARENT_SPAN = new WeakMap(); -let orchestrionSubscribed = false; - const _mongooseChannelIntegration = (() => { - return { - name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - // mongoose >= 9.7 publishes via its own diagnostics_channel; reuse - // the shared native subscriber so this single integration covers all - // versions after it replaces the OTel one. Idempotent and inert on - // < 9.7 (those channels just never get published to) - subscribeMongooseDiagnosticChannels(diagnosticsChannel.tracingChannel); - - // mongoose < 9.7 is covered by the orchestrion-injected channels. - subscribeOrchestrionMongooseChannels(); - }); + return extendIntegration(mongooseIntegration(), { + setup(client) { + invokeOrchestrionInstrumentation(client, mongooseModuleNames, instrumentMongoose, []); }, - }; + }); }) satisfies IntegrationFn; -function subscribeOrchestrionMongooseChannels(): void { - if (orchestrionSubscribed) { - return; - } - orchestrionSubscribed = true; - - DEBUG_BUILD && debug.log('[orchestrion:mongoose] subscribing to injected channels'); - +// mongoose < 9.7 is covered by the orchestrion-injected channels. +function instrumentMongoose(): void { // Context-capture builders: stash the active span at build time so `exec()` // can parent to it. for (const channelName of MONGOOSE_CONTEXT_CAPTURE_CHANNELS) { 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..20752e8a9979 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -2,14 +2,13 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; import { defineIntegration, + extendIntegration, isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { subscribeMysql2DiagnosticChannels } from '../../mysql2/mysql2-dc-subscriber'; import type { ChannelName } from '../../orchestrion/channels'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; @@ -21,8 +20,10 @@ import { NET_PEER_NAME, NET_PEER_PORT, } from '@sentry/conventions/attributes'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { mysql2ModuleNames } from '../../orchestrion/config/mysql2'; +import { mysql2Integration } from '../../mysql2'; -const INTEGRATION_NAME = 'Mysql2' as const; const ORIGIN = 'auto.db.orchestrion.mysql2'; const DB_SYSTEM_VALUE_MYSQL = 'mysql'; @@ -66,9 +67,6 @@ interface Mysql2Connection { * `experimentalUseDiagnosticsChannelInjection`. */ function instrumentMysql2(): void { - // mysql2 >= 3.20.0: native diagnostics_channel path (inert on older versions, which never publish). - subscribeMysql2DiagnosticChannels(diagnosticsChannel.tracingChannel); - // mysql2 < 3.20.0: orchestrion-injected channels (inert on >= 3.20.0, which we don't transform). subscribeQueryChannel(CHANNELS.MYSQL2_QUERY); subscribeQueryChannel(CHANNELS.MYSQL2_EXECUTE); @@ -135,19 +133,11 @@ 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(); - }); + return extendIntegration(mysql2Integration(), { + 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 b172c6924300..8ebc5ee182e1 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). @@ -42,36 +41,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 6dedde81bafb..e08ba313ae81 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 / @@ -302,33 +301,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/tedious.ts b/packages/server-utils/src/integrations/tracing-channel/tedious.ts index 99a927f5dd7e..75bbbde4d3e4 100644 --- a/packages/server-utils/src/integrations/tracing-channel/tedious.ts +++ b/packages/server-utils/src/integrations/tracing-channel/tedious.ts @@ -6,13 +6,11 @@ import { EventEmitter } from 'node:events'; import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; import { - debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, SPAN_STATUS_ERROR, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; import { DB_NAME, @@ -22,8 +20,9 @@ import { NET_PEER_NAME, NET_PEER_PORT, } from '@sentry/conventions/attributes'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { tediousModuleNames } from '../../orchestrion/config/tedious'; // NOTE: this uses the same name as the OTel integration by design. When orchestrion injection is active, // `_init` swaps the OTel `Tedious` integration out of the defaults and appends this one (matched by name). @@ -227,26 +226,21 @@ function once(fn: (...args: Args) => void): (...args: Ar }; } +function instrumentTedious(): void { + subscribeConnect(); + subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL, 'execSql'); + subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL_BATCH, 'execSqlBatch'); + subscribeQuery(CHANNELS.TEDIOUS_CALL_PROCEDURE, 'callProcedure'); + subscribeQuery(CHANNELS.TEDIOUS_EXEC_BULK_LOAD, 'execBulkLoad'); + subscribeQuery(CHANNELS.TEDIOUS_PREPARE, 'prepare'); + subscribeQuery(CHANNELS.TEDIOUS_EXECUTE, 'execute'); +} + const _tediousChannelIntegration = (() => { 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:tedious] subscribing to channel "${CHANNELS.TEDIOUS_EXEC_SQL}"`); - - waitForTracingChannelBinding(() => { - subscribeConnect(); - subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL, 'execSql'); - subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL_BATCH, 'execSqlBatch'); - subscribeQuery(CHANNELS.TEDIOUS_CALL_PROCEDURE, 'callProcedure'); - subscribeQuery(CHANNELS.TEDIOUS_EXEC_BULK_LOAD, 'execBulkLoad'); - subscribeQuery(CHANNELS.TEDIOUS_PREPARE, 'prepare'); - subscribeQuery(CHANNELS.TEDIOUS_EXECUTE, 'execute'); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, tediousModuleNames, instrumentTedious, []); }, }; }) 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/config/amqplib.ts b/packages/server-utils/src/orchestrion/config/amqplib.ts index 5f2082859347..2dd098841c38 100644 --- a/packages/server-utils/src/orchestrion/config/amqplib.ts +++ b/packages/server-utils/src/orchestrion/config/amqplib.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // `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 = getModuleNames(amqplibConfig); 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 0b5f0e0ecf10..7a62dd395b5e 100644 --- a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts +++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(anthropicAiConfig); export const anthropicAiChannels = { ANTHROPIC_CHAT: 'orchestrion:@anthropic-ai/sdk:chat', diff --git a/packages/server-utils/src/orchestrion/config/dataloader.ts b/packages/server-utils/src/orchestrion/config/dataloader.ts index cd1f0879bdc3..bd1e3f714f0f 100644 --- a/packages/server-utils/src/orchestrion/config/dataloader.ts +++ b/packages/server-utils/src/orchestrion/config/dataloader.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // `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 = getModuleNames(dataloaderConfig); export const dataloaderChannels = { DATALOADER_CONSTRUCT: 'orchestrion:dataloader:construct', diff --git a/packages/server-utils/src/orchestrion/config/express.ts b/packages/server-utils/src/orchestrion/config/express.ts index 81fe89947e3d..c21f7f5ab8f9 100644 --- a/packages/server-utils/src/orchestrion/config/express.ts +++ b/packages/server-utils/src/orchestrion/config/express.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; export const expressConfig = [ // Express funnels every middleware/route handler through a single method on @@ -57,7 +58,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 = getModuleNames(expressConfig); export const expressChannels = { // Express v4 runs each layer's handler through `Layer.prototype.handle_request` diff --git a/packages/server-utils/src/orchestrion/config/generic-pool.ts b/packages/server-utils/src/orchestrion/config/generic-pool.ts index 724451207ea2..d2bf80e16dd0 100644 --- a/packages/server-utils/src/orchestrion/config/generic-pool.ts +++ b/packages/server-utils/src/orchestrion/config/generic-pool.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // 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 = getModuleNames(genericPoolConfig); 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 693e8ba039bc..96bf6cf3bac2 100644 --- a/packages/server-utils/src/orchestrion/config/google-genai.ts +++ b/packages/server-utils/src/orchestrion/config/google-genai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // `@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 = getModuleNames(googleGenAiConfig); export const googleGenAiChannels = { GOOGLE_GENAI_GENERATE_CONTENT: 'orchestrion:@google/genai:generate-content', diff --git a/packages/server-utils/src/orchestrion/config/graphql.ts b/packages/server-utils/src/orchestrion/config/graphql.ts index 13257755934c..933946cc8e55 100644 --- a/packages/server-utils/src/orchestrion/config/graphql.ts +++ b/packages/server-utils/src/orchestrion/config/graphql.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // `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 = getModuleNames(graphqlConfig); export const graphqlChannels = { GRAPHQL_PARSE: 'orchestrion:graphql:parse', diff --git a/packages/server-utils/src/orchestrion/config/hapi.ts b/packages/server-utils/src/orchestrion/config/hapi.ts index 7c11aac6911a..52891877efc7 100644 --- a/packages/server-utils/src/orchestrion/config/hapi.ts +++ b/packages/server-utils/src/orchestrion/config/hapi.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(hapiConfig); 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 a7c3cd159b75..dfdb0c5407e1 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; import { amqplibConfig } from './amqplib'; import { anthropicAiConfig } from './anthropic-ai'; @@ -85,7 +85,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ */ export function instrumentedModuleNames(instrumentations: InstrumentationConfig[] = []): string[] { return [ - ...uniq([...SENTRY_INSTRUMENTATIONS, ...instrumentations].map(i => i.module.name)), + ...getModuleNames([...SENTRY_INSTRUMENTATIONS, ...instrumentations]), // Additional things that need to be bundled but are not covered by the above // Remix needs to bundle this so @remix-run/server-runtime is _also_ bundled '@remix-run/node', diff --git a/packages/server-utils/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts index 35d2d44cd79b..802ecc5463fc 100644 --- a/packages/server-utils/src/orchestrion/config/ioredis.ts +++ b/packages/server-utils/src/orchestrion/config/ioredis.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(ioredisConfig); export const ioredisChannels = { IOREDIS_COMMAND: 'orchestrion:ioredis:command', diff --git a/packages/server-utils/src/orchestrion/config/kafkajs.ts b/packages/server-utils/src/orchestrion/config/kafkajs.ts index 0d653e0db0eb..ff594a3efc92 100644 --- a/packages/server-utils/src/orchestrion/config/kafkajs.ts +++ b/packages/server-utils/src/orchestrion/config/kafkajs.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(kafkajsConfig); export const kafkajsChannels = { KAFKAJS_SEND_BATCH: 'orchestrion:kafkajs:send_batch', diff --git a/packages/server-utils/src/orchestrion/config/knex.ts b/packages/server-utils/src/orchestrion/config/knex.ts index 8c4c2d068563..fa378aa367a9 100644 --- a/packages/server-utils/src/orchestrion/config/knex.ts +++ b/packages/server-utils/src/orchestrion/config/knex.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(knexConfig); export const knexChannels = { KNEX_QUERY: 'orchestrion:knex:query', diff --git a/packages/server-utils/src/orchestrion/config/koa.ts b/packages/server-utils/src/orchestrion/config/koa.ts index b0d8ad516a37..4909801b49ce 100644 --- a/packages/server-utils/src/orchestrion/config/koa.ts +++ b/packages/server-utils/src/orchestrion/config/koa.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { getModuleNames } from './utils'; export const koaConfig = [ { @@ -6,7 +7,9 @@ export const koaConfig = [ module: { name: 'koa', versionRange: '>=2.0.0 <4', filePath: 'lib/application.js' }, functionQuery: { className: 'Application', methodName: 'use', kind: 'Sync' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const koaModuleNames = getModuleNames(koaConfig); export const koaChannels = { KOA_USE: 'orchestrion:koa:use', diff --git a/packages/server-utils/src/orchestrion/config/langchain.ts b/packages/server-utils/src/orchestrion/config/langchain.ts index df6247c91d1c..25c40c23ef8c 100644 --- a/packages/server-utils/src/orchestrion/config/langchain.ts +++ b/packages/server-utils/src/orchestrion/config/langchain.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // `@langchain/*` packages ship dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the // matcher compares `filePath` exactly, so each hook is declared once per built file. @@ -59,7 +60,9 @@ const embeddingsConfig = EMBEDDINGS_PROVIDERS.flatMap(({ name, versionRange, met ), ); -export const langchainConfig = [...chatModelConfig, ...embeddingsConfig] satisfies InstrumentationConfig[]; +export const langchainConfig = [...chatModelConfig, ...embeddingsConfig] as const satisfies InstrumentationConfig[]; + +export const langchainModuleNames = getModuleNames(langchainConfig); // The embeddings channel strings the subscriber binds to, derived from the provider list above so that // adding a provider is a single edit that both instruments it and subscribes the listener to it. diff --git a/packages/server-utils/src/orchestrion/config/langgraph.ts b/packages/server-utils/src/orchestrion/config/langgraph.ts index 666e4ac3ef12..3ab624d89d26 100644 --- a/packages/server-utils/src/orchestrion/config/langgraph.ts +++ b/packages/server-utils/src/orchestrion/config/langgraph.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // `@langchain/langgraph` ships dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the // matcher compares `filePath` exactly, so each hook is declared once per built file. `StateGraph.compile` @@ -26,7 +27,8 @@ const createReactAgentConfig = ['dist/prebuilt/react_agent_executor.cjs', 'dist/ }), ); -export const langgraphConfig = [...compileConfig, ...createReactAgentConfig] satisfies InstrumentationConfig[]; +export const langgraphConfig = [...compileConfig, ...createReactAgentConfig] as const satisfies InstrumentationConfig[]; +export const langgraphModuleNames = getModuleNames(langgraphConfig); export const langgraphChannels = { LANGGRAPH_STATE_GRAPH_COMPILE: 'orchestrion:@langchain/langgraph:stateGraphCompile', diff --git a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts index e186136d05d1..005f9074c143 100644 --- a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts +++ b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(lruMemoizerConfig); export const lruMemoizerChannels = { LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', diff --git a/packages/server-utils/src/orchestrion/config/mongodb.ts b/packages/server-utils/src/orchestrion/config/mongodb.ts index ee44b79b0bba..6efdeb7193a4 100644 --- a/packages/server-utils/src/orchestrion/config/mongodb.ts +++ b/packages/server-utils/src/orchestrion/config/mongodb.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // The mongodb driver's command architecture changed across majors, mirrored in the vendored OTel // instrumentation's version bands: @@ -58,7 +59,9 @@ export const mongodbConfig = [ module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/get_more.js' }, functionQuery: { functionName: 'getMore', kind: 'Callback' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const mongodbModuleNames = getModuleNames(mongodbConfig); export const mongodbChannels = { MONGODB_COMMAND: 'orchestrion:mongodb:command', diff --git a/packages/server-utils/src/orchestrion/config/mongoose.ts b/packages/server-utils/src/orchestrion/config/mongoose.ts index efc952184ba1..de0041693863 100644 --- a/packages/server-utils/src/orchestrion/config/mongoose.ts +++ b/packages/server-utils/src/orchestrion/config/mongoose.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // mongoose >= 9.7.0 publishes via its own `node:diagnostics_channel` tracing channels (handled by // `subscribeMongooseDiagnosticChannels`), so this transform is gated to `< 9.7.0` to avoid emitting @@ -103,7 +104,9 @@ export const mongooseConfig = [ module: { ...module, filePath: 'lib/query.js' }, functionQuery: { expressionName: methodName, kind: 'Sync' as const }, })), -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const mongooseModuleNames = getModuleNames(mongooseConfig); export const mongooseChannels = { MONGOOSE_QUERY_EXEC: 'orchestrion:mongoose:query_exec', diff --git a/packages/server-utils/src/orchestrion/config/mysql.ts b/packages/server-utils/src/orchestrion/config/mysql.ts index c686f435a9f7..d0f13ddd92e9 100644 --- a/packages/server-utils/src/orchestrion/config/mysql.ts +++ b/packages/server-utils/src/orchestrion/config/mysql.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(mysqlConfig); 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 bac26e323053..aaece6024fa3 100644 --- a/packages/server-utils/src/orchestrion/config/mysql2.ts +++ b/packages/server-utils/src/orchestrion/config/mysql2.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // 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 = getModuleNames(mysql2Config); 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 44900addc39e..5f16ae345e4d 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -128,7 +128,7 @@ export const nestjsConfig = [ }, functionQuery: { functionName: 'Processor', kind: 'Sync' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; 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 d29c12623e24..57a6133cfea8 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(openaiConfig); export const openaiChannels = { // Chat completions, the responses API, and the conversations API all report a `chat` operation with diff --git a/packages/server-utils/src/orchestrion/config/pg.ts b/packages/server-utils/src/orchestrion/config/pg.ts index d000e423068a..b6592ddb5020 100644 --- a/packages/server-utils/src/orchestrion/config/pg.ts +++ b/packages/server-utils/src/orchestrion/config/pg.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(pgConfig); 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 245a939cd7e7..ad80c3eef264 100644 --- a/packages/server-utils/src/orchestrion/config/postgres.ts +++ b/packages/server-utils/src/orchestrion/config/postgres.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; // 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 '..'; // `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 = getModuleNames(postgresJsConfig); export const postgresJsChannels = { POSTGRESJS_HANDLE: 'orchestrion:postgres:handle', diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts index 55db5e45c4e8..05f44dd31251 100644 --- a/packages/server-utils/src/orchestrion/config/redis.ts +++ b/packages/server-utils/src/orchestrion/config/redis.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(redisConfig); export const redisChannels = { REDIS_COMMAND: 'orchestrion:redis:command', diff --git a/packages/server-utils/src/orchestrion/config/tedious.ts b/packages/server-utils/src/orchestrion/config/tedious.ts index c8ac5129e807..281f2b61e6eb 100644 --- a/packages/server-utils/src/orchestrion/config/tedious.ts +++ b/packages/server-utils/src/orchestrion/config/tedious.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; const MODULE_NAME = 'tedious'; @@ -20,6 +21,8 @@ export const tediousConfig: InstrumentationConfig[] = METHODS.map(methodName => functionQuery: { className: 'Connection', methodName, kind: 'Sync' }, })); +export const tediousModuleNames = getModuleNames(tediousConfig); + export const tediousChannels = { TEDIOUS_CONNECT: 'orchestrion:tedious:connect', TEDIOUS_EXEC_SQL: 'orchestrion:tedious:execSql', diff --git a/packages/server-utils/src/orchestrion/config/vercel-ai.ts b/packages/server-utils/src/orchestrion/config/vercel-ai.ts index 3c7d15fa0be7..311a54274d3c 100644 --- a/packages/server-utils/src/orchestrion/config/vercel-ai.ts +++ b/packages/server-utils/src/orchestrion/config/vercel-ai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { getModuleNames } from './utils'; 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 = getModuleNames(vercelAiConfig); export const vercelAiChannels = { // Vercel AI (`ai`): orchestrion injects these so the same channel-based 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 b41290b281e9..135f594d7f08 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 { addNonEnumerableProperty, @@ -129,8 +130,6 @@ const operationErrorInfoBySpan = 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 @@ -140,15 +139,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); diff --git a/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts b/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts index 862e9ee3e747..d76d57933e74 100644 --- a/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts +++ b/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getExtMetadata, getRouteMetadata } from '../../../src/integrations/tracing-channel/hapi-utils'; +import { getExtMetadata, getRouteMetadata } from '../../../src/integrations/tracing-channel/hapi/utils'; describe('getRouteMetadata', () => { const route = { path: '/users/{id}', method: 'get' } as any; diff --git a/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts b/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts index 12f2bd6688df..6b1aca91b08c 100644 --- a/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts +++ b/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts @@ -3,8 +3,11 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { Scope } from '@sentry/core'; import { _INTERNAL_setSpanForScope, + Client, + createTransport, getDefaultCurrentScope, getDefaultIsolationScope, + resolvedSyncPromise, setAsyncContextStrategy, } from '@sentry/core'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -16,7 +19,28 @@ interface TestStore { isolationScope: Scope; } -// `setupOnce` only subscribes once `waitForTracingChannelBinding` sees an +class TestClient extends Client { + public constructor(options: any) { + super(options); + } + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function createTestClient(): Client { + return new TestClient({ + dsn: 'https://username@domain/123', + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + stackParser: () => [], + }); +} + +// `setup` only subscribes once `waitForTracingChannelBinding` sees an // async-context strategy exposing `getTracingChannelBinding`. Install a // minimal one so the subscriptions actually register here. function installTestAsyncContextStrategy(): void { @@ -59,7 +83,7 @@ function installTestAsyncContextStrategy(): void { }); } -// `setupOnce` subscribes to process-global `tracingChannel`s, so asserting the +// `setup` subscribes to process-global `tracingChannel`s, so asserting the // ABSENCE of connect subscribers only holds when no other (default-options) // integration in the same module context has subscribed. vitest isolates // module state per file, so this file keeps that assertion clean (the default @@ -74,7 +98,11 @@ describe('postgresChannelIntegration({ ignoreConnectSpans: true })', () => { }); it('subscribes to the query channel but NOT the connect / pool-connect channels', () => { - postgresChannelIntegration({ ignoreConnectSpans: true }).setupOnce?.(); + const client = createTestClient(); + postgresChannelIntegration({ ignoreConnectSpans: true }).setup?.(client); + // Channel subscribers only register once orchestrion reports a matching + // module as injected. + client.emit('orchestrion.module-runtime-injected', 'pg'); expect(tracingChannel(CHANNELS.PG_QUERY).start.hasSubscribers).toBe(true); expect(tracingChannel(CHANNELS.PG_CONNECT).start.hasSubscribers).toBe(false); diff --git a/packages/server-utils/test/orchestrion/postgres.test.ts b/packages/server-utils/test/orchestrion/postgres.test.ts index 651c0e82ff62..c6645fdc9098 100644 --- a/packages/server-utils/test/orchestrion/postgres.test.ts +++ b/packages/server-utils/test/orchestrion/postgres.test.ts @@ -4,8 +4,11 @@ import type { Scope, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { _INTERNAL_setSpanForScope, + Client, + createTransport, getDefaultCurrentScope, getDefaultIsolationScope, + resolvedSyncPromise, setAsyncContextStrategy, } from '@sentry/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; @@ -17,7 +20,28 @@ interface TestStore { isolationScope: Scope; } -// `bindTracingChannelToSpan` only binds (and `setupOnce` only subscribes via +class TestClient extends Client { + public constructor(options: any) { + super(options); + } + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function createTestClient(): Client { + return new TestClient({ + dsn: 'https://username@domain/123', + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + stackParser: () => [], + }); +} + +// `bindTracingChannelToSpan` only binds (and `setup` only subscribes via // `waitForTracingChannelBinding`) when an async-context strategy exposes a // `getTracingChannelBinding`. Install a minimal one so the channel // subscriptions actually register in this unit-test context (no SDK `init`). @@ -61,6 +85,15 @@ function installTestAsyncContextStrategy(): void { }); } +// Channel subscribers only register once orchestrion reports a matching module +// as injected (`invokeOrchestrionInstrumentation`). Emit that event after +// `setup` so subscriptions land synchronously under the test strategy above. +function setupPostgresChannelIntegration(options?: { ignoreConnectSpans?: boolean }): void { + const client = createTestClient(); + postgresChannelIntegration(options).setup?.(client); + client.emit('orchestrion.module-runtime-injected', 'pg'); +} + // The subscriber builds spans via `startInactiveSpan` and gates on // `getActiveSpan`. We spy both: `getActiveSpan` to satisfy the // requireParentSpan gate, and `startInactiveSpan` to capture the span @@ -84,10 +117,10 @@ describe('postgresChannelIntegration', () => { // Subscribe once for the whole file so a single subscriber handles each // publish (avoids accumulating duplicate subscriptions across tests). The - // strategy must be installed first so `setupOnce`'s `waitForTracingChannelBinding` fires synchronously. + // strategy must be installed first so `setup`'s `waitForTracingChannelBinding` fires synchronously. beforeAll(() => { installTestAsyncContextStrategy(); - postgresChannelIntegration().setupOnce?.(); + setupPostgresChannelIntegration(); }); afterAll(() => {