From d48f476c4d9fbd2709e22e37a00042f2b3cd2eae Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 20 Jul 2026 13:07:09 +0200 Subject: [PATCH] fix(server-utils): Ensure orchestrion instrumentations lazy register on Node (#22518) Co-Authored-By: isaacs Co-Authored-By: Claude Opus 4.8 --- .../instrument.mjs | 15 + .../scenario-bundler.mjs | 38 +++ .../scenario.mjs | 26 ++ .../orchestrion-lazy-registration/test.ts | 37 ++ packages/core/src/client.ts | 15 + packages/core/src/utils/worldwide.ts | 10 + .../integrations/tracing-channel/amqplib.ts | 39 +-- .../integrations/tracing-channel/anthropic.ts | 56 ++-- .../tracing-channel/aws-sdk/index.ts | 317 +++++++++--------- .../tracing-channel/express/index.ts | 18 +- .../tracing-channel/firebase/index.ts | 16 +- .../tracing-channel/generic-pool.ts | 18 +- .../tracing-channel/google-genai.ts | 62 ++-- .../tracing-channel/graphql/index.ts | 53 +-- .../src/integrations/tracing-channel/hapi.ts | 66 ++-- .../integrations/tracing-channel/ioredis.ts | 96 +++--- .../tracing-channel/kafkajs/index.ts | 26 +- .../src/integrations/tracing-channel/koa.ts | 38 +-- .../integrations/tracing-channel/langchain.ts | 96 +++--- .../integrations/tracing-channel/langgraph.ts | 141 ++++---- .../tracing-channel/lru-memoizer.ts | 30 +- .../integrations/tracing-channel/mongodb.ts | 22 +- .../integrations/tracing-channel/mongoose.ts | 22 +- .../src/integrations/tracing-channel/mysql.ts | 120 ++++--- .../integrations/tracing-channel/mysql2.ts | 58 ++-- .../integrations/tracing-channel/openai.ts | 50 ++- .../tracing-channel/postgres-js.ts | 237 +++++++------ .../integrations/tracing-channel/postgres.ts | 42 +-- .../src/integrations/tracing-channel/redis.ts | 54 +-- .../integrations/tracing-channel/tedious.ts | 34 +- .../integrations/tracing-channel/vercel-ai.ts | 23 +- .../src/orchestrion/bundler/options.ts | 18 +- .../src/orchestrion/bundler/webpack-loader.ts | 92 ++++- .../src/orchestrion/config/amqplib.ts | 4 +- .../src/orchestrion/config/anthropic-ai.ts | 4 +- .../src/orchestrion/config/aws-sdk.ts | 4 +- .../config/channel-integration-definitions.ts | 5 + .../src/orchestrion/config/express.ts | 4 +- .../src/orchestrion/config/firebase.ts | 4 +- .../src/orchestrion/config/generic-pool.ts | 4 +- .../src/orchestrion/config/google-genai.ts | 4 +- .../src/orchestrion/config/graphql.ts | 4 +- .../src/orchestrion/config/hapi.ts | 4 +- .../src/orchestrion/config/ioredis.ts | 4 +- .../src/orchestrion/config/kafkajs.ts | 4 +- .../src/orchestrion/config/koa.ts | 4 +- .../src/orchestrion/config/langchain.ts | 4 +- .../src/orchestrion/config/langgraph.ts | 4 +- .../src/orchestrion/config/lru-memoizer.ts | 4 +- .../src/orchestrion/config/mongodb.ts | 4 +- .../src/orchestrion/config/mongoose.ts | 4 +- .../src/orchestrion/config/mysql.ts | 4 +- .../src/orchestrion/config/mysql2.ts | 4 +- .../src/orchestrion/config/openai.ts | 4 +- .../server-utils/src/orchestrion/config/pg.ts | 4 +- .../src/orchestrion/config/postgres.ts | 4 +- .../src/orchestrion/config/redis.ts | 4 +- .../orchestrion/config/subscribe-injection.ts | 9 + .../src/orchestrion/config/tedious.ts | 4 +- .../src/orchestrion/config/vercel-ai.ts | 4 +- .../server-utils/src/orchestrion/detect.ts | 18 +- .../src/orchestrion/instrumentation.ts | 117 +++++++ .../orchestrion/registerChannelIntegration.ts | 20 ++ .../src/orchestrion/runtime/register.ts | 19 +- .../test/orchestrion/bundler.test.ts | 34 ++ .../test/orchestrion/detect.test.ts | 37 ++ .../test/orchestrion/instrumentation.test.ts | 193 +++++++++++ .../postgres-ignore-connect.test.ts | 10 +- .../test/orchestrion/postgres.test.ts | 11 +- .../registerChannelIntegration.test.ts | 23 ++ .../test/orchestrion/webpack-loader.test.ts | 86 +++++ 71 files changed, 1698 insertions(+), 969 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/test.ts create mode 100644 packages/server-utils/src/orchestrion/instrumentation.ts create mode 100644 packages/server-utils/test/orchestrion/detect.test.ts create mode 100644 packages/server-utils/test/orchestrion/instrumentation.test.ts create mode 100644 packages/server-utils/test/orchestrion/webpack-loader.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/instrument.mjs new file mode 100644 index 000000000000..d2c90f204c01 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/instrument.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Opt into diagnostics-channel injection. This registers the runtime module +// hook and swaps the OTel integrations for the channel-based ones. The scenario +// then checks that the channel subscribers are NOT wired up until the module +// is actually loaded. +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs new file mode 100644 index 000000000000..ff2e912ee0f2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs @@ -0,0 +1,38 @@ +import { strict as assert } from 'node:assert'; +import { tracingChannel } from 'node:diagnostics_channel'; + +// Reproduces the force-bundled path (vite SSR, nextjs's bundle-safe packages): +// the module is transformed at BUILD time and inlined, so it is never loaded +// through the runtime module hook and its `orchestrion.module-runtime-injected` +// event never fires. Instead the bundler's `injectDiagnostics` boot banner sets +// `.bundler` and calls the on-inject bridge, which must trigger the lazy +// channel subscription. We simulate that banner here, WITHOUT ever importing +// generic-pool. + +const channel = tracingChannel('orchestrion:generic-pool:acquire'); + +// `init()` (in instrument.mjs) installed the bridge and registered the lazy +// listener, but nothing is injected yet, so the channel has no subscriber. +const marker = globalThis.__SENTRY_ORCHESTRION__; +assert.ok(marker, 'expected __SENTRY_ORCHESTRION__ marker to exist after init'); +assert.equal(typeof marker.onInject, 'function', 'expected the on-inject bridge to be installed by init()'); +assert.equal( + channel.start.hasSubscribers, + false, + 'expected NO subscribers before the bundler banner announces the module', +); + +// Simulate the bundler's `injectDiagnostics` boot banner: record the bundled +// module and fire the bridge for it. In a real build this runs when the app +// bundle boots, after `init()`. +marker.bundler = ['generic-pool']; +marker.onInject('generic-pool'); + +// The bridge re-emitted `orchestrion.module-runtime-injected`, so the +// GenericPool integration must have subscribed, even though generic-pool was +// never loaded through the module hook. +assert.equal( + channel.start.hasSubscribers, + true, + 'expected subscribers after the bundler banner fired the on-inject bridge', +); diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario.mjs new file mode 100644 index 000000000000..e75cb6ef6d20 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario.mjs @@ -0,0 +1,26 @@ +import { strict as assert } from 'node:assert'; +import { tracingChannel } from 'node:diagnostics_channel'; + +// `generic-pool` is a default channel integration and a pure, service-free +// require, so loading it is enough to trigger the runtime hook's injection. +const channel = tracingChannel('orchestrion:generic-pool:acquire'); + +// After `init()` but BEFORE `generic-pool` is loaded, a lazily-registering +// integration must not have subscribed to the channel yet — otherwise every +// default channel integration would consume channel slots up front (Node caps +// channels in use at 1024), even for modules the app never loads. +assert.equal( + channel.start.hasSubscribers, + false, + 'expected NO subscribers on orchestrion:generic-pool:acquire before generic-pool is loaded', +); + +// Loading the module triggers the runtime hook to inject it, which is the point +// at which the integration should wire up its channel subscriber. +await import('generic-pool'); + +assert.equal( + channel.start.hasSubscribers, + true, + 'expected subscribers on orchestrion:generic-pool:acquire after generic-pool is loaded', +); diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/test.ts b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/test.ts new file mode 100644 index 000000000000..2dea68ab1ce7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/test.ts @@ -0,0 +1,37 @@ +import * as path from 'path'; +import { afterAll, test } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +// The runtime module hook needs Node >= 18.19; gate on 20 to stay on the stable +// `Module.registerHooks` / `Channel.hasSubscribers` surface. +conditionalTest({ min: 20 })('orchestrion lazy channel registration', () => { + // The scenario self-asserts (via `node:assert`) that a default channel + // integration has NOT subscribed to its channel until the instrumented module + // is loaded, then that it HAS once loaded. A violation throws, which + // `ensureNoErrorOutput` turns into a test failure. + test('does not attach channel listeners until the module is loaded', async () => { + await createRunner(__dirname, 'scenario.mjs') + .withInstrument(path.join(__dirname, 'instrument.mjs')) + .ensureNoErrorOutput() + .start() + .completed(); + }); + + // A force-bundled module (vite SSR / nextjs bundle-safe packages) is never + // loaded through the runtime hook, so it can only trigger subscription via + // the bundler's boot banner → on-inject bridge. The scenario simulates that + // banner and asserts the channel subscribes without the module ever being + // loaded through the hook. + test('subscribes for a bundler-announced module via the on-inject bridge', async () => { + await createRunner(__dirname, 'scenario-bundler.mjs') + .withInstrument(path.join(__dirname, 'instrument.mjs')) + .ensureNoErrorOutput() + .start() + .completed(); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 8171a53c02c4..f10dbf13be4e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -932,6 +932,16 @@ export abstract class Client { */ public on(hook: 'stopUIProfiler', callback: () => void): () => void; + /** + * A hook that is called when an orchestrion-instrumented module is injected at + * runtime (by the `--import` module hook). Channel-based integrations use it to + * subscribe their diagnostics-channel listeners lazily, only once the module + * they instrument is actually loaded. Receives the injected module name. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'orchestrion.module-runtime-injected', callback: (moduleName: string) => void): () => void; + /** * Register a hook on this client. */ @@ -1193,6 +1203,11 @@ export abstract class Client { */ public emit(hook: 'stopUIProfiler'): void; + /** + * Emit a hook when an orchestrion-instrumented module is injected at runtime. + */ + public emit(hook: 'orchestrion.module-runtime-injected', moduleName: string): void; + /** * Emit a hook that was previously registered via `on()`. */ diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 396c98d952a2..78ca4dc49ad1 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -72,6 +72,16 @@ export type InternalGlobal = { * `init()` and instantiates them. */ integrations?: Map Integration>; + /** + * Bridge installed at `init()` by `registerDiagnosticsChannelInjection`. + * The bundler's `injectDiagnostics` boot banner calls it for each + * transformed module, emitting the `orchestrion.module-runtime-injected` + * client event so channel integrations subscribe for force-bundled modules + * (which the runtime module hook never sees). Absent on bundler-only + * runtimes (e.g. `@sentry/cloudflare`), where the banner's call is a + * guarded no-op. + */ + onInject?: (moduleName: string) => void; }; } & Carrier; diff --git a/packages/server-utils/src/integrations/tracing-channel/amqplib.ts b/packages/server-utils/src/integrations/tracing-channel/amqplib.ts index e6e5925c7c5a..21e840b2c8cb 100644 --- a/packages/server-utils/src/integrations/tracing-channel/amqplib.ts +++ b/packages/server-utils/src/integrations/tracing-channel/amqplib.ts @@ -3,14 +3,12 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { continueTrace, - debug, defineIntegration, getTraceData, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 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,7 +25,8 @@ import { SERVER_PORT, URL_FULL, } from '@sentry/conventions/attributes'; -import { DEBUG_BUILD } from '../../debug-build'; +import { amqplibModuleNames } from '../../orchestrion/config/amqplib'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; @@ -156,36 +155,24 @@ 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; - 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; +function instrumentAmqplib(): void { + subscribeConnect(); + subscribePublish(); + subscribeConfirmPublish(); + subscribeConsume(); + subscribeDispatch(); + subscribeSettle(); +} + /** * Producer span for `Channel.prototype.publish`. Creates a PRODUCER span, injects the trace headers * into the publish options, and ends when the (synchronous) publish call returns. Skips the confirm diff --git a/packages/server-utils/src/integrations/tracing-channel/anthropic.ts b/packages/server-utils/src/integrations/tracing-channel/anthropic.ts index 56d534db3f8f..0946346cb986 100644 --- a/packages/server-utils/src/integrations/tracing-channel/anthropic.ts +++ b/packages/server-utils/src/integrations/tracing-channel/anthropic.ts @@ -5,7 +5,6 @@ import { _INTERNAL_shouldSkipAiProviderWrapping, addAnthropicRequestAttributes, addAnthropicResponseAttributes, - debug, defineIntegration, extractAnthropicRequestAttributes, instrumentAsyncIterableStream, @@ -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 { anthropicAiModuleNames } from '../../orchestrion/config/anthropic-ai'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // 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,43 +46,34 @@ interface AnthropicChannelContext { result?: unknown; } -let subscribed = false; - 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; +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), + }, + ); + } +} + /** * Build the span for an instrumented call. * Returning `undefined` opts the payload out so no span is opened. diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts index 70482dcbfd5f..fd5e69400ed9 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -1,12 +1,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span } from '@sentry/core'; -import { - debug, - defineIntegration, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startInactiveSpan, - waitForTracingChannelBinding, -} from '@sentry/core'; +import { debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; import { _AWS_REQUEST_ID as AWS_REQUEST_ID, AWS_REQUEST_EXTENDED_ID, @@ -16,6 +10,8 @@ import { } from '@sentry/conventions/attributes'; import { DEBUG_BUILD } from '../../../debug-build'; import { CHANNELS } from '../../../orchestrion/channels'; +import { awsSdkModuleNames } from '../../../orchestrion/config/aws-sdk'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; import type { TracingChannelLifeCycleOptions } from '../../../tracing-channel'; import { bindTracingChannelToSpan, safeChannelCallback } from '../../../tracing-channel'; import { AWS_SDK_ORIGIN } from './constants'; @@ -74,179 +70,172 @@ const _awsChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - const getSpan = (data: AwsSendChannelContext): Span | undefined => - safeChannelCallback(() => { - const command = data.arguments[0] as AwsV3Command | undefined; - const commandName = command?.constructor?.name; - if (!command || !commandName) { - // Not a recognizable v3 command call; leave the active context untouched. - return undefined; - } - - const clientConfig = data.self?.config; - const serviceName = - clientConfig?.serviceId ?? - // `clientName` isn't available at the `send` boundary; fall back to the client's - // constructor name (e.g. `S3Client` -> `S3`). `serviceId` is set for all AWS clients. - removeSuffixFromStringIfExists(data.self?.constructor?.name || 'AWS', 'Client'); - - // Commands with all-optional members can be constructed without an input (`new - // ListBucketsCommand()`); the OTel path traces those too, so default rather than bail. - // The default is assigned back onto the command (not kept detached) because service hooks - // mutate `commandInput` (trace-propagation headers, `MessageAttributeNames`) and those - // writes must reach the serialized request. Current smithy clients already default `input` - // to `{}` in the command constructor; this only affects older clients in our range. - if (!command.input) { - command.input = {}; - } - const normalizedRequest = normalizeV3Request(serviceName, commandName, command.input, undefined); - const requestMetadata = servicesExtensions.requestPreSpanHook(normalizedRequest); + setup(client) { + invokeOrchestrionInstrumentation(client, awsSdkModuleNames, instrumentAwsSdk, [servicesExtensions]); + }, + }; +}) satisfies IntegrationFn; - const span = startInactiveSpan({ - name: requestMetadata.spanName ?? `${normalizedRequest.serviceName}.${normalizedRequest.commandName}`, - // `rpc` matches what the exporter infers from `rpc.service` for the OTel aws-sdk spans; - // service extensions override it where inference yields a different op (DynamoDB: `db`). - op: requestMetadata.spanOp || 'rpc', - attributes: { - [SENTRY_KIND]: 'client', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: AWS_SDK_ORIGIN, - ...extractAttributesFromNormalizedRequest(normalizedRequest), - ...requestMetadata.spanAttributes, - }, - }); +function instrumentAwsSdk(servicesExtensions: ServicesExtensions): void { + const getSpan = (data: AwsSendChannelContext): Span | undefined => + safeChannelCallback(() => { + const command = data.arguments[0] as AwsV3Command | undefined; + const commandName = command?.constructor?.name; + if (!command || !commandName) { + // Not a recognizable v3 command call; leave the active context untouched. + return undefined; + } - data._sentryNormalizedRequest = normalizedRequest; - data._sentryRequestMetadata = requestMetadata; + const clientConfig = data.self?.config; + const serviceName = + clientConfig?.serviceId ?? + // `clientName` isn't available at the `send` boundary; fall back to the client's + // constructor name (e.g. `S3Client` -> `S3`). `serviceId` is set for all AWS clients. + removeSuffixFromStringIfExists(data.self?.constructor?.name || 'AWS', 'Client'); + + // Commands with all-optional members can be constructed without an input (`new + // ListBucketsCommand()`); the OTel path traces those too, so default rather than bail. + // The default is assigned back onto the command (not kept detached) because service hooks + // mutate `commandInput` (trace-propagation headers, `MessageAttributeNames`) and those + // writes must reach the serialized request. Current smithy clients already default `input` + // to `{}` in the command constructor; this only affects older clients in our range. + if (!command.input) { + command.input = {}; + } + const normalizedRequest = normalizeV3Request(serviceName, commandName, command.input, undefined); + const requestMetadata = servicesExtensions.requestPreSpanHook(normalizedRequest); + + const span = startInactiveSpan({ + name: requestMetadata.spanName ?? `${normalizedRequest.serviceName}.${normalizedRequest.commandName}`, + // `rpc` matches what the exporter infers from `rpc.service` for the OTel aws-sdk spans; + // service extensions override it where inference yields a different op (DynamoDB: `db`). + op: requestMetadata.spanOp || 'rpc', + attributes: { + [SENTRY_KIND]: 'client', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: AWS_SDK_ORIGIN, + ...extractAttributesFromNormalizedRequest(normalizedRequest), + ...requestMetadata.spanAttributes, + }, + }); - // `region` resolves asynchronously while `send` proceeds (a channel subscriber cannot delay - // the traced call the way the OTel middleware does). Backfill it onto the span and the - // normalized request once available; `deferSpanEnd` holds the span open until this settles - // so `cloud.region` cannot be lost when `send` settles first (e.g. an early failure). - // - // The provider call is guarded separately: the span is already started, so a synchronous - // throw bubbling into the enclosing `safeChannelCallback` would discard it without ending it (a leaked - // open span). - let regionResult: string | Promise | undefined; - try { - regionResult = clientConfig?.region?.(); - } catch { + data._sentryNormalizedRequest = normalizedRequest; + data._sentryRequestMetadata = requestMetadata; + + // `region` resolves asynchronously while `send` proceeds (a channel subscriber cannot delay + // the traced call the way the OTel middleware does). Backfill it onto the span and the + // normalized request once available; `deferSpanEnd` holds the span open until this settles + // so `cloud.region` cannot be lost when `send` settles first (e.g. an early failure). + // + // The provider call is guarded separately: the span is already started, so a synchronous + // throw bubbling into the enclosing `safeChannelCallback` would discard it without ending it (a leaked + // open span). + let regionResult: string | Promise | undefined; + try { + regionResult = clientConfig?.region?.(); + } catch { + // Nothing to do; continue without a region. + } + // The `.finally` self-reference is safe: the callback only runs after initialization. + const regionHolder: { settled: boolean; promise: Promise } = { + settled: false, + promise: Promise.resolve(regionResult) + .then(region => { + if (region) { + normalizedRequest.region = region; + span.setAttribute(CLOUD_REGION, region); + } + }) + .catch(() => { // Nothing to do; continue without a region. - } - // The `.finally` self-reference is safe: the callback only runs after initialization. - const regionHolder: { settled: boolean; promise: Promise } = { - settled: false, - promise: Promise.resolve(regionResult) - .then(region => { - if (region) { - normalizedRequest.region = region; - span.setAttribute(CLOUD_REGION, region); - } - }) - .catch(() => { - // Nothing to do; continue without a region. - }) - .finally(() => { - regionHolder.settled = true; - }), - }; - data._sentryRegion = regionHolder; - - // Inject trace-propagation headers into outgoing messages (SQS/SNS/Lambda). Runs before - // `send` proceeds, so the mutated `commandInput` is used to build the request. - safeChannelCallback(() => servicesExtensions.requestPostSpanHook(normalizedRequest, span)); - - return span; - }); - - const opts: TracingChannelLifeCycleOptions = { - deferSpanEnd({ span, data, end }) { - const normalizedRequest = data._sentryNormalizedRequest; - const requestMetadata = data._sentryRequestMetadata; - if (!normalizedRequest) { - return false; - } + }) + .finally(() => { + regionHolder.settled = true; + }), + }; + data._sentryRegion = regionHolder; - const failed = 'error' in data; + // Inject trace-propagation headers into outgoing messages (SQS/SNS/Lambda). Runs before + // `send` proceeds, so the mutated `commandInput` is used to build the request. + safeChannelCallback(() => servicesExtensions.requestPostSpanHook(normalizedRequest, span)); - // The channel `result`/`error` are untyped; the `$metadata` casts below name smithy's - // `ResponseMetadata` shape (`any`-valued, see `setMetadataAttributes`). - safeChannelCallback(() => { - if (failed) { - const err = data.error as - | { $metadata?: Record; RequestId?: string; extendedRequestId?: string } - | undefined; - const errMetadata = err?.$metadata; - // Like the OTel path, read RequestId/extendedRequestId off the error itself, with - // `$metadata` (which smithy service errors also carry) as the fallback. A spread won't - // do: `$metadata` includes these keys with `undefined` values, clobbering the fallback. - setMetadataAttributes(span, { - requestId: err?.RequestId ?? errMetadata?.requestId, - httpStatusCode: errMetadata?.httpStatusCode, - extendedRequestId: err?.extendedRequestId ?? errMetadata?.extendedRequestId, - }); - return; - } + return span; + }); - const output = data.result as { $metadata?: Record } | undefined; - setMetadataAttributes(span, output?.$metadata); + const opts: TracingChannelLifeCycleOptions = { + deferSpanEnd({ span, data, end }) { + const normalizedRequest = data._sentryNormalizedRequest; + const requestMetadata = data._sentryRequestMetadata; + if (!normalizedRequest) { + return false; + } - const normalizedResponse: NormalizedResponse = { - data: output, - request: normalizedRequest, - requestId: output?.$metadata?.requestId, - }; - servicesExtensions.responseHook(normalizedResponse, span); + const failed = 'error' in data; + + // The channel `result`/`error` are untyped; the `$metadata` casts below name smithy's + // `ResponseMetadata` shape (`any`-valued, see `setMetadataAttributes`). + safeChannelCallback(() => { + if (failed) { + const err = data.error as + | { $metadata?: Record; RequestId?: string; extendedRequestId?: string } + | undefined; + const errMetadata = err?.$metadata; + // Like the OTel path, read RequestId/extendedRequestId off the error itself, with + // `$metadata` (which smithy service errors also carry) as the fallback. A spread won't + // do: `$metadata` includes these keys with `undefined` values, clobbering the fallback. + setMetadataAttributes(span, { + requestId: err?.RequestId ?? errMetadata?.requestId, + httpStatusCode: errMetadata?.httpStatusCode, + extendedRequestId: err?.extendedRequestId ?? errMetadata?.extendedRequestId, }); + return; + } - // Streaming responses end the span when their wrapped stream is consumed (see - // bedrock-runtime); the helper must not end it on `send` settling. Errors always end here. - if (requestMetadata?.isStream && !failed) { - return true; - } - - // Normally the region settles long before `send` does (the SDK awaits it internally to - // build the endpoint), but when `send` settles first (e.g. an early failure) hold the span - // open until the region backfill lands. The error status was already applied by the - // helper's `error` subscriber, so a plain `end()` suffices. - const region = data._sentryRegion; - if (region && !region.settled) { - void region.promise.then(() => end()); - return true; - } + const output = data.result as { $metadata?: Record } | undefined; + setMetadataAttributes(span, output?.$metadata); - return false; - }, - }; + const normalizedResponse: NormalizedResponse = { + data: output, + request: normalizedRequest, + requestId: output?.$metadata?.requestId, + }; + servicesExtensions.responseHook(normalizedResponse, span); + }); - // The AWS SDK's `Client.prototype.send` lives in different smithy packages across versions; the - // transform injects one channel per package. Only the package hosting the app's client fires, so - // subscribing to all of them is safe and never double-instruments a single call. - const awsSendChannels = [ - CHANNELS.AWS_SMITHY_CORE_SEND, - CHANNELS.AWS_SMITHY_CLIENT_SEND, - CHANNELS.AWS_SDK_SMITHY_CLIENT_SEND, - ] as const; + // Streaming responses end the span when their wrapped stream is consumed (see + // bedrock-runtime); the helper must not end it on `send` settling. Errors always end here. + if (requestMetadata?.isStream && !failed) { + return true; + } - DEBUG_BUILD && debug.log(`[orchestrion:aws-sdk] subscribing to channels "${awsSendChannels.join('", "')}"`); + // Normally the region settles long before `send` does (the SDK awaits it internally to + // build the endpoint), but when `send` settles first (e.g. an early failure) hold the span + // open until the region backfill lands. The error status was already applied by the + // helper's `error` subscriber, so a plain `end()` suffices. + const region = data._sentryRegion; + if (region && !region.settled) { + void region.promise.then(() => end()); + return true; + } - waitForTracingChannelBinding(() => { - for (const channelName of awsSendChannels) { - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(channelName), - getSpan, - opts, - ); - } - }); + return false; }, }; -}) satisfies IntegrationFn; + + // The AWS SDK's `Client.prototype.send` lives in different smithy packages across versions; the + // transform injects one channel per package. Only the package hosting the app's client fires, so + // subscribing to all of them is safe and never double-instruments a single call. + const awsSendChannels = [ + CHANNELS.AWS_SMITHY_CORE_SEND, + CHANNELS.AWS_SMITHY_CLIENT_SEND, + CHANNELS.AWS_SDK_SMITHY_CLIENT_SEND, + ] as const; + + DEBUG_BUILD && debug.log(`[orchestrion:aws-sdk] subscribing to channels "${awsSendChannels.join('", "')}"`); + + for (const channelName of awsSendChannels) { + bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(channelName), getSpan, opts); + } +} /** * EXPERIMENTAL — orchestrion-driven aws-sdk (v3) integration. 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..cc6481a8444c 100644 --- a/packages/server-utils/src/integrations/tracing-channel/express/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/express/index.ts @@ -1,6 +1,8 @@ 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 { expressModuleNames } from '../../../orchestrion/config/express'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; import type { ExpressIntegrationOptions } from './types'; import { instrumentExpress } from './instrumentation'; @@ -11,15 +13,11 @@ 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, + diagnosticsChannel.tracingChannel, + ]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/firebase/index.ts b/packages/server-utils/src/integrations/tracing-channel/firebase/index.ts index 9d8f9f9cfb4f..7def70ca52ec 100644 --- a/packages/server-utils/src/integrations/tracing-channel/firebase/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/firebase/index.ts @@ -1,6 +1,7 @@ -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 { firebaseModuleNames } from '../../../orchestrion/config/firebase'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; import { instrumentFirebase } from './instrumentation'; const INTEGRATION_NAME = 'Firebase' as const; @@ -8,15 +9,8 @@ const INTEGRATION_NAME = 'Firebase' as const; const _firebaseChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - instrumentFirebase(); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, firebaseModuleNames, instrumentFirebase, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts b/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts index 576f0b4ca8e9..8a8094bb3dfb 100644 --- a/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts +++ b/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts @@ -1,12 +1,9 @@ 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 { genericPoolModuleNames } from '../../orchestrion/config/generic-pool'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import { bindTracingChannelToSpan } from '../../tracing-channel'; // Same name as the OTel integration by design — when enabled, the OTel @@ -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 345b402d347d..5f7cafbadbd0 100644 --- a/packages/server-utils/src/integrations/tracing-channel/google-genai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/google-genai.ts @@ -5,7 +5,6 @@ import { _INTERNAL_shouldSkipAiProviderWrapping, addGoogleGenAIRequestAttributes, addGoogleGenAIResponseAttributes, - debug, defineIntegration, extractGoogleGenAIRequestAttributes, getActiveSpan, @@ -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 { googleGenAiModuleNames } from '../../orchestrion/config/google-genai'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // 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,46 +43,37 @@ interface GoogleGenAIChannelContext { result?: unknown; } -let subscribed = false; - 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; +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), + }, + ); + } +} + /** * Build the span for an instrumented call. * Returning `undefined` opts the payload out so no span is opened. 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 ecfd91c2785b..e1fc57fec6f8 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts @@ -1,9 +1,11 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; -import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import type { Client, IntegrationFn } from '@sentry/core'; +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 { graphqlModuleNames } from '../../../orchestrion/config/graphql'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; import { bindTracingChannelToSpan, safeChannelCallback } from '../../../tracing-channel'; import { finalizeExecuteSpan, @@ -40,32 +42,30 @@ const _graphqlChannelIntegration = ((options: GraphqlDiagnosticChannelsOptions = return { name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_PARSE), () => - safeChannelCallback(() => startParseSpan()), - ); - - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_VALIDATE), - data => safeChannelCallback(() => startValidateSpan(data.arguments[1])), - { beforeSpanEnd: (span, data) => void safeChannelCallback(() => finalizeValidateSpan(span, data.result)) }, - ); - - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_EXECUTE), - data => safeChannelCallback(() => startExecuteSpan(data.arguments, data.self, config, getConfig)), - { beforeSpanEnd: (span, data) => void safeChannelCallback(() => finalizeExecuteSpan(span, data.result)) }, - ); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, graphqlModuleNames, instrumentGraphql, [config, getConfig]); }, }; }) satisfies IntegrationFn; +function instrumentGraphql(config: GraphqlResolvedConfig, getConfig: () => GraphqlResolvedConfig): void { + bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_PARSE), () => + safeChannelCallback(() => startParseSpan()), + ); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_VALIDATE), + data => safeChannelCallback(() => startValidateSpan(data.arguments[1])), + { beforeSpanEnd: (span, data) => void safeChannelCallback(() => finalizeValidateSpan(span, data.result)) }, + ); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_EXECUTE), + data => safeChannelCallback(() => startExecuteSpan(data.arguments, data.self, config, getConfig)), + { beforeSpanEnd: (span, data) => void safeChannelCallback(() => finalizeExecuteSpan(span, data.result)) }, + ); +} + /** * EXPERIMENTAL — orchestrion-driven graphql integration for graphql v14–16 (v17 publishes native * `diagnostics_channel` events handled by `@sentry/server-utils`'s graphql integration instead). @@ -87,8 +87,11 @@ export const graphqlChannelIntegration = defineIntegration(_graphqlChannelIntegr */ export const graphqlDiagnosticsChannelIntegration = (options?: GraphqlDiagnosticChannelsOptions) => { const orchestrion = graphqlChannelIntegration(options); + // The native half is the base integration's own `setupOnce`; the orchestrion half + // registers lazily via `setup` (only once `graphql` is injected), so it isn't + // merged onto the base `setupOnce` — both run. return extendIntegration(graphqlNativeIntegration(options), { name: INTEGRATION_NAME, - setupOnce: () => orchestrion.setupOnce?.(), + setup: (client: Client) => orchestrion.setup?.(client), }); }; diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi.ts b/packages/server-utils/src/integrations/tracing-channel/hapi.ts index 48a2d441873f..87430202dce2 100644 --- a/packages/server-utils/src/integrations/tracing-channel/hapi.ts +++ b/packages/server-utils/src/integrations/tracing-channel/hapi.ts @@ -1,8 +1,9 @@ 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 { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; +import { hapiModuleNames } from '../../orchestrion/config/hapi'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import { wrapExtArguments, wrapRouteArguments } from './hapi-utils'; // NOTE: same name as the OTel integration by design — when enabled, the OTel @@ -26,42 +27,41 @@ interface HapiChannelContext { 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() {}, + setup(client) { + invokeOrchestrionInstrumentation(client, hapiModuleNames, instrumentHapi, [], { + requiresTracingChannelBinding: false, }); }, }; }) satisfies IntegrationFn; +function instrumentHapi(): void { + // `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() {}, + }); +} + /** * EXPERIMENTAL — orchestrion-driven hapi integration. Subscribes to the * `orchestrion:@hapi/hapi:route` / `:ext` channels injected into `@hapi/hapi`'s diff --git a/packages/server-utils/src/integrations/tracing-channel/ioredis.ts b/packages/server-utils/src/integrations/tracing-channel/ioredis.ts index 65d1fa69ac13..4b2ead6b40fa 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; @@ -98,60 +93,51 @@ export function startIORedisCommandSpan(data: IORedisCommandContext): Span | und } const _ioredisChannelIntegration = ((options: IORedisChannelIntegrationOptions = {}) => { - const responseHook = options.responseHook; - return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19. - if (!diagnosticsChannel.tracingChannel) { + setup(client) { + invokeOrchestrionInstrumentation(client, ioredisModuleNames, instrumentIoredis, [options]); + }, + }; +}) satisfies IntegrationFn; + +function instrumentIoredis(options: IORedisChannelIntegrationOptions): void { + const responseHook = options.responseHook; + + 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' }, }); }, - }; -}) satisfies IntegrationFn; + { requiresParentSpan: true }, + ); +} function runResponseHook(hook: IORedisResponseHook, span: Span, command: RedisCommand, result: unknown): void { try { 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..2126c7e9452c 100644 --- a/packages/server-utils/src/integrations/tracing-channel/kafkajs/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/kafkajs/index.ts @@ -1,9 +1,10 @@ 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 { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../../../orchestrion/channels'; +import { kafkajsModuleNames } from '../../../orchestrion/config/kafkajs'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; import { isWrappedConsumerCallback, wrapEachBatch, wrapEachMessage } from './consumer'; import { applyErrorToSpans, startProducerSpan } from './spans'; import type { ConsumerRunConfig, ProducerBatch } from './types'; @@ -81,22 +82,19 @@ function subscribeToConsumer(): void { 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, [], { + requiresTracingChannelBinding: false, + }); }, }; }) satisfies IntegrationFn; +function instrumentKafkajs(): void { + subscribeToProducer(); + subscribeToConsumer(); +} + /** * EXPERIMENTAL — orchestrion-driven kafkajs integration. * diff --git a/packages/server-utils/src/integrations/tracing-channel/koa.ts b/packages/server-utils/src/integrations/tracing-channel/koa.ts index 982faee9765e..2892be700479 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 { koaModuleNames } from '../../orchestrion/config/koa'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // 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 { @@ -81,27 +78,26 @@ 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, [ignoreLayersType], { + requiresTracingChannelBinding: false, }); }, }; }) satisfies IntegrationFn; +function instrumentKoa(ignoreLayersType: KoaLayerType[]): void { + diagnosticsChannel.tracingChannel(CHANNELS.KOA_USE).subscribe({ + start(rawCtx) { + handleUse(rawCtx as KoaUseContext, ignoreLayersType); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); +} + function handleUse(ctx: KoaUseContext, ignoreLayersType: KoaLayerType[]): void { const middleware = ctx.arguments[0]; if (typeof middleware === 'function') { diff --git a/packages/server-utils/src/integrations/tracing-channel/langchain.ts b/packages/server-utils/src/integrations/tracing-channel/langchain.ts index 008d52638241..284491363063 100644 --- a/packages/server-utils/src/integrations/tracing-channel/langchain.ts +++ b/packages/server-utils/src/integrations/tracing-channel/langchain.ts @@ -6,18 +6,17 @@ 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 { bindTracingChannelToSpan } from '../../tracing-channel'; +import { langchainModuleNames } from '../../orchestrion/config/langchain'; +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 +37,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`/ @@ -51,58 +48,51 @@ function markProvidersSkipped(): void { 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; +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); + } + + // Embeddings don't use the callback system — the OTel path wraps the method in its own span, so + // do the same here. + for (const channelName of langchainEmbeddingsChannels) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => createEmbeddingsSpan(data, options), + { captureError: () => ({ mechanism: { handled: false, type: 'auto.ai.langchain' } }) }, + ); + } +} + function createEmbeddingsSpan(data: EmbeddingsChannelContext, options: LangChainOptions): Span { // `embedQuery`/`embedDocuments` call the provider SDK internally, so skip that SDK's own // instrumentation before its channel fires (the producer runs at the embeddings channel's `start`). diff --git a/packages/server-utils/src/integrations/tracing-channel/langgraph.ts b/packages/server-utils/src/integrations/tracing-channel/langgraph.ts index c5e601c61d9d..4cf37d55f9a0 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 { langgraphModuleNames } from '../../orchestrion/config/langgraph'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // 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,8 +33,6 @@ 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; @@ -41,84 +40,74 @@ 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); + setup(client) { + invokeOrchestrionInstrumentation(client, langgraphModuleNames, instrumentLanggraph, [options]); + }, + }; +}) satisfies IntegrationFn; - // `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; +function instrumentLanggraph(options: LangGraphOptions): void { + const resolvedOptions = resolveAIRecordingOptions(options); + const sentryHandler = createLangChainCallbackHandler(resolvedOptions); - return startInactiveSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(name)); - }, - { - beforeSpanEnd: (_span, data) => { - wrapCompiledGraphInvoke( - data.result, - getFirstArgObject(data.arguments) ?? {}, - resolvedOptions, - null, - sentryHandler, - ); - }, - }, - ); + // 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; - // 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, + return startInactiveSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(name)); + }, + { + beforeSpanEnd: (_span, data) => { + wrapCompiledGraphInvoke( + data.result, + getFirstArgObject(data.arguments) ?? {}, + resolvedOptions, + null, + sentryHandler, ); - 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; - }); - }); + }, }, - }; -}) satisfies IntegrationFn; + ); + + // 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; + }); +} function getFirstArgObject(args: unknown[] | undefined): Record | undefined { const first = (args ?? [])[0]; 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..435f702e6684 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 { lruMemoizerModuleNames } from '../../orchestrion/config/lru-memoizer'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // Same name as the OTel integration by design — when enabled, the OTel // 'LruMemoizer' integration is omitted from the default set. @@ -16,25 +17,20 @@ interface LruMemoizerLoadContext { 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; +function instrumentLruMemoizer(): void { + 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, + ); +} + /** * EXPERIMENTAL — orchestrion-driven lru-memoizer integration. Subscribes to * `orchestrion:lru-memoizer:load` (injected into `lru-memoizer/lib/async.js`'s diff --git a/packages/server-utils/src/integrations/tracing-channel/mongodb.ts b/packages/server-utils/src/integrations/tracing-channel/mongodb.ts index 85899d910792..15d5ca58c24a 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 { mongodbModuleNames } from '../../orchestrion/config/mongodb'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; const INTEGRATION_NAME = 'Mongo' as const; @@ -49,20 +51,18 @@ 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; +function instrumentMongodb(): void { + subscribeV4Command(); + subscribeV4Checkout(); + subscribeV3Wireprotocol(); +} + /** * `Connection.prototype.command` (mongodb >=4.0) one span per command. * Handles both the >=6.4 promise form and the >=4.0 <6.4 callback form diff --git a/packages/server-utils/src/integrations/tracing-channel/mongoose.ts b/packages/server-utils/src/integrations/tracing-channel/mongoose.ts index 4dc77d0224d0..e1d1798a78e9 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mongoose.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mongoose.ts @@ -1,14 +1,15 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span } from '@sentry/core'; -import { debug, defineIntegration, getActiveSpan, waitForTracingChannelBinding } from '@sentry/core'; +import { defineIntegration, getActiveSpan, waitForTracingChannelBinding } from '@sentry/core'; import { subscribeMongooseDiagnosticChannels } from '../../mongoose/mongoose-dc-subscriber'; 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 type { SentryTracingChannel } from '../../tracing-channel'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { mongooseModuleNames } from '../../orchestrion/config/mongoose'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; const INTEGRATION_NAME = 'Mongoose' as const; @@ -62,21 +63,20 @@ const _mongooseChannelIntegration = (() => { return { name: INTEGRATION_NAME, setupOnce() { + // mongoose >= 9.7 publishes via its own diagnostics_channel; reuse the + // shared native subscriber eagerly 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(); }); }, + setup(client) { + // mongoose < 9.7 is covered by the orchestrion-injected channels, so this + // half subscribes lazily, once mongoose is actually instrumented + invokeOrchestrionInstrumentation(client, mongooseModuleNames, subscribeOrchestrionMongooseChannels, []); + }, }; }) satisfies IntegrationFn; @@ -86,8 +86,6 @@ function subscribeOrchestrionMongooseChannels(): void { } orchestrionSubscribed = true; - DEBUG_BUILD && debug.log('[orchestrion:mongoose] subscribing to injected channels'); - // 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 02588b15d0c3..6263aa6c62c4 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql.ts @@ -4,16 +4,15 @@ import type { IntegrationFn, Scope } from '@sentry/core'; import { isObjectLike, bindScopeToEmitter, - debug, defineIntegration, getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { mysqlModuleNames } from '../../orchestrion/config/mysql'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, OTel 'Mysql' integration is omitted from the default set. @@ -61,70 +60,65 @@ interface MysqlConnection { 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', - op: 'db', - attributes: { - [SENTRY_KIND]: 'client', - [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; +function instrumentMysql(): void { + 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', + op: 'db', + attributes: { + [SENTRY_KIND]: 'client', + [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; + }, + }, + ); +} + function hasOnMethod(obj: object): obj is { on: (event: string, listener: (arg?: unknown) => void) => unknown } { return 'on' in obj && typeof (obj as { on?: unknown }).on === 'function'; } diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts index d8f8e1c7bf0d..214c81b86635 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -12,6 +12,8 @@ import { subscribeMysql2DiagnosticChannels } from '../../mysql2/mysql2-dc-subscr import type { ChannelName } from '../../orchestrion/channels'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { mysql2ModuleNames } from '../../orchestrion/config/mysql2'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import { DB_NAME, DB_STATEMENT, @@ -27,12 +29,14 @@ const ORIGIN = 'auto.db.orchestrion.mysql2'; const DB_SYSTEM_VALUE_MYSQL = 'mysql'; /** - * The shape orchestrion's transform attaches to the tracing-channel `context` object. Documented here - * rather than imported because orchestrion's runtime doesn't export it. + * The shape orchestrion's transform attaches to the tracing-channel `context` + * object. Documented here rather than imported because orchestrion's runtime + * doesn't export it. */ interface Mysql2QueryChannelContext { - // The live args array passed to the wrapped `query`/`execute` call: `arguments[0]` is the SQL (a - // string, `Query`, or `{ sql, values }`), `arguments[1]` is the values array or a callback. + // The live args array passed to the wrapped `query`/`execute` call: + // `arguments[0]` is the SQL (a string, `Query`, or `{ sql, values }`), + // `arguments[1]` is the values array or a callback. arguments: unknown[]; self?: Mysql2Connection; result?: unknown; @@ -56,20 +60,14 @@ interface Mysql2Connection { * Orchestrion-driven mysql2 integration. * * Subscribes to: - * - the `orchestrion:mysql2:query`/`:execute` channels the code transform injects into mysql2's - * `query`/`execute` (mysql2 `< 3.20.0`), and - * - mysql2's native `node:diagnostics_channel` tracing channels (mysql2 `>= 3.20.0`), which the - * transform intentionally leaves alone. + * - the `orchestrion:mysql2:query`/`:execute` channels the code transform + * injects into mysql2's `query`/`execute` (mysql2 `< 3.20.0`), and + * - mysql2's native `node:diagnostics_channel` tracing channels + * (mysql2 `>= 3.20.0`), which the transform intentionally leaves alone. * - * The two version ranges never overlap, so no query is double-counted. Requires the orchestrion - * runtime hook or bundler plugin to be active — wire that up via - * `experimentalUseDiagnosticsChannelInjection`. + * The two version ranges never overlap, so no query is double-counted. */ -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). +function instrumentMysql2Orchestrion(): void { subscribeQueryChannel(CHANNELS.MYSQL2_QUERY); subscribeQueryChannel(CHANNELS.MYSQL2_EXECUTE); } @@ -117,8 +115,8 @@ function extractSql(firstArg: unknown): string | undefined { } function getConnectionAttributes(config: Mysql2ConnectionConfig | undefined): SpanAttributes { - // Pool connections nest the real config under `.connectionConfig`; single connections expose it - // directly. Matches `@opentelemetry/instrumentation-mysql2`. + // Pool connections nest the real config under `.connectionConfig`; single + // connections expose it directly. const { host, port, database, user } = config?.connectionConfig ?? config ?? {}; const portNumber = typeof port === 'string' ? parseInt(port, 10) : port; const portIsNumber = typeof portNumber === 'number' && !isNaN(portNumber); @@ -138,27 +136,33 @@ const _mysql2ChannelIntegration = (() => { return { name: INTEGRATION_NAME, setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + // mysql2 >= 3.20.0 native diagnostics_channel path: subscribe eagerly, + // because they do not get an orchestrion event to indicate readiness if (!diagnosticsChannel.tracingChannel) { return; } - waitForTracingChannelBinding(() => { - instrumentMysql2(); + subscribeMysql2DiagnosticChannels(diagnosticsChannel.tracingChannel); }); }, + setup(client) { + // mysql2 < 3.20.0 orchestrion channels, subscribe lazily + invokeOrchestrionInstrumentation(client, mysql2ModuleNames, instrumentMysql2Orchestrion, []); + }, }; }) satisfies IntegrationFn; /** * Orchestrion-driven mysql2 integration. * - * Adds Sentry tracing instrumentation for the [mysql2](https://www.npmjs.com/package/mysql2) library - * via diagnostics-channel injection. See {@link instrumentMysql2} for how the two mysql2 version - * ranges are covered. + * Adds Sentry tracing instrumentation for the + * [mysql2](https://www.npmjs.com/package/mysql2) library via diagnostics-channel + * injection. See {@link instrumentMysql2} for how the two mysql2 version ranges + * are covered. * - * Known limitation vs. the OTel integration it replaces: the callback-less streaming form - * (`connection.query(sql).on('result', ...)`) is not traced — see the `mysql2` orchestrion config for - * why. The callback and promise forms (the common case) are fully instrumented. + * Known limitation vs. older OTel integration it replaced: the callback-less + * streaming form (`connection.query(sql).on('result', ...)`) is not traced. + * See the `mysql2` orchestrion config for why. The callback and promise forms + * (the common case) are fully instrumented. */ export const mysql2ChannelIntegration = defineIntegration(_mysql2ChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/openai.ts b/packages/server-utils/src/integrations/tracing-channel/openai.ts index b172c6924300..3575bc91bfa4 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 { openaiModuleNames } from '../../orchestrion/config/openai'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // 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,40 +41,31 @@ interface OpenAiChatChannelContext { result?: unknown; } -let subscribed = false; - 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; +function instrumentOpenai(options: OpenAiOptions): void { + 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), + }, + ); + } +} + /** * Build the span for an instrumented `create` call. * Returning `undefined` opts the payload out so no span is opened. 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 c00b36efb73a..5fd055f17eae 100644 --- a/packages/server-utils/src/integrations/tracing-channel/postgres-js.ts +++ b/packages/server-utils/src/integrations/tracing-channel/postgres-js.ts @@ -12,11 +12,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 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 { postgresJsModuleNames } from '../../orchestrion/config/postgres'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // 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 @@ -25,7 +26,8 @@ const INTEGRATION_NAME = 'PostgresJs' as const; const ORIGIN = 'auto.db.orchestrion.postgresjs'; -// Not part of `@sentry/conventions`, so we keep it inline (matches the OTel `PostgresJsInstrumentation`). +// Not part of `@sentry/conventions`, so we keep it inline (matches older OTel +// `PostgresJsInstrumentation`). const DB_RESPONSE_STATUS_CODE = 'db.response.status_code'; const NOOP = (): void => {}; @@ -125,7 +127,8 @@ function setConnectionAttributes(span: Span, query: PostgresQuery, context: Post * Backfill connection attributes onto a query's span from a channel whose `self` * is the connection object and `arguments[0]` the query. Shared by the `execute` * and `connect` channels; both carry that shape and both resolve the context via - * the `connectionContexts` WeakMap. Idempotent (guarded inside `setConnectionAttributes`). + * the `connectionContexts` WeakMap. Idempotent (guarded inside + * `setConnectionAttributes`). */ function attachConnectionAttributesFromChannel(message: PostgresJsQueryContext): void { const connection = message.self as object | undefined; @@ -196,135 +199,127 @@ function wrapQuerySettlement(data: PostgresJsQueryContext, span: Span, sanitized } const _postgresJsChannelIntegration = ((options: PostgresJsChannelIntegrationOptions = {}) => { - const { requireParentSpan, requestHook } = options; - return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19. - if (!diagnosticsChannel.tracingChannel) { - return; + setup(client) { + invokeOrchestrionInstrumentation(client, postgresJsModuleNames, instrumentPostgresJs, [options]); + }, + }; +}) satisfies IntegrationFn; + +function instrumentPostgresJs(options: PostgresJsChannelIntegrationOptions): void { + const { requireParentSpan, requestHook } = options; + + // Connection + execute are pure observers (no span, no async binding). + 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); + + // `sentry.kind: client` matches the mysql/pg channel subscribers. + const span = startInactiveSpan({ + name: sanitizedSqlQuery || 'postgresjs.query', + op: 'db', + attributes: { + [SENTRY_KIND]: 'client', + [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); - - // `sentry.kind: 'client'` matches the mysql/pg channel subscribers. - const span = startInactiveSpan({ - name: sanitizedSqlQuery || 'postgresjs.query', - op: 'db', - attributes: { - [SENTRY_KIND]: 'client', - [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; }, - }; -}) satisfies IntegrationFn; + { + 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 + }, + }, + ); +} /** - * EXPERIMENTAL — orchestrion-driven postgres.js (`postgres` v3.x) integration. + * Orchestrion-driven postgres.js (`postgres` v3.x) integration. * * Subscribes to the `orchestrion:postgres:handle` / `:connection` / `:execute` / * `:connect` diagnostics channels injected into postgres.js' `Query.prototype.handle` diff --git a/packages/server-utils/src/integrations/tracing-channel/postgres.ts b/packages/server-utils/src/integrations/tracing-channel/postgres.ts index ee9d975acb40..2cb53db0abd8 100644 --- a/packages/server-utils/src/integrations/tracing-channel/postgres.ts +++ b/packages/server-utils/src/integrations/tracing-channel/postgres.ts @@ -4,16 +4,15 @@ import type { IntegrationFn, Scope, SpanAttributes } from '@sentry/core'; import { isObjectLike, bindScopeToEmitter, - debug, defineIntegration, getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { pgModuleNames } from '../../orchestrion/config/pg'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Postgres' integration is omitted from the default set. @@ -82,29 +81,26 @@ interface PgPoolOptions extends PgConnectionParams { 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, instrumentPostgres, [options]); }, }; }) satisfies IntegrationFn; +function instrumentPostgres(options: { ignoreConnectSpans?: boolean }): void { + // 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); + } +} + /** * Subscribe to a pg tracing-channel and manage a span across its lifecycle. * Shared by the query/connect/pool-connect channels. They differ only in how @@ -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 42f23341b419..f85a56799a69 100644 --- a/packages/server-utils/src/integrations/tracing-channel/redis.ts +++ b/packages/server-utils/src/integrations/tracing-channel/redis.ts @@ -16,20 +16,19 @@ import { import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { isObjectLike, - debug, defineIntegration, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startInactiveSpan, - waitForTracingChannelBinding, withActiveSpan, } 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 { redisModuleNames } from '../../orchestrion/config/redis'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // 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,36 +301,39 @@ function bindNodeRedisBatchChannel(channelName: string, getOperation: (data: Com } const _redisChannelIntegration = ((options: RedisChannelIntegrationOptions = {}) => { - const responseHook = options.responseHook; - 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) { + // redis v2-v3 uses a nested callback, not `bindStore`, so it subscribes + // without the async-context binding. Kept separate so a missing binding + // never defers it: on the bundler path the wait would push subscription + // past `Sentry.init()` and early commands would emit with no subscriber + invokeOrchestrionInstrumentation(client, redisModuleNames, instrumentLegacyRedis, [options], { + requiresTracingChannelBinding: false, }); + // node-redis v4/v5 binds spans into async context via `bindTracingChannelToSpan`. + invokeOrchestrionInstrumentation(client, redisModuleNames, instrumentNodeRedis, [options]); }, }; }) satisfies IntegrationFn; +function instrumentLegacyRedis(options: RedisChannelIntegrationOptions): void { + subscribeLegacyRedisCommand(options.responseHook); +} + +function instrumentNodeRedis(options: RedisChannelIntegrationOptions): void { + const responseHook = options.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', + ); +} + /** * EXPERIMENTAL — orchestrion-driven redis integration for `redis` v2-v3 and * node-redis v4/v5 `<5.12.0` (`@redis/client`). Covers single commands, `connect`, diff --git a/packages/server-utils/src/integrations/tracing-channel/tedious.ts b/packages/server-utils/src/integrations/tracing-channel/tedious.ts index ebcf1524d494..9b152182f12c 100644 --- a/packages/server-utils/src/integrations/tracing-channel/tedious.ts +++ b/packages/server-utils/src/integrations/tracing-channel/tedious.ts @@ -6,12 +6,10 @@ 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_STATUS_ERROR, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; import { DB_NAME, @@ -22,8 +20,9 @@ import { NET_PEER_PORT, SENTRY_KIND, } from '@sentry/conventions/attributes'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; +import { tediousModuleNames } from '../../orchestrion/config/tedious'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // 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). @@ -230,27 +229,24 @@ function once(fn: (...args: Args) => void): (...args: Ar 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, [], { + requiresTracingChannelBinding: false, }); }, }; }) satisfies IntegrationFn; +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'); +} + /** * EXPERIMENTAL - orchestrion-driven tedious integration. * 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..5ea857acaea3 100644 --- a/packages/server-utils/src/integrations/tracing-channel/vercel-ai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/vercel-ai.ts @@ -1,7 +1,9 @@ -import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import type { Client, IntegrationFn } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; import { vercelAiIntegration as baseVercelAiIntegration } from '../../vercel-ai'; import * as dc from 'node:diagnostics_channel'; +import { vercelAiModuleNames } from '../../orchestrion/config/vercel-ai'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import { subscribeVercelAiOrchestrionChannels } from '../../vercel-ai/vercel-ai-orchestrion-subscriber'; type VercelAiOptions = Parameters[0]; @@ -17,21 +19,20 @@ type VercelAiOptions = Parameters[0]; const _vercelAiChannelIntegration = ((options: VercelAiOptions = {}) => { const parentIntegration = baseVercelAiIntegration(options); + // The native `ai:telemetry` half is the base integration's own `setupOnce`; the + // orchestrion half registers lazily via `setup`, only once `ai` is injected. return extendIntegration(parentIntegration, { options, - setupOnce() { - // Bail if this is not available - if (!dc.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - subscribeVercelAiOrchestrionChannels(dc.tracingChannel, options); - }); + setup(client: Client) { + invokeOrchestrionInstrumentation(client, vercelAiModuleNames, instrumentVercelAiOrchestrion, [options]); }, }); }) satisfies IntegrationFn; +function instrumentVercelAiOrchestrion(options: VercelAiOptions): void { + subscribeVercelAiOrchestrionChannels(dc.tracingChannel, options); +} + /** * Auto-instrument the `ai` SDK. Supported are: * - v7 via native `ai:telemetry` tracing channel diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index 1d8efcc61532..14f9912940df 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -94,7 +94,23 @@ export function orchestrionTransformOptions(options: PluginOptions): CodeTransfo instrumentations, customTransforms, injectDiagnostics: (diag: { transformedModules: string[]; failedModules: string[] }) => { - return `(globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{}).bundler=${JSON.stringify(diag.transformedModules)};`; + // Record the transformed modules for detection AND fire the on-inject + // bridge for each, so channel integrations subscribe. These modules are + // bundled, so the runtime module hook never sees them. The bridge + // (installed by `registerDiagnosticsChannelInjection`) re-emits the + // `orchestrion.module-runtime-injected` event the subscription waits on. + // When the bridge isn't installed (bundler-only runtimes, or the banner + // runs before `init()`), the guarded call is a no-op and the recorded + // `.bundler` list still drives subscription at `init()`. + const modules = JSON.stringify(diag.transformedModules); + return ( + '(function(){' + + 'var g=globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{};' + + `var m=${modules};` + + 'g.bundler=m;' + + "if(typeof g.onInject==='function')m.forEach(function(n){g.onInject(n);});" + + '})();' + ); }, }; } diff --git a/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts index 26de2b44d5c6..90f2b15f22ab 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts @@ -4,10 +4,92 @@ // needs its own entrypoint/subpath rather than being reachable from another module. import codeTransformerLoaderImpl from '@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'; -// Explicitly typed so the emitted declaration doesn't reference the bundled devDependency. -// (Nothing imports this subpath from TS — bundlers load it by file path — so the loose -// signature is never consumed.) -const codeTransformerLoader: (this: unknown, code: string, inputSourceMap?: unknown) => void = - codeTransformerLoaderImpl; +// The loader context we rely on beyond the transform itself: `resourcePath` to +// name the module, `async` for the transformed result, and `_compilation` to +// tell webpack (present) from Turbopack (absent). +interface LoaderContext { + resourcePath: string; + async: () => (error: unknown, code?: string, map?: unknown) => void; + _compilation?: unknown; +} + +type LoaderFn = (this: LoaderContext, code: string, inputSourceMap?: unknown) => void; + +const upstreamLoader: LoaderFn = codeTransformerLoaderImpl; + +/** + * The npm package name for a module path. + * Reads the segment after the LAST `node_modules`, so pnpm's nested layout + * resolves to the real package. Matches the name that channel integrations + * await. + */ +function packageNameFromPath(resourcePath: string): string | undefined { + const marker = '/node_modules/'; + const normalized = resourcePath.replace(/\\/g, '/'); + const index = normalized.lastIndexOf(marker); + if (index === -1) { + return undefined; + } + + const [scopeOrName, name] = normalized.slice(index + marker.length).split('/'); + if (!scopeOrName) { + return undefined; + } + + return scopeOrName.startsWith('@') && name ? `${scopeOrName}/${name}` : scopeOrName; +} + +/** + * Announce a runtime-injected module the way the banner and runtime `--import` + * hook do, so the lazily-registered channel integrations subscribe. Appended to + * each transformed module's code, it runs when that module loads. + */ +function onInjectSnippet(moduleName: string): string { + const name = JSON.stringify(moduleName); + return ( + ';(function(){' + + 'var g=globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{};' + + 'if(!Array.isArray(g.bundler))g.bundler=[];' + + `if(g.bundler.indexOf(${name})<0)g.bundler.push(${name});` + + `if(typeof g.onInject==='function')g.onInject(${name});` + + '})();\n' + ); +} + +/** + * Wraps the upstream code-transform loader. + * + * Under Turbopack the transform runs as a loader, but the webpack *plugin* that + * emits the `injectDiagnostics` boot banner never runs, because Turbopack takes + * loaders, not plugins. That banner is what calls `onInject` for bundled + * modules, so without it the channel integrations never learn their module + * loaded and never subscribe. When there is no webpack compilation (Turbopack + * case), append the `onInject` call to each transformed module here instead. + * Under webpack leave it to the banner, so signal fires exactly once per module + */ +const codeTransformerLoader: LoaderFn = function (code, inputSourceMap) { + if (this._compilation) { + upstreamLoader.call(this, code, inputSourceMap); + return; + } + + const realAsync = this.async.bind(this); + const { resourcePath } = this; + + this.async = () => { + const callback = realAsync(); + return (error: unknown, outputCode?: string, outputMap?: unknown): void => { + // The upstream loader returns the input code unchanged when it did not + // transform the module, so a changed string means a channel-publishing + // module we must announce. + const transformed = !error && typeof outputCode === 'string' && outputCode !== code; + const moduleName = transformed ? packageNameFromPath(resourcePath) : undefined; + const finalCode = moduleName ? `${outputCode}${onInjectSnippet(moduleName)}` : outputCode; + callback(error, finalCode, outputMap); + }; + }; + + upstreamLoader.call(this, code, inputSourceMap); +}; export default codeTransformerLoader; diff --git a/packages/server-utils/src/orchestrion/config/amqplib.ts b/packages/server-utils/src/orchestrion/config/amqplib.ts index dea498f607c9..5a74e81e66d1 100644 --- a/packages/server-utils/src/orchestrion/config/amqplib.ts +++ b/packages/server-utils/src/orchestrion/config/amqplib.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // `amqplib` splits its API across three files: // - `lib/channel_model.js` holds `class Channel` (publish/consume/ack/nack/reject/…) and @@ -76,6 +76,8 @@ export const amqplibConfig = [ }, ] satisfies InstrumentationConfig[]; +export const amqplibModuleNames = getModuleNames(amqplibConfig); + export const amqplibChannels = { AMQPLIB_PUBLISH: 'orchestrion:amqplib:publish', AMQPLIB_CONFIRM_PUBLISH: 'orchestrion:amqplib:confirmPublish', diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts index 202fd538eabc..10ce95629fa8 100644 --- a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts +++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const anthropicAiConfig = [ // One entry each for CJS/ESM @@ -34,6 +34,8 @@ export const anthropicAiConfig = [ })), ] satisfies InstrumentationConfig[]; +export const anthropicAiModuleNames = getModuleNames(anthropicAiConfig); + export const anthropicAiChannels = { ANTHROPIC_CHAT: 'orchestrion:@anthropic-ai/sdk:chat', ANTHROPIC_MODELS: 'orchestrion:@anthropic-ai/sdk:models', diff --git a/packages/server-utils/src/orchestrion/config/aws-sdk.ts b/packages/server-utils/src/orchestrion/config/aws-sdk.ts index a6d1d73fe5dd..f7a80d6f7eeb 100644 --- a/packages/server-utils/src/orchestrion/config/aws-sdk.ts +++ b/packages/server-utils/src/orchestrion/config/aws-sdk.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '../apmTypes'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // The AWS SDK (v3) routes every command through the smithy `Client.prototype.send` method. Which // package hosts that `Client` class changed across versions, so we target all of them; only the one @@ -28,6 +28,8 @@ export const awsSdkConfig = [ }, ] satisfies InstrumentationConfig[]; +export const awsSdkModuleNames = getModuleNames(awsSdkConfig); + export const awsSdkChannels = { AWS_SMITHY_CORE_SEND: 'orchestrion:@smithy/core:send', AWS_SMITHY_CLIENT_SEND: 'orchestrion:@smithy/smithy-client:send', diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 649cb816e837..91dbcdd2c126 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -46,3 +46,8 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ export function subscriberExportForModule(moduleName: string): string | undefined { return CHANNEL_INTEGRATION_DEFINITIONS.find(d => (d.modules as readonly string[]).includes(moduleName))?.exportName; } + +/** Look up the instrumented package names a subscriber export covers. */ +export function modulesForSubscriberExport(exportName: string): readonly string[] { + return CHANNEL_INTEGRATION_DEFINITIONS.find(d => d.exportName === exportName)?.modules ?? []; +} diff --git a/packages/server-utils/src/orchestrion/config/express.ts b/packages/server-utils/src/orchestrion/config/express.ts index 0c349454184f..70f3c2b56e1d 100644 --- a/packages/server-utils/src/orchestrion/config/express.ts +++ b/packages/server-utils/src/orchestrion/config/express.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const expressConfig = [ // Express funnels every middleware/route handler through a single method on @@ -60,6 +60,8 @@ export const expressConfig = [ }, ] satisfies InstrumentationConfig[]; +export const expressModuleNames = getModuleNames(expressConfig); + export const expressChannels = { // Express v4 runs each layer's handler through `Layer.prototype.handle_request` // in the `express` module. diff --git a/packages/server-utils/src/orchestrion/config/firebase.ts b/packages/server-utils/src/orchestrion/config/firebase.ts index 1f4bea63c3c5..1a0b42eb20d4 100644 --- a/packages/server-utils/src/orchestrion/config/firebase.ts +++ b/packages/server-utils/src/orchestrion/config/firebase.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // firebase 9+ ships firestore as `@firebase/firestore` (matches the OTel integration's range). Only the // `lite` SDK exposes the free `addDoc`/`getDocs`/`setDoc`/`deleteDoc` functions we trace, and only the @@ -74,6 +74,8 @@ export const firebaseConfig = [ })), ] satisfies InstrumentationConfig[]; +export const firebaseModuleNames = getModuleNames(firebaseConfig); + export const firebaseChannels = { FIREBASE_FIRESTORE_ADD_DOC: 'orchestrion:@firebase/firestore:add-doc', FIREBASE_FIRESTORE_GET_DOCS: 'orchestrion:@firebase/firestore:get-docs', diff --git a/packages/server-utils/src/orchestrion/config/generic-pool.ts b/packages/server-utils/src/orchestrion/config/generic-pool.ts index cb3f2f64d524..4f02340af582 100644 --- a/packages/server-utils/src/orchestrion/config/generic-pool.ts +++ b/packages/server-utils/src/orchestrion/config/generic-pool.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // Two shapes of `acquire`, both publishing to the same `orchestrion:generic-pool:acquire` channel: // - v3+: `class Pool { acquire(priority) }` returns a promise, so `kind: 'Auto'` resolves to `wrapPromise`. @@ -19,6 +19,8 @@ export const genericPoolConfig = [ }, ] satisfies InstrumentationConfig[]; +export const genericPoolModuleNames = getModuleNames(genericPoolConfig); + export const genericPoolChannels = { GENERIC_POOL_ACQUIRE: 'orchestrion:generic-pool:acquire', } as const; diff --git a/packages/server-utils/src/orchestrion/config/google-genai.ts b/packages/server-utils/src/orchestrion/config/google-genai.ts index 8426f2306e0d..666dc06afd00 100644 --- a/packages/server-utils/src/orchestrion/config/google-genai.ts +++ b/packages/server-utils/src/orchestrion/config/google-genai.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // `@google/genai` ships one bundled file per module format and the matcher compares `filePath` exactly, // so we list every file the `node` export condition resolves to across the supported range: `index.js` @@ -34,6 +34,8 @@ export const googleGenAiConfig = [ ), ] satisfies InstrumentationConfig[]; +export const googleGenAiModuleNames = getModuleNames(googleGenAiConfig); + export const googleGenAiChannels = { GOOGLE_GENAI_GENERATE_CONTENT: 'orchestrion:@google/genai:generate-content', GOOGLE_GENAI_EMBED_CONTENT: 'orchestrion:@google/genai:embed-content', diff --git a/packages/server-utils/src/orchestrion/config/graphql.ts b/packages/server-utils/src/orchestrion/config/graphql.ts index 3517ea137626..f8390e0133db 100644 --- a/packages/server-utils/src/orchestrion/config/graphql.ts +++ b/packages/server-utils/src/orchestrion/config/graphql.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // `parse`/`validate`/`execute` are top-level named `function` declarations in graphql's compiled // files, stable across the supported majors, so `functionName` matches. `execute` returns @@ -28,4 +28,6 @@ export const graphqlChannels = { GRAPHQL_EXECUTE: 'orchestrion:graphql:execute', } as const; +export const graphqlModuleNames = getModuleNames(graphqlConfig); + export const graphqlSubscribeInjection = toSubscribeInjections(graphqlConfig); diff --git a/packages/server-utils/src/orchestrion/config/hapi.ts b/packages/server-utils/src/orchestrion/config/hapi.ts index 94752b564666..ba06bc86e4f4 100644 --- a/packages/server-utils/src/orchestrion/config/hapi.ts +++ b/packages/server-utils/src/orchestrion/config/hapi.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const hapiConfig = [ // hapi's `route`/`ext` live on an anonymous class (`internals.Server = class {}`), @@ -18,6 +18,8 @@ export const hapiConfig = [ }, ] satisfies InstrumentationConfig[]; +export const hapiModuleNames = getModuleNames(hapiConfig); + export const hapiChannels = { HAPI_ROUTE: 'orchestrion:@hapi/hapi:route', HAPI_EXT: 'orchestrion:@hapi/hapi:ext', diff --git a/packages/server-utils/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts index 5755759c549b..3aa4c014cd00 100644 --- a/packages/server-utils/src/orchestrion/config/ioredis.ts +++ b/packages/server-utils/src/orchestrion/config/ioredis.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const ioredisConfig = [ // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) @@ -27,6 +27,8 @@ export const ioredisConfig = [ }, ] satisfies InstrumentationConfig[]; +export const ioredisModuleNames = getModuleNames(ioredisConfig); + export const ioredisChannels = { IOREDIS_COMMAND: 'orchestrion:ioredis:command', IOREDIS_CONNECT: 'orchestrion:ioredis:connect', diff --git a/packages/server-utils/src/orchestrion/config/kafkajs.ts b/packages/server-utils/src/orchestrion/config/kafkajs.ts index 0e42054b20b6..acb593cb0043 100644 --- a/packages/server-utils/src/orchestrion/config/kafkajs.ts +++ b/packages/server-utils/src/orchestrion/config/kafkajs.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const kafkajsConfig = [ { @@ -22,6 +22,8 @@ export const kafkajsConfig = [ }, ] satisfies InstrumentationConfig[]; +export const kafkajsModuleNames = getModuleNames(kafkajsConfig); + export const kafkajsChannels = { KAFKAJS_SEND_BATCH: 'orchestrion:kafkajs:send_batch', KAFKAJS_CONSUMER_RUN: 'orchestrion:kafkajs:consumer_run', diff --git a/packages/server-utils/src/orchestrion/config/koa.ts b/packages/server-utils/src/orchestrion/config/koa.ts index 5699f2bab8b0..a12ef52e0b72 100644 --- a/packages/server-utils/src/orchestrion/config/koa.ts +++ b/packages/server-utils/src/orchestrion/config/koa.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '../apmTypes'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const koaConfig = [ { @@ -9,6 +9,8 @@ export const koaConfig = [ }, ] satisfies InstrumentationConfig[]; +export const koaModuleNames = getModuleNames(koaConfig); + export const koaChannels = { KOA_USE: 'orchestrion:koa:use', } as const; diff --git a/packages/server-utils/src/orchestrion/config/langchain.ts b/packages/server-utils/src/orchestrion/config/langchain.ts index 88a5addeb8c9..43a311d868e3 100644 --- a/packages/server-utils/src/orchestrion/config/langchain.ts +++ b/packages/server-utils/src/orchestrion/config/langchain.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // `@langchain/*` packages ship dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the // matcher compares `filePath` exactly, so each hook is declared once per built file. @@ -68,6 +68,8 @@ export const langchainEmbeddingsChannels = EMBEDDINGS_PROVIDERS.flatMap(({ name, methods.map(method => `orchestrion:${name}:${method}`), ); +export const langchainModuleNames = getModuleNames(langchainConfig); + export const langchainChannels = { LANGCHAIN_CHAT_MODEL_INVOKE: 'orchestrion:@langchain/core:chatModelInvoke', LANGCHAIN_CHAT_MODEL_STREAM: 'orchestrion:@langchain/core:chatModelStream', diff --git a/packages/server-utils/src/orchestrion/config/langgraph.ts b/packages/server-utils/src/orchestrion/config/langgraph.ts index 4daa0f8e3d34..060a18943d1d 100644 --- a/packages/server-utils/src/orchestrion/config/langgraph.ts +++ b/packages/server-utils/src/orchestrion/config/langgraph.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // `@langchain/langgraph` ships dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the // matcher compares `filePath` exactly, so each hook is declared once per built file. `StateGraph.compile` @@ -29,6 +29,8 @@ const createReactAgentConfig = ['dist/prebuilt/react_agent_executor.cjs', 'dist/ export const langgraphConfig = [...compileConfig, ...createReactAgentConfig] satisfies InstrumentationConfig[]; +export const langgraphModuleNames = getModuleNames(langgraphConfig); + export const langgraphChannels = { LANGGRAPH_STATE_GRAPH_COMPILE: 'orchestrion:@langchain/langgraph:stateGraphCompile', LANGGRAPH_CREATE_REACT_AGENT: 'orchestrion:@langchain/langgraph:createReactAgent', diff --git a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts index 5193072808b9..2806a3a2e9d2 100644 --- a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts +++ b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const lruMemoizerConfig = [ { @@ -10,6 +10,8 @@ export const lruMemoizerConfig = [ }, ] satisfies InstrumentationConfig[]; +export const lruMemoizerModuleNames = getModuleNames(lruMemoizerConfig); + export const lruMemoizerChannels = { LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', } as const; diff --git a/packages/server-utils/src/orchestrion/config/mongodb.ts b/packages/server-utils/src/orchestrion/config/mongodb.ts index 45044530c5ef..c1a9c27ea329 100644 --- a/packages/server-utils/src/orchestrion/config/mongodb.ts +++ b/packages/server-utils/src/orchestrion/config/mongodb.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // The mongodb driver's command architecture changed across majors, mirrored in the vendored OTel // instrumentation's version bands: @@ -61,6 +61,8 @@ export const mongodbConfig = [ }, ] satisfies InstrumentationConfig[]; +export const mongodbModuleNames = getModuleNames(mongodbConfig); + export const mongodbChannels = { MONGODB_COMMAND: 'orchestrion:mongodb:command', MONGODB_CHECKOUT: 'orchestrion:mongodb:checkout', diff --git a/packages/server-utils/src/orchestrion/config/mongoose.ts b/packages/server-utils/src/orchestrion/config/mongoose.ts index 4b7069c07d3a..b1bbea4a2dfb 100644 --- a/packages/server-utils/src/orchestrion/config/mongoose.ts +++ b/packages/server-utils/src/orchestrion/config/mongoose.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // mongoose >= 9.7.0 publishes via its own `node:diagnostics_channel` tracing channels (handled by // `subscribeMongooseDiagnosticChannels`), so this transform is gated to `< 9.7.0` to avoid emitting @@ -106,6 +106,8 @@ export const mongooseConfig = [ })), ] satisfies InstrumentationConfig[]; +export const mongooseModuleNames = getModuleNames(mongooseConfig); + export const mongooseChannels = { MONGOOSE_QUERY_EXEC: 'orchestrion:mongoose:query_exec', MONGOOSE_AGGREGATE_EXEC: 'orchestrion:mongoose:aggregate_exec', diff --git a/packages/server-utils/src/orchestrion/config/mysql.ts b/packages/server-utils/src/orchestrion/config/mysql.ts index 2a720b1fa189..b77f3f1ac10f 100644 --- a/packages/server-utils/src/orchestrion/config/mysql.ts +++ b/packages/server-utils/src/orchestrion/config/mysql.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const mysqlConfig = [ { @@ -9,6 +9,8 @@ export const mysqlConfig = [ }, ] satisfies InstrumentationConfig[]; +export const mysqlModuleNames = getModuleNames(mysqlConfig); + export const mysqlChannels = { MYSQL_QUERY: 'orchestrion:mysql:query', } as const; diff --git a/packages/server-utils/src/orchestrion/config/mysql2.ts b/packages/server-utils/src/orchestrion/config/mysql2.ts index f0d49bf530d4..8da5685a40a4 100644 --- a/packages/server-utils/src/orchestrion/config/mysql2.ts +++ b/packages/server-utils/src/orchestrion/config/mysql2.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection // prototype) to orchestrion channel injection. @@ -43,6 +43,8 @@ export const mysql2Config = [ }, ] satisfies InstrumentationConfig[]; +export const mysql2ModuleNames = getModuleNames(mysql2Config); + export const mysql2Channels = { MYSQL2_QUERY: 'orchestrion:mysql2:query', MYSQL2_EXECUTE: 'orchestrion:mysql2:execute', diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index 9055cf0bb1ab..a42f7eda4ede 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const openaiConfig = [ // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, @@ -30,6 +30,8 @@ export const openaiConfig = [ })), ] 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 // identical span handling, so they share one channel. diff --git a/packages/server-utils/src/orchestrion/config/pg.ts b/packages/server-utils/src/orchestrion/config/pg.ts index 9be137a02653..44ccefdba858 100644 --- a/packages/server-utils/src/orchestrion/config/pg.ts +++ b/packages/server-utils/src/orchestrion/config/pg.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const pgConfig = [ // `pg` (node-postgres). @@ -42,6 +42,8 @@ export const pgConfig = [ }, ] satisfies InstrumentationConfig[]; +export const pgModuleNames = getModuleNames(pgConfig); + export const pgChannels = { PG_QUERY: 'orchestrion:pg:query', PG_CONNECT: 'orchestrion:pg:connect', diff --git a/packages/server-utils/src/orchestrion/config/postgres.ts b/packages/server-utils/src/orchestrion/config/postgres.ts index 2af4ff5472f0..4be82009a917 100644 --- a/packages/server-utils/src/orchestrion/config/postgres.ts +++ b/packages/server-utils/src/orchestrion/config/postgres.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; // postgres.js (`postgres` npm package, v3.x). Named after the npm package; // `postgres` doesn't collide with `pg.ts` (that file instruments `pg`/`pg-pool`). @@ -48,6 +48,8 @@ const postgresJsInstrumentationConfig = (dir: string): InstrumentationConfig[] = export const postgresJsConfig = ['src', 'cjs/src'].flatMap(postgresJsInstrumentationConfig); +export const postgresJsModuleNames = getModuleNames(postgresJsConfig); + export const postgresJsChannels = { POSTGRESJS_HANDLE: 'orchestrion:postgres:handle', POSTGRESJS_CONNECTION: 'orchestrion:postgres:connection', diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts index d156bb448002..e84ca3e13f46 100644 --- a/packages/server-utils/src/orchestrion/config/redis.ts +++ b/packages/server-utils/src/orchestrion/config/redis.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const redisConfig = [ // redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an @@ -63,6 +63,8 @@ export const redisConfig = [ }, ] satisfies InstrumentationConfig[]; +export const redisModuleNames = getModuleNames(redisConfig); + export const redisChannels = { REDIS_COMMAND: 'orchestrion:redis:command', NODE_REDIS_COMMAND: 'orchestrion:@redis/client:command', diff --git a/packages/server-utils/src/orchestrion/config/subscribe-injection.ts b/packages/server-utils/src/orchestrion/config/subscribe-injection.ts index 43faff7313e1..c8001e31d775 100644 --- a/packages/server-utils/src/orchestrion/config/subscribe-injection.ts +++ b/packages/server-utils/src/orchestrion/config/subscribe-injection.ts @@ -52,3 +52,12 @@ export function toSubscribeInjections(configs: InstrumentationConfig[]): Instrum return injections; } + +/** + * The distinct instrumented package names (`module.name`) across a library's + * channel configs. Integrations pass this to `invokeOrchestrionInstrumentation` + * so they subscribe only once one of their packages is actually injected. + */ +export function getModuleNames(configs: readonly InstrumentationConfig[]): string[] { + return [...new Set(configs.map(config => config.module.name))]; +} diff --git a/packages/server-utils/src/orchestrion/config/tedious.ts b/packages/server-utils/src/orchestrion/config/tedious.ts index 52d6ae195b6d..7c63a3cd8cad 100644 --- a/packages/server-utils/src/orchestrion/config/tedious.ts +++ b/packages/server-utils/src/orchestrion/config/tedious.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; const MODULE_NAME = 'tedious'; @@ -21,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 b1874f7bac75..be25e300a664 100644 --- a/packages/server-utils/src/orchestrion/config/vercel-ai.ts +++ b/packages/server-utils/src/orchestrion/config/vercel-ai.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { toSubscribeInjections } from './subscribe-injection'; +import { getModuleNames, toSubscribeInjections } from './subscribe-injection'; export const vercelAiConfig = [ // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting @@ -70,4 +70,6 @@ function vercelAiEntries( })); } +export const vercelAiModuleNames = getModuleNames(vercelAiConfig); + export const vercelAiSubscribeInjection = toSubscribeInjections(vercelAiConfig); diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index f6b7cb1c9e73..8fbf69a22aa5 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -10,7 +10,23 @@ import { debug, GLOBAL_OBJ } from '@sentry/core'; * will ever publish to those channels. */ export function isOrchestrionInjected(): boolean { - return !!GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + const marker = GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + return !!(marker?.runtime || marker?.bundler || marker?.integrations); +} + +/** + * The module names (e.g. `mysql`, `@hapi/hapi`) orchestrion has already injected + * into this process — from the runtime `--import` hook (`runtime`) and/or a + * bundler plugin (`bundler`). Channel-based integrations use it to decide whether + * to subscribe now (their module is already loaded) or wait for the runtime + * injection event. + * + * `bundler` can be `true` rather than an array (Bun's banner sets a plain flag); + * that carries no module names, so it contributes nothing here. + */ +export function getOrchestrionInjectedModules(): string[] { + const { runtime, bundler } = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ?? {}; + return [...(runtime ?? []), ...(Array.isArray(bundler) ? bundler : [])]; } /** diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts new file mode 100644 index 000000000000..868a41bd9b50 --- /dev/null +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -0,0 +1,117 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { Client } from '@sentry/core'; +import { addNonEnumerableProperty, waitForTracingChannelBinding } from '@sentry/core'; +import { getOrchestrionInjectedModules } from './detect'; + +// Marks an instrumentation callback as already run, so it subscribes at most once +// even when reached through several integration names (e.g. Deno wraps a shared +// integration under `DenoMysql`) or several clients. +const INSTRUMENTED = Symbol.for('SentryOrchestrionInstrumented'); + +// generic function arguments can only be `any` +// oxlint-disable-next-line typescript/no-explicit-any +type InstrumentationFn = ((...args: any[]) => void) & { [INSTRUMENTED]?: boolean }; + +const globalAny = globalThis as { Bun?: unknown; Deno?: unknown }; +const isBun = typeof globalAny.Bun !== 'undefined'; +const isDeno = typeof globalAny.Deno !== 'undefined'; + +/** + * Run an integration's channel-subscription callback once one of its modules is + * orchestrion-injected, and never more than once. + * + * Channel integrations must NOT subscribe eagerly at `Sentry.init()`: Node caps + * the number of `diagnostics_channel` channels in use at 1024, and every default + * channel integration binding several channels up front would burn that budget + * for modules the app never loads. So we defer: + * + * - If a module is already injected, then a bundler transformed and loaded it + * (which records it via `registerOrchestrionChannelIntegration`), or the + * runtime hook injected it before `init()`, so subscribe right away. + * - Otherwise wait for the runtime hook's `orchestrion.module-runtime-injected` + * event, which fires when the module is loaded and transformed, before it can + * publish to its channels. + * + * Bun and Deno have no such channel limit and no reliable per-module injection + * tracking, so there we just subscribe immediately. + * + * `waitForTracingChannelBinding` still wraps the callback: the async-context + * binding the subscription needs for span parenting may not exist yet. + * + * Note: it is possible to register extra event listeners, if the same callback + * is registered multiple times. However, this is (a) not something that happens + * normally, and (b) ultimately fine, because it'll only call the callback one + * time. There'll just be a few no-op client event listeners. + */ +export function invokeOrchestrionInstrumentation( + client: Client, + moduleNames: readonly string[], + callback: Callback, + args: Parameters, + // Most integrations parent spans via `bindTracingChannelToSpan`, which needs + // the async-context binding set up at subscribe time. A few (e.g. Hapi, + // KafkaJS, Koa, tedious) subscribe without it and create spans directly; + // they pass `false` so a missing binding never blocks their subscription. + { requiresTracingChannelBinding = true }: { requiresTracingChannelBinding?: boolean } = {}, +): void { + // `tracingChannel` is unavailable before Node 18.19; nothing to subscribe to. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + if (hasBeenInstrumented(callback)) { + return; + } + + // cleanup function is passed in so that we only remove the listener + // once the callback is actually instrumented and called. + const run = (cleanup?: () => void): void => { + const subscribe = (): void => { + if (hasBeenInstrumented(callback)) { + return; + } + markInstrumented(callback); + cleanup?.(); + callback(...args); + }; + + if (!requiresTracingChannelBinding) { + subscribe(); + return; + } + + // Mark as instrumented only if the channel binding actually lands. + // Avoids a very narrow case where the binding never arrives within the + // retry window. So, don't mark until we know we're actually calling. + waitForTracingChannelBinding(subscribe); + }; + + if (isBun || isDeno) { + run(); + return; + } + + const injected = getOrchestrionInjectedModules(); + if (moduleNames.some(name => injected.includes(name))) { + run(); + return; + } + + const cleanup = client.on('orchestrion.module-runtime-injected', (moduleName: string) => { + if (hasBeenInstrumented(callback)) { + cleanup(); + return; + } + if (moduleNames.includes(moduleName)) { + run(cleanup); + } + }); +} + +function hasBeenInstrumented(callback: InstrumentationFn): boolean { + return callback[INSTRUMENTED] ?? false; +} + +function markInstrumented(callback: InstrumentationFn): void { + addNonEnumerableProperty(callback, INSTRUMENTED, true); +} diff --git a/packages/server-utils/src/orchestrion/registerChannelIntegration.ts b/packages/server-utils/src/orchestrion/registerChannelIntegration.ts index 72f4a9a777fd..026c1005d9ce 100644 --- a/packages/server-utils/src/orchestrion/registerChannelIntegration.ts +++ b/packages/server-utils/src/orchestrion/registerChannelIntegration.ts @@ -1,5 +1,6 @@ import type { Integration } from '@sentry/core'; import { getClient, GLOBAL_OBJ } from '@sentry/core'; +import { modulesForSubscriberExport } from './config/channel-integration-definitions'; /** * Register an orchestrion channel-subscriber integration from an instrumented @@ -33,6 +34,25 @@ import { getClient, GLOBAL_OBJ } from '@sentry/core'; */ export function registerOrchestrionChannelIntegration(name: string, integrationFn: () => Integration): void { const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); + + // Record the instrumented package(s) as bundler-injected BEFORE adding the + // integration. The integration subscribes lazily, gated on its module showing + // up as injected (see `invokeOrchestrionInstrumentation`); this is that signal + // for the bundler path. Its module was just transformed and loaded, so the + // integration must subscribe immediately rather than wait for a runtime event + // that (being bundled, not loaded through the module hook) never fires. Skip + // when `bundler` is a non-array flag (Bun's banner sets `true`), which carries + // no module names. Those runtimes subscribe eagerly anyway. + const modules = modulesForSubscriberExport(name); + if (modules.length && (Array.isArray(marker.bundler) || marker.bundler === undefined)) { + const bundler = (marker.bundler ??= []); + for (const moduleName of modules) { + if (!bundler.includes(moduleName)) { + bundler.push(moduleName); + } + } + } + (marker.integrations ??= new Map()).set(name, integrationFn); getClient()?.addIntegration(integrationFn()); } diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 0cdf73ac9b5a..268fa6ec978a 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,4 +1,4 @@ -import { debug, GLOBAL_OBJ, parseSemver } from '@sentry/core'; +import { debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; import { SENTRY_INSTRUMENTATIONS } from '../config'; @@ -36,6 +36,19 @@ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolea * the channel-based integrations subscribe to. */ export function registerDiagnosticsChannelInjection(): void { + // Install the on-inject bridge. Force-bundled modules (vite SSR, nextjs's + // bundle-safe packages) are transformed at build time and never loaded + // through the module hook, so the hook's own client event never fires for + // them. Instead, the bundler's `injectDiagnostics` boot banner (see + // `bundler/options.ts`) calls this bridge for each such module once the + // bundle boots, which re-emits that same event so the lazily-registered + // channel integrations subscribe. `getClient()` is read lazily since the + // banner runs after `init()`. + const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); + marker.onInject ??= (moduleName: string): void => { + getClient()?.emit('orchestrion.module-runtime-injected', moduleName); + }; + if (GLOBAL_OBJ?.__SENTRY_ORCHESTRION__?.runtime) { return; } @@ -54,6 +67,10 @@ export function registerDiagnosticsChannelInjection(): void { GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); + // Tell channel integrations their module just loaded, so they subscribe + // now. They hold off at `init()` to avoid claiming channel slots for + // modules that never load, because Node caps channels in use at 1024. + getClient()?.emit('orchestrion.module-runtime-injected', moduleName); } }); diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index ee1c746fc6de..26830031002d 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -6,6 +6,7 @@ import type { ResolvedConfig } from 'vite'; import type { Compiler } from 'webpack'; import { describe, expect, it, vi } from 'vitest'; import { sentryOrchestrionPlugin as esbuildPlugin } from '../../src/orchestrion/bundler/esbuild'; +import { orchestrionTransformOptions } from '../../src/orchestrion/bundler/options'; import { sentryOrchestrionPlugin as rollupPlugin } from '../../src/orchestrion/bundler/rollup'; import { sentryOrchestrionPlugin as vitePlugin } from '../../src/orchestrion/bundler/vite'; import { @@ -167,3 +168,36 @@ describe('resolveOrchestrionRuntimeRequest', () => { expect(resolveOrchestrionRuntimeRequest('some-package-that-does-not-exist')).toBeUndefined(); }); }); + +describe('orchestrionTransformOptions injectDiagnostics banner', () => { + // Evaluate the emitted boot-banner snippet against a fake global, mirroring + // what runs when a bundled app boots. The banner must record `.bundler` for + // detection AND fire the on-inject bridge for each transformed module + // (force-bundled modules never reach the runtime hook, so the bridge is the + // only thing that triggers their channel subscription). + function runBanner(transformedModules: string[], global: Record): void { + const opts = orchestrionTransformOptions({}); + const banner = opts.injectDiagnostics?.({ transformedModules, failedModules: [] }); + expect(typeof banner).toBe('string'); + // `globalThis` inside the snippet resolves to the sandbox object we pass in. + new Function('globalThis', banner as string)(global); + } + + it('records `.bundler` and fires the on-inject bridge for each module', () => { + const onInject = vi.fn(); + const global: Record = { __SENTRY_ORCHESTRION__: { onInject } }; + + runBanner(['mysql', 'pg'], global); + + expect((global.__SENTRY_ORCHESTRION__ as { bundler?: string[] }).bundler).toEqual(['mysql', 'pg']); + expect(onInject.mock.calls.map(c => c[0])).toEqual(['mysql', 'pg']); + }); + + it('is a guarded no-op for the bridge when none is installed (bundler-only runtimes)', () => { + const global: Record = {}; + + expect(() => runBanner(['mysql'], global)).not.toThrow(); + // `.bundler` is still recorded so `init()` can drive subscription from it. + expect((global.__SENTRY_ORCHESTRION__ as { bundler?: string[] }).bundler).toEqual(['mysql']); + }); +}); diff --git a/packages/server-utils/test/orchestrion/detect.test.ts b/packages/server-utils/test/orchestrion/detect.test.ts new file mode 100644 index 000000000000..b578340f47fa --- /dev/null +++ b/packages/server-utils/test/orchestrion/detect.test.ts @@ -0,0 +1,37 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { isOrchestrionInjected } from '../../src/orchestrion/detect'; + +describe('isOrchestrionInjected', () => { + beforeEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + afterEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + it('is false when no marker exists', () => { + expect(isOrchestrionInjected()).toBe(false); + }); + + it.each([ + ['runtime', { runtime: [] }], + ['bundler array', { bundler: ['mysql'] }], + ['bundler true', { bundler: true }], + ['integrations', { integrations: new Map() }], + ] as const)('is true when %s injection is present', (_label, marker) => { + // Cast through `unknown`: rows are `as const` (readonly) and `bundler: true` + // is a valid runtime shape the marker type doesn't spell out. + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = marker as unknown as typeof GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + expect(isOrchestrionInjected()).toBe(true); + }); + + // The bridge is installed before hook registration succeeds, so a marker + // carrying only `onInject` means nothing will publish channels. Opt-in paths + // (knex, dataloader, Nest) must still fall back to OTel in that case. + it('is false when only the on-inject bridge is installed', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { onInject: () => {} }; + expect(isOrchestrionInjected()).toBe(false); + }); +}); diff --git a/packages/server-utils/test/orchestrion/instrumentation.test.ts b/packages/server-utils/test/orchestrion/instrumentation.test.ts new file mode 100644 index 000000000000..bd4a47ee7488 --- /dev/null +++ b/packages/server-utils/test/orchestrion/instrumentation.test.ts @@ -0,0 +1,193 @@ +import type { AsyncContextStrategy } from '@sentry/core'; +import { GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { invokeOrchestrionInstrumentation } from '../../src/orchestrion/instrumentation'; + +// Minimal async-context strategy exposing a truthy tracing-channel binding, so +// `waitForTracingChannelBinding` inside the helper runs the callback synchronously. +function installBinding(): void { + setAsyncContextStrategy({ + getTracingChannelBinding: () => ({ + asyncLocalStorage: {}, + getStoreWithActiveSpan: () => ({}) as never, + }), + } as unknown as AsyncContextStrategy); +} + +// client that emits `orchestrion.module-runtime-injected` +function makeClient(): { + on: (hook: string, cb: (moduleName: string) => void) => () => void; + inject: (moduleName: string) => void; + listenerCount: () => number; +} { + const listeners = new Set<(moduleName: string) => void>(); + return { + on: (_hook, cb) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + inject: moduleName => { + for (const cb of [...listeners]) { + cb(moduleName); + } + }, + listenerCount: () => listeners.size, + }; +} + +describe('invokeOrchestrionInstrumentation', () => { + beforeEach(() => { + installBinding(); + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + afterEach(() => { + setAsyncContextStrategy(undefined); + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + it('subscribes immediately when a module is already injected', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['mysql'] }; + const client = makeClient(); + const callback = vi.fn(); + + invokeOrchestrionInstrumentation(client as never, ['mysql'], callback, []); + + expect(callback).toHaveBeenCalledTimes(1); + // Nothing to wait for, so no listener is registered. + expect(client.listenerCount()).toBe(0); + }); + + it('does not mark the callback when the binding never becomes available, so a later attempt recovers', async () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['mysql'] }; + // No async-context strategy, so `waitForTracingChannelBinding` finds no binding and gives up. + setAsyncContextStrategy(undefined); + const client = makeClient(); + const callback = vi.fn(); + + invokeOrchestrionInstrumentation(client as never, ['mysql'], callback, []); + + // Wait past `waitForTracingChannelBinding`'s single ~1ms retry; it bails without subscribing. + await new Promise(resolve => setTimeout(resolve, 10)); + expect(callback).not.toHaveBeenCalled(); + + // The callback must NOT have been marked as instrumented — once the binding exists, a later + // invocation subscribes rather than being permanently skipped. + installBinding(); + invokeOrchestrionInstrumentation(client as never, ['mysql'], callback, []); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('subscribes without the binding when requiresTracingChannelBinding is false', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['@hapi/hapi'] }; + // No async-context strategy: integrations that need the binding would bail, + // but Hapi/KafkaJS/Koa/tedious subscribe directly and must still run. + setAsyncContextStrategy(undefined); + const client = makeClient(); + const callback = vi.fn(); + + invokeOrchestrionInstrumentation(client as never, ['@hapi/hapi'], callback, [], { + requiresTracingChannelBinding: false, + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not subscribe until the module is injected at runtime', () => { + const client = makeClient(); + const callback = vi.fn(); + + invokeOrchestrionInstrumentation(client as never, ['mysql'], callback, []); + + expect(callback).not.toHaveBeenCalled(); + expect(client.listenerCount()).toBe(1); + + // A different module injecting must not trigger it. + client.inject('redis'); + expect(callback).not.toHaveBeenCalled(); + + // Our module injecting subscribes, exactly once, and removes the listener. + client.inject('mysql'); + expect(callback).toHaveBeenCalledTimes(1); + expect(client.listenerCount()).toBe(0); + + client.inject('mysql'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('keeps the listener when the binding is not ready, so a later injection recovers', async () => { + // No async-context strategy, so the first injection finds no binding. + setAsyncContextStrategy(undefined); + const client = makeClient(); + const callback = vi.fn(); + + invokeOrchestrionInstrumentation(client as never, ['pg', 'pg-pool'], callback, []); + expect(client.listenerCount()).toBe(1); + + // First matching injection fires with no binding: the single retry bails, + // the callback stays unmarked, and the listener must remain for a retry. + client.inject('pg'); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(callback).not.toHaveBeenCalled(); + expect(client.listenerCount()).toBe(1); + + // Binding now available; a later matching injection subscribes. + installBinding(); + client.inject('pg-pool'); + expect(callback).toHaveBeenCalledTimes(1); + expect(client.listenerCount()).toBe(0); + }); + + it('matches on any of several module names', () => { + const client = makeClient(); + const callback = vi.fn(); + + invokeOrchestrionInstrumentation(client as never, ['pg', 'pg-pool'], callback, []); + client.inject('pg-pool'); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('forwards the provided args to the callback', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['mysql'] }; + const client = makeClient(); + const callback = vi.fn(); + + invokeOrchestrionInstrumentation(client as never, ['mysql'], callback, ['a', 1]); + + expect(callback).toHaveBeenCalledWith('a', 1); + }); + + it('runs the callback at most once across repeated invocations (dedup by callback)', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['mysql'] }; + const client = makeClient(); + const callback = vi.fn(); + + invokeOrchestrionInstrumentation(client as never, ['mysql'], callback, []); + invokeOrchestrionInstrumentation(client as never, ['mysql'], callback, []); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('treats bundler-recorded modules as injected', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: ['mysql'] }; + const client = makeClient(); + const callback = vi.fn(); + + invokeOrchestrionInstrumentation(client as never, ['mysql'], callback, []); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('ignores a non-array bundler flag (Bun sets `true`)', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: true as unknown as string[] }; + const client = makeClient(); + const callback = vi.fn(); + + invokeOrchestrionInstrumentation(client as never, ['mysql'], callback, []); + + // Not treated as injected, so it waits for the runtime event instead. + expect(callback).not.toHaveBeenCalled(); + expect(client.listenerCount()).toBe(1); + }); +}); 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..81f4d5539cda 100644 --- a/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts +++ b/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts @@ -1,10 +1,11 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { tracingChannel } from 'node:diagnostics_channel'; -import type { Scope } from '@sentry/core'; +import type { Client, Scope } from '@sentry/core'; import { _INTERNAL_setSpanForScope, getDefaultCurrentScope, getDefaultIsolationScope, + GLOBAL_OBJ, setAsyncContextStrategy, } from '@sentry/core'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -67,14 +68,19 @@ function installTestAsyncContextStrategy(): void { describe('postgresChannelIntegration({ ignoreConnectSpans: true })', () => { beforeAll(() => { installTestAsyncContextStrategy(); + // Mark `pg` injected so the integration's lazy `setup` subscribes right away. + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['pg', 'pg-pool'] }; }); afterAll(() => { setAsyncContextStrategy(undefined); + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; }); it('subscribes to the query channel but NOT the connect / pool-connect channels', () => { - postgresChannelIntegration({ ignoreConnectSpans: true }).setupOnce?.(); + postgresChannelIntegration({ ignoreConnectSpans: true }).setup?.({ + on: () => () => undefined, + } as unknown as Client); 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..214c78d34d7c 100644 --- a/packages/server-utils/test/orchestrion/postgres.test.ts +++ b/packages/server-utils/test/orchestrion/postgres.test.ts @@ -1,11 +1,12 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { tracingChannel } from 'node:diagnostics_channel'; -import type { Scope, Span } from '@sentry/core'; +import type { Client, Scope, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { _INTERNAL_setSpanForScope, getDefaultCurrentScope, getDefaultIsolationScope, + GLOBAL_OBJ, setAsyncContextStrategy, } from '@sentry/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; @@ -84,14 +85,18 @@ 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. + // Mark `pg` injected so the integration's lazy `setup` subscribes right away + // (its normal gate — subscribe only once the module is loaded). beforeAll(() => { installTestAsyncContextStrategy(); - postgresChannelIntegration().setupOnce?.(); + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['pg', 'pg-pool'] }; + postgresChannelIntegration().setup?.({ on: () => () => undefined } as unknown as Client); }); afterAll(() => { setAsyncContextStrategy(undefined); + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; }); beforeEach(() => { diff --git a/packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts b/packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts index 6c8af08a002b..bbf38be5c47d 100644 --- a/packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts +++ b/packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts @@ -43,4 +43,27 @@ describe('registerOrchestrionChannelIntegration', () => { // still stored for the next init() to pick up expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.has('myChannelIntegration')).toBe(true); }); + + it('records the export name`s instrumented package(s) as bundler-injected', () => { + // `postgresChannelIntegration` covers both `pg` and `pg-pool`. + registerOrchestrionChannelIntegration('postgresChannelIntegration', factory('Postgres')); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['pg', 'pg-pool']); + }); + + it('deduplicates recorded modules across repeated registration', () => { + registerOrchestrionChannelIntegration('mysqlChannelIntegration', factory('Mysql')); + registerOrchestrionChannelIntegration('mysqlChannelIntegration', factory('Mysql')); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); + }); + + it('leaves a non-array bundler flag (Bun sets `true`) untouched', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: true as unknown as string[] }; + registerOrchestrionChannelIntegration('mysqlChannelIntegration', factory('Mysql')); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toBe(true); + }); + + it('records nothing for an export name with no instrumented package mapping', () => { + registerOrchestrionChannelIntegration('myChannelIntegration', factory('X')); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toBeUndefined(); + }); }); diff --git a/packages/server-utils/test/orchestrion/webpack-loader.test.ts b/packages/server-utils/test/orchestrion/webpack-loader.test.ts new file mode 100644 index 000000000000..1b6052512fc4 --- /dev/null +++ b/packages/server-utils/test/orchestrion/webpack-loader.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import loader from '../../src/orchestrion/bundler/webpack-loader'; + +// Stand in for the upstream code-transform loader: a changed string signals a +// transformed module, the input unchanged signals a pass-through. +vi.mock('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader', () => ({ + default: function (this: { async: () => (e: unknown, c?: string, m?: unknown) => void }, code: string, map: unknown) { + const callback = this.async(); + callback(null, code === 'PASS' ? code : `${code};//transformed`, map); + }, +})); + +interface Ctx { + resourcePath: string; + _compilation?: unknown; +} + +function runLoader(ctx: Ctx, code: string): string | undefined { + let output: string | undefined; + const context = { + ...ctx, + async: () => (_error: unknown, outCode?: string) => { + output = outCode; + }, + }; + (loader as (this: unknown, code: string) => void).call(context, code); + return output; +} + +const IOREDIS = '/app/node_modules/ioredis/built/Redis.js'; + +describe('orchestrion webpack/Turbopack loader', () => { + it('appends the onInject call for a transformed module under Turbopack (no compilation)', () => { + const output = runLoader({ resourcePath: IOREDIS }, 'code'); + + expect(output).toContain('code;//transformed'); + expect(output).toContain('g.onInject("ioredis")'); + }); + + it('records the module on `.bundler` even when the bridge is not installed yet', () => { + const output = runLoader({ resourcePath: IOREDIS }, 'code') as string; + const snippet = output.slice('code;//transformed'.length); + + // Bridge absent (early load): records `.bundler`, no throw. + const early: { __SENTRY_ORCHESTRION__?: { bundler?: string[]; onInject?: (name: string) => void } } = {}; + new Function('globalThis', snippet)(early); + expect(early.__SENTRY_ORCHESTRION__?.bundler).toEqual(['ioredis']); + + // Bridge present (loaded after init): records AND fires the bridge. + const injected: string[] = []; + const late = { __SENTRY_ORCHESTRION__: { onInject: (name: string) => injected.push(name) } }; + new Function('globalThis', snippet)(late); + expect(injected).toEqual(['ioredis']); + }); + + it('does not append when webpack runs the plugin banner (compilation present)', () => { + const output = runLoader({ resourcePath: IOREDIS, _compilation: {} }, 'code'); + + expect(output).toBe('code;//transformed'); + expect(output).not.toContain('onInject'); + }); + + it('does not append for a pass-through (untransformed) module', () => { + const output = runLoader({ resourcePath: IOREDIS }, 'PASS'); + + expect(output).toBe('PASS'); + }); + + it.each([ + ['/app/node_modules/ioredis/built/Redis.js', 'ioredis'], + ['/app/node_modules/@redis/client/dist/lib/client/index.js', '@redis/client'], + // pnpm's nested layout: the real package is after the LAST node_modules. + ['/app/node_modules/.pnpm/ioredis@5.10.1/node_modules/ioredis/built/Redis.js', 'ioredis'], + ])('derives the package name from %s as %s', (resourcePath, expected) => { + const output = runLoader({ resourcePath }, 'code'); + + expect(output).toContain(`g.onInject("${expected}")`); + }); + + it('does not append when the path has no node_modules segment', () => { + const output = runLoader({ resourcePath: '/app/src/index.js' }, 'code'); + + expect(output).toBe('code;//transformed'); + expect(output).not.toContain('onInject'); + }); +});