diff --git a/packages/node/src/sdk/client.ts b/packages/node/src/sdk/client.ts index 76a4323f3f75..01de1b58fd82 100644 --- a/packages/node/src/sdk/client.ts +++ b/packages/node/src/sdk/client.ts @@ -17,6 +17,7 @@ import { type SentryTracerProvider, setOpenTelemetryContextAsyncContextStrategy, } from '@sentry/opentelemetry'; +import { registerDiagnosticsChannelInjection } from '@sentry/server-runtime-injection/register'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import { isMainThread, threadId } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; @@ -24,6 +25,10 @@ import type { NodeClientOptions } from '../types'; const DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS = 60_000; // 60s was chosen arbitrarily +// Treeshakable guard to remove all code related to runtime diagnostics-channel injection. Set to +// `false` at build time by the Sentry bundler plugins' `bundleSizeOptimizations.excludeChannelInjection`. +declare const __SENTRY_CHANNEL_INJECTION__: boolean | undefined; + /** A client for using Sentry with Node & OpenTelemetry. */ export class NodeClient extends ServerRuntimeClient { public traceProvider: SentryTracerProvider | undefined; @@ -82,6 +87,21 @@ export class NodeClient extends ServerRuntimeClient { // Same constructor anchoring as above: every client must continue incoming (remote) traces, // also manually constructed ones that never run `initOtel`. registerPrepareSpanScope(this); + + // Install the channel-based (orchestrion diagnostics-channel) instrumentation hooks here, in the + // constructor, so that every client installs them — not only the one built by the Node SDK's + // `init()`. Downstream SDKs construct a client without going through that path, and would + // otherwise never install the hooks. Registration is idempotent (a global marker guards it), so + // a second construction is harmless. The channel integrations capture errors as well as spans, + // so this is independent of tracing. Opt out at runtime with `enableRuntimeChannelInjection: + // false`, or at build time via the bundler plugins' + // `bundleSizeOptimizations.excludeChannelInjection` (which tree-shakes this whole block away). + if ( + (typeof __SENTRY_CHANNEL_INJECTION__ === 'undefined' || __SENTRY_CHANNEL_INJECTION__) && + options.enableRuntimeChannelInjection !== false + ) { + registerDiagnosticsChannelInjection(); + } } /** @inheritDoc */ diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index d98b800c8abb..6f7d96a22f42 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -17,7 +17,6 @@ import { } from '@sentry/core'; import { isMainThread, parentPort } from 'node:worker_threads'; import { detectOrchestrionSetup, getErrorIntegrations, getTracingIntegrations } from '@sentry/server-utils'; -import { registerDiagnosticsChannelInjection } from '@sentry/server-runtime-injection/register'; import { DEBUG_BUILD } from '../debug-build'; import { childProcessIntegration } from '../integrations/childProcess'; import { consoleIntegration } from '../integrations/console'; @@ -41,10 +40,6 @@ import { defaultStackParser, getSentryRelease } from './api'; import { NodeClient } from './client'; import { initOpenTelemetry } from './initOtel'; -// Treeshakable guard to remove all code related to runtime diagnostics-channel injection. Set to -// `false` at build time by the Sentry bundler plugins' `bundleSizeOptimizations.excludeChannelInjection`. -declare const __SENTRY_CHANNEL_INJECTION__: boolean | undefined; - /** * Get the base default integrations shared by all Node SDK default-integration sets. */ @@ -134,9 +129,10 @@ function _init( applySdkMetadata(options, 'node'); - // Enable debug logging before channel-injection registration below, so its failure modes (e.g. no - // available Node hook API, dep-resolution errors) actually surface. `getClientOptions` resolves - // `debug` the same way for the client; resolving it here as well keeps the two in agreement. + // Enable debug logging before the client is created, so failure modes during setup (e.g. the + // channel-injection registration finding no available Node hook API, or dep-resolution errors) + // actually surface. `getClientOptions` resolves `debug` the same way for the client; resolving it + // here as well keeps the two in agreement. if (envToBool(options.debug ?? process.env.SENTRY_DEBUG)) { if (DEBUG_BUILD) { debug.enable(); @@ -158,18 +154,6 @@ function _init( tracesSampleRate: getTracesSampleRate(options.tracesSampleRate), }; - // Install the channel-based (orchestrion diagnostics-channel) instrumentation hooks by default, - // independent of tracing — the channel integrations also capture errors, not just spans. Opt out at - // runtime with `enableRuntimeChannelInjection: false`, or at build time via the bundler plugins' - // `bundleSizeOptimizations.excludeChannelInjection` (which tree-shakes this whole block away). - // Install as early as possible, before the app imports its instrumented modules. - if ( - (typeof __SENTRY_CHANNEL_INJECTION__ === 'undefined' || __SENTRY_CHANNEL_INJECTION__) && - options.enableRuntimeChannelInjection !== false - ) { - registerDiagnosticsChannelInjection(); - } - // Only use Node SDK defaults if none provided. const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(optionsWithResolvedTracing); diff --git a/packages/node/test/sdk/diagnosticsChannelInjection.test.ts b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts index fab55e968706..c90d467da20f 100644 --- a/packages/node/test/sdk/diagnosticsChannelInjection.test.ts +++ b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts @@ -14,7 +14,9 @@ vi.mock('@sentry/server-utils', async importOriginal => { return { ...actual, detectOrchestrionSetup }; }); +import { NodeClient } from '../../src/sdk/client'; import { init } from '../../src/sdk'; +import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions'; import { cleanupOtel, resetGlobals } from '../helpers/mockSdkInit'; // eslint-disable-next-line no-var @@ -83,3 +85,44 @@ describe('diagnostics-channel injection', () => { } }); }); + +// The registration lives in the `NodeClient` constructor, so downstream SDKs that build a client +// directly (without going through the Node SDK's `init()`) still install the injection hooks. +describe('diagnostics-channel injection on direct client construction', () => { + beforeEach(() => { + global.__SENTRY__ = {}; + vi.spyOn(debug, 'enable').mockImplementation(() => undefined); + }); + + afterEach(() => { + cleanupOtel(); + resetGlobals(); + vi.clearAllMocks(); + }); + + it('registers the injection hooks when a NodeClient is constructed directly', () => { + new NodeClient(getDefaultNodeClientOptions({ enableOpenTelemetrySetup: false })); + + expect(registerDiagnosticsChannelInjection).toHaveBeenCalledTimes(1); + }); + + it('does not register the injection hooks when `enableRuntimeChannelInjection` is false', () => { + new NodeClient( + getDefaultNodeClientOptions({ enableRuntimeChannelInjection: false, enableOpenTelemetrySetup: false }), + ); + + expect(registerDiagnosticsChannelInjection).not.toHaveBeenCalled(); + }); + + it('does not register the injection hooks when the `__SENTRY_CHANNEL_INJECTION__` build flag is false', () => { + vi.stubGlobal('__SENTRY_CHANNEL_INJECTION__', false); + + try { + new NodeClient(getDefaultNodeClientOptions({ enableOpenTelemetrySetup: false })); + + expect(registerDiagnosticsChannelInjection).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); +});