From 0af2bbc94d75ac1d21b98d4fe870838c952cbac3 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 21 Aug 2026 10:52:44 +0200 Subject: [PATCH] feat(node): Ensure orchestrion runtime is injected when integration is passed --- .../node/src/integrations/tracing/index.ts | 62 ++++++++++++++++++- packages/node/src/sdk/index.ts | 36 ++++++++--- .../sdk/diagnosticsChannelInjection.test.ts | 35 ++++++++++- 3 files changed, 120 insertions(+), 13 deletions(-) diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 2d7274326a40..24ad01b406e1 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -27,7 +27,11 @@ import { } from '@sentry/server-utils/orchestrion'; import { fastifyIntegration } from './fastify'; -export function getAutoPerformanceIntegrations(): Integration[] { +/** + * This explicitly has no return type to ensure this is inferred properly. + * We use this to ensure that AUTO_PERFORMANCE_INTEGRATION_NAMES is in sync with the integrations returned by this function. + */ +function _getAutoPerformanceIntegrations() { return [ expressIntegration(), fastifyIntegration(), @@ -58,3 +62,59 @@ export function getAutoPerformanceIntegrations(): Integration[] { firebaseIntegration(), ]; } + +export function getAutoPerformanceIntegrations(): Integration[] { + return _getAutoPerformanceIntegrations(); +} + +/** + * Union of the `name` of every integration returned by {@link _getAutoPerformanceIntegrations}. + * Derived from that function's inferred (intentionally un-annotated) return type, so it stays in + * sync automatically as integrations are added or removed. + */ +type AutoPerformanceIntegrationName = ReturnType[number]['name']; + +/** + * Builds a readonly tuple that must list **every** member of `T` exactly. A missing member makes the + * call fail to compile — the error names the missing member(s) — while an unknown/misspelled member + * is rejected by the element constraint. This enforces exhaustiveness at the declaration itself, so + * no separate assertion is needed. + */ +const tupleOfAllNames = + () => + (names: [T] extends [U[number]] ? U : Exclude): U => + names as U; + +/** + * The names of all auto performance integrations, as a runtime constant so callers can check for + * these integrations (e.g. to gate channel-based instrumentation) without instantiating them. Typed + * so that adding an integration to {@link _getAutoPerformanceIntegrations} without listing it here + * (or vice versa) is a compile error. + */ +export const AUTO_PERFORMANCE_INTEGRATION_NAMES = tupleOfAllNames()([ + 'Express', + 'Fastify', + 'Graphql', + 'Mongo', + 'Mongoose', + 'Mysql', + 'Mysql2', + 'Redis', + 'Postgres', + 'Prisma', + 'Hapi', + 'Koa', + 'Tedious', + 'GenericPool', + 'Kafka', + 'Amqplib', + 'LruMemoizer', + 'LangChain', + 'LangGraph', + 'VercelAI', + 'OpenAI', + 'Anthropic_AI', + 'Google_GenAI', + 'PostgresJs', + 'Firebase', +]); diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 2bb381588581..272130110cd1 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -32,7 +32,7 @@ import { onUnhandledRejectionIntegration } from '../integrations/onunhandledreje import { processSessionIntegration } from '../integrations/processSession'; import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight'; import { systemErrorIntegration } from '../integrations/systemError'; -import { getAutoPerformanceIntegrations } from '../integrations/tracing'; +import { AUTO_PERFORMANCE_INTEGRATION_NAMES, getAutoPerformanceIntegrations } from '../integrations/tracing'; import { makeNodeTransport } from '../transports'; import type { NodeClientOptions, NodeOptions } from '../types'; import { getEntryPointType } from '../utils/entry-point'; @@ -89,6 +89,20 @@ export function getDefaultIntegrations(options: Options): Integration[] { ]; } +/** + * Whether the user explicitly configured a channel-based (orchestrion) integration via the + * `integrations` option. These integrations (e.g. `expressIntegration()`) can capture errors even + * with tracing off, so their diagnostics-channel module hooks must be installed regardless of spans. + */ +function hasUserConfiguredChannelIntegration(options: NodeClientOptions): boolean { + if (!Array.isArray(options.integrations)) { + return false; + } + return options.integrations.some(integration => + (AUTO_PERFORMANCE_INTEGRATION_NAMES as readonly string[]).includes(integration.name), + ); +} + /** * Initialize Sentry for Node. */ @@ -154,20 +168,22 @@ function _init( tracesSampleRate: getTracesSampleRate(options.tracesSampleRate), }; - // Gate channel-based (orchestrion diagnostics-channel) instrumentation on span recording: the - // channel integrations only produce spans, so with tracing off there are no subscribers and - // injecting the module hooks would be pointless work. Install the hooks as early as possible, - // before the app imports its instrumented modules. - const useChannelInjection = hasSpansEnabled(optionsWithResolvedTracing); - if (useChannelInjection) { - registerDiagnosticsChannelInjection(); - } - // Only use Node SDK defaults if none provided. const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(optionsWithResolvedTracing); const clientOptions = getClientOptions({ ...options, defaultIntegrations }, getDefaultIntegrationsImpl); + // Gate channel-based (orchestrion diagnostics-channel) instrumentation. Register the module hooks + // when tracing is on (the channel integrations produce spans) OR when the user explicitly added a + // channel-based integration (e.g. `expressIntegration()`) via `integrations` — those can capture + // errors even with tracing off, so their subscribers must fire regardless of spans. Install the + // hooks as early as possible, before the app imports its instrumented modules. + const useChannelInjection = + hasSpansEnabled(optionsWithResolvedTracing) || hasUserConfiguredChannelIntegration(clientOptions); + if (useChannelInjection) { + registerDiagnosticsChannelInjection(); + } + const scope = getCurrentScope(); scope.update(clientOptions.initialScope); diff --git a/packages/node/test/sdk/diagnosticsChannelInjection.test.ts b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts index e06405efaae9..f350c0d590ca 100644 --- a/packages/node/test/sdk/diagnosticsChannelInjection.test.ts +++ b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts @@ -14,6 +14,7 @@ vi.mock('@sentry/server-utils/orchestrion', async importOriginal => { return { ...actual, detectOrchestrionSetup }; }); +import { expressIntegration } from '../../src'; import { init } from '../../src/sdk'; import { cleanupOtel, resetGlobals } from '../helpers/mockSdkInit'; @@ -23,8 +24,9 @@ declare var global: any; const PUBLIC_DSN = 'https://username@domain/123'; // Channel-based (orchestrion diagnostics-channel) instrumentation is the default in v11: `init()` -// installs the injection hooks unconditionally when span recording is enabled, and skips them when -// tracing is off (there would be no channel subscribers to feed). +// installs the injection hooks when span recording is enabled, or when a channel-based integration +// (e.g. `expressIntegration()`) is configured — those can capture errors even with tracing off. With +// tracing off and no such integration there are no channel subscribers, so the hooks are skipped. describe('diagnostics-channel injection default', () => { beforeEach(() => { global.__SENTRY__ = {}; @@ -50,4 +52,33 @@ describe('diagnostics-channel injection default', () => { expect(registerDiagnosticsChannelInjection).not.toHaveBeenCalled(); expect(detectOrchestrionSetup).not.toHaveBeenCalled(); }); + + it('registers the injection hooks when a channel-based integration is configured, even with tracing disabled', () => { + init({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false, integrations: [expressIntegration()] }); + + expect(registerDiagnosticsChannelInjection).toHaveBeenCalledTimes(1); + expect(detectOrchestrionSetup).toHaveBeenCalledTimes(1); + }); + + it('registers the injection hooks when a channel-based integration is added via an `integrations` function', () => { + init({ + dsn: PUBLIC_DSN, + enableOpenTelemetrySetup: false, + integrations: defaults => [...defaults, expressIntegration()], + }); + + expect(registerDiagnosticsChannelInjection).toHaveBeenCalledTimes(1); + expect(detectOrchestrionSetup).toHaveBeenCalledTimes(1); + }); + + it('does not register the injection hooks when only non-channel integrations are configured and tracing is disabled', () => { + init({ + dsn: PUBLIC_DSN, + enableOpenTelemetrySetup: false, + integrations: [{ name: 'CustomNonChannelIntegration', setup: () => undefined }], + }); + + expect(registerDiagnosticsChannelInjection).not.toHaveBeenCalled(); + expect(detectOrchestrionSetup).not.toHaveBeenCalled(); + }); });