From 1c5b1031d9696cf6087c4f253757b9c33612e306 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 16 Jul 2026 10:17:44 +0200 Subject: [PATCH 01/10] feat(node): Only setup orchestrion channel listeners when needed fixes mysql --- .../suites/orchestrion-mysql/test.ts | 2 +- .../suites/orchestrion-postgres/test.ts | 2 +- packages/bun/src/plugin.ts | 4 +- packages/core/src/client.ts | 16 ++ .../integrations/tracing/dataloader/index.ts | 19 +- .../src/integrations/tracing/knex/index.ts | 18 +- .../integrations/tracing-channel/amqplib.ts | 37 +-- .../integrations/tracing-channel/anthropic.ts | 54 ++--- .../tracing-channel/dataloader.ts | 34 +-- .../tracing-channel/express/index.ts | 16 +- .../express/instrumentation.ts | 14 +- .../tracing-channel/fastify/index.ts | 5 +- .../tracing-channel/fastify/utils.ts | 2 +- .../tracing-channel/generic-pool.ts | 18 +- .../tracing-channel/google-genai.ts | 60 ++--- .../tracing-channel/graphql/index.ts | 82 +------ .../graphql/instrumentation.ts | 68 ++++++ .../src/integrations/tracing-channel/hapi.ts | 70 ------ .../tracing-channel/hapi/index.ts | 23 ++ .../tracing-channel/hapi/instrumentation.ts | 44 ++++ .../{hapi-types.ts => hapi/types.ts} | 0 .../{hapi-utils.ts => hapi/utils.ts} | 4 +- .../integrations/tracing-channel/ioredis.ts | 94 +++----- .../tracing-channel/kafkajs/index.ts | 96 +------- .../kafkajs/instrumentation.ts | 78 ++++++ .../src/integrations/tracing-channel/knex.ts | 28 +-- .../tracing-channel/lru-memoizer.ts | 30 +-- .../src/integrations/tracing-channel/mysql.ts | 120 +++++---- .../integrations/tracing-channel/mysql2.ts | 21 +- .../integrations/tracing-channel/openai.ts | 48 ++-- .../tracing-channel/postgres-js.ts | 228 +++++++++--------- .../integrations/tracing-channel/postgres.ts | 42 ++-- .../src/integrations/tracing-channel/redis.ts | 45 ++-- .../integrations/tracing-channel/vercel-ai.ts | 19 +- .../server-utils/src/orchestrion/channels.ts | 4 - .../src/orchestrion/config/amqplib.ts | 5 +- .../src/orchestrion/config/anthropic-ai.ts | 5 +- .../src/orchestrion/config/dataloader.ts | 5 +- .../src/orchestrion/config/express.ts | 5 +- .../src/orchestrion/config/generic-pool.ts | 5 +- .../src/orchestrion/config/google-genai.ts | 5 +- .../src/orchestrion/config/graphql.ts | 5 +- .../src/orchestrion/config/hapi.ts | 5 +- .../src/orchestrion/config/index.ts | 4 - .../src/orchestrion/config/ioredis.ts | 5 +- .../src/orchestrion/config/kafkajs.ts | 5 +- .../src/orchestrion/config/knex.ts | 5 +- .../src/orchestrion/config/lru-memoizer.ts | 5 +- .../src/orchestrion/config/mysql.ts | 5 +- .../src/orchestrion/config/mysql2.ts | 5 +- .../src/orchestrion/config/nestjs.ts | 5 +- .../src/orchestrion/config/openai.ts | 5 +- .../server-utils/src/orchestrion/config/pg.ts | 5 +- .../src/orchestrion/config/postgres.ts | 77 +++--- .../src/orchestrion/config/prisma.ts | 6 - .../src/orchestrion/config/react-router.ts | 6 - .../src/orchestrion/config/redis.ts | 5 +- .../src/orchestrion/config/vercel-ai.ts | 5 +- .../server-utils/src/orchestrion/detect.ts | 8 + .../src/orchestrion/instrumentation.ts | 67 +++++ .../src/orchestrion/runtime/register.ts | 3 +- .../vercel-ai-orchestrion-subscriber.ts | 14 +- 62 files changed, 859 insertions(+), 866 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/graphql/instrumentation.ts delete mode 100644 packages/server-utils/src/integrations/tracing-channel/hapi.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/hapi/index.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/hapi/instrumentation.ts rename packages/server-utils/src/integrations/tracing-channel/{hapi-types.ts => hapi/types.ts} (100%) rename packages/server-utils/src/integrations/tracing-channel/{hapi-utils.ts => hapi/utils.ts} (99%) create mode 100644 packages/server-utils/src/integrations/tracing-channel/kafkajs/instrumentation.ts delete mode 100644 packages/server-utils/src/orchestrion/config/prisma.ts delete mode 100644 packages/server-utils/src/orchestrion/config/react-router.ts create mode 100644 packages/server-utils/src/orchestrion/instrumentation.ts diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts index d29903b76970..7344e37c221c 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts @@ -49,7 +49,7 @@ describe('orchestrion mysql instrumentation (Bun)', () => { // with the expected SQL expect(line).toContain('statement=SELECT 1 AS solution'); // injected banner ran at bundle boot - expect(line).toContain('"bundler":true'); + expect(line).toContain('"bundler":[]'); } finally { if (outfile) { rmSync(dirname(outfile), { recursive: true, force: true }); diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts index 89d6cfb49b19..c129f4e86f4e 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts @@ -54,7 +54,7 @@ describe('orchestrion pg instrumentation (Bun)', () => { // with the expected SQL expect(line).toContain('statement=SELECT 1 AS solution'); // injected banner ran at bundle boot - expect(line).toContain('"bundler":true'); + expect(line).toContain('"bundler":[]'); } finally { if (outfile) { rmSync(dirname(outfile), { recursive: true, force: true }); diff --git a/packages/bun/src/plugin.ts b/packages/bun/src/plugin.ts index c0c9ac73fa79..f0dba86a54e6 100644 --- a/packages/bun/src/plugin.ts +++ b/packages/bun/src/plugin.ts @@ -44,7 +44,7 @@ import { } from '@sentry/server-utils/orchestrion/config'; const BUNDLER_MARKER_BANNER = - ';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=true;'; + ';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=[];'; // Minimal shape of Bun's `PluginBuilder` that we touch. Typed locally instead // of depending on `bun-types`, which would pull Bun's globals. @@ -57,7 +57,7 @@ interface BunPluginBuilder { * with the central `SENTRY_INSTRUMENTATIONS`. The plugin injects * `diagnostics_channel.tracingChannel` calls into the instrumented libraries as * `bun build` bundles them, and injects a banner that sets - * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` when the bundle boots + * `globalThis.__SENTRY_ORCHESTRION__.bundler = []` when the bundle boots * * Pass the result to `Bun.build({ plugins: [...] })`. * diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 87b62479d18e..97b1b00c10ff 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -927,6 +927,16 @@ export abstract class Client { */ public on(hook: 'stopUIProfiler', callback: () => void): () => void; + /** + * A hook that is called when the orchestrion runtime hook injects diagnostics + * channels into a module as it is loaded. + * + * The callback receives the name of the instrumented module. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'orchestrion.module-runtime-injected', callback: (moduleName: string) => void): () => void; + /** * Register a hook on this client. */ @@ -1188,6 +1198,12 @@ export abstract class Client { */ public emit(hook: 'stopUIProfiler'): void; + /** + * Emit a hook event when the orchestrion runtime hook injects diagnostics + * channels into a module as it is loaded. + */ + public emit(hook: 'orchestrion.module-runtime-injected', moduleName: string): void; + /** * Emit a hook that was previously registered via `on()`. */ diff --git a/packages/node/src/integrations/tracing/dataloader/index.ts b/packages/node/src/integrations/tracing/dataloader/index.ts index 8ac7d3b6d36a..c6a04f5fa1c2 100644 --- a/packages/node/src/integrations/tracing/dataloader/index.ts +++ b/packages/node/src/integrations/tracing/dataloader/index.ts @@ -9,19 +9,24 @@ const INTEGRATION_NAME = 'Dataloader' as const; export const instrumentDataloader = generateInstrumentOnce(INTEGRATION_NAME, () => new DataloaderInstrumentation()); const _dataloaderIntegration = (() => { + // Decide in setup/setupOnce, not in the factory: the runtime channel injection runs inside `Sentry.init()`, + // after the integrations array has already been built, so `isOrchestrionInjected()` is only + // reliable by `setup`. When the diagnostics channels are injected (runtime hook or bundler + // plugin), subscribe to them (the channel integration needs the client to register its + // injection listener); otherwise fall back to the vendored OTel instrumentation. + return { name: INTEGRATION_NAME, setupOnce() { - // Decide here, not in the factory: the runtime channel injection runs inside `Sentry.init()`, - // after the integrations array has already been built, so `isOrchestrionInjected()` is only - // reliable by `setupOnce`. When the diagnostics channels are injected (runtime hook or bundler - // plugin), subscribe to them; otherwise fall back to the vendored OTel instrumentation. - if (isOrchestrionInjected()) { - dataloaderChannelIntegration().setupOnce?.(); - } else { + if (!isOrchestrionInjected()) { instrumentDataloader(); } }, + setup(client) { + if (isOrchestrionInjected()) { + dataloaderChannelIntegration().setup?.(client); + } + }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/knex/index.ts b/packages/node/src/integrations/tracing/knex/index.ts index e914bba0a841..36a65c17a6d9 100644 --- a/packages/node/src/integrations/tracing/knex/index.ts +++ b/packages/node/src/integrations/tracing/knex/index.ts @@ -9,18 +9,24 @@ const INTEGRATION_NAME = 'Knex' as const; export const instrumentKnex = generateInstrumentOnce(INTEGRATION_NAME, () => new KnexInstrumentation()); const _knexIntegration = (() => { + // Decide in setup/setupOnce, not in the factory: the runtime channel injection runs inside `Sentry.init()`, + // after the integrations array has already been built, so `isOrchestrionInjected()` is only + // reliable by `setup`. When the diagnostics channels are injected (runtime hook or bundler + // plugin), subscribe to them (the channel integration needs the client to register its + // injection listener); otherwise fall back to the vendored OTel instrumentation. + return { name: INTEGRATION_NAME, setupOnce() { - // Prefer the diagnostics-channel subscriber when orchestrion injected its channels; otherwise - // fall back to the vendored OTel instrumentation. `isOrchestrionInjected()` is only reliable by - // `setupOnce` (the runtime injection runs during `Sentry.init()`, after integrations are built). - if (isOrchestrionInjected()) { - knexChannelIntegration().setupOnce?.(); - } else { + if (!isOrchestrionInjected()) { instrumentKnex(); } }, + setup(client) { + if (isOrchestrionInjected()) { + knexChannelIntegration().setup?.(client); + } + }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/amqplib.ts b/packages/server-utils/src/integrations/tracing-channel/amqplib.ts index a745ed88c9d2..419f657dfccb 100644 --- a/packages/server-utils/src/integrations/tracing-channel/amqplib.ts +++ b/packages/server-utils/src/integrations/tracing-channel/amqplib.ts @@ -3,7 +3,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { continueTrace, - debug, defineIntegration, getTraceData, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -11,7 +10,6 @@ import { SPAN_STATUS_ERROR, startInactiveSpan, timestampInSeconds, - waitForTracingChannelBinding, } from '@sentry/core'; // eslint-disable-next-line typescript/no-deprecated -- NET_PEER_* emitted alongside SERVER_* for backwards compatibility (TODO(v11): remove) import { @@ -27,9 +25,10 @@ import { SERVER_PORT, URL_FULL, } from '@sentry/conventions/attributes'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { amqplibModuleNames } from '../../orchestrion/config/amqplib'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Amqplib' integration is omitted from the default set. @@ -156,32 +155,20 @@ interface AmqpConnectContext { const NOOP = (): void => {}; -// Guards against subscribing to the amqplib channels more than once in a process. Core dedupes -// `setupOnce` by integration *name*, which is not enough here: the Deno SDK wraps this integration -// under a different name (`DenoAmqplib`) via `extendIntegration`, so adding both would otherwise run -// the subscribe logic twice and emit duplicate spans for every operation. -let subscribed = false; +function instrumentAmqplib(): void { + subscribeConnect(); + subscribePublish(); + subscribeConfirmPublish(); + subscribeConsume(); + subscribeDispatch(); + subscribeSettle(); +} const _amqplibChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel || subscribed) { - return; - } - subscribed = true; - - DEBUG_BUILD && debug.log('[orchestrion:amqplib] subscribing to amqplib tracing channels'); - - waitForTracingChannelBinding(() => { - subscribeConnect(); - subscribePublish(); - subscribeConfirmPublish(); - subscribeConsume(); - subscribeDispatch(); - subscribeSettle(); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, amqplibModuleNames, instrumentAmqplib, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/anthropic.ts b/packages/server-utils/src/integrations/tracing-channel/anthropic.ts index a6425437c7f7..68f2133b9321 100644 --- a/packages/server-utils/src/integrations/tracing-channel/anthropic.ts +++ b/packages/server-utils/src/integrations/tracing-channel/anthropic.ts @@ -4,7 +4,6 @@ import { _INTERNAL_shouldSkipAiProviderWrapping, addAnthropicRequestAttributes, addAnthropicResponseAttributes, - debug, defineIntegration, extractAnthropicRequestAttributes, GEN_AI_REQUEST_MODEL_ATTRIBUTE, @@ -14,11 +13,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, shouldEnableTruncation, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { anthropicAiModuleNames } from '../../orchestrion/config/anthropic-ai'; // Same name as the OTel integration by design: when enabled, the OTel 'Anthropic_AI' // integration is dropped from the default set (see the Node opt-in loader). @@ -47,39 +46,30 @@ interface AnthropicChannelContext { result?: unknown; } -let subscribed = false; +function instrumentAnthropic(options: AnthropicAiOptions): void { + for (const { channel, operation, methodPath, stream } of INSTRUMENTED_CHANNELS) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channel), + data => createGenAiSpan(data, operation, methodPath, options), + { + beforeSpanEnd: (span, data) => { + addAnthropicResponseAttributes( + span, + data.result as AnthropicAiResponse, + resolveAIRecordingOptions(options).recordOutputs, + ); + }, + deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, stream, options), + }, + ); + } +} const _anthropicChannelIntegration = ((options: AnthropicAiOptions = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - // tracingChannel is unavailable before Node 18.19 and prevent double-subscribe - if (!diagnosticsChannel.tracingChannel || subscribed) { - return; - } - subscribed = true; - - // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers - // after `setupOnce` runs, so wait for it before subscribing. - waitForTracingChannelBinding(() => { - for (const { channel, operation, methodPath, stream } of INSTRUMENTED_CHANNELS) { - DEBUG_BUILD && debug.log(`[orchestrion:anthropic] subscribing to channel "${channel}"`); - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(channel), - data => createGenAiSpan(data, operation, methodPath, options), - { - beforeSpanEnd: (span, data) => { - addAnthropicResponseAttributes( - span, - data.result as AnthropicAiResponse, - resolveAIRecordingOptions(options).recordOutputs, - ); - }, - deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, stream, options), - }, - ); - } - }); + setup(client) { + invokeOrchestrionInstrumentation(client, anthropicAiModuleNames, instrumentAnthropic, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/dataloader.ts b/packages/server-utils/src/integrations/tracing-channel/dataloader.ts index 80b998ab11e5..ae9c20cb4d16 100644 --- a/packages/server-utils/src/integrations/tracing-channel/dataloader.ts +++ b/packages/server-utils/src/integrations/tracing-channel/dataloader.ts @@ -1,22 +1,19 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span, StartSpanOptions } from '@sentry/core'; import { - debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan, startSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import type { ChannelName } from '../../orchestrion/channels'; import { CHANNELS } from '../../orchestrion/channels'; import type { TracingChannelPayloadWithSpan } from '../../tracing-channel'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { dataloaderModuleNames } from '../../orchestrion/config/dataloader'; -// NOTE: this uses the same name as the OTel integration by design. -// When enabled, the OTel 'Dataloader' integration is omitted from the default set. const INTEGRATION_NAME = 'Dataloader' as const; const MODULE_NAME = 'dataloader'; @@ -78,25 +75,20 @@ function makeSpanOptions(loader: DataLoaderInstance | undefined, operation: Oper }; } +function instrumentDataloader(): void { + subscribeConstruct(); + subscribeLoad(); + subscribeSimpleOperation(CHANNELS.DATALOADER_LOAD_MANY, 'loadMany'); + subscribeSimpleOperation(CHANNELS.DATALOADER_PRIME, 'prime'); + subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR, 'clear'); + subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR_ALL, 'clearAll'); +} + const _dataloaderChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && debug.log('[orchestrion:dataloader] subscribing to dataloader tracing channels'); - - waitForTracingChannelBinding(() => { - subscribeConstruct(); - subscribeLoad(); - subscribeSimpleOperation(CHANNELS.DATALOADER_LOAD_MANY, 'loadMany'); - subscribeSimpleOperation(CHANNELS.DATALOADER_PRIME, 'prime'); - subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR, 'clear'); - subscribeSimpleOperation(CHANNELS.DATALOADER_CLEAR_ALL, 'clearAll'); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, dataloaderModuleNames, instrumentDataloader, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/express/index.ts b/packages/server-utils/src/integrations/tracing-channel/express/index.ts index 2c7f19b4206a..8ef81a5cabbf 100644 --- a/packages/server-utils/src/integrations/tracing-channel/express/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/express/index.ts @@ -1,8 +1,9 @@ -import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; import type { ExpressIntegrationOptions } from './types'; import { instrumentExpress } from './instrumentation'; +import { expressModuleNames } from '../../../orchestrion/config/express'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Express' integration is omitted from the default set. @@ -11,15 +12,8 @@ const INTEGRATION_NAME = 'Express' as const; const _expressChannelIntegration = ((options: ExpressIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - instrumentExpress(options, diagnosticsChannel.tracingChannel); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, expressModuleNames, instrumentExpress, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts index 19db7a815d14..eec5430a57bb 100644 --- a/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts +++ b/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts @@ -1,4 +1,4 @@ -import type * as diagnosticsChannel from 'node:diagnostics_channel'; +import * as diagnosticsChannel from 'node:diagnostics_channel'; import { HTTP_ROUTE } from '@sentry/conventions/attributes'; import type { Span } from '@sentry/core'; import { @@ -45,16 +45,8 @@ const ATTR_EXPRESS_TYPE = 'express.type'; const NOOP = (): void => {}; -let _isInstrumented = false; - -export function instrumentExpress( - options: ExpressIntegrationOptions, - tracingChannel: typeof diagnosticsChannel.tracingChannel, -): void { - if (_isInstrumented) { - return; - } - _isInstrumented = true; +export function instrumentExpress(options: ExpressIntegrationOptions): void { + const tracingChannel = diagnosticsChannel.tracingChannel; // Record each layer's registered path *pattern* as it is registered, so the // matched route can be reconstructed with its parameters intact at request diff --git a/packages/server-utils/src/integrations/tracing-channel/fastify/index.ts b/packages/server-utils/src/integrations/tracing-channel/fastify/index.ts index edab7d03804c..4bf64ae93eea 100644 --- a/packages/server-utils/src/integrations/tracing-channel/fastify/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/fastify/index.ts @@ -1,7 +1,6 @@ import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import type { FastifyIntegration, FastifyReply, FastifyRequest } from './types'; - import { instrumentFastify as _instrumentFastify } from './instrumentation'; import { defaultShouldHandleError, INTEGRATION_NAME } from './utils'; import { subscribeToFastifyErrorChannel, handleFastifyError as _handleFastifyError } from './errors'; @@ -76,9 +75,7 @@ const _fastifyIntegration = (({ shouldHandleError }: Partial = {}) => - _fastifyIntegration(options), -); +export const fastifyIntegration = defineIntegration(_fastifyIntegration); /** * @deprecated This export is deprecated and will not longer be exposed in the next major version. diff --git a/packages/server-utils/src/integrations/tracing-channel/fastify/utils.ts b/packages/server-utils/src/integrations/tracing-channel/fastify/utils.ts index 9b34135844df..e560590d9234 100644 --- a/packages/server-utils/src/integrations/tracing-channel/fastify/utils.ts +++ b/packages/server-utils/src/integrations/tracing-channel/fastify/utils.ts @@ -1,6 +1,6 @@ import type { FastifyReply, FastifyRequest } from './types'; -export const INTEGRATION_NAME = 'Fastify'; +export const INTEGRATION_NAME = 'Fastify' as const; /** * Default function to determine if an error should be sent to Sentry diff --git a/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts b/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts index 576f0b4ca8e9..68c43a683716 100644 --- a/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts +++ b/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts @@ -1,13 +1,10 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { - defineIntegration, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startInactiveSpan, - waitForTracingChannelBinding, -} from '@sentry/core'; +import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { genericPoolModuleNames } from '../../orchestrion/config/generic-pool'; // Same name as the OTel integration by design — when enabled, the OTel // 'GenericPool' integration is omitted from the default set. @@ -20,13 +17,8 @@ interface GenericPoolAcquireContext { const _genericPoolChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => instrumentGenericPool()); + setup(client) { + invokeOrchestrionInstrumentation(client, genericPoolModuleNames, instrumentGenericPool, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/google-genai.ts b/packages/server-utils/src/integrations/tracing-channel/google-genai.ts index 312e4716cf61..bcbcefde42b9 100644 --- a/packages/server-utils/src/integrations/tracing-channel/google-genai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/google-genai.ts @@ -4,7 +4,6 @@ import { _INTERNAL_shouldSkipAiProviderWrapping, addGoogleGenAIRequestAttributes, addGoogleGenAIResponseAttributes, - debug, defineIntegration, extractGoogleGenAIRequestAttributes, GEN_AI_REQUEST_MODEL_ATTRIBUTE, @@ -15,11 +14,11 @@ import { shouldEnableTruncation, spanToJSON, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { googleGenAIModuleNames } from '../../orchestrion/config/google-genai'; // Same name as the OTel integration by design: when enabled, the OTel 'Google_GenAI' // integration is dropped from the default set (see the Node opt-in loader). @@ -44,42 +43,33 @@ interface GoogleGenAIChannelContext { result?: unknown; } -let subscribed = false; +function instrumentGoogleGenAI(options: GoogleGenAIOptions): void { + for (const { channel, operation } of INSTRUMENTED_CHANNELS) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channel), + data => createGenAiSpan(data, operation, options), + { + beforeSpanEnd: (span, data) => { + // Embeddings responses carry no content attributes. + if (operation !== 'embeddings') { + addGoogleGenAIResponseAttributes( + span, + data.result as GoogleGenAIResponse, + resolveAIRecordingOptions(options).recordOutputs, + ); + } + }, + deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), + }, + ); + } +} const _googleGenAIChannelIntegration = ((options: GoogleGenAIOptions = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe. - if (!diagnosticsChannel.tracingChannel || subscribed) { - return; - } - subscribed = true; - - // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers - // after `setupOnce` runs, so wait for it before subscribing. - waitForTracingChannelBinding(() => { - for (const { channel, operation } of INSTRUMENTED_CHANNELS) { - DEBUG_BUILD && debug.log(`[orchestrion:google-genai] subscribing to channel "${channel}"`); - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(channel), - data => createGenAiSpan(data, operation, options), - { - beforeSpanEnd: (span, data) => { - // Embeddings responses carry no content attributes. - if (operation !== 'embeddings') { - addGoogleGenAIResponseAttributes( - span, - data.result as GoogleGenAIResponse, - resolveAIRecordingOptions(options).recordOutputs, - ); - } - }, - deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), - }, - ); - } - }); + setup(client) { + invokeOrchestrionInstrumentation(client, googleGenAIModuleNames, instrumentGoogleGenAI, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts index 2a3baa669e16..5ef80494a276 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts @@ -1,82 +1,20 @@ -import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; -import { DEBUG_BUILD } from '../../../debug-build'; +import { defineIntegration, extendIntegration } from '@sentry/core'; import { graphqlIntegration as graphqlNativeIntegration } from '../../../graphql'; import type { GraphqlDiagnosticChannelsOptions } from '../../../graphql/graphql-dc-subscriber'; -import { CHANNELS } from '../../../orchestrion/channels'; -import { bindTracingChannelToSpan } from '../../../tracing-channel'; -import { - finalizeExecuteSpan, - finalizeValidateSpan, - startExecuteSpan, - startParseSpan, - startValidateSpan, -} from './spans'; -import type { GraphqlResolvedConfig } from './types'; + +import { instrumentGraphql } from './instrumentation'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; +import { graphqlModuleNames } from '../../../orchestrion/config/graphql'; // Same name as the OTel/native integration by design, so enabling injection swaps this in for it. const INTEGRATION_NAME = 'Graphql' as const; -// The context orchestrion's transform attaches to each channel: `arguments` is the live args of the -// wrapped call, `result` the settled return value. -interface GraphqlChannelContext { - arguments: unknown[]; - self?: unknown; - result?: unknown; - error?: unknown; -} - -function getOptionsWithDefaults(options: GraphqlDiagnosticChannelsOptions): GraphqlResolvedConfig { - return { - ignoreResolveSpans: options.ignoreResolveSpans !== false, - ignoreTrivialResolveSpans: options.ignoreTrivialResolveSpans !== false, - useOperationNameForRootSpan: options.useOperationNameForRootSpan !== false, - }; -} - -/** - * Runs a span-building callback so a throw inside it can never break the user's graphql call: these - * run inside the `tracingChannel(...).trace*` machinery wrapping the real function (as the `getSpan` - * producer / `beforeSpanEnd` handler), where an unguarded throw would propagate into the traced call. - */ -function safe(fn: () => T): T | undefined { - try { - return fn(); - } catch (error) { - DEBUG_BUILD && debug.warn('[orchestrion:graphql] error building span', error); - return undefined; - } -} - const _graphqlChannelIntegration = ((options: GraphqlDiagnosticChannelsOptions = {}) => { - const config = getOptionsWithDefaults(options); - const getConfig = (): GraphqlResolvedConfig => config; - return { name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_PARSE), () => - safe(() => startParseSpan()), - ); - - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_VALIDATE), - data => safe(() => startValidateSpan(data.arguments[1])), - { beforeSpanEnd: (span, data) => void safe(() => finalizeValidateSpan(span, data.result)) }, - ); - - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_EXECUTE), - data => safe(() => startExecuteSpan(data.arguments, data.self, config, getConfig)), - { beforeSpanEnd: (span, data) => void safe(() => finalizeExecuteSpan(span, data.result)) }, - ); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, graphqlModuleNames, instrumentGraphql, [options]); }, }; }) satisfies IntegrationFn; @@ -102,8 +40,6 @@ export const graphqlChannelIntegration = defineIntegration(_graphqlChannelIntegr */ export const graphqlDiagnosticsChannelIntegration = (options?: GraphqlDiagnosticChannelsOptions) => { const orchestrion = graphqlChannelIntegration(options); - return extendIntegration(graphqlNativeIntegration(options), { - name: INTEGRATION_NAME, - setupOnce: () => orchestrion.setupOnce?.(), - }); + const graphqlNative = graphqlNativeIntegration(options); + return extendIntegration(orchestrion, { ...graphqlNative }); }; diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/instrumentation.ts new file mode 100644 index 000000000000..b8c913d18b92 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/instrumentation.ts @@ -0,0 +1,68 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../../../debug-build'; +import type { GraphqlDiagnosticChannelsOptions } from '../../../graphql/graphql-dc-subscriber'; +import { CHANNELS } from '../../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../../tracing-channel'; +import { + finalizeExecuteSpan, + finalizeValidateSpan, + startExecuteSpan, + startParseSpan, + startValidateSpan, +} from './spans'; +import type { GraphqlResolvedConfig } from './types'; + +// The context orchestrion's transform attaches to each channel: `arguments` is the live args of the +// wrapped call, `result` the settled return value. +interface GraphqlChannelContext { + arguments: unknown[]; + self?: unknown; + result?: unknown; + error?: unknown; +} + +function getOptionsWithDefaults(options: GraphqlDiagnosticChannelsOptions): GraphqlResolvedConfig { + return { + ignoreResolveSpans: options.ignoreResolveSpans !== false, + ignoreTrivialResolveSpans: options.ignoreTrivialResolveSpans !== false, + useOperationNameForRootSpan: options.useOperationNameForRootSpan !== false, + }; +} + +/** + * Runs a span-building callback so a throw inside it can never break the user's graphql call: these + * run inside the `tracingChannel(...).trace*` machinery wrapping the real function (as the `getSpan` + * producer / `beforeSpanEnd` handler), where an unguarded throw would propagate into the traced call. + */ +function safe(fn: () => T): T | undefined { + try { + return fn(); + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:graphql] error building span', error); + return undefined; + } +} + +export function instrumentGraphql(options: GraphqlDiagnosticChannelsOptions = {}): void { + const config = getOptionsWithDefaults(options); + const getConfig = (): GraphqlResolvedConfig => config; + + const tracingChannel = diagnosticsChannel.tracingChannel; + + bindTracingChannelToSpan(tracingChannel(CHANNELS.GRAPHQL_PARSE), () => + safe(() => startParseSpan()), + ); + + bindTracingChannelToSpan( + tracingChannel(CHANNELS.GRAPHQL_VALIDATE), + data => safe(() => startValidateSpan(data.arguments[1])), + { beforeSpanEnd: (span, data) => void safe(() => finalizeValidateSpan(span, data.result)) }, + ); + + bindTracingChannelToSpan( + tracingChannel(CHANNELS.GRAPHQL_EXECUTE), + data => safe(() => startExecuteSpan(data.arguments, data.self, config, getConfig)), + { beforeSpanEnd: (span, data) => void safe(() => finalizeExecuteSpan(span, data.result)) }, + ); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi.ts b/packages/server-utils/src/integrations/tracing-channel/hapi.ts deleted file mode 100644 index 48a2d441873f..000000000000 --- a/packages/server-utils/src/integrations/tracing-channel/hapi.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as diagnosticsChannel from 'node:diagnostics_channel'; -import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; -import { CHANNELS } from '../../orchestrion/channels'; -import { wrapExtArguments, wrapRouteArguments } from './hapi-utils'; - -// NOTE: same name as the OTel integration by design — when enabled, the OTel -// 'Hapi' integration is omitted from the default set. -const INTEGRATION_NAME = 'Hapi' as const; - -/** - * The shape orchestrion's transform attaches to the `@hapi/hapi` route/ext - * tracing-channel `context` objects. - * - * `arguments` is the *live* args array passed to `server.route` / `server.ext`; - * we mutate it in place to swap handlers for span-creating proxies. `self` is - * the hapi server instance: the root server has `self.realm.plugin === undefined`, - * while a plugin's clone server exposes the registering plugin's name there. - */ -interface HapiChannelContext { - arguments: unknown[]; - self?: { realm?: { plugin?: string } }; -} - -const _hapiChannelIntegration = (() => { - return { - name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && - debug.log(`[orchestrion:hapi] subscribing to channels "${CHANNELS.HAPI_ROUTE}" / "${CHANNELS.HAPI_EXT}"`); - - // `subscribe` requires all five lifecycle hooks. We only act on `start`, - // which orchestrion fires synchronously with the live args array — that's - // the moment we mutate the handlers in place. - diagnosticsChannel.tracingChannel(CHANNELS.HAPI_ROUTE).subscribe({ - start(rawCtx) { - const ctx = rawCtx as HapiChannelContext; - wrapRouteArguments(ctx.arguments, ctx.self?.realm?.plugin); - }, - end() {}, - asyncStart() {}, - asyncEnd() {}, - error() {}, - }); - - diagnosticsChannel.tracingChannel(CHANNELS.HAPI_EXT).subscribe({ - start(rawCtx) { - const ctx = rawCtx as HapiChannelContext; - wrapExtArguments(ctx.arguments, ctx.self?.realm?.plugin); - }, - end() {}, - asyncStart() {}, - asyncEnd() {}, - error() {}, - }); - }, - }; -}) satisfies IntegrationFn; - -/** - * EXPERIMENTAL — orchestrion-driven hapi integration. Subscribes to the - * `orchestrion:@hapi/hapi:route` / `:ext` channels injected into `@hapi/hapi`'s - * `lib/server.js`. Requires the orchestrion runtime hook or bundler plugin. - */ -export const hapiChannelIntegration = defineIntegration(_hapiChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi/index.ts b/packages/server-utils/src/integrations/tracing-channel/hapi/index.ts new file mode 100644 index 000000000000..f6044080312b --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/hapi/index.ts @@ -0,0 +1,23 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; +import { hapiModuleNames } from '../../../orchestrion/config/hapi'; +import { instrumentHapi } from './instrumentation'; + +const INTEGRATION_NAME = 'Hapi' as const; + +const _hapiChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, hapiModuleNames, instrumentHapi, []); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven hapi integration. Subscribes to the + * `orchestrion:@hapi/hapi:route` / `:ext` channels injected into `@hapi/hapi`'s + * `lib/server.js`. Requires the orchestrion runtime hook or bundler plugin. + */ +export const hapiChannelIntegration = defineIntegration(_hapiChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/hapi/instrumentation.ts new file mode 100644 index 000000000000..6249e9305cff --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/hapi/instrumentation.ts @@ -0,0 +1,44 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { CHANNELS } from '../../../orchestrion/channels'; +import { wrapExtArguments, wrapRouteArguments } from './utils'; + +/** + * The shape orchestrion's transform attaches to the `@hapi/hapi` route/ext + * tracing-channel `context` objects. + * + * `arguments` is the *live* args array passed to `server.route` / `server.ext`; + * we mutate it in place to swap handlers for span-creating proxies. `self` is + * the hapi server instance: the root server has `self.realm.plugin === undefined`, + * while a plugin's clone server exposes the registering plugin's name there. + */ +interface HapiChannelContext { + arguments: unknown[]; + self?: { realm?: { plugin?: string } }; +} + +export function instrumentHapi() { + // `subscribe` requires all five lifecycle hooks. We only act on `start`, + // which orchestrion fires synchronously with the live args array — that's + // the moment we mutate the handlers in place. + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_ROUTE).subscribe({ + start(rawCtx) { + const ctx = rawCtx as HapiChannelContext; + wrapRouteArguments(ctx.arguments, ctx.self?.realm?.plugin); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); + + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_EXT).subscribe({ + start(rawCtx) { + const ctx = rawCtx as HapiChannelContext; + wrapExtArguments(ctx.arguments, ctx.self?.realm?.plugin); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi-types.ts b/packages/server-utils/src/integrations/tracing-channel/hapi/types.ts similarity index 100% rename from packages/server-utils/src/integrations/tracing-channel/hapi-types.ts rename to packages/server-utils/src/integrations/tracing-channel/hapi/types.ts diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi-utils.ts b/packages/server-utils/src/integrations/tracing-channel/hapi/utils.ts similarity index 99% rename from packages/server-utils/src/integrations/tracing-channel/hapi-utils.ts rename to packages/server-utils/src/integrations/tracing-channel/hapi/utils.ts index 82af2a11f196..e40411f78e90 100644 --- a/packages/server-utils/src/integrations/tracing-channel/hapi-utils.ts +++ b/packages/server-utils/src/integrations/tracing-channel/hapi/utils.ts @@ -25,10 +25,10 @@ import type { ServerRequestExtType, ServerRoute, ServerRouteOptions, -} from './hapi-types'; +} from './types'; // eslint-disable-next-line typescript/no-deprecated -- TODO(v11): Replace deprecated attributes import { HTTP_METHOD, HTTP_ROUTE } from '@sentry/conventions/attributes'; -import { AttributeNames, handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './hapi-types'; +import { AttributeNames, handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './types'; type SpanAttributes = Record; diff --git a/packages/server-utils/src/integrations/tracing-channel/ioredis.ts b/packages/server-utils/src/integrations/tracing-channel/ioredis.ts index 65d1fa69ac13..dd50a1798a1d 100644 --- a/packages/server-utils/src/integrations/tracing-channel/ioredis.ts +++ b/packages/server-utils/src/integrations/tracing-channel/ioredis.ts @@ -5,17 +5,12 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { DB_STATEMENT, DB_SYSTEM, NET_PEER_NAME, NET_PEER_PORT } from '@sentry/conventions/attributes'; import type { IntegrationFn, Span } from '@sentry/core'; -import { - debug, - defineIntegration, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startInactiveSpan, - waitForTracingChannelBinding, -} from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; +import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; import { defaultDbStatementSerializer } from '../../redis/redis-statement-serializer'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { ioredisModuleNames } from '../../orchestrion/config/ioredis'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // Distinct from the OTel `Redis` integration, which is composite (node-redis + // ioredis + the >=5.11.0 diagnostics_channel subscriber) and stays in the set; @@ -97,59 +92,50 @@ export function startIORedisCommandSpan(data: IORedisCommandContext): Span | und }); } -const _ioredisChannelIntegration = ((options: IORedisChannelIntegrationOptions = {}) => { +function instrumentIORedis(options: IORedisChannelIntegrationOptions) { const responseHook = options.responseHook; - return { - name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19. - if (!diagnosticsChannel.tracingChannel) { + const commandChannel = diagnosticsChannel.tracingChannel( + CHANNELS.IOREDIS_COMMAND, + ); + const connectChannel = diagnosticsChannel.tracingChannel( + CHANNELS.IOREDIS_CONNECT, + ); + + bindTracingChannelToSpan(commandChannel, startIORedisCommandSpan, { + // ioredis' `requireParentSpan` default: only create a span under an active span. + requiresParentSpan: true, + beforeSpanEnd(span, data) { + if ('error' in data || !responseHook) { return; } + const command = data.arguments?.[0] as RedisCommand | undefined; + if (command) { + runResponseHook(responseHook, span, command, data.result); + } + }, + }); - DEBUG_BUILD && - debug.log(`[orchestrion:ioredis] subscribing to "${CHANNELS.IOREDIS_COMMAND}"/"${CHANNELS.IOREDIS_CONNECT}"`); - - const commandChannel = diagnosticsChannel.tracingChannel( - CHANNELS.IOREDIS_COMMAND, - ); - const connectChannel = diagnosticsChannel.tracingChannel( - CHANNELS.IOREDIS_CONNECT, - ); - - // `bindTracingChannelToSpan` uses `bindStore`, which needs the async-context - // binding that `initOpenTelemetry()` registers after integration `setupOnce` — - // defer until it's available (matches the native redis diagnostics-channel subscriber). - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan(commandChannel, startIORedisCommandSpan, { - // ioredis' `requireParentSpan` default: only create a span under an active span. - requiresParentSpan: true, - beforeSpanEnd(span, data) { - if ('error' in data || !responseHook) { - return; - } - const command = data.arguments?.[0] as RedisCommand | undefined; - if (command) { - runResponseHook(responseHook, span, command, data.result); - } - }, - }); - - bindTracingChannelToSpan( - connectChannel, - data => { - const { host, port } = getConnectionOptions(data.self); - return startInactiveSpan({ - name: 'connect', - op: 'db', - attributes: { ...connectionAttributes(host, port), [DB_STATEMENT]: 'connect' }, - }); - }, - { requiresParentSpan: true }, - ); + bindTracingChannelToSpan( + connectChannel, + data => { + const { host, port } = getConnectionOptions(data.self); + return startInactiveSpan({ + name: 'connect', + op: 'db', + attributes: { ...connectionAttributes(host, port), [DB_STATEMENT]: 'connect' }, }); }, + { requiresParentSpan: true }, + ); +} + +const _ioredisChannelIntegration = ((options: IORedisChannelIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, ioredisModuleNames, instrumentIORedis, [options]); + }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/kafkajs/index.ts b/packages/server-utils/src/integrations/tracing-channel/kafkajs/index.ts index a1add0e544b3..35a2efd47a13 100644 --- a/packages/server-utils/src/integrations/tracing-channel/kafkajs/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/kafkajs/index.ts @@ -1,98 +1,16 @@ -import * as diagnosticsChannel from 'node:diagnostics_channel'; -import type { TracingChannelSubscribers } from 'node:diagnostics_channel'; -import type { IntegrationFn, Span } from '@sentry/core'; -import { debug, defineIntegration } from '@sentry/core'; -import { DEBUG_BUILD } from '../../../debug-build'; -import { CHANNELS } from '../../../orchestrion/channels'; -import { isWrappedConsumerCallback, wrapEachBatch, wrapEachMessage } from './consumer'; -import { applyErrorToSpans, startProducerSpan } from './spans'; -import type { ConsumerRunConfig, ProducerBatch } from './types'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { invokeOrchestrionInstrumentation } from '../../../orchestrion/instrumentation'; +import { instrumentKafkajs } from './instrumentation'; +import { kafkajsModuleNames } from '../../../orchestrion/config/kafkajs'; -// NOTE: this uses the same name as the OTel `Kafka` integration by design. When enabled, the OTel -// integration is omitted from the default set (see `experimentalUseDiagnosticsChannelInjection`). const INTEGRATION_NAME = 'Kafka' as const; -/** The tracing-channel context the transform attaches around `messageProducer.js`'s `sendBatch`. */ -interface SendBatchChannelContext { - // `arguments[0]` is the `{ topicMessages }` batch (kafkajs normalizes `send` into `sendBatch`). - arguments: [ProducerBatch?, ...unknown[]]; - error?: unknown; - // The producer spans opened at `start`, ended on `asyncEnd` (and marked errored on `error`). - _sentrySpans?: Span[]; -} - -/** The tracing-channel context the transform attaches around `consumer/index.js`'s `run`. */ -interface ConsumerRunChannelContext { - // `arguments[0]` is the `run(config)` config whose `eachMessage`/`eachBatch` we swap in place. - arguments: [ConsumerRunConfig?, ...unknown[]]; -} - -function subscribeToProducer(): void { - const channel = diagnosticsChannel.tracingChannel(CHANNELS.KAFKAJS_SEND_BATCH); - // Node types `subscribe` as requiring every lifecycle handler; runtime accepts a partial set, so we - // pass only the ones we use (matching `bindTracingChannelToSpan`'s handling in `tracing-channel.ts`). - const subscribers: Partial> = { - start(ctx) { - const spans: Span[] = []; - // `startProducerSpan` mutates each message's headers; doing it at `start` means the mutation - // reaches the real call, propagating the producer's trace to consumers. - (ctx.arguments[0]?.topicMessages ?? []).forEach(topicMessage => { - topicMessage.messages.forEach(message => { - spans.push(startProducerSpan(topicMessage.topic, message)); - }); - }); - ctx._sentrySpans = spans; - }, - error(ctx) { - if (ctx._sentrySpans) { - applyErrorToSpans(ctx._sentrySpans, ctx.error); - } - }, - asyncEnd(ctx) { - // `asyncEnd` fires on both success and failure; `error` (above) has already set the status. - ctx._sentrySpans?.forEach(span => span.end()); - }, - }; - channel.subscribe(subscribers as TracingChannelSubscribers); -} - -function subscribeToConsumer(): void { - const channel = diagnosticsChannel.tracingChannel(CHANNELS.KAFKAJS_CONSUMER_RUN); - const subscribers: Partial> = { - start(ctx) { - const config = ctx.arguments[0]; - if (!config || typeof config !== 'object') { - return; - } - // Swap the user callbacks for span-creating wrappers before `run` destructures its config. The - // `isWrappedConsumerCallback` guard keeps this idempotent: a config object reused across another - // `run` (or a second consumer) must not have its wrapper wrapped again, which would double spans. - if (typeof config.eachMessage === 'function' && !isWrappedConsumerCallback(config.eachMessage)) { - config.eachMessage = wrapEachMessage(config.eachMessage); - } - if (typeof config.eachBatch === 'function' && !isWrappedConsumerCallback(config.eachBatch)) { - config.eachBatch = wrapEachBatch(config.eachBatch); - } - }, - }; - channel.subscribe(subscribers as TracingChannelSubscribers); -} - const _kafkajsChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && - debug.log( - `[orchestrion:kafkajs] subscribing to channels "${CHANNELS.KAFKAJS_SEND_BATCH}", "${CHANNELS.KAFKAJS_CONSUMER_RUN}"`, - ); - - subscribeToProducer(); - subscribeToConsumer(); + setup(client) { + invokeOrchestrionInstrumentation(client, kafkajsModuleNames, instrumentKafkajs, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/kafkajs/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/kafkajs/instrumentation.ts new file mode 100644 index 000000000000..fdc209a2d0d4 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/kafkajs/instrumentation.ts @@ -0,0 +1,78 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { TracingChannelSubscribers } from 'node:diagnostics_channel'; +import type { Span } from '@sentry/core'; +import { CHANNELS } from '../../../orchestrion/channels'; +import { isWrappedConsumerCallback, wrapEachBatch, wrapEachMessage } from './consumer'; +import { applyErrorToSpans, startProducerSpan } from './spans'; +import type { ConsumerRunConfig, ProducerBatch } from './types'; + +/** The tracing-channel context the transform attaches around `messageProducer.js`'s `sendBatch`. */ +interface SendBatchChannelContext { + // `arguments[0]` is the `{ topicMessages }` batch (kafkajs normalizes `send` into `sendBatch`). + arguments: [ProducerBatch?, ...unknown[]]; + error?: unknown; + // The producer spans opened at `start`, ended on `asyncEnd` (and marked errored on `error`). + _sentrySpans?: Span[]; +} + +/** The tracing-channel context the transform attaches around `consumer/index.js`'s `run`. */ +interface ConsumerRunChannelContext { + // `arguments[0]` is the `run(config)` config whose `eachMessage`/`eachBatch` we swap in place. + arguments: [ConsumerRunConfig?, ...unknown[]]; +} + +function subscribeToProducer(): void { + const channel = diagnosticsChannel.tracingChannel(CHANNELS.KAFKAJS_SEND_BATCH); + // Node types `subscribe` as requiring every lifecycle handler; runtime accepts a partial set, so we + // pass only the ones we use (matching `bindTracingChannelToSpan`'s handling in `tracing-channel.ts`). + const subscribers: Partial> = { + start(ctx) { + const spans: Span[] = []; + // `startProducerSpan` mutates each message's headers; doing it at `start` means the mutation + // reaches the real call, propagating the producer's trace to consumers. + (ctx.arguments[0]?.topicMessages ?? []).forEach(topicMessage => { + topicMessage.messages.forEach(message => { + spans.push(startProducerSpan(topicMessage.topic, message)); + }); + }); + ctx._sentrySpans = spans; + }, + error(ctx) { + if (ctx._sentrySpans) { + applyErrorToSpans(ctx._sentrySpans, ctx.error); + } + }, + asyncEnd(ctx) { + // `asyncEnd` fires on both success and failure; `error` (above) has already set the status. + ctx._sentrySpans?.forEach(span => span.end()); + }, + }; + channel.subscribe(subscribers as TracingChannelSubscribers); +} + +function subscribeToConsumer(): void { + const channel = diagnosticsChannel.tracingChannel(CHANNELS.KAFKAJS_CONSUMER_RUN); + const subscribers: Partial> = { + start(ctx) { + const config = ctx.arguments[0]; + if (!config || typeof config !== 'object') { + return; + } + // Swap the user callbacks for span-creating wrappers before `run` destructures its config. The + // `isWrappedConsumerCallback` guard keeps this idempotent: a config object reused across another + // `run` (or a second consumer) must not have its wrapper wrapped again, which would double spans. + if (typeof config.eachMessage === 'function' && !isWrappedConsumerCallback(config.eachMessage)) { + config.eachMessage = wrapEachMessage(config.eachMessage); + } + if (typeof config.eachBatch === 'function' && !isWrappedConsumerCallback(config.eachBatch)) { + config.eachBatch = wrapEachBatch(config.eachBatch); + } + }, + }; + channel.subscribe(subscribers as TracingChannelSubscribers); +} + +export function instrumentKafkajs(): void { + subscribeToProducer(); + subscribeToConsumer(); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/knex.ts b/packages/server-utils/src/integrations/tracing-channel/knex.ts index 68f08edbf6f1..404a8ed6e0b0 100644 --- a/packages/server-utils/src/integrations/tracing-channel/knex.ts +++ b/packages/server-utils/src/integrations/tracing-channel/knex.ts @@ -5,7 +5,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { - debug, defineIntegration, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -13,7 +12,6 @@ import { SPAN_STATUS_ERROR, startInactiveSpan, truncate, - waitForTracingChannelBinding, } from '@sentry/core'; import { DB_NAME, @@ -25,9 +23,10 @@ import { NET_PEER_PORT, NET_TRANSPORT, } from '@sentry/conventions/attributes'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { knexModuleNames } from '../../orchestrion/config/knex'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; // NOTE: this uses the same name as the OTel integration by design. `@sentry/node`'s `knexIntegration` // picks this subscriber over the vendored OTel path when orchestrion injection is active. @@ -100,23 +99,18 @@ interface KnexBuilderChannelContext { result?: KnexBuilder; } +function instrumentKnex() { + subscribeBuilder(CHANNELS.KNEX_QUERY_BUILDER); + subscribeBuilder(CHANNELS.KNEX_SCHEMA_BUILDER); + subscribeBuilder(CHANNELS.KNEX_RAW); + subscribeQuery(); +} + const _knexChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && debug.log(`[orchestrion:knex] subscribing to channel "${CHANNELS.KNEX_QUERY}"`); - - waitForTracingChannelBinding(() => { - subscribeBuilder(CHANNELS.KNEX_QUERY_BUILDER); - subscribeBuilder(CHANNELS.KNEX_SCHEMA_BUILDER); - subscribeBuilder(CHANNELS.KNEX_RAW); - subscribeQuery(); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, knexModuleNames, instrumentKnex, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts index 982e5200b4ef..80b036e24bcb 100644 --- a/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts +++ b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts @@ -1,9 +1,10 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; +import { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { lruMemoizerModuleNames } from '../../orchestrion/config/lru-memoizer'; // Same name as the OTel integration by design — when enabled, the OTel // 'LruMemoizer' integration is omitted from the default set. @@ -13,24 +14,19 @@ interface LruMemoizerLoadContext { arguments: unknown[]; } +function instrumentLruMemoizer() { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD), + // We only want the helper's caller-context restore for the callback lru-memoizer fires from a detached `setImmediate`. + () => undefined, + ); +} + const _lruMemoizerChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && debug.log(`[orchestrion:lru-memoizer] subscribing to channel "${CHANNELS.LRU_MEMOIZER_LOAD}"`); - - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD), - // We only want the helper's caller-context restore for the callback lru-memoizer fires from a detached `setImmediate`. - () => undefined, - ); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, lruMemoizerModuleNames, instrumentLruMemoizer, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql.ts b/packages/server-utils/src/integrations/tracing-channel/mysql.ts index 9e96e23e3900..4a13d5eb00ba 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql.ts @@ -3,17 +3,16 @@ import type { IntegrationFn, Scope } from '@sentry/core'; import { isObjectLike, bindScopeToEmitter, - debug, defineIntegration, getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { mysqlModuleNames } from '../../orchestrion/config/mysql'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, OTel 'Mysql' integration is omitted from the default set. @@ -58,69 +57,64 @@ interface MysqlConnection { config?: MysqlConnectionConfig; } +function instrumentMysql() { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.MYSQL_QUERY), + data => { + const sql = extractSql(data.arguments[0]); + const { host, port, database, user } = getConnectionConfig(data.self); + const portNumber = typeof port === 'string' ? parseInt(port, 10) : port; + const portIsNumber = typeof portNumber === 'number' && !isNaN(portNumber); + + // For the streamed path: mysql emits the `Query` emitter's events from its socket data + // handler with the caller's context lost. `deferSpanEnd` replays this scope onto the emitter. + data._sentryCallerScope = getCurrentScope(); + + return startInactiveSpan({ + name: sql ?? 'mysql.query', + kind: SPAN_KIND.CLIENT, + op: 'db', + attributes: { + [ATTR_DB_SYSTEM]: 'mysql', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.orchestrion.mysql', + [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database), + ...(database ? { [ATTR_DB_NAME]: database } : {}), + ...(user ? { [ATTR_DB_USER]: user } : {}), + ...(sql ? { [ATTR_DB_STATEMENT]: sql } : {}), + ...(host ? { [ATTR_NET_PEER_NAME]: host } : {}), + ...(portIsNumber ? { [ATTR_NET_PEER_PORT]: portNumber } : {}), + }, + }); + }, + { + // No-callback `query(sql)` returns a streamable `Query` emitter as `result`; it settles on the + // emitter's `'end'`/`'error'`, not the channel, so defer ending to those. + deferSpanEnd({ data, end }) { + const result = data.result; + if (!result || typeof result !== 'object' || !hasOnMethod(result)) { + return false; + } + + // Replay the caller's scope so user listeners on the emitter nest under it, not a new trace. + const callerScope = data._sentryCallerScope; + if (callerScope) { + bindScopeToEmitter(result, callerScope); + } + + result.on('error', err => end(err)); + result.on('end', () => end()); + + return true; + }, + }, + ); +} + const _mysqlChannelIntegration = (() => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && debug.log(`[orchestrion:mysql] subscribing to channel "${CHANNELS.MYSQL_QUERY}"`); - - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.MYSQL_QUERY), - data => { - const sql = extractSql(data.arguments[0]); - const { host, port, database, user } = getConnectionConfig(data.self); - const portNumber = typeof port === 'string' ? parseInt(port, 10) : port; - const portIsNumber = typeof portNumber === 'number' && !isNaN(portNumber); - - // For the streamed path: mysql emits the `Query` emitter's events from its socket data - // handler with the caller's context lost. `deferSpanEnd` replays this scope onto the emitter. - data._sentryCallerScope = getCurrentScope(); - - return startInactiveSpan({ - name: sql ?? 'mysql.query', - kind: SPAN_KIND.CLIENT, - op: 'db', - attributes: { - [ATTR_DB_SYSTEM]: 'mysql', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.orchestrion.mysql', - [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database), - ...(database ? { [ATTR_DB_NAME]: database } : {}), - ...(user ? { [ATTR_DB_USER]: user } : {}), - ...(sql ? { [ATTR_DB_STATEMENT]: sql } : {}), - ...(host ? { [ATTR_NET_PEER_NAME]: host } : {}), - ...(portIsNumber ? { [ATTR_NET_PEER_PORT]: portNumber } : {}), - }, - }); - }, - { - // No-callback `query(sql)` returns a streamable `Query` emitter as `result`; it settles on the - // emitter's `'end'`/`'error'`, not the channel, so defer ending to those. - deferSpanEnd({ data, end }) { - const result = data.result; - if (!result || typeof result !== 'object' || !hasOnMethod(result)) { - return false; - } - - // Replay the caller's scope so user listeners on the emitter nest under it, not a new trace. - const callerScope = data._sentryCallerScope; - if (callerScope) { - bindScopeToEmitter(result, callerScope); - } - - result.on('error', err => end(err)); - result.on('end', () => end()); - - return true; - }, - }, - ); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, mysqlModuleNames, instrumentMysql, []); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts index 9e59f521d363..bf7c70ffe497 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -2,12 +2,12 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; import { defineIntegration, + extendIntegration, isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; import { subscribeMysql2DiagnosticChannels } from '../../mysql2/mysql2-dc-subscriber'; import type { ChannelName } from '../../orchestrion/channels'; @@ -21,6 +21,9 @@ import { NET_PEER_NAME, NET_PEER_PORT, } from '@sentry/conventions/attributes'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { mysql2ModuleNames } from '../../orchestrion/config/mysql2'; +import { mysql2Integration } from '../../mysql2'; const INTEGRATION_NAME = 'Mysql2' as const; const ORIGIN = 'auto.db.orchestrion.mysql2'; @@ -135,19 +138,11 @@ function getConnectionAttributes(config: Mysql2ConnectionConfig | undefined): Sp } const _mysql2ChannelIntegration = (() => { - return { - name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - instrumentMysql2(); - }); + return extendIntegration(mysql2Integration(), { + setup(client) { + invokeOrchestrionInstrumentation(client, mysql2ModuleNames, instrumentMysql2, []); }, - }; + }); }) satisfies IntegrationFn; /** diff --git a/packages/server-utils/src/integrations/tracing-channel/openai.ts b/packages/server-utils/src/integrations/tracing-channel/openai.ts index b172c6924300..8ebc5ee182e1 100644 --- a/packages/server-utils/src/integrations/tracing-channel/openai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/openai.ts @@ -4,7 +4,6 @@ import { _INTERNAL_shouldSkipAiProviderWrapping, addOpenAiRequestAttributes, addOpenAiResponseAttributes, - debug, defineIntegration, extractOpenAiRequestAttributes, instrumentOpenAiStream, @@ -12,11 +11,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, shouldEnableTruncation, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { openaiModuleNames } from '../../orchestrion/config/openai'; // Same name as the OTel integration by design: when enabled, the OTel 'OpenAI' // integration is dropped from the default set (see the Node opt-in loader). @@ -42,36 +41,27 @@ interface OpenAiChatChannelContext { result?: unknown; } -let subscribed = false; +function instrumentOpenAi(options: OpenAiOptions) { + for (const { channel, operation } of INSTRUMENTED_CHANNELS) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channel), + data => createGenAiSpan(data, operation, options), + { + beforeSpanEnd: (span, data) => { + addOpenAiResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); + }, + // Streaming: the result is a `Stream` consumed later, so instrument it and let it end the span. + deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), + }, + ); + } +} const _openaiChannelIntegration = ((options: OpenAiOptions = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe. - if (!diagnosticsChannel.tracingChannel || subscribed) { - return; - } - subscribed = true; - - // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers - // after `setupOnce` runs, so wait for it before subscribing. - waitForTracingChannelBinding(() => { - for (const { channel, operation } of INSTRUMENTED_CHANNELS) { - DEBUG_BUILD && debug.log(`[orchestrion:openai] subscribing to channel "${channel}"`); - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(channel), - data => createGenAiSpan(data, operation, options), - { - beforeSpanEnd: (span, data) => { - addOpenAiResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); - }, - // Streaming: the result is a `Stream` consumed later, so instrument it and let it end the span. - deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), - }, - ); - } - }); + setup(client) { + invokeOrchestrionInstrumentation(client, openaiModuleNames, instrumentOpenAi, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/postgres-js.ts b/packages/server-utils/src/integrations/tracing-channel/postgres-js.ts index 0a2440937643..4258498e4c86 100644 --- a/packages/server-utils/src/integrations/tracing-channel/postgres-js.ts +++ b/packages/server-utils/src/integrations/tracing-channel/postgres-js.ts @@ -13,11 +13,12 @@ import { SPAN_KIND, SPAN_STATUS_ERROR, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { postgresJsModuleNames } from '../../orchestrion/config/postgres'; // Same name as the OTel `PostgresJs` integration by design: when this is // enabled, the OTel integration of the same name is dropped from the default @@ -196,130 +197,123 @@ function wrapQuerySettlement(data: PostgresJsQueryContext, span: Span, sanitized } } -const _postgresJsChannelIntegration = ((options: PostgresJsChannelIntegrationOptions = {}) => { +function instrumentPostgresJs(options: PostgresJsChannelIntegrationOptions) { const { requireParentSpan, requestHook } = options; - return { - name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19. - if (!diagnosticsChannel.tracingChannel) { - return; + // Connection + execute are pure observers (no span, no async binding), so + // subscribe immediately — factory-time `Connection()` calls happen before + // `waitForTracingChannelBinding` resolves and must still be recorded. + diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_CONNECTION).subscribe({ + start: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + end: recordConnectionFromChannel, + }); + + // Per-connection attributes for queries reusing an already-open connection + // (`c.execute(q)`, `self === c`). `execute` is also called bare + // (`self === undefined`) for the first query on each connection, `fetchState` + // and `retry`; those miss here (the `connect` channel below covers the first + // user query, and the single-endpoint fallback covers the common case). + diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_EXECUTE).subscribe({ + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + start: attachConnectionAttributesFromChannel, + }); + + // The connection's `connect(query)` method (`self === c`, `arguments[0]` the + // query) fires when a fresh connection is opened for a query. That first query + // is later dispatched via a bare `execute` (no `self`), so this is where it + // gets its connection attributes in multi-endpoint apps. + diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_CONNECT).subscribe({ + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + start: attachConnectionAttributesFromChannel, + }); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_HANDLE), + data => { + const query = data.self; + if (!query) { + return undefined; } - DEBUG_BUILD && debug.log(`[orchestrion:postgresjs] subscribing to "${CHANNELS.POSTGRESJS_HANDLE}"`); - - // Connection + execute are pure observers (no span, no async binding), so - // subscribe immediately — factory-time `Connection()` calls happen before - // `waitForTracingChannelBinding` resolves and must still be recorded. - diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_CONNECTION).subscribe({ - start: NOOP, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, - end: recordConnectionFromChannel, - }); + // Opt out of re-entrant `handle()` calls (then/catch/finally re-invoke it, guarded by + // `executed`) and queries already wrapped by the portable `instrumentPostgresJsSql`. The + // parent-span requirement is applied via `requiresParentSpan` below. + if (query.executed === true || (query as Record)[QUERY_FROM_INSTRUMENTED_SQL]) { + return undefined; + } - // Per-connection attributes for queries reusing an already-open connection - // (`c.execute(q)`, `self === c`). `execute` is also called bare - // (`self === undefined`) for the first query on each connection, `fetchState` - // and `retry`; those miss here (the `connect` channel below covers the first - // user query, and the single-endpoint fallback covers the common case). - diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_EXECUTE).subscribe({ - end: NOOP, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, - start: attachConnectionAttributesFromChannel, + const fullQuery = _INTERNAL_reconstructPostgresQuery(query.strings); + const sanitizedSqlQuery = _INTERNAL_sanitizeSqlQuery(fullQuery); + + // `kind: CLIENT` matches the mysql/pg channel subscribers. + const span = startInactiveSpan({ + name: sanitizedSqlQuery || 'postgresjs.query', + op: 'db', + kind: SPAN_KIND.CLIENT, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [DB_SYSTEM_NAME]: 'postgres', + [DB_QUERY_TEXT]: sanitizedSqlQuery, + }, }); - // The connection's `connect(query)` method (`self === c`, `arguments[0]` the - // query) fires when a fresh connection is opened for a query. That first query - // is later dispatched via a bare `execute` (no `self`), so this is where it - // gets its connection attributes in multi-endpoint apps. - diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_CONNECT).subscribe({ - end: NOOP, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, - start: attachConnectionAttributesFromChannel, - }); + // Stash for the `execute`/`connect` channels to attach per-connection attributes. + (query as Record)[QUERY_SPAN] = span; - // The span-creating `handle` subscription needs the async-context binding - // that `initOpenTelemetry()` registers after integration setup. - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.POSTGRESJS_HANDLE), - data => { - const query = data.self; - if (!query) { - return undefined; - } - - // Opt out of re-entrant `handle()` calls (then/catch/finally re-invoke it, guarded by - // `executed`) and queries already wrapped by the portable `instrumentPostgresJsSql`. The - // parent-span requirement is applied via `requiresParentSpan` below. - if (query.executed === true || (query as Record)[QUERY_FROM_INSTRUMENTED_SQL]) { - return undefined; - } - - const fullQuery = _INTERNAL_reconstructPostgresQuery(query.strings); - const sanitizedSqlQuery = _INTERNAL_sanitizeSqlQuery(fullQuery); - - // `kind: CLIENT` matches the mysql/pg channel subscribers. - const span = startInactiveSpan({ - name: sanitizedSqlQuery || 'postgresjs.query', - op: 'db', - kind: SPAN_KIND.CLIENT, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [DB_SYSTEM_NAME]: 'postgres', - [DB_QUERY_TEXT]: sanitizedSqlQuery, - }, - }); - - // Stash for the `execute`/`connect` channels to attach per-connection attributes. - (query as Record)[QUERY_SPAN] = span; - - // Single-endpoint fallback: resolve context now so `requestHook` has it - // and the first-query-per-connection (bare `execute`) path still gets attrs. - const context = resolveSingleEndpoint(); - if (context) { - setConnectionAttributes(span, query, context); - } - - if (requestHook) { - try { - requestHook(span, sanitizedSqlQuery, context); - } catch (e) { - span.setAttribute('sentry.hook.error', 'requestHook failed'); - DEBUG_BUILD && debug.error('[orchestrion:postgresjs] error in requestHook:', e); - } - } - - wrapQuerySettlement(data, span, sanitizedSqlQuery); - - return span; - }, - { - requiresParentSpan: requireParentSpan !== false, - deferSpanEnd({ data }) { - // `handle` is async: its promise settles on dispatch (asyncEnd), long - // before the query does. The resolve/reject wrappers own the ending. - if ((data as Record)[SPAN_ENDED]) { - return true; // wrappers already ended it - } - if ('error' in data) { - return false; // `handle()` itself threw; the error subscriber annotated the span, let the helper end it - } - // NOTE: for a cursor consumed as an async iterator, only the first batch - // reaches `handle` (the `executed` guard blocks the rest), so the span - // ends on the first batch — a pre-existing flaw kept for parity. - return true; // query in flight; the wrappers will end the span when it settles - }, - }, - ); - }); + // Single-endpoint fallback: resolve context now so `requestHook` has it + // and the first-query-per-connection (bare `execute`) path still gets attrs. + const context = resolveSingleEndpoint(); + if (context) { + setConnectionAttributes(span, query, context); + } + + if (requestHook) { + try { + requestHook(span, sanitizedSqlQuery, context); + } catch (e) { + span.setAttribute('sentry.hook.error', 'requestHook failed'); + DEBUG_BUILD && debug.error('[orchestrion:postgresjs] error in requestHook:', e); + } + } + + wrapQuerySettlement(data, span, sanitizedSqlQuery); + + return span; + }, + { + requiresParentSpan: requireParentSpan !== false, + deferSpanEnd({ data }) { + // `handle` is async: its promise settles on dispatch (asyncEnd), long + // before the query does. The resolve/reject wrappers own the ending. + if ((data as Record)[SPAN_ENDED]) { + return true; // wrappers already ended it + } + if ('error' in data) { + return false; // `handle()` itself threw; the error subscriber annotated the span, let the helper end it + } + // NOTE: for a cursor consumed as an async iterator, only the first batch + // reaches `handle` (the `executed` guard blocks the rest), so the span + // ends on the first batch — a pre-existing flaw kept for parity. + return true; // query in flight; the wrappers will end the span when it settles + }, + }, + ); +} + +const _postgresJsChannelIntegration = ((options: PostgresJsChannelIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, postgresJsModuleNames, instrumentPostgresJs, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/postgres.ts b/packages/server-utils/src/integrations/tracing-channel/postgres.ts index 9c69bdb5140b..85f53c4177fe 100644 --- a/packages/server-utils/src/integrations/tracing-channel/postgres.ts +++ b/packages/server-utils/src/integrations/tracing-channel/postgres.ts @@ -3,17 +3,16 @@ import type { IntegrationFn, Scope, SpanAttributes } from '@sentry/core'; import { isObjectLike, bindScopeToEmitter, - debug, defineIntegration, getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan, - waitForTracingChannelBinding, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { pgModuleNames } from '../../orchestrion/config/pg'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Postgres' integration is omitted from the default set. @@ -79,28 +78,25 @@ interface PgPoolOptions extends PgConnectionParams { max?: number; } +function instrumentPg(options: { ignoreConnectSpans?: boolean }) { + // Query spans: `pg`/native `Client.prototype.query`. Only this channel can return a streamable + // `Submittable` result, so it's the only one that defers span-ending to the emitter (see below). + subscribeQueryLikeChannel(CHANNELS.PG_QUERY, querySpanOptions, { deferStreamedResult: true }); + + // Connect spans, gated by `ignoreConnectSpans` (same as OTel pg). + // `Client.prototype.connect` (pg + native) + // and `Pool.prototype.connect` (pg-pool). + if (!options.ignoreConnectSpans) { + subscribeQueryLikeChannel(CHANNELS.PG_CONNECT, connectSpanOptions); + subscribeQueryLikeChannel(CHANNELS.PGPOOL_CONNECT, poolConnectSpanOptions); + } +} + const _postgresChannelIntegration = ((options: { ignoreConnectSpans?: boolean } = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. - if (!diagnosticsChannel.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - // Query spans: `pg`/native `Client.prototype.query`. Only this channel can return a streamable - // `Submittable` result, so it's the only one that defers span-ending to the emitter (see below). - subscribeQueryLikeChannel(CHANNELS.PG_QUERY, querySpanOptions, { deferStreamedResult: true }); - - // Connect spans, gated by `ignoreConnectSpans` (same as OTel pg). - // `Client.prototype.connect` (pg + native) - // and `Pool.prototype.connect` (pg-pool). - if (!options.ignoreConnectSpans) { - subscribeQueryLikeChannel(CHANNELS.PG_CONNECT, connectSpanOptions); - subscribeQueryLikeChannel(CHANNELS.PGPOOL_CONNECT, poolConnectSpanOptions); - } - }); + setup(client) { + invokeOrchestrionInstrumentation(client, pgModuleNames, instrumentPg, [options]); }, }; }) satisfies IntegrationFn; @@ -116,8 +112,6 @@ function subscribeQueryLikeChannel( getSpanOptions: (ctx: PgChannelContext) => { name: string; op: string; attributes: SpanAttributes }, { deferStreamedResult = false }: { deferStreamedResult?: boolean } = {}, ): void { - DEBUG_BUILD && debug.log(`[orchestrion:pg] subscribing to channel "${channelName}"`); - bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(channelName), data => { diff --git a/packages/server-utils/src/integrations/tracing-channel/redis.ts b/packages/server-utils/src/integrations/tracing-channel/redis.ts index 6a1d5e4d779e..207c5e5b23bf 100644 --- a/packages/server-utils/src/integrations/tracing-channel/redis.ts +++ b/packages/server-utils/src/integrations/tracing-channel/redis.ts @@ -15,21 +15,20 @@ import { import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { isObjectLike, - debug, - defineIntegration, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, SPAN_STATUS_ERROR, startInactiveSpan, - waitForTracingChannelBinding, withActiveSpan, + defineIntegration, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { defaultDbStatementSerializer } from '../../redis/redis-statement-serializer'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { redisModuleNames } from '../../orchestrion/config/redis'; // A distinct name from the composite OTel `Redis` integration — they can't share one, and // `Redis` stays in the set for its native diagnostics_channel subscriber (node-redis >=5.12 / @@ -311,33 +310,25 @@ function bindNodeRedisBatchChannel(channelName: string, getOperation: (data: Com ); } -const _redisChannelIntegration = ((options: RedisChannelIntegrationOptions = {}) => { +function instrumentRedis(options: RedisChannelIntegrationOptions) { const responseHook = options.responseHook; + subscribeLegacyRedisCommand(responseHook); + bindNodeRedisCommandChannel(CHANNELS.NODE_REDIS_COMMAND, getSendCommandArgs, responseHook); + bindNodeRedisCommandChannel(CHANNELS.NODE_REDIS_EXECUTOR, getExecutorArgs, responseHook); + bindNodeRedisConnectChannel(); + bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_MULTI, () => 'MULTI'); + bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_PIPELINE, () => 'PIPELINE'); + bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_BATCH, data => + data.arguments?.[2] !== undefined ? 'MULTI' : 'PIPELINE', + ); +} + +const _redisChannelIntegration = ((options: RedisChannelIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - if (!diagnosticsChannel.tracingChannel) { - return; - } - - DEBUG_BUILD && - debug.log(`[orchestrion:redis] subscribing to "${CHANNELS.REDIS_COMMAND}" and node-redis channels`); - - // redis v2-v3 uses a nested callback rather than `bindStore`, so it can be - // subscribed synchronously here. - subscribeLegacyRedisCommand(responseHook); - - waitForTracingChannelBinding(() => { - bindNodeRedisCommandChannel(CHANNELS.NODE_REDIS_COMMAND, getSendCommandArgs, responseHook); - bindNodeRedisCommandChannel(CHANNELS.NODE_REDIS_EXECUTOR, getExecutorArgs, responseHook); - bindNodeRedisConnectChannel(); - bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_MULTI, () => 'MULTI'); - bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_PIPELINE, () => 'PIPELINE'); - bindNodeRedisBatchChannel(CHANNELS.NODE_REDIS_BATCH, data => - data.arguments?.[2] !== undefined ? 'MULTI' : 'PIPELINE', - ); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, redisModuleNames, instrumentRedis, [options]); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/integrations/tracing-channel/vercel-ai.ts b/packages/server-utils/src/integrations/tracing-channel/vercel-ai.ts index 72ef44bcbd86..1d40479eed01 100644 --- a/packages/server-utils/src/integrations/tracing-channel/vercel-ai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/vercel-ai.ts @@ -1,8 +1,9 @@ import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; import { vercelAiIntegration as baseVercelAiIntegration } from '../../vercel-ai'; -import * as dc from 'node:diagnostics_channel'; -import { subscribeVercelAiOrchestrionChannels } from '../../vercel-ai/vercel-ai-orchestrion-subscriber'; +import { instrumentVercelAi } from '../../vercel-ai/vercel-ai-orchestrion-subscriber'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { vercelAiModuleNames } from '../../orchestrion/config/vercel-ai'; type VercelAiOptions = Parameters[0]; @@ -13,21 +14,13 @@ type VercelAiOptions = Parameters[0]; // subscriber suppresses them at the source: it flips the wrapped call's // `experimental_telemetry.isEnabled` to `false`, so `ai` falls back to its // internal no-op tracer and never creates the native spans in the first place. -// See `subscribeVercelAiOrchestrionChannels`. const _vercelAiChannelIntegration = ((options: VercelAiOptions = {}) => { const parentIntegration = baseVercelAiIntegration(options); return extendIntegration(parentIntegration, { options, - setupOnce() { - // Bail if this is not available - if (!dc.tracingChannel) { - return; - } - - waitForTracingChannelBinding(() => { - subscribeVercelAiOrchestrionChannels(dc.tracingChannel, options); - }); + setup(client) { + invokeOrchestrionInstrumentation(client, vercelAiModuleNames, instrumentVercelAi, [options]); }, }); }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index d51de03ceb95..24a5ae35da0c 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -23,8 +23,6 @@ import { nestjsChannels } from './config/nestjs'; import { openaiChannels } from './config/openai'; import { pgChannels } from './config/pg'; import { postgresJsChannels } from './config/postgres'; -import { prismaChannels } from './config/prisma'; -import { reactRouterChannels } from './config/react-router'; import { redisChannels } from './config/redis'; import { remixChannels } from './config/remix'; import { tediousChannels } from './config/tedious'; @@ -72,8 +70,6 @@ export const CHANNELS = { ...openaiChannels, ...pgChannels, ...postgresJsChannels, - ...prismaChannels, - ...reactRouterChannels, ...redisChannels, ...remixChannels, ...tediousChannels, diff --git a/packages/server-utils/src/orchestrion/config/amqplib.ts b/packages/server-utils/src/orchestrion/config/amqplib.ts index 5f2082859347..893a376c7ffe 100644 --- a/packages/server-utils/src/orchestrion/config/amqplib.ts +++ b/packages/server-utils/src/orchestrion/config/amqplib.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; // `amqplib` splits its API across three files: // - `lib/channel_model.js` holds `class Channel` (publish/consume/ack/nack/reject/…) and @@ -73,7 +74,9 @@ export const amqplibConfig = [ module: { ...module, filePath: 'lib/connect.js' }, functionQuery: { functionName: 'connect', kind: 'Callback' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const amqplibModuleNames = uniq(amqplibConfig.map(config => config.module.name)); export const amqplibChannels = { AMQPLIB_PUBLISH: 'orchestrion:amqplib:publish', diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts index 0b5f0e0ecf10..9193dce701fe 100644 --- a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts +++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const anthropicAiConfig = [ // One entry each for CJS/ESM @@ -31,7 +32,9 @@ export const anthropicAiConfig = [ module: { name: '@anthropic-ai/sdk', versionRange: '>=0.19.2 <1', filePath }, functionQuery: { className: 'Messages', methodName: 'stream', kind: 'Sync' as const }, })), -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const anthropicAiModuleNames = uniq(anthropicAiConfig.map(config => config.module.name)); export const anthropicAiChannels = { ANTHROPIC_CHAT: 'orchestrion:@anthropic-ai/sdk:chat', diff --git a/packages/server-utils/src/orchestrion/config/dataloader.ts b/packages/server-utils/src/orchestrion/config/dataloader.ts index cd1f0879bdc3..3d192542137b 100644 --- a/packages/server-utils/src/orchestrion/config/dataloader.ts +++ b/packages/server-utils/src/orchestrion/config/dataloader.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; // `dataloader` ships a single transpiled CommonJS `index.js`. Its class methods are emitted as // `_proto. = function () {}` (named function *expressions*), so they match on @@ -43,7 +44,9 @@ export const dataloaderConfig = [ module, functionQuery: { expressionName: 'clearAll', kind: 'Sync' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const dataloaderModuleNames = uniq(dataloaderConfig.map(config => config.module.name)); export const dataloaderChannels = { DATALOADER_CONSTRUCT: 'orchestrion:dataloader:construct', diff --git a/packages/server-utils/src/orchestrion/config/express.ts b/packages/server-utils/src/orchestrion/config/express.ts index 81fe89947e3d..5ecfd85d8dfe 100644 --- a/packages/server-utils/src/orchestrion/config/express.ts +++ b/packages/server-utils/src/orchestrion/config/express.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const expressConfig = [ // Express funnels every middleware/route handler through a single method on @@ -57,7 +58,9 @@ export const expressConfig = [ module: { name: 'router', versionRange: '>=2.0.0 <3', filePath: 'index.js' }, functionQuery: { expressionName: 'use', kind: 'Sync' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const expressModuleNames = uniq(expressConfig.map(config => config.module.name)); export const expressChannels = { // Express v4 runs each layer's handler through `Layer.prototype.handle_request` diff --git a/packages/server-utils/src/orchestrion/config/generic-pool.ts b/packages/server-utils/src/orchestrion/config/generic-pool.ts index 724451207ea2..177a4dbf5f5f 100644 --- a/packages/server-utils/src/orchestrion/config/generic-pool.ts +++ b/packages/server-utils/src/orchestrion/config/generic-pool.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; // Two shapes of `acquire`, both publishing to the same `orchestrion:generic-pool:acquire` channel: // - v3+: `class Pool { acquire(priority) }` returns a promise, so `kind: 'Auto'` resolves to `wrapPromise`. @@ -16,7 +17,9 @@ export const genericPoolConfig = [ module: { name: 'generic-pool', versionRange: '>=2.4.0 <3', filePath: 'lib/generic-pool.js' }, functionQuery: { expressionName: 'acquire', kind: 'Callback' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const genericPoolModuleNames = uniq(genericPoolConfig.map(config => config.module.name)); export const genericPoolChannels = { GENERIC_POOL_ACQUIRE: 'orchestrion:generic-pool:acquire', diff --git a/packages/server-utils/src/orchestrion/config/google-genai.ts b/packages/server-utils/src/orchestrion/config/google-genai.ts index 693e8ba039bc..2aea712119b8 100644 --- a/packages/server-utils/src/orchestrion/config/google-genai.ts +++ b/packages/server-utils/src/orchestrion/config/google-genai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; // `@google/genai` ships one bundled file per module format and the matcher compares `filePath` exactly, // so we list every file the `node` export condition resolves to across the supported range: `index.js` @@ -31,7 +32,9 @@ export const googleGenAiConfig = [ functionQuery: { className: 'Chat', methodName, kind: 'Auto' as const }, })), ), -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const googleGenAIModuleNames = uniq(googleGenAiConfig.map(config => config.module.name)); export const googleGenAiChannels = { GOOGLE_GENAI_GENERATE_CONTENT: 'orchestrion:@google/genai:generate-content', diff --git a/packages/server-utils/src/orchestrion/config/graphql.ts b/packages/server-utils/src/orchestrion/config/graphql.ts index 13257755934c..1239f7e52f65 100644 --- a/packages/server-utils/src/orchestrion/config/graphql.ts +++ b/packages/server-utils/src/orchestrion/config/graphql.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; // `parse`/`validate`/`execute` are top-level named `function` declarations in graphql's compiled // files, stable across the supported majors, so `functionName` matches. `execute` returns @@ -19,7 +20,9 @@ export const graphqlConfig = [ module: { name: 'graphql', versionRange: '>=14.0.0 <17', filePath: 'execution/execute.js' }, functionQuery: { functionName: 'execute', kind: 'Auto' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const graphqlModuleNames = uniq(graphqlConfig.map(config => config.module.name)); export const graphqlChannels = { GRAPHQL_PARSE: 'orchestrion:graphql:parse', diff --git a/packages/server-utils/src/orchestrion/config/hapi.ts b/packages/server-utils/src/orchestrion/config/hapi.ts index 7c11aac6911a..57943ca021f9 100644 --- a/packages/server-utils/src/orchestrion/config/hapi.ts +++ b/packages/server-utils/src/orchestrion/config/hapi.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const hapiConfig = [ // hapi's `route`/`ext` live on an anonymous class (`internals.Server = class {}`), @@ -15,7 +16,9 @@ export const hapiConfig = [ module: { name: '@hapi/hapi', versionRange: '>=17.0.0 <22.0.0', filePath: 'lib/server.js' }, functionQuery: { methodName: 'ext', kind: 'Sync' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const hapiModuleNames = uniq(hapiConfig.map(config => config.module.name)); export const hapiChannels = { HAPI_ROUTE: 'orchestrion:@hapi/hapi:route', diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index ccc361c12755..5e76b0d94ce2 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -26,8 +26,6 @@ import { nestjsConfig } from './nestjs'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { postgresJsConfig } from './postgres'; -import { prismaConfig } from './prisma'; -import { reactRouterConfig } from './react-router'; import { redisConfig } from './redis'; import { remixConfig } from './remix'; import { tediousConfig } from './tedious'; @@ -68,8 +66,6 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...openaiConfig, ...pgConfig, ...postgresJsConfig, - ...prismaConfig, - ...reactRouterConfig, ...redisConfig, ...remixConfig, ...tediousConfig, diff --git a/packages/server-utils/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts index 35d2d44cd79b..4f3253c58631 100644 --- a/packages/server-utils/src/orchestrion/config/ioredis.ts +++ b/packages/server-utils/src/orchestrion/config/ioredis.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const ioredisConfig = [ // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) @@ -24,7 +25,9 @@ export const ioredisConfig = [ module: { name: 'ioredis', versionRange: '>=5.0.0 <5.11.0', filePath: 'built/Redis.js' }, functionQuery: { className: 'Redis', methodName: 'connect', kind: 'Async' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const ioredisModuleNames = uniq(ioredisConfig.map(config => config.module.name)); export const ioredisChannels = { IOREDIS_COMMAND: 'orchestrion:ioredis:command', diff --git a/packages/server-utils/src/orchestrion/config/kafkajs.ts b/packages/server-utils/src/orchestrion/config/kafkajs.ts index 0d653e0db0eb..6aa0403bd29e 100644 --- a/packages/server-utils/src/orchestrion/config/kafkajs.ts +++ b/packages/server-utils/src/orchestrion/config/kafkajs.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const kafkajsConfig = [ { @@ -19,7 +20,9 @@ export const kafkajsConfig = [ // `ctx.arguments` when invoking the original. functionQuery: { expressionName: 'run', kind: 'Async' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const kafkajsModuleNames = uniq(kafkajsConfig.map(config => config.module.name)); export const kafkajsChannels = { KAFKAJS_SEND_BATCH: 'orchestrion:kafkajs:send_batch', diff --git a/packages/server-utils/src/orchestrion/config/knex.ts b/packages/server-utils/src/orchestrion/config/knex.ts index 8c4c2d068563..82c803baf239 100644 --- a/packages/server-utils/src/orchestrion/config/knex.ts +++ b/packages/server-utils/src/orchestrion/config/knex.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; const MODULE_NAME = 'knex'; @@ -43,7 +44,9 @@ export const knexConfig: InstrumentationConfig[] = [ ...CLIENT_FILES.flatMap(({ filePath, versionRange }) => CLIENT_METHODS.map(methodName => clientMethod(methodName, filePath, versionRange)), ), -]; +] as const satisfies InstrumentationConfig[]; + +export const knexModuleNames = uniq(knexConfig.map(config => config.module.name)); export const knexChannels = { KNEX_QUERY: 'orchestrion:knex:query', diff --git a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts index e186136d05d1..8ca759109326 100644 --- a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts +++ b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const lruMemoizerConfig = [ { @@ -7,7 +8,9 @@ export const lruMemoizerConfig = [ module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' }, functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const lruMemoizerModuleNames = uniq(lruMemoizerConfig.map(config => config.module.name)); export const lruMemoizerChannels = { LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', diff --git a/packages/server-utils/src/orchestrion/config/mysql.ts b/packages/server-utils/src/orchestrion/config/mysql.ts index c686f435a9f7..e9c39e4f8435 100644 --- a/packages/server-utils/src/orchestrion/config/mysql.ts +++ b/packages/server-utils/src/orchestrion/config/mysql.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const mysqlConfig = [ { @@ -6,7 +7,9 @@ export const mysqlConfig = [ module: { name: 'mysql', versionRange: '>=2.0.0 <3', filePath: 'lib/Connection.js' }, functionQuery: { expressionName: 'query', kind: 'Auto' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const mysqlModuleNames = uniq(mysqlConfig.map(config => config.module.name)); export const mysqlChannels = { MYSQL_QUERY: 'orchestrion:mysql:query', diff --git a/packages/server-utils/src/orchestrion/config/mysql2.ts b/packages/server-utils/src/orchestrion/config/mysql2.ts index bac26e323053..4e8009492da4 100644 --- a/packages/server-utils/src/orchestrion/config/mysql2.ts +++ b/packages/server-utils/src/orchestrion/config/mysql2.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; // Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection // prototype) to orchestrion channel injection. @@ -40,7 +41,9 @@ export const mysql2Config = [ module: { name: 'mysql2', versionRange: '>=3.11.5 <3.20.0', filePath: 'lib/base/connection.js' }, functionQuery: { className: 'BaseConnection', methodName: 'execute', kind: 'Callback' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const mysql2ModuleNames = uniq(mysql2Config.map(config => config.module.name)); export const mysql2Channels = { MYSQL2_QUERY: 'orchestrion:mysql2:query', diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index 44900addc39e..fab747862e87 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; /** * Wrap an instrumentation that targets nodes via a raw esquery selector @@ -128,7 +129,9 @@ export const nestjsConfig = [ }, functionQuery: { functionName: 'Processor', kind: 'Sync' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const nestjsModuleNames = uniq(nestjsConfig.map(config => config.module.name)); export const nestjsChannels = { NESTJS_APP_CREATION: 'orchestrion:@nestjs/core:nestFactoryCreate', diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index d29c12623e24..966db896e4ea 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const openaiConfig = [ // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, @@ -27,7 +28,9 @@ export const openaiConfig = [ module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Auto' as const }, })), -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const openaiModuleNames = uniq(openaiConfig.map(config => config.module.name)); export const openaiChannels = { // Chat completions, the responses API, and the conversations API all report a `chat` operation with diff --git a/packages/server-utils/src/orchestrion/config/pg.ts b/packages/server-utils/src/orchestrion/config/pg.ts index d000e423068a..d2b7e5fa380c 100644 --- a/packages/server-utils/src/orchestrion/config/pg.ts +++ b/packages/server-utils/src/orchestrion/config/pg.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const pgConfig = [ // `pg` (node-postgres). @@ -39,7 +40,9 @@ export const pgConfig = [ module: { name: 'pg-pool', versionRange: '>=2.0.0 <4', filePath: 'index.js' }, functionQuery: { className: 'Pool', methodName: 'connect', kind: 'Auto' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const pgModuleNames = uniq(pgConfig.map(config => config.module.name)); export const pgChannels = { PG_QUERY: 'orchestrion:pg:query', diff --git a/packages/server-utils/src/orchestrion/config/postgres.ts b/packages/server-utils/src/orchestrion/config/postgres.ts index 245a939cd7e7..1e945bd4abe3 100644 --- a/packages/server-utils/src/orchestrion/config/postgres.ts +++ b/packages/server-utils/src/orchestrion/config/postgres.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; // postgres.js (`postgres` npm package, v3.x). Named after the npm package; // `postgres` doesn't collide with `pg.ts` (that file instruments `pg`/`pg-pool`). @@ -7,45 +8,47 @@ import type { InstrumentationConfig } from '..'; // `cf/*` workerd build has no channel subscribers, see the integration). // Both builds share the same class/function shapes, so a single `flatMap` // over the two dirs emits one entry per (dir, target). -const postgresJsInstrumentationConfig = (dir: string): InstrumentationConfig[] => [ - // `Query.prototype.handle` (`class Query extends Promise`) is the single - // funnel every query passes through (`then`/`catch`/`finally`/`.execute()`/ - // `.forEach()`/cursor all call it), guarded by `this.executed`. `Async` - // because `handle` is `async`. - { - channelName: 'handle', - module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/query.js` }, - functionQuery: { className: 'Query', methodName: 'handle', kind: 'Async' }, - }, - // `function Connection(options, ...)` (default export of `connection.js`) - // returns the connection object; used to build the endpoint registry that - // resolves `server.address`/`server.port`/`db.namespace`. - { - channelName: 'connection', - module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, - functionQuery: { functionName: 'Connection', kind: 'Sync' }, - }, - // The nested `function execute(q)` inside `Connection`; the per-connection - // hook that attaches connection attributes to the query's span. - { - channelName: 'execute', - module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, - functionQuery: { functionName: 'execute', kind: 'Sync' }, - }, - // The connection object's `connect(query)` method. Matched by `methodName` - // (an object-literal method): `functionName` would hit the unrelated - // socket-level `async function connect()` in the same file. `self` is the - // connection object and `arguments[0]` the query, so the first query that - // opens a connection (dispatched via a bare `execute` with no `self`) still - // gets connection attributes in multi-endpoint apps. - { - channelName: 'connect', - module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, - functionQuery: { methodName: 'connect', kind: 'Sync' }, - }, -]; +const postgresJsInstrumentationConfig = (dir: string): InstrumentationConfig[] => + [ + // `Query.prototype.handle` (`class Query extends Promise`) is the single + // funnel every query passes through (`then`/`catch`/`finally`/`.execute()`/ + // `.forEach()`/cursor all call it), guarded by `this.executed`. `Async` + // because `handle` is `async`. + { + channelName: 'handle', + module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/query.js` }, + functionQuery: { className: 'Query', methodName: 'handle', kind: 'Async' }, + }, + // `function Connection(options, ...)` (default export of `connection.js`) + // returns the connection object; used to build the endpoint registry that + // resolves `server.address`/`server.port`/`db.namespace`. + { + channelName: 'connection', + module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, + functionQuery: { functionName: 'Connection', kind: 'Sync' }, + }, + // The nested `function execute(q)` inside `Connection`; the per-connection + // hook that attaches connection attributes to the query's span. + { + channelName: 'execute', + module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, + functionQuery: { functionName: 'execute', kind: 'Sync' }, + }, + // The connection object's `connect(query)` method. Matched by `methodName` + // (an object-literal method): `functionName` would hit the unrelated + // socket-level `async function connect()` in the same file. `self` is the + // connection object and `arguments[0]` the query, so the first query that + // opens a connection (dispatched via a bare `execute` with no `self`) still + // gets connection attributes in multi-endpoint apps. + { + channelName: 'connect', + module: { name: 'postgres', versionRange: '>=3.0.0 <4', filePath: `${dir}/connection.js` }, + functionQuery: { methodName: 'connect', kind: 'Sync' }, + }, + ] as const satisfies InstrumentationConfig[]; export const postgresJsConfig = ['src', 'cjs/src'].flatMap(postgresJsInstrumentationConfig); +export const postgresJsModuleNames = uniq(postgresJsConfig.map(config => config.module.name)); export const postgresJsChannels = { POSTGRESJS_HANDLE: 'orchestrion:postgres:handle', diff --git a/packages/server-utils/src/orchestrion/config/prisma.ts b/packages/server-utils/src/orchestrion/config/prisma.ts deleted file mode 100644 index eace86695028..000000000000 --- a/packages/server-utils/src/orchestrion/config/prisma.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { InstrumentationConfig } from '..'; - -// TODO: Stub for the `prisma` orchestrion integration (ports `@prisma/instrumentation`). -export const prismaConfig: InstrumentationConfig[] = []; - -export const prismaChannels = {} as const; diff --git a/packages/server-utils/src/orchestrion/config/react-router.ts b/packages/server-utils/src/orchestrion/config/react-router.ts deleted file mode 100644 index f21348123ca0..000000000000 --- a/packages/server-utils/src/orchestrion/config/react-router.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { InstrumentationConfig } from '..'; - -// TODO: Stub for the `react-router` orchestrion integration (ports `ReactRouterInstrumentation`). -export const reactRouterConfig: InstrumentationConfig[] = []; - -export const reactRouterChannels = {} as const; diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts index 55db5e45c4e8..0e6970ffbd41 100644 --- a/packages/server-utils/src/orchestrion/config/redis.ts +++ b/packages/server-utils/src/orchestrion/config/redis.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const redisConfig = [ // redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an @@ -60,7 +61,9 @@ export const redisConfig = [ module: { name: '@redis/client', versionRange: '^1.0.0', filePath: 'dist/lib/client/index.js' }, functionQuery: { className: 'RedisClient', methodName: 'multiExecutor', kind: 'Async' }, }, -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const redisModuleNames = uniq(redisConfig.map(config => config.module.name)); export const redisChannels = { REDIS_COMMAND: 'orchestrion:redis:command', diff --git a/packages/server-utils/src/orchestrion/config/vercel-ai.ts b/packages/server-utils/src/orchestrion/config/vercel-ai.ts index 3c7d15fa0be7..7754c72f0e92 100644 --- a/packages/server-utils/src/orchestrion/config/vercel-ai.ts +++ b/packages/server-utils/src/orchestrion/config/vercel-ai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; export const vercelAiConfig = [ // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting @@ -22,7 +23,9 @@ export const vercelAiConfig = [ // The following entry is only present in v6 and later ...vercelAiEntries('>=6.0.0 <7.0.0', 'executeToolCall', 'executeToolCall', 'Async'), -] satisfies InstrumentationConfig[]; +] as const satisfies InstrumentationConfig[]; + +export const vercelAiModuleNames = uniq(vercelAiConfig.map(config => config.module.name)); export const vercelAiChannels = { // Vercel AI (`ai`): orchestrion injects these so the same channel-based diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index f6b7cb1c9e73..5a26a4504b87 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -50,3 +50,11 @@ export function detectOrchestrionSetup(): void { : '[Sentry] Bundler plugin did not run', ); } + +/** + * Get a list of all module names (e.g. express, @nestjs/core, ...) that have been injected by orchestrion, either at runtime or via a bundler plugin. + */ +export function getOrchestrionInjectedModules(): string[] { + const { runtime, bundler } = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ?? {}; + return [...(runtime ?? []), ...(bundler ?? [])]; +} diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts new file mode 100644 index 000000000000..948b562961e3 --- /dev/null +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -0,0 +1,67 @@ +import type { Client } from '@sentry/core'; +import { addNonEnumerableProperty, waitForTracingChannelBinding } from '@sentry/core'; +import { getOrchestrionInjectedModules } from './detect'; +import * as diagnosticsChannel from 'node:diagnostics_channel'; + +const INSTRUMENTATION_FN_SYMBOL = Symbol.for('InstrumentationFn'); +// oxlint-disable-next-line typescript/no-explicit-any +type InstrumentationFn = ((...args: any[]) => void) & { [INSTRUMENTATION_FN_SYMBOL]?: boolean }; + +/** + * Run the provided instrumentation callback when one of the provided module names is orchestrion-injected. + * If it is already injected, it will invoce the callback immediately (e.g. when build-time injection is used). + * If runtime injection is used, it may invoke the callback at a later point in time, when the injection actually happens. + * The callback will never be invoked more than once. + */ +export function invokeOrchestrionInstrumentation( + client: Client, + moduleNames: string[], + callback: Callback, + args: Parameters, +) { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + // If already injected, skip + if (hasBeenInjected(callback)) { + return; + } + + const instrumentationFn = () => { + markAsInjected(callback); + + waitForTracingChannelBinding(() => { + callback(...args); + }); + }; + + // First, check if the modules have been injected already + const modules = getOrchestrionInjectedModules(); + if (moduleNames.some(name => modules.includes(name))) { + instrumentationFn(); + return; + } + + // Then, register a listener for the `orchestrion.module-runtime-injected` event + const cleanup = client.on('orchestrion.module-runtime-injected', (moduleName: string) => { + if (hasBeenInjected(callback)) { + cleanup(); + return; + } + + if (moduleNames.includes(moduleName)) { + instrumentationFn(); + cleanup(); + } + }); +} + +function hasBeenInjected(callback: InstrumentationFn) { + return callback[INSTRUMENTATION_FN_SYMBOL] ?? false; +} + +function markAsInjected(callback: InstrumentationFn) { + addNonEnumerableProperty(callback, INSTRUMENTATION_FN_SYMBOL, true); +} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index bd559b2a6e48..0cf8fcba56af 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,4 +1,4 @@ -import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { debug, getClient, GLOBAL_OBJ } from '@sentry/core'; import { createRequire } from 'node:module'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; @@ -134,6 +134,7 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); + getClient()?.emit('orchestrion.module-runtime-injected', moduleName); } }; diff --git a/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts b/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts index b41290b281e9..135f594d7f08 100644 --- a/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts +++ b/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { Span } from '@sentry/core'; import { addNonEnumerableProperty, @@ -129,8 +130,6 @@ const operationErrorInfoBySpan = new WeakMap(); // telemetry object we replaced on a prior call — would be misread as user-disabled and lose its span. const suppressedTelemetry = new WeakSet(); -let subscribed = false; - /** * Subscribe the v6 orchestrion channel adapter. Safe to always call: inert on * `ai` >= 7 (those channels are never published) and when orchestrion injection @@ -140,15 +139,8 @@ let subscribed = false; * `subscribeVercelAiTracingChannel`); `options` pins the recording settings at * subscribe time so we never look the integration up per event. */ -export function subscribeVercelAiOrchestrionChannels( - tracingChannel: VercelAiTracingChannelFactory, - options: VercelAiChannelOptions = {}, -): void { - if (subscribed) { - return; - } - subscribed = true; - +export function instrumentVercelAi(options: VercelAiChannelOptions = {}): void { + const tracingChannel = diagnosticsChannel.tracingChannel; try { bindOperation(tracingChannel, CHANNELS.VERCEL_AI_GENERATE_TEXT, buildTextMessage('generateText'), options); bindOperation(tracingChannel, CHANNELS.VERCEL_AI_STREAM_TEXT, buildTextMessage('streamText'), options); From cd1e0f96f5289194ea8b56594957dfa7d2b60239 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 17 Jul 2026 09:53:41 +0200 Subject: [PATCH 02/10] avoid microticks, no longer needed --- .../suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs | 4 ---- .../suites/tracing/mysql2-tracing-channel/scenario.mjs | 4 ---- .../suites/tracing/redis-dc/scenario-redis-5-tracing.mjs | 4 ---- 3 files changed, 12 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs index 8df52a5d19a3..499bff7fb206 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs @@ -1,10 +1,6 @@ import * as Sentry from '@sentry/node'; async function run() { - // Yield a microtick so the DC subscriber (deferred via Promise.resolve().then) - // is registered before ioredis creates its native TracingChannels on import. - await Promise.resolve(); - const { default: Redis } = await import('ioredis-5'); const redisClient = new Redis({ host: '127.0.0.1', port: 6382, lazyConnect: true }); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs index c5398cc3e0bb..b3b9238b8d49 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs @@ -10,10 +10,6 @@ const CONNECT_CONFIG = { }; async function run() { - // Yield a microtick so the DC subscriber (deferred via Promise.resolve().then) - // is registered before mysql2 publishes on its native TracingChannels. - await Promise.resolve(); - // Gate on the DB actually accepting a connection before opening the span (see `waitForConnection`). // MySQL keeps finalizing for a short window after the healthcheck passes and drops early handshakes, // so this retries a real connect. It runs outside an active span, so the connect stays uninstrumented. diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5-tracing.mjs b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5-tracing.mjs index f1b7ccd30e77..b98a66cdd273 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5-tracing.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/scenario-redis-5-tracing.mjs @@ -1,10 +1,6 @@ import * as Sentry from '@sentry/node'; async function run() { - // Yield a microtick so the DC subscriber (deferred via Promise.resolve().then) - // is registered before node-redis eagerly creates its native TracingChannels on require(). - await Promise.resolve(); - const { createClient } = await import('redis-5-tracing'); const redisClient = await createClient({ socket: { host: '127.0.0.1', port: 6381 } }).connect(); From 3152754aca5caf009dadd73a7120c568e596196f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 17 Jul 2026 10:15:33 +0200 Subject: [PATCH 03/10] fix lint --- .../server-utils/src/integrations/tracing-channel/mysql2.ts | 1 - .../test/integrations/tracing-channel/hapi-utils.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts index bf7c70ffe497..4f49f40a1ff4 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -25,7 +25,6 @@ import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentat import { mysql2ModuleNames } from '../../orchestrion/config/mysql2'; import { mysql2Integration } from '../../mysql2'; -const INTEGRATION_NAME = 'Mysql2' as const; const ORIGIN = 'auto.db.orchestrion.mysql2'; const DB_SYSTEM_VALUE_MYSQL = 'mysql'; diff --git a/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts b/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts index 862e9ee3e747..d76d57933e74 100644 --- a/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts +++ b/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getExtMetadata, getRouteMetadata } from '../../../src/integrations/tracing-channel/hapi-utils'; +import { getExtMetadata, getRouteMetadata } from '../../../src/integrations/tracing-channel/hapi/utils'; describe('getRouteMetadata', () => { const route = { path: '/users/{id}', method: 'get' } as any; From ca6b0680cc6286bf16865e34a786cab395b55cda Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 17 Jul 2026 10:15:44 +0200 Subject: [PATCH 04/10] insturment everything on deno/bun --- .../server-utils/src/orchestrion/instrumentation.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts index 948b562961e3..b00f8d52af58 100644 --- a/packages/server-utils/src/orchestrion/instrumentation.ts +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -7,6 +7,10 @@ const INSTRUMENTATION_FN_SYMBOL = Symbol.for('InstrumentationFn'); // oxlint-disable-next-line typescript/no-explicit-any type InstrumentationFn = ((...args: any[]) => void) & { [INSTRUMENTATION_FN_SYMBOL]?: boolean }; +const globalAny = globalThis as { Bun?: unknown; Deno?: unknown }; +const isBun = typeof globalAny.Bun !== 'undefined'; +const isDeno = typeof globalAny.Deno !== 'undefined'; + /** * Run the provided instrumentation callback when one of the provided module names is orchestrion-injected. * If it is already injected, it will invoce the callback immediately (e.g. when build-time injection is used). @@ -37,6 +41,13 @@ export function invokeOrchestrionInstrumentation modules.includes(name))) { From 9b33e483a4c81904fb8bb60bdc6fdb75ebfd78a2 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 17 Jul 2026 10:18:10 +0200 Subject: [PATCH 05/10] fix tests --- .../postgres-ignore-connect.test.ts | 34 ++++++++++++++-- .../test/orchestrion/postgres.test.ts | 39 +++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts b/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts index 12f2bd6688df..6b1aca91b08c 100644 --- a/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts +++ b/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts @@ -3,8 +3,11 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { Scope } from '@sentry/core'; import { _INTERNAL_setSpanForScope, + Client, + createTransport, getDefaultCurrentScope, getDefaultIsolationScope, + resolvedSyncPromise, setAsyncContextStrategy, } from '@sentry/core'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -16,7 +19,28 @@ interface TestStore { isolationScope: Scope; } -// `setupOnce` only subscribes once `waitForTracingChannelBinding` sees an +class TestClient extends Client { + public constructor(options: any) { + super(options); + } + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function createTestClient(): Client { + return new TestClient({ + dsn: 'https://username@domain/123', + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + stackParser: () => [], + }); +} + +// `setup` only subscribes once `waitForTracingChannelBinding` sees an // async-context strategy exposing `getTracingChannelBinding`. Install a // minimal one so the subscriptions actually register here. function installTestAsyncContextStrategy(): void { @@ -59,7 +83,7 @@ function installTestAsyncContextStrategy(): void { }); } -// `setupOnce` subscribes to process-global `tracingChannel`s, so asserting the +// `setup` subscribes to process-global `tracingChannel`s, so asserting the // ABSENCE of connect subscribers only holds when no other (default-options) // integration in the same module context has subscribed. vitest isolates // module state per file, so this file keeps that assertion clean (the default @@ -74,7 +98,11 @@ describe('postgresChannelIntegration({ ignoreConnectSpans: true })', () => { }); it('subscribes to the query channel but NOT the connect / pool-connect channels', () => { - postgresChannelIntegration({ ignoreConnectSpans: true }).setupOnce?.(); + const client = createTestClient(); + postgresChannelIntegration({ ignoreConnectSpans: true }).setup?.(client); + // Channel subscribers only register once orchestrion reports a matching + // module as injected. + client.emit('orchestrion.module-runtime-injected', 'pg'); expect(tracingChannel(CHANNELS.PG_QUERY).start.hasSubscribers).toBe(true); expect(tracingChannel(CHANNELS.PG_CONNECT).start.hasSubscribers).toBe(false); diff --git a/packages/server-utils/test/orchestrion/postgres.test.ts b/packages/server-utils/test/orchestrion/postgres.test.ts index 651c0e82ff62..c6645fdc9098 100644 --- a/packages/server-utils/test/orchestrion/postgres.test.ts +++ b/packages/server-utils/test/orchestrion/postgres.test.ts @@ -4,8 +4,11 @@ import type { Scope, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { _INTERNAL_setSpanForScope, + Client, + createTransport, getDefaultCurrentScope, getDefaultIsolationScope, + resolvedSyncPromise, setAsyncContextStrategy, } from '@sentry/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; @@ -17,7 +20,28 @@ interface TestStore { isolationScope: Scope; } -// `bindTracingChannelToSpan` only binds (and `setupOnce` only subscribes via +class TestClient extends Client { + public constructor(options: any) { + super(options); + } + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function createTestClient(): Client { + return new TestClient({ + dsn: 'https://username@domain/123', + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + stackParser: () => [], + }); +} + +// `bindTracingChannelToSpan` only binds (and `setup` only subscribes via // `waitForTracingChannelBinding`) when an async-context strategy exposes a // `getTracingChannelBinding`. Install a minimal one so the channel // subscriptions actually register in this unit-test context (no SDK `init`). @@ -61,6 +85,15 @@ function installTestAsyncContextStrategy(): void { }); } +// Channel subscribers only register once orchestrion reports a matching module +// as injected (`invokeOrchestrionInstrumentation`). Emit that event after +// `setup` so subscriptions land synchronously under the test strategy above. +function setupPostgresChannelIntegration(options?: { ignoreConnectSpans?: boolean }): void { + const client = createTestClient(); + postgresChannelIntegration(options).setup?.(client); + client.emit('orchestrion.module-runtime-injected', 'pg'); +} + // The subscriber builds spans via `startInactiveSpan` and gates on // `getActiveSpan`. We spy both: `getActiveSpan` to satisfy the // requireParentSpan gate, and `startInactiveSpan` to capture the span @@ -84,10 +117,10 @@ describe('postgresChannelIntegration', () => { // Subscribe once for the whole file so a single subscriber handles each // publish (avoids accumulating duplicate subscriptions across tests). The - // strategy must be installed first so `setupOnce`'s `waitForTracingChannelBinding` fires synchronously. + // strategy must be installed first so `setup`'s `waitForTracingChannelBinding` fires synchronously. beforeAll(() => { installTestAsyncContextStrategy(); - postgresChannelIntegration().setupOnce?.(); + setupPostgresChannelIntegration(); }); afterAll(() => { From 39f9ee5198871d3e47db6344a65f85dd6a480d64 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 17 Jul 2026 10:25:54 +0200 Subject: [PATCH 06/10] avoid mysql2 double subscribe --- .../server-utils/src/integrations/tracing-channel/mysql2.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts index 4f49f40a1ff4..20752e8a9979 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -9,7 +9,6 @@ import { SPAN_KIND, startInactiveSpan, } from '@sentry/core'; -import { subscribeMysql2DiagnosticChannels } from '../../mysql2/mysql2-dc-subscriber'; import type { ChannelName } from '../../orchestrion/channels'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; @@ -68,9 +67,6 @@ interface Mysql2Connection { * `experimentalUseDiagnosticsChannelInjection`. */ function instrumentMysql2(): void { - // mysql2 >= 3.20.0: native diagnostics_channel path (inert on older versions, which never publish). - subscribeMysql2DiagnosticChannels(diagnosticsChannel.tracingChannel); - // mysql2 < 3.20.0: orchestrion-injected channels (inert on >= 3.20.0, which we don't transform). subscribeQueryChannel(CHANNELS.MYSQL2_QUERY); subscribeQueryChannel(CHANNELS.MYSQL2_EXECUTE); From c4faf2d06f8c454963749b4f0946781ba054809d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 17 Jul 2026 10:54:18 +0200 Subject: [PATCH 07/10] fix nuxt --- .../test-applications/nuxt-4-orchestrion/nuxt.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts index 4f663643f739..f17deed5d17d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts @@ -6,7 +6,7 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '@sentry/serv // `injectDiagnostics` option (sourcemap-safe) instead of a hand-rolled plugin. const orchestrionBundlerMarker = [ 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', - 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', + 'globalThis.__SENTRY_ORCHESTRION__.bundler = [];', '', ].join('\n'); From 5ad1e7d5f8ab419ff3d3180321a2590c990f397d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 17 Jul 2026 11:02:01 +0200 Subject: [PATCH 08/10] WIP turbopack --- packages/core/src/utils/worldwide.ts | 8 ++ .../turbopack/constructTurbopackConfig.ts | 24 +++--- .../turbopack/generateValueInjectionRules.ts | 11 +++ .../constructTurbopackConfig.test.ts | 26 +++++++ .../generateValueInjectionRules.test.ts | 20 +++++ packages/server-utils/package.json | 11 +++ packages/server-utils/rollup.npm.config.mjs | 3 + .../src/orchestrion/bundler/inject.ts | 30 ++++++++ .../orchestrion/bundler/turbopack-loader.ts | 60 +++++++++++++++ .../src/orchestrion/bundler/webpack.ts | 10 +++ .../src/orchestrion/instrumentation.ts | 22 +++++- .../test/orchestrion/instrumentation.test.ts | 73 +++++++++++++++++++ .../test/orchestrion/turbopack-loader.test.ts | 68 +++++++++++++++++ 13 files changed, 350 insertions(+), 16 deletions(-) create mode 100644 packages/server-utils/src/orchestrion/bundler/inject.ts create mode 100644 packages/server-utils/src/orchestrion/bundler/turbopack-loader.ts create mode 100644 packages/server-utils/test/orchestrion/instrumentation.test.ts create mode 100644 packages/server-utils/test/orchestrion/turbopack-loader.test.ts diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 42a7ffdfaec4..bbb09aab7c87 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -64,6 +64,14 @@ export type InternalGlobal = { /** Empty array signifies bundler plugin ran */ bundler?: string[]; }; + /** + * Bridge set by the SDK during `init` so that build-time (bundler) injected modules can announce + * themselves the moment they load. Bundlers that transform modules but provide no runtime boot hook + * (e.g. Turbopack, which only takes loaders) prepend a prologue to each instrumented module that + * calls this with the module name; the bridge emits `orchestrion.module-runtime-injected` so channel + * subscribers get wired up before the module publishes. Mirrors the runtime module hook's own event. + */ + __SENTRY_ORCHESTRION_ON_INJECT__?: (moduleName: string) => void; } & Carrier; /** Get's the global object for the current JavaScript runtime */ diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 31298b6067a6..219e0f5699ff 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,15 +1,9 @@ import { debug } from '@sentry/core'; import * as path from 'path'; -import { getOrchestrionLoaderPath, getSentryInstrumentations } from '@sentry/server-utils/orchestrion/webpack'; +import { getSentryOrchestrionLoaderPath } from '@sentry/server-utils/orchestrion/webpack'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; -import type { - JSONValue, - NextConfigObject, - SentryBuildOptions, - TurbopackMatcherWithRule, - TurbopackOptions, -} from '../types'; +import type { NextConfigObject, SentryBuildOptions, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; import { generateValueInjectionRules } from './generateValueInjectionRules'; @@ -58,6 +52,10 @@ export function constructTurbopackConfig({ nextJsVersion, tunnelPath, vercelCronsConfig, + // Mark the bundler injection path as active at boot (before `Sentry.init()`), so + // `isOrchestrionInjected()` is reliable even without the runtime hook. Individual modules add + // themselves to this list as they load; see the orchestrion loader's injected prologue. + injectOrchestrionBundlerMarker: !!userSentryOptions?._experimental?.useDiagnosticsChannelInjection, }); for (const { matcher, rule } of valueInjectionRules) { @@ -138,13 +136,9 @@ function maybeAddOrchestrionRule( matcher: '*.{js,mjs,cjs}', rule: { condition: 'node', - loaders: [ - { - loader: getOrchestrionLoaderPath(), - // `instrumentations` is JSON-serializable - options: { instrumentations: getSentryInstrumentations() as unknown as JSONValue[] }, - }, - ], + // The Sentry loader reads the instrumentation config itself and appends a self-registration + // prologue to each transformed module, so it needs no `options` — use the string shortcut. + loaders: [getSentryOrchestrionLoaderPath()], }, }); } diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 8bca01cb2ce4..1d1836dd01aa 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -12,11 +12,13 @@ export function generateValueInjectionRules({ nextJsVersion, tunnelPath, vercelCronsConfig, + injectOrchestrionBundlerMarker, }: { routeManifest?: RouteManifest; nextJsVersion?: string; tunnelPath?: string; vercelCronsConfig?: VercelCronsConfig; + injectOrchestrionBundlerMarker?: boolean; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; const isomorphicValues: Record = {}; @@ -45,6 +47,15 @@ export function generateValueInjectionRules({ // Use process.cwd() to get the project directory at build time serverValues.__SENTRY_SERVER_MODULES__ = getPackageModules(process.cwd()); + // Mark the bundler (build-time) orchestrion injection as active at boot. This runs at the top of the + // server `instrumentation` file — before `Sentry.init()` and before the runtime hook — so + // `isOrchestrionInjected()` is reliable for bundler-only setups too. Turbopack has no plugin/boot + // hook to emit the full transformed-module list the way the webpack plugin does, so we seed an empty + // array here; each transformed module then appends itself as it loads (see the orchestrion loader). + if (injectOrchestrionBundlerMarker) { + serverValues.__SENTRY_ORCHESTRION__ = { bundler: [] }; + } + if (Object.keys(isomorphicValues).length > 0) { clientValues = { ...clientValues, ...isomorphicValues }; serverValues = { ...serverValues, ...isomorphicValues }; diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index 242174f015de..a453d207ba65 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -1574,4 +1574,30 @@ describe('safelyAddTurbopackRule', () => { }); }); }); + + describe('orchestrion diagnostics-channel injection', () => { + it('does not add the orchestrion loader rule by default', () => { + const result = constructTurbopackConfig({ + userNextConfig: {}, + userSentryOptions: {}, + nextJsVersion: '16.0.0', + }); + + expect(result.rules?.['*.{js,mjs,cjs}']).toBeUndefined(); + }); + + it('adds the Sentry orchestrion loader rule (node server only) when injection is enabled', () => { + const result = constructTurbopackConfig({ + userNextConfig: {}, + userSentryOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + nextJsVersion: '16.0.0', + }); + + const rule = result.rules?.['*.{js,mjs,cjs}'] as { condition?: unknown; loaders?: unknown[] } | undefined; + expect(rule?.condition).toBe('node'); + // A single loader referenced by path (string shortcut, no options). + expect(rule?.loaders).toHaveLength(1); + expect(typeof rule?.loaders?.[0]).toBe('string'); + }); + }); }); diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts index d4fed2368101..e821a7f019c3 100644 --- a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -70,6 +70,26 @@ describe('generateValueInjectionRules', () => { }); }); + describe('orchestrion bundler marker', () => { + it('does not inject the orchestrion marker by default', () => { + const result = generateValueInjectionRules({}); + + const serverRule = result.find(rule => rule.matcher === '**/instrumentation.*'); + const values = (serverRule?.rule as { loaders: [{ options: { values: Record } }] }).loaders[0] + .options.values; + expect(values).not.toHaveProperty('__SENTRY_ORCHESTRION__'); + }); + + it('seeds an empty bundler array at boot when injectOrchestrionBundlerMarker is set', () => { + const result = generateValueInjectionRules({ injectOrchestrionBundlerMarker: true }); + + const serverRule = result.find(rule => rule.matcher === '**/instrumentation.*'); + const values = (serverRule?.rule as { loaders: [{ options: { values: Record } }] }).loaders[0] + .options.values; + expect(values.__SENTRY_ORCHESTRION__).toEqual({ bundler: [] }); + }); + }); + describe('with nextJsVersion only', () => { it('should generate client and server rules when nextJsVersion is provided', () => { const result = generateValueInjectionRules({ diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 278b74fa803e..474c4797e0ba 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -57,6 +57,11 @@ "import": "./build/esm/orchestrion/bundler/esbuild.js", "require": "./build/cjs/orchestrion/bundler/esbuild.js" }, + "./orchestrion/turbopack-loader": { + "types": "./build/types/orchestrion/bundler/turbopack-loader.d.ts", + "import": "./build/esm/orchestrion/bundler/turbopack-loader.js", + "require": "./build/cjs/orchestrion/bundler/turbopack-loader.js" + }, "./orchestrion/import-hook": { "import": "./build/orchestrion/import-hook.mjs" } @@ -86,6 +91,9 @@ ], "orchestrion/esbuild": [ "build/types-ts3.8/orchestrion/bundler/esbuild.d.ts" + ], + "orchestrion/turbopack-loader": [ + "build/types-ts3.8/orchestrion/bundler/turbopack-loader.d.ts" ] }, "*": { @@ -109,6 +117,9 @@ ], "orchestrion/esbuild": [ "build/types/orchestrion/bundler/esbuild.d.ts" + ], + "orchestrion/turbopack-loader": [ + "build/types/orchestrion/bundler/turbopack-loader.d.ts" ] } }, diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 0b6412cee679..29795567ac2e 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -37,6 +37,9 @@ export default [ 'src/orchestrion/bundler/rollup.ts', 'src/orchestrion/bundler/webpack.ts', 'src/orchestrion/bundler/esbuild.ts', + // Referenced by absolute path (never imported), so it must be its own entrypoint to land in + // build/. Turbopack `require`s it as a loader; see `getSentryOrchestrionLoaderPath`. + 'src/orchestrion/bundler/turbopack-loader.ts', ], packageSpecificConfig: { output: { diff --git a/packages/server-utils/src/orchestrion/bundler/inject.ts b/packages/server-utils/src/orchestrion/bundler/inject.ts new file mode 100644 index 000000000000..5e7109351337 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/inject.ts @@ -0,0 +1,30 @@ +// Shared source of truth for the runtime globals the build-time (bundler) injection uses to +// announce itself. Kept dependency-free so the Turbopack loader can import it in the build process +// without pulling in the SDK runtime. The matching runtime side lives in `../instrumentation.ts` +// (the `__SENTRY_ORCHESTRION_ON_INJECT__` bridge) and `../detect.ts` (`__SENTRY_ORCHESTRION__`). + +/** + * Prologue appended to every module the bundler actually instruments. It runs when the module is + * first loaded — for lazily-loaded server deps that is during request handling, after `Sentry.init()` + * — and does two things: records the module in `__SENTRY_ORCHESTRION__.bundler` (so detection/reporting + * see it) and calls the SDK bridge so channel subscribers get wired up before the module publishes. + * + * This exists because some bundlers (notably Turbopack) accept only loaders, not plugins, so there is + * no build lifecycle hook to emit the aggregate module list at boot the way the webpack plugin does. + * Announcing per-module on load mirrors how the runtime module hook already reports injected modules. + * + * Emitted as a single line (no newlines) so it never shifts the module's source-map mappings, and + * wrapped in try/catch so an injection failure can never break the instrumented module. + */ +export function buildInjectPrologue(moduleName: string): string { + const name = JSON.stringify(moduleName); + return ( + ';(function(){try{' + + 'var g=(globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{});' + + 'g.bundler=g.bundler||[];' + + `if(g.bundler.indexOf(${name})===-1)g.bundler.push(${name});` + + 'var cb=globalThis.__SENTRY_ORCHESTRION_ON_INJECT__;' + + `if(typeof cb==="function")cb(${name});` + + '}catch(e){}})();' + ); +} diff --git a/packages/server-utils/src/orchestrion/bundler/turbopack-loader.ts b/packages/server-utils/src/orchestrion/bundler/turbopack-loader.ts new file mode 100644 index 000000000000..e7f580456189 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/turbopack-loader.ts @@ -0,0 +1,60 @@ +// EXPERIMENTAL — orchestrion code-transform loader for Turbopack. +// +// Turbopack accepts webpack loaders but not plugins, so — unlike the webpack path, where the plugin +// injects one boot-time snippet listing every transformed module — there is no build lifecycle hook +// to surface the injected-module list at runtime. This loader wraps the same code transform and, for +// each module it actually instruments, appends a self-registration prologue (see `./inject`) so the +// module announces itself to the SDK when it loads. That gives the *precise* set of build-time +// instrumented modules, matching how the runtime module hook reports the ones it injects. + +import { createCodeTransformer } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; +import { buildInjectPrologue } from './inject'; + +// The subset of the webpack loader context we rely on (Turbopack provides the same shape). +interface LoaderContext { + resourcePath: string; + async: () => (error: Error | null, code?: string, map?: unknown) => void; +} + +// Built once per loader process; the transform is pure config, so it is safe to reuse. +let transformer: ReturnType | undefined; + +function getTransformer(): ReturnType { + return (transformer ??= createCodeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS })); +} + +// The package name a module path belongs to, e.g. `.../node_modules/ioredis/built/Redis.js` → `ioredis` +// and `.../node_modules/@scope/pkg/x.js` → `@scope/pkg`. Uses the last `node_modules` segment so nested +// dependencies resolve to their own name, and normalizes Windows separators. +function moduleNameFromPath(id: string): string | undefined { + const re = /(?:^|[\\/])node_modules[\\/]((?:@[^\\/]+[\\/])?[^\\/]+)/g; + let name: string | undefined; + for (let match = re.exec(id); match; match = re.exec(id)) { + name = match[1]; + } + return name?.replace(/\\/g, '/'); +} + +/** + * Webpack/Turbopack loader that instruments supported modules and appends the self-registration + * prologue to the ones it transforms. Non-matching modules pass through untouched; any failure falls + * back to the original source so a build never breaks. + */ +export default function sentryOrchestrionLoader(this: LoaderContext, code: string, map?: unknown): void { + const callback = this.async(); + + try { + const result = getTransformer().transform(code, this.resourcePath, map as string | undefined); + if (!result) { + callback(null, code, map); + return; + } + + const moduleName = moduleNameFromPath(this.resourcePath); + const prologue = moduleName ? buildInjectPrologue(moduleName) : ''; + callback(null, `${result.code}${prologue}`, result.map); + } catch { + callback(null, code, map); + } +} diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 5dce4de27d62..00f74a6d0d02 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -26,6 +26,16 @@ export function getOrchestrionLoaderPath(): string { return getOrchestrionRequire().resolve('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'); } +/** + * Absolute path to the Sentry orchestrion loader — the code-transform loader wrapped so each + * instrumented module announces itself at runtime (see `./turbopack-loader`). Turbopack builds use + * this instead of the raw loader because Turbopack has no plugin/boot hook to emit the injected-module + * list. `createRequire().resolve` always picks the CJS build, which is what a loader must be. + */ +export function getSentryOrchestrionLoaderPath(): string { + return getOrchestrionRequire().resolve('@sentry/server-utils/orchestrion/turbopack-loader'); +} + /** * Absolute path to the `@apm-js-collab/tracing-hooks` package directory, resolved from this * package's own dependency graph. SDKs inject it at build time so the runtime module hook can diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts index b00f8d52af58..8ac2571c6602 100644 --- a/packages/server-utils/src/orchestrion/instrumentation.ts +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -1,5 +1,5 @@ import type { Client } from '@sentry/core'; -import { addNonEnumerableProperty, waitForTracingChannelBinding } from '@sentry/core'; +import { addNonEnumerableProperty, getClient, GLOBAL_OBJ, waitForTracingChannelBinding } from '@sentry/core'; import { getOrchestrionInjectedModules } from './detect'; import * as diagnosticsChannel from 'node:diagnostics_channel'; @@ -28,6 +28,10 @@ export function invokeOrchestrionInstrumentation { + getClient()?.emit('orchestrion.module-runtime-injected', moduleName); + }; +} + function hasBeenInjected(callback: InstrumentationFn) { return callback[INSTRUMENTATION_FN_SYMBOL] ?? 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..c0c1f9d259fe --- /dev/null +++ b/packages/server-utils/test/orchestrion/instrumentation.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type * as SentryCore from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; + +// A minimal event bus standing in for the client, plus synchronous binding resolution so the whole +// bundler runtime path (bridge -> event -> listener -> instrumentation callback) runs inline. +const handlers: Record void>> = {}; +const fakeClient = { + on(hook: string, cb: (...args: unknown[]) => void) { + (handlers[hook] ??= []).push(cb); + return () => { + handlers[hook] = (handlers[hook] ?? []).filter(h => h !== cb); + }; + }, + emit(hook: string, ...args: unknown[]) { + (handlers[hook] ?? []).slice().forEach(h => h(...args)); + }, +}; + +vi.mock('@sentry/core', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + getClient: () => fakeClient, + waitForTracingChannelBinding: (cb: () => void) => cb(), + }; +}); + +import { invokeOrchestrionInstrumentation } from '../../src/orchestrion/instrumentation'; + +describe('invokeOrchestrionInstrumentation — bundler runtime path', () => { + afterEach(() => { + for (const key of Object.keys(handlers)) { + handlers[key] = []; + } + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__; + }); + + it('installs the on-inject bridge during setup', () => { + invokeOrchestrionInstrumentation(fakeClient as never, ['ioredis'], vi.fn(), []); + expect(typeof GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__).toBe('function'); + }); + + it('subscribes when a build-time injected module announces itself via the bridge', () => { + const callback = vi.fn(); + invokeOrchestrionInstrumentation(fakeClient as never, ['ioredis'], callback as never, []); + + // Not injected yet at init → callback must not have run. + expect(callback).not.toHaveBeenCalled(); + + // The module's appended prologue calls this on load; it must trigger the subscription. + GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__?.('ioredis'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('ignores announcements for modules the integration does not handle', () => { + const callback = vi.fn(); + invokeOrchestrionInstrumentation(fakeClient as never, ['ioredis'], callback as never, []); + + GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__?.('some-other-package'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not invoke the callback more than once across repeated announcements', () => { + const callback = vi.fn(); + invokeOrchestrionInstrumentation(fakeClient as never, ['ioredis'], callback as never, []); + + GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__?.('ioredis'); + GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__?.('ioredis'); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/server-utils/test/orchestrion/turbopack-loader.test.ts b/packages/server-utils/test/orchestrion/turbopack-loader.test.ts new file mode 100644 index 000000000000..765ef2d37850 --- /dev/null +++ b/packages/server-utils/test/orchestrion/turbopack-loader.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock the third-party transform so the test covers OUR loader logic (appending the prologue, module +// name derivation, passthrough) rather than the real code transform. Returns non-null for everything +// except paths containing `skip`, so we can exercise both branches. +vi.mock('@apm-js-collab/code-transformer-bundler-plugins/core', () => ({ + createCodeTransformer: () => ({ + transform: (code: string, id: string) => + id.includes('skip') ? null : { code: `/*instrumented*/${code}`, map: undefined }, + getCodeToInject: () => undefined, + }), +})); + +import { buildInjectPrologue } from '../../src/orchestrion/bundler/inject'; +import loader from '../../src/orchestrion/bundler/turbopack-loader'; + +function runLoader(resourcePath: string, code: string): string | undefined { + let out: string | undefined; + const ctx = { + resourcePath, + async: () => (_error: Error | null, transformed?: string) => { + out = transformed; + }, + }; + (loader as (this: unknown, code: string, map?: unknown) => void).call(ctx, code, null); + return out; +} + +describe('buildInjectPrologue', () => { + it('records the module in `.bundler` and calls the on-inject bridge, guarded by try/catch', () => { + const prologue = buildInjectPrologue('ioredis'); + + expect(prologue).toContain('globalThis.__SENTRY_ORCHESTRION__'); + expect(prologue).toContain('g.bundler.push("ioredis")'); + expect(prologue).toContain('globalThis.__SENTRY_ORCHESTRION_ON_INJECT__'); + expect(prologue).toContain('cb("ioredis")'); + expect(prologue).toContain('try{'); + // Single line so it never shifts source-map mappings. + expect(prologue).not.toContain('\n'); + }); +}); + +describe('sentry orchestrion turbopack loader', () => { + it('appends the self-registration prologue to instrumented modules', () => { + const out = runLoader('/app/node_modules/ioredis/built/Redis.js', 'ORIGINAL'); + + expect(out).toContain('/*instrumented*/ORIGINAL'); + expect(out).toContain(buildInjectPrologue('ioredis')); + }); + + it('passes non-instrumented modules through untouched', () => { + const out = runLoader('/app/node_modules/skip-me/index.js', 'ORIGINAL'); + + expect(out).toBe('ORIGINAL'); + }); + + it('derives scoped package names for the prologue', () => { + const out = runLoader('/app/node_modules/@scope/pkg/index.js', 'ORIGINAL'); + + expect(out).toContain(buildInjectPrologue('@scope/pkg')); + }); + + it('uses the innermost node_modules segment for nested dependencies', () => { + const out = runLoader('/app/node_modules/a/node_modules/ioredis/built/Redis.js', 'ORIGINAL'); + + expect(out).toContain(buildInjectPrologue('ioredis')); + }); +}); From fe3577dbdd7b135ceddeebcf3ec893762f395053 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 17 Jul 2026 11:25:36 +0200 Subject: [PATCH 09/10] fix nuxt --- .../nuxt-4-orchestrion/nuxt.config.ts | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts index f17deed5d17d..0331ba939ded 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts @@ -1,14 +1,5 @@ -import codeTransformerRollup from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; -import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config'; - -// Tells the SDK the orchestrion bundler transform ran, so `detectOrchestrionSetup()` -// no-ops the runtime diagnostics-channel hook. Injected via `codeTransformerRollup`'s -// `injectDiagnostics` option (sourcemap-safe) instead of a hand-rolled plugin. -const orchestrionBundlerMarker = [ - 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', - 'globalThis.__SENTRY_ORCHESTRION__.bundler = [];', - '', -].join('\n'); +import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ @@ -40,13 +31,11 @@ export default defineNuxtConfig({ inline: [...INSTRUMENTED_MODULE_NAMES, 'standard-as-callback'], }, rollupConfig: { - plugins: [ - codeTransformerRollup({ - instrumentations: SENTRY_INSTRUMENTATIONS, - injectDiagnostics: () => orchestrionBundlerMarker, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any, - ], + // The Sentry rollup plugin runs the orchestrion code transform and injects a boot marker listing + // the modules it actually transformed (`__SENTRY_ORCHESTRION__.bundler = [...]`). The SDK's + // per-module channel gating reads that list, so it must be the real set, not an empty array. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plugins: [sentryOrchestrionPlugin() as any], }, }, }); From 33bf3413af4296689c1dcb52c064b38aa7c10bf3 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 17 Jul 2026 11:30:31 +0200 Subject: [PATCH 10/10] ref --- packages/server-utils/src/orchestrion/config/amqplib.ts | 4 ++-- .../server-utils/src/orchestrion/config/anthropic-ai.ts | 4 ++-- packages/server-utils/src/orchestrion/config/dataloader.ts | 4 ++-- packages/server-utils/src/orchestrion/config/express.ts | 4 ++-- .../server-utils/src/orchestrion/config/generic-pool.ts | 4 ++-- .../server-utils/src/orchestrion/config/google-genai.ts | 4 ++-- packages/server-utils/src/orchestrion/config/graphql.ts | 4 ++-- packages/server-utils/src/orchestrion/config/hapi.ts | 4 ++-- packages/server-utils/src/orchestrion/config/index.ts | 4 ++-- packages/server-utils/src/orchestrion/config/ioredis.ts | 4 ++-- packages/server-utils/src/orchestrion/config/kafkajs.ts | 4 ++-- packages/server-utils/src/orchestrion/config/knex.ts | 4 ++-- .../server-utils/src/orchestrion/config/lru-memoizer.ts | 4 ++-- packages/server-utils/src/orchestrion/config/mysql.ts | 4 ++-- packages/server-utils/src/orchestrion/config/mysql2.ts | 4 ++-- packages/server-utils/src/orchestrion/config/nestjs.ts | 4 ++-- packages/server-utils/src/orchestrion/config/openai.ts | 4 ++-- packages/server-utils/src/orchestrion/config/pg.ts | 4 ++-- packages/server-utils/src/orchestrion/config/postgres.ts | 4 ++-- packages/server-utils/src/orchestrion/config/redis.ts | 4 ++-- packages/server-utils/src/orchestrion/config/utils.ts | 6 ++++++ packages/server-utils/src/orchestrion/config/vercel-ai.ts | 4 ++-- 22 files changed, 48 insertions(+), 42 deletions(-) create mode 100644 packages/server-utils/src/orchestrion/config/utils.ts diff --git a/packages/server-utils/src/orchestrion/config/amqplib.ts b/packages/server-utils/src/orchestrion/config/amqplib.ts index 893a376c7ffe..2dd098841c38 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; // `amqplib` splits its API across three files: // - `lib/channel_model.js` holds `class Channel` (publish/consume/ack/nack/reject/…) and @@ -76,7 +76,7 @@ export const amqplibConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const amqplibModuleNames = uniq(amqplibConfig.map(config => config.module.name)); +export const amqplibModuleNames = getModuleNames(amqplibConfig); export const amqplibChannels = { AMQPLIB_PUBLISH: 'orchestrion:amqplib:publish', diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts index 9193dce701fe..7a62dd395b5e 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const anthropicAiConfig = [ // One entry each for CJS/ESM @@ -34,7 +34,7 @@ export const anthropicAiConfig = [ })), ] as const satisfies InstrumentationConfig[]; -export const anthropicAiModuleNames = uniq(anthropicAiConfig.map(config => config.module.name)); +export const anthropicAiModuleNames = getModuleNames(anthropicAiConfig); export const anthropicAiChannels = { ANTHROPIC_CHAT: 'orchestrion:@anthropic-ai/sdk:chat', diff --git a/packages/server-utils/src/orchestrion/config/dataloader.ts b/packages/server-utils/src/orchestrion/config/dataloader.ts index 3d192542137b..bd1e3f714f0f 100644 --- a/packages/server-utils/src/orchestrion/config/dataloader.ts +++ b/packages/server-utils/src/orchestrion/config/dataloader.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; // `dataloader` ships a single transpiled CommonJS `index.js`. Its class methods are emitted as // `_proto. = function () {}` (named function *expressions*), so they match on @@ -46,7 +46,7 @@ export const dataloaderConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const dataloaderModuleNames = uniq(dataloaderConfig.map(config => config.module.name)); +export const dataloaderModuleNames = getModuleNames(dataloaderConfig); export const dataloaderChannels = { DATALOADER_CONSTRUCT: 'orchestrion:dataloader:construct', diff --git a/packages/server-utils/src/orchestrion/config/express.ts b/packages/server-utils/src/orchestrion/config/express.ts index 5ecfd85d8dfe..c21f7f5ab8f9 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const expressConfig = [ // Express funnels every middleware/route handler through a single method on @@ -60,7 +60,7 @@ export const expressConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const expressModuleNames = uniq(expressConfig.map(config => config.module.name)); +export const expressModuleNames = getModuleNames(expressConfig); export const expressChannels = { // Express v4 runs each layer's handler through `Layer.prototype.handle_request` diff --git a/packages/server-utils/src/orchestrion/config/generic-pool.ts b/packages/server-utils/src/orchestrion/config/generic-pool.ts index 177a4dbf5f5f..d2bf80e16dd0 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; // Two shapes of `acquire`, both publishing to the same `orchestrion:generic-pool:acquire` channel: // - v3+: `class Pool { acquire(priority) }` returns a promise, so `kind: 'Auto'` resolves to `wrapPromise`. @@ -19,7 +19,7 @@ export const genericPoolConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const genericPoolModuleNames = uniq(genericPoolConfig.map(config => config.module.name)); +export const genericPoolModuleNames = getModuleNames(genericPoolConfig); export const genericPoolChannels = { GENERIC_POOL_ACQUIRE: 'orchestrion:generic-pool:acquire', diff --git a/packages/server-utils/src/orchestrion/config/google-genai.ts b/packages/server-utils/src/orchestrion/config/google-genai.ts index 2aea712119b8..96bf6cf3bac2 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; // `@google/genai` ships one bundled file per module format and the matcher compares `filePath` exactly, // so we list every file the `node` export condition resolves to across the supported range: `index.js` @@ -34,7 +34,7 @@ export const googleGenAiConfig = [ ), ] as const satisfies InstrumentationConfig[]; -export const googleGenAIModuleNames = uniq(googleGenAiConfig.map(config => config.module.name)); +export const googleGenAIModuleNames = getModuleNames(googleGenAiConfig); export const googleGenAiChannels = { GOOGLE_GENAI_GENERATE_CONTENT: 'orchestrion:@google/genai:generate-content', diff --git a/packages/server-utils/src/orchestrion/config/graphql.ts b/packages/server-utils/src/orchestrion/config/graphql.ts index 1239f7e52f65..933946cc8e55 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; // `parse`/`validate`/`execute` are top-level named `function` declarations in graphql's compiled // files, stable across the supported majors, so `functionName` matches. `execute` returns @@ -22,7 +22,7 @@ export const graphqlConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const graphqlModuleNames = uniq(graphqlConfig.map(config => config.module.name)); +export const graphqlModuleNames = getModuleNames(graphqlConfig); export const graphqlChannels = { GRAPHQL_PARSE: 'orchestrion:graphql:parse', diff --git a/packages/server-utils/src/orchestrion/config/hapi.ts b/packages/server-utils/src/orchestrion/config/hapi.ts index 57943ca021f9..52891877efc7 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const hapiConfig = [ // hapi's `route`/`ext` live on an anonymous class (`internals.Server = class {}`), @@ -18,7 +18,7 @@ export const hapiConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const hapiModuleNames = uniq(hapiConfig.map(config => config.module.name)); +export const hapiModuleNames = getModuleNames(hapiConfig); export const hapiChannels = { HAPI_ROUTE: 'orchestrion:@hapi/hapi:route', diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 5e76b0d94ce2..505a184ffc44 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; import { amqplibConfig } from './amqplib'; import { anthropicAiConfig } from './anthropic-ai'; @@ -84,7 +84,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ * externalized and their transform never runs. */ export function instrumentedModuleNames(instrumentations: InstrumentationConfig[] = []): string[] { - return uniq([...SENTRY_INSTRUMENTATIONS, ...instrumentations].map(i => i.module.name)); + return getModuleNames([...SENTRY_INSTRUMENTATIONS, ...instrumentations]); } /** The instrumented module names from the default Sentry config, with no custom additions. */ diff --git a/packages/server-utils/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts index 4f3253c58631..802ecc5463fc 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const ioredisConfig = [ // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) @@ -27,7 +27,7 @@ export const ioredisConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const ioredisModuleNames = uniq(ioredisConfig.map(config => config.module.name)); +export const ioredisModuleNames = getModuleNames(ioredisConfig); export const ioredisChannels = { IOREDIS_COMMAND: 'orchestrion:ioredis:command', diff --git a/packages/server-utils/src/orchestrion/config/kafkajs.ts b/packages/server-utils/src/orchestrion/config/kafkajs.ts index 6aa0403bd29e..ff594a3efc92 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const kafkajsConfig = [ { @@ -22,7 +22,7 @@ export const kafkajsConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const kafkajsModuleNames = uniq(kafkajsConfig.map(config => config.module.name)); +export const kafkajsModuleNames = getModuleNames(kafkajsConfig); export const kafkajsChannels = { KAFKAJS_SEND_BATCH: 'orchestrion:kafkajs:send_batch', diff --git a/packages/server-utils/src/orchestrion/config/knex.ts b/packages/server-utils/src/orchestrion/config/knex.ts index 82c803baf239..fa378aa367a9 100644 --- a/packages/server-utils/src/orchestrion/config/knex.ts +++ b/packages/server-utils/src/orchestrion/config/knex.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; const MODULE_NAME = 'knex'; @@ -46,7 +46,7 @@ export const knexConfig: InstrumentationConfig[] = [ ), ] as const satisfies InstrumentationConfig[]; -export const knexModuleNames = uniq(knexConfig.map(config => config.module.name)); +export const knexModuleNames = getModuleNames(knexConfig); export const knexChannels = { KNEX_QUERY: 'orchestrion:knex:query', diff --git a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts index 8ca759109326..005f9074c143 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const lruMemoizerConfig = [ { @@ -10,7 +10,7 @@ export const lruMemoizerConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const lruMemoizerModuleNames = uniq(lruMemoizerConfig.map(config => config.module.name)); +export const lruMemoizerModuleNames = getModuleNames(lruMemoizerConfig); export const lruMemoizerChannels = { LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', diff --git a/packages/server-utils/src/orchestrion/config/mysql.ts b/packages/server-utils/src/orchestrion/config/mysql.ts index e9c39e4f8435..d0f13ddd92e9 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const mysqlConfig = [ { @@ -9,7 +9,7 @@ export const mysqlConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const mysqlModuleNames = uniq(mysqlConfig.map(config => config.module.name)); +export const mysqlModuleNames = getModuleNames(mysqlConfig); export const mysqlChannels = { MYSQL_QUERY: 'orchestrion:mysql:query', diff --git a/packages/server-utils/src/orchestrion/config/mysql2.ts b/packages/server-utils/src/orchestrion/config/mysql2.ts index 4e8009492da4..aaece6024fa3 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; // Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection // prototype) to orchestrion channel injection. @@ -43,7 +43,7 @@ export const mysql2Config = [ }, ] as const satisfies InstrumentationConfig[]; -export const mysql2ModuleNames = uniq(mysql2Config.map(config => config.module.name)); +export const mysql2ModuleNames = getModuleNames(mysql2Config); export const mysql2Channels = { MYSQL2_QUERY: 'orchestrion:mysql2:query', diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index fab747862e87..4800ed3ca1b0 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -1,5 +1,5 @@ import type { InstrumentationConfig } from '..'; -import { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; /** * Wrap an instrumentation that targets nodes via a raw esquery selector @@ -131,7 +131,7 @@ export const nestjsConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const nestjsModuleNames = uniq(nestjsConfig.map(config => config.module.name)); +export const nestjsModuleNames = getModuleNames(nestjsConfig); export const nestjsChannels = { NESTJS_APP_CREATION: 'orchestrion:@nestjs/core:nestFactoryCreate', diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index 966db896e4ea..57a6133cfea8 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const openaiConfig = [ // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, @@ -30,7 +30,7 @@ export const openaiConfig = [ })), ] as const satisfies InstrumentationConfig[]; -export const openaiModuleNames = uniq(openaiConfig.map(config => config.module.name)); +export const openaiModuleNames = getModuleNames(openaiConfig); export const openaiChannels = { // Chat completions, the responses API, and the conversations API all report a `chat` operation with diff --git a/packages/server-utils/src/orchestrion/config/pg.ts b/packages/server-utils/src/orchestrion/config/pg.ts index d2b7e5fa380c..b6592ddb5020 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const pgConfig = [ // `pg` (node-postgres). @@ -42,7 +42,7 @@ export const pgConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const pgModuleNames = uniq(pgConfig.map(config => config.module.name)); +export const pgModuleNames = getModuleNames(pgConfig); export const pgChannels = { PG_QUERY: 'orchestrion:pg:query', diff --git a/packages/server-utils/src/orchestrion/config/postgres.ts b/packages/server-utils/src/orchestrion/config/postgres.ts index 1e945bd4abe3..ad80c3eef264 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; // postgres.js (`postgres` npm package, v3.x). Named after the npm package; // `postgres` doesn't collide with `pg.ts` (that file instruments `pg`/`pg-pool`). @@ -48,7 +48,7 @@ const postgresJsInstrumentationConfig = (dir: string): InstrumentationConfig[] = ] as const satisfies InstrumentationConfig[]; export const postgresJsConfig = ['src', 'cjs/src'].flatMap(postgresJsInstrumentationConfig); -export const postgresJsModuleNames = uniq(postgresJsConfig.map(config => config.module.name)); +export const postgresJsModuleNames = getModuleNames(postgresJsConfig); export const postgresJsChannels = { POSTGRESJS_HANDLE: 'orchestrion:postgres:handle', diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts index 0e6970ffbd41..05f44dd31251 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const redisConfig = [ // redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an @@ -63,7 +63,7 @@ export const redisConfig = [ }, ] as const satisfies InstrumentationConfig[]; -export const redisModuleNames = uniq(redisConfig.map(config => config.module.name)); +export const redisModuleNames = getModuleNames(redisConfig); export const redisChannels = { REDIS_COMMAND: 'orchestrion:redis:command', diff --git a/packages/server-utils/src/orchestrion/config/utils.ts b/packages/server-utils/src/orchestrion/config/utils.ts new file mode 100644 index 000000000000..4edac964f267 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/utils.ts @@ -0,0 +1,6 @@ +import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; + +export function getModuleNames(config: InstrumentationConfig[]): string[] { + return uniq(config.map(config => config.module.name)); +} diff --git a/packages/server-utils/src/orchestrion/config/vercel-ai.ts b/packages/server-utils/src/orchestrion/config/vercel-ai.ts index 7754c72f0e92..311a54274d3c 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 { uniq } from '@sentry/core'; +import { getModuleNames } from './utils'; export const vercelAiConfig = [ // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting @@ -25,7 +25,7 @@ export const vercelAiConfig = [ ...vercelAiEntries('>=6.0.0 <7.0.0', 'executeToolCall', 'executeToolCall', 'Async'), ] as const satisfies InstrumentationConfig[]; -export const vercelAiModuleNames = uniq(vercelAiConfig.map(config => config.module.name)); +export const vercelAiModuleNames = getModuleNames(vercelAiConfig); export const vercelAiChannels = { // Vercel AI (`ai`): orchestrion injects these so the same channel-based