diff --git a/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/test.ts b/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/test.ts index ede616d629e1..44846d763a2e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/test.ts @@ -1,4 +1,5 @@ import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('genericPool v2 auto instrumentation', () => { @@ -6,6 +7,9 @@ describe('genericPool v2 auto instrumentation', () => { cleanupChildProcesses(); }); + // The orchestrion channel integration replaces the OTel one 1:1 but tags spans with its own origin. + const ORIGIN = isOrchestrionEnabled() ? 'auto.db.orchestrion.generic_pool' : 'auto.db.otel.generic_pool'; + createEsmAndCjsTests( __dirname, 'scenario.mjs', @@ -17,18 +21,18 @@ describe('genericPool v2 auto instrumentation', () => { spans: expect.arrayContaining([ expect.objectContaining({ description: 'generic-pool.acquire', - origin: 'auto.db.otel.generic_pool', + origin: ORIGIN, data: { - 'sentry.origin': 'auto.db.otel.generic_pool', + 'sentry.origin': ORIGIN, }, status: 'ok', }), expect.objectContaining({ description: 'generic-pool.acquire', - origin: 'auto.db.otel.generic_pool', + origin: ORIGIN, data: { - 'sentry.origin': 'auto.db.otel.generic_pool', + 'sentry.origin': ORIGIN, }, status: 'ok', }), @@ -47,15 +51,18 @@ describe('genericPool v2 auto instrumentation', () => { 'instrument.mjs', (createRunner, test) => { test('marks the `generic-pool.acquire` span as errored when acquiring fails', async () => { + // The orchestrion path also records the rejection's `error.type` on the span. + const errorData = isOrchestrionEnabled() + ? { 'sentry.origin': ORIGIN, 'error.type': 'Error' } + : { 'sentry.origin': ORIGIN }; + const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ expect.objectContaining({ description: 'generic-pool.acquire', - origin: 'auto.db.otel.generic_pool', - data: { - 'sentry.origin': 'auto.db.otel.generic_pool', - }, + origin: ORIGIN, + data: errorData, status: 'internal_error', }), ]), diff --git a/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts b/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts index d5ccd13362e6..e878467d0cd8 100644 --- a/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts @@ -1,4 +1,5 @@ import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('genericPool auto instrumentation', () => { @@ -6,6 +7,9 @@ describe('genericPool auto instrumentation', () => { cleanupChildProcesses(); }); + // The orchestrion channel integration replaces the OTel one 1:1 but tags spans with its own origin. + const ORIGIN = isOrchestrionEnabled() ? 'auto.db.orchestrion.generic_pool' : 'auto.db.otel.generic_pool'; + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('should auto-instrument `genericPool` package when calling pool.require()', async () => { const EXPECTED_TRANSACTION = { @@ -13,18 +17,18 @@ describe('genericPool auto instrumentation', () => { spans: expect.arrayContaining([ expect.objectContaining({ description: 'generic-pool.acquire', - origin: 'auto.db.otel.generic_pool', + origin: ORIGIN, data: { - 'sentry.origin': 'auto.db.otel.generic_pool', + 'sentry.origin': ORIGIN, }, status: 'ok', }), expect.objectContaining({ description: 'generic-pool.acquire', - origin: 'auto.db.otel.generic_pool', + origin: ORIGIN, data: { - 'sentry.origin': 'auto.db.otel.generic_pool', + 'sentry.origin': ORIGIN, }, status: 'ok', }), @@ -37,15 +41,18 @@ describe('genericPool auto instrumentation', () => { createEsmAndCjsTests(__dirname, 'scenario-error.mjs', 'instrument.mjs', (createRunner, test) => { test('marks the `generic-pool.acquire` span as errored when acquiring fails', async () => { + // The orchestrion path also records the rejection's `error.type` on the span. + const errorData = isOrchestrionEnabled() + ? { 'sentry.origin': ORIGIN, 'error.type': 'TimeoutError' } + : { 'sentry.origin': ORIGIN }; + const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ expect.objectContaining({ description: 'generic-pool.acquire', - origin: 'auto.db.otel.generic_pool', - data: { - 'sentry.origin': 'auto.db.otel.generic_pool', - }, + origin: ORIGIN, + data: errorData, status: 'internal_error', }), ]), diff --git a/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts b/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts new file mode 100644 index 000000000000..576f0b4ca8e9 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/generic-pool.ts @@ -0,0 +1,53 @@ +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 { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +// Same name as the OTel integration by design — when enabled, the OTel +// 'GenericPool' integration is omitted from the default set. +const INTEGRATION_NAME = 'GenericPool' as const; + +interface GenericPoolAcquireContext { + arguments: unknown[]; +} + +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()); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven generic-pool integration. Subscribes to + * `orchestrion:generic-pool:acquire` (injected into `generic-pool/lib/Pool.js`'s + * `Pool.prototype.acquire`). Creates a `generic-pool.acquire` span for each + * acquisition. Requires the orchestrion runtime hook or bundler plugin. + */ +export const genericPoolChannelIntegration = defineIntegration(_genericPoolChannelIntegration); + +function instrumentGenericPool(): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.GENERIC_POOL_ACQUIRE), + () => + startInactiveSpan({ + name: 'generic-pool.acquire', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.orchestrion.generic_pool', + }, + }), + ); +} diff --git a/packages/server-utils/src/orchestrion/config/generic-pool.ts b/packages/server-utils/src/orchestrion/config/generic-pool.ts index f44e2aa0f839..83866505162f 100644 --- a/packages/server-utils/src/orchestrion/config/generic-pool.ts +++ b/packages/server-utils/src/orchestrion/config/generic-pool.ts @@ -1,6 +1,23 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; -// TODO: Stub for the `generic-pool` orchestrion integration (ports `@opentelemetry/instrumentation-generic-pool`). -export const genericPoolConfig: InstrumentationConfig[] = []; +// 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`. +// - v2.4–v3: `Pool.prototype.acquire = function acquire(callback, priority)` is callback-based. +// Versions before 2.4 assigned an anonymous `acquire` per pool instance (a factory), which a static +// transform can't target, so they're out of scope (matching how the OTel instrumentation split them). +export const genericPoolConfig = [ + { + channelName: 'acquire', + module: { name: 'generic-pool', versionRange: '>=3.0.0 <4', filePath: 'lib/Pool.js' }, + functionQuery: { className: 'Pool', methodName: 'acquire', kind: 'Auto' }, + }, + { + channelName: 'acquire', + module: { name: 'generic-pool', versionRange: '>=2.4.0 <3', filePath: 'lib/generic-pool.js' }, + functionQuery: { expressionName: 'acquire', kind: 'Callback' }, + }, +] satisfies InstrumentationConfig[]; -export const genericPoolChannels = {} as const; +export const genericPoolChannels = { + GENERIC_POOL_ACQUIRE: 'orchestrion:generic-pool:acquire', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 4fe46c7180cd..0d587b0f7fc8 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,5 +1,6 @@ import { amqplibChannelIntegration } from '../integrations/tracing-channel/amqplib'; import { anthropicChannelIntegration } from '../integrations/tracing-channel/anthropic'; +import { genericPoolChannelIntegration } from '../integrations/tracing-channel/generic-pool'; import { googleGenAIChannelIntegration } from '../integrations/tracing-channel/google-genai'; import { graphqlChannelIntegration, @@ -23,6 +24,7 @@ export { nestjsChannels } from './config/nestjs'; export { amqplibChannelIntegration, anthropicChannelIntegration, + genericPoolChannelIntegration, googleGenAIChannelIntegration, graphqlChannelIntegration, hapiChannelIntegration, @@ -66,6 +68,7 @@ export const channelIntegrations = { postgresIntegration: postgresChannelIntegration, postgresJsIntegration: postgresJsChannelIntegration, mysqlIntegration: mysqlChannelIntegration, + genericPoolIntegration: genericPoolChannelIntegration, lruMemoizerIntegration: lruMemoizerChannelIntegration, openaiIntegration: openaiChannelIntegration, anthropicIntegration: anthropicChannelIntegration,