From 0bc684344389a99fb3d34c17b90af97a45354ec7 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 2 Jul 2026 15:35:57 -0700 Subject: [PATCH 01/18] deps(server-utils): Bump @apm-js-collab/code-transformer to 0.16.0 --- packages/server-utils/package.json | 2 +- yarn.lock | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index a09654179232..b6a95bdd9646 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -83,7 +83,7 @@ }, "dependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", - "@apm-js-collab/code-transformer": "^0.15.0", + "@apm-js-collab/code-transformer": "^0.16.0", "@apm-js-collab/tracing-hooks": "^0.10.1", "@sentry/conventions": "^0.15.1", "@sentry/core": "10.64.0", diff --git a/yarn.lock b/yarn.lock index 4913e2a20b85..e93385a39148 100644 --- a/yarn.lock +++ b/yarn.lock @@ -426,6 +426,18 @@ semifies "^1.0.0" source-map "^0.6.0" +"@apm-js-collab/code-transformer@^0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.16.0.tgz#f49f3f88839907aea86a23c0a8102ad5038b8273" + integrity sha512-J3YRXRsxr/d48E7iDOAyVLrqQMCGU1iHWxXPKq7EeXQTRDDJ50piOfQNqxsM7u4XogJlirXvLHIznr0T33HTKw== + dependencies: + "@types/estree" "^1.0.8" + astring "^1.9.0" + esquery "^1.7.0" + meriyah "^6.1.4" + semifies "^1.0.0" + source-map "^0.6.0" + "@apm-js-collab/tracing-hooks@^0.10.1": version "0.10.1" resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.1.tgz#52a463f6dd03e99582a7dde9816257efabdf63b9" From 7412ae7e6aec5138a5e4e8a1e4e95a024df598df Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 2 Jul 2026 15:43:50 -0700 Subject: [PATCH 02/18] deps(server-utils): Bump @apm-js-collab/tracing-hooks to 0.11.0 --- packages/server-utils/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index b6a95bdd9646..4240d4337287 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -84,7 +84,7 @@ "dependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", "@apm-js-collab/code-transformer": "^0.16.0", - "@apm-js-collab/tracing-hooks": "^0.10.1", + "@apm-js-collab/tracing-hooks": "^0.11.0", "@sentry/conventions": "^0.15.1", "@sentry/core": "10.64.0", "magic-string": "~0.30.0" diff --git a/yarn.lock b/yarn.lock index e93385a39148..e66c8ba79e24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -438,12 +438,12 @@ semifies "^1.0.0" source-map "^0.6.0" -"@apm-js-collab/tracing-hooks@^0.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.1.tgz#52a463f6dd03e99582a7dde9816257efabdf63b9" - integrity sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA== +"@apm-js-collab/tracing-hooks@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.11.0.tgz#4c9c378695d65e90574893c1faba3c13b5bdbd14" + integrity sha512-5hWEcCGF4hcNh9lyB70p58pXn4HyUAVCad44wK6j110Ky+ivG19TfKHLB2aIDgItabCj6VPZm0DMAMNRnJDNBw== dependencies: - "@apm-js-collab/code-transformer" "^0.15.0" + "@apm-js-collab/code-transformer" "^0.16.0" debug "^4.4.1" module-details-from-path "^1.0.4" From 56dbced20e880bf14079df39fe3a8b84dcc6dd88 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 24 Jun 2026 11:56:43 -0700 Subject: [PATCH 03/18] feat(nestjs): initial orchestrion span for app creation Not yet wired into the NestJS SDK, but the initial channels and span creation logic in place. --- .../integrations/tracing-channel/nestjs.ts | 77 ++++++++ .../server-utils/src/orchestrion/channels.ts | 2 + .../src/orchestrion/config/index.ts | 2 + .../src/orchestrion/config/nestjs.ts | 19 ++ .../server-utils/src/orchestrion/index.ts | 3 + .../test/orchestrion/nestjs.test.ts | 175 ++++++++++++++++++ 6 files changed, 278 insertions(+) create mode 100644 packages/server-utils/src/integrations/tracing-channel/nestjs.ts create mode 100644 packages/server-utils/src/orchestrion/config/nestjs.ts create mode 100644 packages/server-utils/test/orchestrion/nestjs.test.ts diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts new file mode 100644 index 000000000000..fe6cdf0ca831 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts @@ -0,0 +1,77 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +// NOTE: this uses the same name as the OTel integration by design. +// When enabled, the OTel 'Nest' integration is omitted from the default set. +const INTEGRATION_NAME = 'Nest'; + +// Span op/origin/attribute values inlined to match the vendored +// `@opentelemetry/instrumentation-nestjs-core` output exactly (the +// `@sentry/nestjs` e2e suite asserts these). They are NOT imported from +// `@sentry/nestjs` because that package depends on this one, not vice versa. +// Orchestrion's whole point is to keep this surface free of OTel. +const NESTJS_COMPONENT = '@nestjs/core'; +const ORIGIN_NESTJS = 'auto.http.otel.nestjs'; +const ATTR_COMPONENT = 'component'; +const ATTR_NESTJS_TYPE = 'nestjs.type'; +const ATTR_NESTJS_VERSION = 'nestjs.version'; +const ATTR_NESTJS_MODULE = 'nestjs.module'; +const TYPE_APP_CREATION = 'app_creation'; + +/** + * The shape orchestrion's `tracePromise` transform attaches to the + * tracing-channel context for `NestFactoryStatic.prototype.create`. + * `arguments[0]` is the root application module class. + */ +interface NestFactoryCreateData { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +const _nestjsChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + DEBUG_BUILD && debug.log(`[orchestrion:nestjs] subscribing to channel "${CHANNELS.NESTJS_APP_CREATION}"`); + + // App-creation span: `bindTracingChannelToSpan` opens the span on + // `start`, makes it the active context for the bootstrap, and ends + // it on `asyncEnd` (or `end` if `create` throws synchronously). + // `captureError: false`. Failed bootstrap surfaces to the caller. + // We just annotate the span. + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), + data => { + const moduleCls = data.arguments?.[0] as { name?: string } | undefined; + return startInactiveSpan({ + name: 'Create Nest App', + op: `${TYPE_APP_CREATION}.nestjs`, + attributes: { + [ATTR_COMPONENT]: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, + [ATTR_NESTJS_TYPE]: TYPE_APP_CREATION, + ...(data.moduleVersion ? { [ATTR_NESTJS_VERSION]: data.moduleVersion } : {}), + ...(moduleCls?.name ? { [ATTR_NESTJS_MODULE]: moduleCls.name } : {}), + }, + }); + }, + { captureError: false }, + ); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL orchestrion-driven NestJS integration. + * + * Subscribes to the diagnostics_channels the orchestrion code transform + * injects into `@nestjs/core` (see `orchestrion/config.ts`). Requires the + * orchestrion runtime hook or bundler plugin to be active. + */ +export const nestjsChannelIntegration = defineIntegration(_nestjsChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 2f681a061238..23a08b2279c6 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -10,6 +10,7 @@ import { vercelAiChannels } from './config/vercel-ai'; import { hapiChannels } from './config/hapi'; import { redisChannels } from './config/redis'; import { expressChannels } from './config/express'; +import { nestjsChannels } from './config/nestjs'; /** * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. @@ -37,6 +38,7 @@ export const CHANNELS = { ...hapiChannels, ...redisChannels, ...expressChannels, + ...nestjsChannels, } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index bbbbf37d0b70..b859273f844f 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -11,6 +11,7 @@ import { vercelAiConfig } from './vercel-ai'; import { hapiConfig } from './hapi'; import { redisConfig } from './redis'; import { expressConfig } from './express'; +import { nestjsConfig } from './nestjs'; export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, @@ -25,6 +26,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...hapiConfig, ...redisConfig, ...expressConfig, + ...nestjsConfig, ]; /** diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts new file mode 100644 index 000000000000..c1204240f62a --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -0,0 +1,19 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const nestjsConfig = [ + { + // `@nestjs/core/nest-factory.js` exports `class NestFactoryStatic` with an + // `async create(moduleCls, serverOrOptions, options)` method (the app + // bootstrap). A plain `className`+`methodName` match works here, unlike + // mysql's prototype-assignment shape. `Async` ends the span on + // `asyncEnd`, covering the full async bootstrap. Mirrors the vendored + // `@opentelemetry/instrumentation-nestjs-core` `NestFactory.create` wrap. + channelName: 'nestFactoryCreate', + module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'nest-factory.js' }, + functionQuery: { className: 'NestFactoryStatic', methodName: 'create', kind: 'Async' }, + }, +] satisfies InstrumentationConfig[]; + +export const nestjsChannels = { + NESTJS_APP_CREATION: 'orchestrion:@nestjs/core:nestFactoryCreate', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 6ac9761087f6..f95fc4aa82a7 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -28,6 +28,9 @@ export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../i export type { PostgresJsChannelIntegrationOptions } from '../integrations/tracing-channel/postgres-js'; export { redisChannelIntegration } from '../integrations/tracing-channel/redis'; export type { RedisChannelIntegrationOptions, RedisResponseHook } from '../integrations/tracing-channel/redis'; +// Not part of `channelIntegrations` below: `Nest` isn't a `@sentry/node` default integration (it's added +// by the standalone `@sentry/nestjs` SDK), so it's not swapped via the generic default-integration path. +export { nestjsChannelIntegration } from '../integrations/tracing-channel/nestjs'; /** * The canonical set of orchestrion diagnostics-channel integrations, keyed by their public diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts new file mode 100644 index 000000000000..16d220e312d6 --- /dev/null +++ b/packages/server-utils/test/orchestrion/nestjs.test.ts @@ -0,0 +1,175 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + Client, + createTransport, + getCurrentScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + getGlobalScope, + getIsolationScope, + initAndBind, + resolvedSyncPromise, + setAsyncContextStrategy, + spanToJSON, +} from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { nestjsChannelIntegration } from '../../src/orchestrion'; +import { CHANNELS } from '../../src/orchestrion/channels'; + +// Mirrors harness in `tracing-channel.test.ts`: `bindTracingChannelToSpan` +// only creates/ends spans when an async-context binding is available, so the +// strategy below must be installed for the subscriber to do anything. +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(): void { + //@ts-expect-error - just a mock for the test, this is fine + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); +} + +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return ( + asyncStorage.getStore() || { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + } + ); + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +interface NestFactoryCreateData { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +describe('nestjsChannelIntegration: app_creation', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + // Grab the bound span off the channel payload so we can assert on it + // after the operation settles. subscriber stamps it at `start` on + // `data._sentrySpan` + function captureSpan(): { getSpan: () => Span | undefined } { + let span: Span | undefined; + const grab = (data: NestFactoryCreateData): void => { + span ??= (data as { _sentrySpan?: Span })._sentrySpan; + }; + // The raw node `tracingChannel` type wants all five handlers; only + // `end`/`asyncEnd` carry the bound span by the time it settles. + tracingChannel(CHANNELS.NESTJS_APP_CREATION).subscribe({ + start: () => undefined, + asyncStart: () => undefined, + asyncEnd: grab, + end: grab, + error: () => undefined, + }); + return { getSpan: () => span }; + } + + it('opens a "Create Nest App" span with the OTel-compatible op/origin/attributes', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + const { getSpan } = captureSpan(); + const channel = tracingChannel(CHANNELS.NESTJS_APP_CREATION); + + class AppModule {} + await channel.tracePromise(async () => ({ app: true }), { arguments: [AppModule], moduleVersion: '10.4.1' }); + + const span = getSpan(); + expect(span).toBeDefined(); + const json = spanToJSON(span!); + expect(json.description).toBe('Create Nest App'); + expect(json.op).toBe('app_creation.nestjs'); + expect(json.origin).toBe('auto.http.otel.nestjs'); + expect(json.data).toMatchObject({ + component: '@nestjs/core', + 'nestjs.type': 'app_creation', + 'nestjs.version': '10.4.1', + 'nestjs.module': 'AppModule', + }); + // Span was ended on `asyncEnd`. + expect(json.timestamp).toBeDefined(); + }); + + it('omits optional attributes when version/module are absent', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + const { getSpan } = captureSpan(); + const channel = tracingChannel(CHANNELS.NESTJS_APP_CREATION); + + await channel.tracePromise(async () => ({ app: true }), { arguments: [] }); + + const json = spanToJSON(getSpan()!); + expect(json.data['nestjs.version']).toBeUndefined(); + expect(json.data['nestjs.module']).toBeUndefined(); + expect(json.data['nestjs.type']).toBe('app_creation'); + }); +}); From 41fec12b782027a9d9db11e374c27f0ac30224ff Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 24 Jun 2026 13:09:47 -0700 Subject: [PATCH 04/18] feat(nest): orchestrion hooks for request_context, request_handler Port the entire vendored OTel core `instrumentation-nestjs-core` functionality. Still remaining are the 4 Sentry decorator instrumentations and then wiring up the final `experimentalUseDiagnosticsChannelIntegration()` piece with `replacedOtelIntegrationNames: ['Nest']` in the Node SDK, and an E2E test to verify that it matches the OTel functionality. --- .../integrations/tracing-channel/nestjs.ts | 190 +++++++++++++++++- .../src/orchestrion/config/nestjs.ts | 11 + .../test/orchestrion/nestjs.test.ts | 142 +++++++++++++ 3 files changed, 340 insertions(+), 3 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts index fe6cdf0ca831..ff7428e547d7 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts @@ -1,6 +1,12 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; -import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; +import type { IntegrationFn, SpanAttributes } from '@sentry/core'; +import { + debug, + defineIntegration, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, + startSpan, +} from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; @@ -20,7 +26,28 @@ const ATTR_COMPONENT = 'component'; const ATTR_NESTJS_TYPE = 'nestjs.type'; const ATTR_NESTJS_VERSION = 'nestjs.version'; const ATTR_NESTJS_MODULE = 'nestjs.module'; +const ATTR_NESTJS_CONTROLLER = 'nestjs.controller'; +const ATTR_NESTJS_CALLBACK = 'nestjs.callback'; +const ATTR_HTTP_ROUTE = 'http.route'; +const ATTR_HTTP_METHOD = 'http.method'; +const ATTR_HTTP_URL = 'http.url'; const TYPE_APP_CREATION = 'app_creation'; +const TYPE_REQUEST_CONTEXT = 'request_context'; +const TYPE_REQUEST_HANDLER = 'handler'; + +type AnyFn = (this: unknown, ...args: unknown[]) => unknown; + +// Marks a function as already wrapped so repeated subscriptions (e.g. a second +// `setupOnce`) don't double-wrap a callback or returned handler. +const SENTRY_WRAPPED = Symbol.for('sentry.orchestrion.nestjs.wrapped'); + +function isWrapped(fn: AnyFn): boolean { + return !!(fn as AnyFn & Record)[SENTRY_WRAPPED]; +} + +function markWrapped(fn: AnyFn): void { + (fn as AnyFn & Record)[SENTRY_WRAPPED] = true; +} /** * The shape orchestrion's `tracePromise` transform attaches to the @@ -34,11 +61,118 @@ interface NestFactoryCreateData { error?: unknown; } +/** + * The shape orchestrion's `traceSync` transform attaches to the + * tracing-channel context for `RouterExecutionContext.prototype.create`. + * `arguments[0]` is the controller instance, `arguments[1]` the route handler + * callback, and `result` is the per-request handler `create` returns. + */ +interface RouterCreateData { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +/** Minimal request shape, across the express/fastify adapters. */ +interface NestRequest { + route?: { path?: string }; + routeOptions?: { url?: string }; + routerPath?: string; + method?: string; + originalUrl?: string; + url?: string; +} + +interface ReflectWithMetadata { + getMetadataKeys?: (target: object) => unknown[]; + getMetadata?: (key: unknown, target: object) => unknown; + defineMetadata?: (key: unknown, value: unknown, target: object) => void; +} + +// Copy NestJS reflect-metadata from the original handler onto the wrapper so +// other decorators (param decorators, guards, `@EventPattern`, ...) that +// read it keep working. No-op when `reflect-metadata` isn't loaded. Mirrors +// vendored `@opentelemetry/instrumentation-nestjs-core` behaviour. +function copyReflectMetadata(from: object, to: object): void { + const R = Reflect as unknown as ReflectWithMetadata; + if ( + typeof R.getMetadataKeys !== 'function' || + typeof R.getMetadata !== 'function' || + typeof R.defineMetadata !== 'function' + ) { + return; + } + for (const key of R.getMetadataKeys(from)) { + R.defineMetadata(key, R.getMetadata(key, from), to); + } +} + +// Wraps the route-handler callback (`create`'s `arguments[1]`) so each +// invocation opens the `handler.nestjs` span (REQUEST_HANDLER). Preserves the +// original `.name` and reflect-metadata so NestJS reflection is unaffected. +function wrapRouteHandler(callback: AnyFn, moduleVersion?: string): AnyFn { + if (isWrapped(callback)) { + return callback; + } + const spanName = callback.name || 'anonymous nest handler'; + const attributes: SpanAttributes = { + [ATTR_COMPONENT]: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, + [ATTR_NESTJS_TYPE]: TYPE_REQUEST_HANDLER, + [ATTR_NESTJS_CALLBACK]: callback.name, + ...(moduleVersion ? { [ATTR_NESTJS_VERSION]: moduleVersion } : {}), + }; + const wrapped = function (this: unknown, ...args: unknown[]): unknown { + return startSpan({ name: spanName, op: `${TYPE_REQUEST_HANDLER}.nestjs`, attributes }, () => + callback.apply(this, args), + ); + }; + if (callback.name) { + Object.defineProperty(wrapped, 'name', { value: callback.name }); + } + copyReflectMetadata(callback, wrapped); + markWrapped(wrapped); + return wrapped; +} + +// Wraps the per-request handler `create` returns so each request opens the +// `request_context.nestjs` span (REQUEST_CONTEXT), carrying the controller / +// callback names captured at setup plus the per-request http.* attributes. +function wrapRequestContextHandler( + handler: AnyFn, + instanceName: string, + callbackName: string, + moduleVersion?: string, +): AnyFn { + const spanName = callbackName ? `${instanceName}.${callbackName}` : instanceName; + const wrapped = function (this: unknown, ...handlerArgs: unknown[]): unknown { + const req = (handlerArgs[0] || {}) as NestRequest; + const httpRoute = req.route?.path || req.routeOptions?.url || req.routerPath; + const attributes: SpanAttributes = { + [ATTR_COMPONENT]: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, + [ATTR_NESTJS_TYPE]: TYPE_REQUEST_CONTEXT, + [ATTR_NESTJS_CONTROLLER]: instanceName, + [ATTR_NESTJS_CALLBACK]: callbackName, + ...(moduleVersion ? { [ATTR_NESTJS_VERSION]: moduleVersion } : {}), + ...(httpRoute ? { [ATTR_HTTP_ROUTE]: httpRoute } : {}), + ...(req.method ? { [ATTR_HTTP_METHOD]: req.method } : {}), + ...(req.originalUrl || req.url ? { [ATTR_HTTP_URL]: req.originalUrl || req.url } : {}), + }; + return startSpan({ name: spanName, op: `${TYPE_REQUEST_CONTEXT}.nestjs`, attributes }, () => + handler.apply(this, handlerArgs), + ); + }; + markWrapped(wrapped); + return wrapped; +} + const _nestjsChannelIntegration = (() => { return { name: INTEGRATION_NAME, setupOnce() { - DEBUG_BUILD && debug.log(`[orchestrion:nestjs] subscribing to channel "${CHANNELS.NESTJS_APP_CREATION}"`); + DEBUG_BUILD && debug.log('[orchestrion:nestjs] subscribing to @nestjs/core channels'); // App-creation span: `bindTracingChannelToSpan` opens the span on // `start`, makes it the active context for the bootstrap, and ends @@ -63,6 +197,56 @@ const _nestjsChannelIntegration = (() => { }, { captureError: false }, ); + + // Request-context + request-handler spans. + // + // `RouterExecutionContext.create` runs once per route at setup + // it receives `(instance, callback, ...)` and RETURNS the per-request + // handler. We don't span `create` itself. Instead `start` wraps the + // callback arg (-> one handler span per call) and `end` reassigns + // `data.result` to replace the returned handler (-> one request_context + // span per request); `traceSync` always returns whatever `end` leaves on + // `ctx.result`, so no opt-in is needed. + // + // Both wrappers open their span at invoke time, inside the request + // context, so they parent correctly. + const routerCh = diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_ROUTER_CONTEXT); + const routerMeta = new WeakMap(); + routerCh.subscribe({ + start(data) { + const instance = data.arguments?.[0] as { constructor?: { name?: string } } | undefined; + const callback = data.arguments?.[1]; + const instanceName = instance?.constructor?.name || 'UnnamedInstance'; + const callbackName = typeof callback === 'function' ? callback.name : ''; + routerMeta.set(data, { instanceName, callbackName, moduleVersion: data.moduleVersion }); + + if (typeof callback === 'function') { + data.arguments[1] = wrapRouteHandler(callback as AnyFn, data.moduleVersion); + } + }, + end(data) { + const handler = data.result; + const meta = routerMeta.get(data); + if (typeof handler === 'function' && meta && !isWrapped(handler as AnyFn)) { + data.result = wrapRequestContextHandler( + handler as AnyFn, + meta.instanceName, + meta.callbackName, + meta.moduleVersion, + ); + } + routerMeta.delete(data); + }, + asyncStart() { + // `create` is synchronous; no async events fire. + }, + asyncEnd() { + // `create` is synchronous; no async events fire. + }, + error(data) { + routerMeta.delete(data); + }, + }); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index c1204240f62a..76d894bdb93d 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -12,8 +12,19 @@ export const nestjsConfig = [ module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'nest-factory.js' }, functionQuery: { className: 'NestFactoryStatic', methodName: 'create', kind: 'Async' }, }, + { + // `@nestjs/core/router/router-execution-context.js` exports + // `class RouterExecutionContext` with a synchronous `create(instance, + // callback, ...)` that RETURNS the per-request handler. The subscriber + // wraps the `callback` arg (-> one handler span) and reassigns the returned + // handler (-> request_context span). + channelName: 'routerExecutionContextCreate', + module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'router/router-execution-context.js' }, + functionQuery: { className: 'RouterExecutionContext', methodName: 'create', kind: 'Sync' }, + }, ] satisfies InstrumentationConfig[]; export const nestjsChannels = { NESTJS_APP_CREATION: 'orchestrion:@nestjs/core:nestFactoryCreate', + NESTJS_ROUTER_CONTEXT: 'orchestrion:@nestjs/core:routerExecutionContextCreate', } as const; diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts index 16d220e312d6..7d6cd2e7af81 100644 --- a/packages/server-utils/test/orchestrion/nestjs.test.ts +++ b/packages/server-utils/test/orchestrion/nestjs.test.ts @@ -5,6 +5,7 @@ import { _INTERNAL_setSpanForScope, Client, createTransport, + getActiveSpan, getCurrentScope, getDefaultCurrentScope, getDefaultIsolationScope, @@ -173,3 +174,144 @@ describe('nestjsChannelIntegration: app_creation', () => { expect(json.data['nestjs.type']).toBe('app_creation'); }); }); + +type AnyFn = (this: unknown, ...args: unknown[]) => unknown; + +interface RouterCreateData { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +describe('nestjsChannelIntegration: request_context / request_handler', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + // Drives `RouterExecutionContext.create` over the channel: the subscriber's + // `start` wraps the callback arg, its `end` reassigns the returned handler on + // `data.result`. `makeHandler` stands in for the real `create` body. Returns + // the effective return (the substituted `data.result`) and the wrapped + // callback (`data.arguments[1]`). + function driveCreate( + instance: object, + callback: AnyFn, + moduleVersion: string | undefined, + makeHandler: (data: RouterCreateData) => AnyFn, + ): { effectiveHandler: AnyFn; wrappedCallback: AnyFn } { + const channel = tracingChannel(CHANNELS.NESTJS_ROUTER_CONTEXT); + const data: RouterCreateData = { arguments: [instance, callback], moduleVersion }; + channel.traceSync(() => makeHandler(data), data); + return { effectiveHandler: data.result as AnyFn, wrappedCallback: data.arguments[1] as AnyFn }; + } + + it('opens a request_context span (named Controller.method) with OTel-compatible attributes', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + class CatsController {} + const instance = new CatsController(); + function getCats(): string { + return 'cats'; + } + + let contextSpanJson: ReturnType | undefined; + const { effectiveHandler } = driveCreate(instance, getCats, '10.4.1', () => { + // The per-request handler `create` returns. Capture the active span here: + // when invoked it runs inside the request_context span. + return function perRequest(): unknown { + contextSpanJson = spanToJSON(getActiveSpan()!); + return 'ok'; + }; + }); + + effectiveHandler.call(undefined, { + method: 'GET', + originalUrl: '/cats?q=1', + url: '/cats?q=1', + route: { path: '/cats' }, + }); + + expect(contextSpanJson).toBeDefined(); + expect(contextSpanJson!.description).toBe('CatsController.getCats'); + expect(contextSpanJson!.op).toBe('request_context.nestjs'); + expect(contextSpanJson!.origin).toBe('auto.http.otel.nestjs'); + expect(contextSpanJson!.data).toMatchObject({ + 'component': '@nestjs/core', + 'nestjs.type': 'request_context', + 'nestjs.controller': 'CatsController', + 'nestjs.callback': 'getCats', + 'nestjs.version': '10.4.1', + 'http.route': '/cats', + 'http.method': 'GET', + 'http.url': '/cats?q=1', + }); + }); + + it('wraps the callback arg into a request_handler span, preserving its name', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + class CatsController {} + const instance = new CatsController(); + let handlerSpanJson: ReturnType | undefined; + function getCats(): string { + handlerSpanJson = spanToJSON(getActiveSpan()!); + return 'cats'; + } + + const { wrappedCallback } = driveCreate(instance, getCats, '10.4.1', () => () => undefined); + + // `create`'s callback arg was replaced with a wrapper that preserves `.name`. + expect(wrappedCallback).not.toBe(getCats); + expect(wrappedCallback.name).toBe('getCats'); + + wrappedCallback.call(instance); + + expect(handlerSpanJson).toBeDefined(); + expect(handlerSpanJson!.description).toBe('getCats'); + expect(handlerSpanJson!.op).toBe('handler.nestjs'); + expect(handlerSpanJson!.origin).toBe('auto.http.otel.nestjs'); + expect(handlerSpanJson!.data).toMatchObject({ + 'component': '@nestjs/core', + 'nestjs.type': 'handler', + 'nestjs.callback': 'getCats', + 'nestjs.version': '10.4.1', + }); + }); + + it('nests the request_handler span under the request_context span', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + class CatsController {} + const instance = new CatsController(); + let contextSpanId: string | undefined; + let handlerParentSpanId: string | undefined; + function getCats(): string { + handlerParentSpanId = spanToJSON(getActiveSpan()!).parent_span_id; + return 'cats'; + } + + // The per-request handler calls the (wrapped) callback, like the real one. + const { effectiveHandler } = driveCreate(instance, getCats, undefined, data => { + return function perRequest(this: unknown): unknown { + contextSpanId = getActiveSpan()!.spanContext().spanId; + return (data.arguments[1] as AnyFn).call(instance); + }; + }); + + effectiveHandler.call(undefined, { method: 'GET', route: { path: '/cats' } }); + + expect(contextSpanId).toBeDefined(); + expect(handlerParentSpanId).toBe(contextSpanId); + }); +}); From c481865a88dc36698eaa8eb3ccc39b377bccd43c Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 24 Jun 2026 14:53:41 -0700 Subject: [PATCH 05/18] feat(nest): port remainder of vendored NestJS OTel instrumentation Port the @Injectable and @Catch decorators, so that the entire NestJS OTel instrumentation is ported to Sentry Orchestrion implementation. Not yet wired up into the `experimentalUseDiagnosticsChannelInjection`, so still dormant at this stage. Pieces to come in following commits: - schedule (@Cron/@Interval/@Timeout): error capture + isolation scope, no spans created - event (@OnEvent), bullmq (@Processor): all the same astQuery inner-arrow-function pattern. - Final wire-in: add `experimentalUseDiagnosticsChannelInjection` + `replacedOtelIntegrationNames: ['Nest']` + opt-in e2e diffing against the OTel baseline. --- .../tracing-channel/nestjs-decorators.ts | 285 ++++++++++++++++++ .../integrations/tracing-channel/nestjs.ts | 148 +++++---- .../src/orchestrion/config/nestjs.ts | 54 +++- .../test/orchestrion/nestjs.test.ts | 214 ++++++++++++- 4 files changed, 629 insertions(+), 72 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts new file mode 100644 index 000000000000..78294b97ecb3 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts @@ -0,0 +1,285 @@ +import type { Span, SpanAttributes } from '@sentry/core'; +import { + addNonEnumerableProperty, + getActiveSpan, + isThenable, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, + startSpan, + startSpanManual, + withActiveSpan, +} from '@sentry/core'; + +/** + * A function of unknown signature. + */ +export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; + +const OP_MIDDLEWARE = 'middleware.nestjs'; +const ORIGIN_MIDDLEWARE = 'auto.middleware.nestjs'; + +/** The class an `@Injectable` decorator is applied to (`ctx.arguments[0]`). */ +export interface InjectableTarget { + name?: string; + sentryPatched?: boolean; + __SENTRY_INTERNAL__?: boolean; + prototype: { + use?: AnyFn; + canActivate?: AnyFn; + transform?: AnyFn; + intercept?: AnyFn; + }; +} + +/** The class a `@Catch` decorator is applied to (an exception filter). */ +export interface CatchTarget { + name?: string; + sentryPatched?: boolean; + __SENTRY_INTERNAL__?: boolean; + prototype: { catch?: AnyFn }; +} + +interface NestCallHandler { + handle: AnyFn; +} + +interface SubscriptionLike { + add: (teardown: () => void) => void; +} + +interface ObservableLike { + subscribe: AnyFn; +} + +/** + * Mark a target class as patched so it's instrumented only once (mirrors the + * vendored `isPatched`). Also give idempotency across repeated subscriptions. + */ +function isTargetPatched(target: { sentryPatched?: boolean }): boolean { + if (target.sentryPatched) { + return true; + } + addNonEnumerableProperty(target as object, 'sentryPatched', true); + return false; +} + +/** + * Span options for middleware/guard/pipe/interceptor spans + * name = provided name or class name. + */ +function getMiddlewareSpanOptions( + target: { name?: string }, + name?: string, + componentType?: string, +): { name: string; attributes: SpanAttributes } { + return { + name: name ?? target.name ?? 'unknown', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: OP_MIDDLEWARE, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: componentType ? `${ORIGIN_MIDDLEWARE}.${componentType}` : ORIGIN_MIDDLEWARE, + }, + }; +} + +/** + * Proxy a middleware `next()` so the span ends when `next` is called, then + * restore the previous active span for the continuation. + */ +function getNextProxy(next: AnyFn, span: Span, prevSpan: Span | undefined): AnyFn { + return new Proxy(next, { + apply: (originalNext, thisArgNext, argsNext) => { + span.end(); + if (prevSpan) { + return withActiveSpan(prevSpan, () => Reflect.apply(originalNext, thisArgNext, argsNext)); + } + return Reflect.apply(originalNext, thisArgNext, argsNext); + }, + }); +} + +/** + * End the given span when the interceptor's returned observable is + * unsubscribed (i.e. the response is sent), keeping it active across the + * subscription. + */ +function instrumentObservable(observable: ObservableLike, activeSpan: Span | undefined): void { + if (!activeSpan) { + return; + } + observable.subscribe = new Proxy(observable.subscribe, { + apply: (originalSubscribe, thisArgSubscribe, argsSubscribe) => { + return withActiveSpan(activeSpan, () => { + const subscription = originalSubscribe.apply(thisArgSubscribe, argsSubscribe) as SubscriptionLike; + subscription.add(() => activeSpan.end()); + return subscription; + }); + }, + }); +} + +function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContexts: WeakSet): AnyFn { + return new Proxy(intercept, { + apply: (originalIntercept, thisArg, argsIntercept) => { + const context = argsIntercept[0] as object | undefined; + const next = argsIntercept[1] as NestCallHandler | undefined; + const parentSpan = getActiveSpan(); + let afterSpan: Span | undefined; + + if ( + !context || + !next || + typeof next.handle !== 'function' || + target.name === 'SentryTracingInterceptor' // don't trace Sentry's own interceptor + ) { + return originalIntercept.apply(thisArg, argsIntercept); + } + + return startSpanManual(getMiddlewareSpanOptions(target, undefined, 'interceptor'), (beforeSpan: Span) => { + // `next.handle()` is the boundary between the "before" and "after" + // interceptor work: end the before-span and open the after-span (once + // per execution context), which `instrumentObservable` later closes. + next.handle = new Proxy(next.handle, { + apply: (originalHandle, thisArgHandle, argsHandle) => { + beforeSpan.end(); + const run = (): unknown => { + const handleReturn = Reflect.apply(originalHandle, thisArgHandle, argsHandle); + if (!seenContexts.has(context)) { + seenContexts.add(context); + afterSpan = startInactiveSpan( + getMiddlewareSpanOptions(target, 'Interceptors - After Route', 'interceptor'), + ); + } + return handleReturn; + }; + return parentSpan ? withActiveSpan(parentSpan, run) : run(); + }, + }); + + let returned: unknown; + try { + returned = originalIntercept.apply(thisArg, argsIntercept); + } catch (e) { + beforeSpan.end(); + afterSpan?.end(); + throw e; + } + + if (!afterSpan) { + return returned; + } + + // async interceptor: returns a Promise + if (isThenable(returned)) { + return returned.then( + (observable: unknown) => { + instrumentObservable(observable as ObservableLike, afterSpan ?? parentSpan); + return observable; + }, + (e: unknown) => { + beforeSpan.end(); + afterSpan?.end(); + throw e; + }, + ); + } + + // sync interceptor: returns an Observable + if (typeof (returned as ObservableLike).subscribe === 'function') { + instrumentObservable(returned as ObservableLike, afterSpan); + } + + return returned; + }); + }, + }); +} + +/** + * Port the vendored `@Injectable` instrumentation + * patch the decorated class's prototype methods so each runtime + * invocation opens the corresponding middleware/guard/pipe/interceptor span. + * The runtime guards (req/res/next, context, value+metadata) avoid false + * positives on non-middleware classes that happen to expose a same-named + * method. + */ +export function patchInjectableTarget(target: InjectableTarget, seenContexts: WeakSet): void { + const proto = target?.prototype; + if (!proto || target.__SENTRY_INTERNAL__ || isTargetPatched(target)) { + return; + } + + // middleware + if (typeof proto.use === 'function') { + proto.use = new Proxy(proto.use, { + apply: (originalUse, thisArgUse, argsUse) => { + const [req, res, next] = argsUse as unknown[]; + if (!req || !res || !next || typeof next !== 'function') { + return originalUse.apply(thisArgUse, argsUse); + } + const prevSpan = getActiveSpan(); + return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => { + const nextProxy = getNextProxy(next as AnyFn, span, prevSpan); + const rest = (argsUse as unknown[]).slice(3); + return originalUse.apply(thisArgUse, [req, res, nextProxy, rest]); + }); + }, + }); + } + + // guards + if (typeof proto.canActivate === 'function') { + proto.canActivate = new Proxy(proto.canActivate, { + apply: (originalCanActivate, thisArg, args) => { + if (!args[0]) { + return originalCanActivate.apply(thisArg, args); + } + return startSpan(getMiddlewareSpanOptions(target, undefined, 'guard'), () => + originalCanActivate.apply(thisArg, args), + ); + }, + }); + } + + // pipes + if (typeof proto.transform === 'function') { + proto.transform = new Proxy(proto.transform, { + apply: (originalTransform, thisArg, args) => { + if (!args[0] || !args[1]) { + return originalTransform.apply(thisArg, args); + } + return startSpan(getMiddlewareSpanOptions(target, undefined, 'pipe'), () => + originalTransform.apply(thisArg, args), + ); + }, + }); + } + + // interceptors + if (typeof proto.intercept === 'function') { + proto.intercept = patchInterceptor(target, proto.intercept, seenContexts); + } +} + +/** + * Port the vendored `@Catch` instrumentation. Patch the exception filter's + * prototype `catch` so each invocation opens an `exception_filter` span. The + * runtime guard (exception + host present) avoids false positives. + */ +export function patchCatchTarget(target: CatchTarget): void { + const proto = target?.prototype; + if (!proto || typeof proto.catch !== 'function' || target.__SENTRY_INTERNAL__ || isTargetPatched(target)) { + return; + } + proto.catch = new Proxy(proto.catch, { + apply: (originalCatch, thisArg, args) => { + const [exception, host] = args as unknown[]; + if (!exception || !host) { + return originalCatch.apply(thisArg, args); + } + return startSpan(getMiddlewareSpanOptions(target, undefined, 'exception_filter'), () => + originalCatch.apply(thisArg, args), + ); + }, + }); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts index ff7428e547d7..9c02ef67e6e5 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts @@ -1,15 +1,11 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; -import { - debug, - defineIntegration, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startInactiveSpan, - startSpan, -} from '@sentry/core'; +import { debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, startSpan } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import type { AnyFn, CatchTarget, InjectableTarget } from './nestjs-decorators'; +import { patchCatchTarget, patchInjectableTarget } from './nestjs-decorators'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Nest' integration is omitted from the default set. @@ -35,7 +31,7 @@ const TYPE_APP_CREATION = 'app_creation'; const TYPE_REQUEST_CONTEXT = 'request_context'; const TYPE_REQUEST_HANDLER = 'handler'; -type AnyFn = (this: unknown, ...args: unknown[]) => unknown; +const NOOP = (): void => {}; // Marks a function as already wrapped so repeated subscriptions (e.g. a second // `setupOnce`) don't double-wrap a callback or returned handler. @@ -50,24 +46,11 @@ function markWrapped(fn: AnyFn): void { } /** - * The shape orchestrion's `tracePromise` transform attaches to the - * tracing-channel context for `NestFactoryStatic.prototype.create`. - * `arguments[0]` is the root application module class. + * The orchestrion tracing-channel context. `arguments` is the live call args + * array; `result` is the return value, which an `end` handler may reassign to + * substitute it (`traceSync`/`tracePromise` always return `ctx.result`). */ -interface NestFactoryCreateData { - arguments: unknown[]; - moduleVersion?: string; - result?: unknown; - error?: unknown; -} - -/** - * The shape orchestrion's `traceSync` transform attaches to the - * tracing-channel context for `RouterExecutionContext.prototype.create`. - * `arguments[0]` is the controller instance, `arguments[1]` the route handler - * callback, and `result` is the per-request handler `create` returns. - */ -interface RouterCreateData { +interface ChannelContext { arguments: unknown[]; moduleVersion?: string; result?: unknown; @@ -90,10 +73,12 @@ interface ReflectWithMetadata { defineMetadata?: (key: unknown, value: unknown, target: object) => void; } -// Copy NestJS reflect-metadata from the original handler onto the wrapper so -// other decorators (param decorators, guards, `@EventPattern`, ...) that -// read it keep working. No-op when `reflect-metadata` isn't loaded. Mirrors -// vendored `@opentelemetry/instrumentation-nestjs-core` behaviour. +/** + * Copy NestJS reflect-metadata from the original handler onto the wrapper so + * other decorators (param decorators, guards, `@EventPattern`, ...) that + * read it keep working. No-op when `reflect-metadata` isn't loaded. Mirrors + * vendored `@opentelemetry/instrumentation-nestjs-core` behaviour. + */ function copyReflectMetadata(from: object, to: object): void { const R = Reflect as unknown as ReflectWithMetadata; if ( @@ -108,9 +93,11 @@ function copyReflectMetadata(from: object, to: object): void { } } -// Wraps the route-handler callback (`create`'s `arguments[1]`) so each -// invocation opens the `handler.nestjs` span (REQUEST_HANDLER). Preserves the -// original `.name` and reflect-metadata so NestJS reflection is unaffected. +/** + * Wrap the route-handler callback (`create`'s `arguments[1]`) so each + * invocation opens the `handler.nestjs` span (REQUEST_HANDLER). Preserve the + * original `.name` and reflect-metadata so NestJS reflection is unaffected. + */ function wrapRouteHandler(callback: AnyFn, moduleVersion?: string): AnyFn { if (isWrapped(callback)) { return callback; @@ -136,9 +123,11 @@ function wrapRouteHandler(callback: AnyFn, moduleVersion?: string): AnyFn { return wrapped; } -// Wraps the per-request handler `create` returns so each request opens the -// `request_context.nestjs` span (REQUEST_CONTEXT), carrying the controller / -// callback names captured at setup plus the per-request http.* attributes. +/** + * Wrap per-request handler `create` returns so each request opens the + * `request_context.nestjs` span (REQUEST_CONTEXT), carrying the controller / + * callback names captured at setup plus the per-request http.* attributes. + */ function wrapRequestContextHandler( handler: AnyFn, instanceName: string, @@ -168,19 +157,44 @@ function wrapRequestContextHandler( return wrapped; } +/** + * Subscribe to a decorator channel (`Injectable`/`Catch`) + * + * The orchestrion transform targets the decorator's inner arrow, so `start` + * receives the decorated class as `arguments[0]`. There is no span around the + * decorator itself. + * + * `patch` method installs the prototype-method proxies that open spans later. + */ +function subscribeDecoratorChannel(channelName: string, patch: (target: T) => void): void { + diagnosticsChannel.tracingChannel(channelName).subscribe({ + start(data) { + const target = data.arguments?.[0] as T | undefined; + if (target) { + patch(target); + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + const _nestjsChannelIntegration = (() => { return { name: INTEGRATION_NAME, setupOnce() { - DEBUG_BUILD && debug.log('[orchestrion:nestjs] subscribing to @nestjs/core channels'); + DEBUG_BUILD && debug.log('[orchestrion:nestjs] subscribing to @nestjs channels'); // App-creation span: `bindTracingChannelToSpan` opens the span on - // `start`, makes it the active context for the bootstrap, and ends - // it on `asyncEnd` (or `end` if `create` throws synchronously). - // `captureError: false`. Failed bootstrap surfaces to the caller. + // `start`, makes it the active context for the bootstrap, and ends it + // on `asyncEnd` (or `end` if `create` throws synchronously). + // + // `captureError: false` a failed bootstrap surfaces to the caller. // We just annotate the span. bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), data => { const moduleCls = data.arguments?.[0] as { name?: string } | undefined; return startInactiveSpan({ @@ -198,28 +212,25 @@ const _nestjsChannelIntegration = (() => { { captureError: false }, ); - // Request-context + request-handler spans. + // request_context + request_handler. `RouterExecutionContext.create` + // runs once per route at setup: it receives `(instance, callback, ...)` + // and RETURNS the per-request handler. We don't span `create` itself. + // `start` wraps the callback arg (-> handler span per call) and `end` + // reassigns `data.result` to replace the returned handler (-> request_context + // span per request); `traceSync` always returns whatever `end` leaves there. // - // `RouterExecutionContext.create` runs once per route at setup - // it receives `(instance, callback, ...)` and RETURNS the per-request - // handler. We don't span `create` itself. Instead `start` wraps the - // callback arg (-> one handler span per call) and `end` reassigns - // `data.result` to replace the returned handler (-> one request_context - // span per request); `traceSync` always returns whatever `end` leaves on - // `ctx.result`, so no opt-in is needed. - // - // Both wrappers open their span at invoke time, inside the request - // context, so they parent correctly. - const routerCh = diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_ROUTER_CONTEXT); + // Both wrappers open their span at invoke time, inside the live + // request context, so they parent correctly. const routerMeta = new WeakMap(); - routerCh.subscribe({ + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_ROUTER_CONTEXT).subscribe({ start(data) { const instance = data.arguments?.[0] as { constructor?: { name?: string } } | undefined; const callback = data.arguments?.[1]; - const instanceName = instance?.constructor?.name || 'UnnamedInstance'; - const callbackName = typeof callback === 'function' ? callback.name : ''; - routerMeta.set(data, { instanceName, callbackName, moduleVersion: data.moduleVersion }); - + routerMeta.set(data, { + instanceName: instance?.constructor?.name || 'UnnamedInstance', + callbackName: typeof callback === 'function' ? callback.name : '', + moduleVersion: data.moduleVersion, + }); if (typeof callback === 'function') { data.arguments[1] = wrapRouteHandler(callback as AnyFn, data.moduleVersion); } @@ -237,16 +248,21 @@ const _nestjsChannelIntegration = (() => { } routerMeta.delete(data); }, - asyncStart() { - // `create` is synchronous; no async events fire. - }, - asyncEnd() { - // `create` is synchronous; no async events fire. - }, + asyncStart: NOOP, + asyncEnd: NOOP, error(data) { routerMeta.delete(data); }, }); + + // @Injectable (middleware/guard/pipe/interceptor) and @Catch (exception + // filter): both decorators share the `(target) => {...}` + // inner-arrow shape. + const seenInterceptorContexts = new WeakSet(); + subscribeDecoratorChannel(CHANNELS.NESTJS_INJECTABLE, target => + patchInjectableTarget(target, seenInterceptorContexts), + ); + subscribeDecoratorChannel(CHANNELS.NESTJS_CATCH, patchCatchTarget); }, }; }) satisfies IntegrationFn; @@ -254,8 +270,8 @@ const _nestjsChannelIntegration = (() => { /** * EXPERIMENTAL orchestrion-driven NestJS integration. * - * Subscribes to the diagnostics_channels the orchestrion code transform - * injects into `@nestjs/core` (see `orchestrion/config.ts`). Requires the - * orchestrion runtime hook or bundler plugin to be active. + * Subscribes to the diagnostics_channels the orchestrion code transform injects + * into `@nestjs/core` and `@nestjs/common` (see `orchestrion/config.ts`). + * Requires the orchestrion runtime hook or bundler plugin to be active. */ export const nestjsChannelIntegration = defineIntegration(_nestjsChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index 76d894bdb93d..ca6a837aa164 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -1,4 +1,22 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { FunctionKind, InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +/** + * Wrap an instrumentation that targets nodes via a raw esquery selector + * (`NstQuery`) rather than the structured `functionQuery`. Needed when the + * target can't be named, e.g. the anonymous arrow a decorator factory returns + * The transformer supports `astQuery` at runtime (it takes precedence over + * `functionQuery`, which then only supplies `kind`), but it isn't in the + * published `InstrumentationConfig` type. Hence the cast. + */ +function astQueryInstrumentation(config: { + channelName: string; + module: InstrumentationConfig['module']; + astQuery: string; + functionQuery: { kind: FunctionKind }; +}): InstrumentationConfig { + return config as unknown as InstrumentationConfig; +} + export const nestjsConfig = [ { @@ -22,9 +40,43 @@ export const nestjsConfig = [ module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'router/router-execution-context.js' }, functionQuery: { className: 'RouterExecutionContext', methodName: 'create', kind: 'Sync' }, }, + astQueryInstrumentation({ + // `@nestjs/common/decorators/core/injectable.decorator.js`: + // `function Injectable(options) { return (target) => { ... }; }` + // The inner decorator arrow is anonymous + returned, so only a raw + // `astQuery` can target it. The subscriber's `start` receives the + // decorated class as `arguments[0]` and patches its prototype + // use/canActivate/transform/intercept methods, reproducing the + // vendored `SentryNestInstrumentation` middleware/guard/pipe/interceptor + // spans. No span on the decorator itself, so `kind: 'Sync'`. + channelName: 'injectableDecorator', + module: { + name: '@nestjs/common', + versionRange: '>=8.0.0 <12', + filePath: 'decorators/core/injectable.decorator.js', + }, + astQuery: 'FunctionDeclaration[id.name="Injectable"] ReturnStatement > ArrowFunctionExpression', + functionQuery: { kind: 'Sync' }, + }), + astQueryInstrumentation({ + // `@nestjs/common/decorators/core/catch.decorator.js`: + // `function Catch(...exceptions) { return (target) => { ... }; }` + // Same anonymous-returned-arrow shape as `Injectable`. The subscriber's + // `start` patches the exception filter's prototype `catch` method to + // open an `exception_filter` span. + // + // Mirrors the vendored `SentryNestInstrumentation` `@Catch` wrap. + channelName: 'catchDecorator', + module: { name: '@nestjs/common', versionRange: '>=8.0.0 <12', filePath: 'decorators/core/catch.decorator.js' }, + astQuery: 'FunctionDeclaration[id.name="Catch"] ReturnStatement > ArrowFunctionExpression', + functionQuery: { kind: 'Sync' }, + }), + ] satisfies InstrumentationConfig[]; export const nestjsChannels = { NESTJS_APP_CREATION: 'orchestrion:@nestjs/core:nestFactoryCreate', NESTJS_ROUTER_CONTEXT: 'orchestrion:@nestjs/core:routerExecutionContextCreate', + NESTJS_INJECTABLE: 'orchestrion:@nestjs/common:injectableDecorator', + NESTJS_CATCH: 'orchestrion:@nestjs/common:catchDecorator', } as const; diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts index 7d6cd2e7af81..83c930dd0c4a 100644 --- a/packages/server-utils/test/orchestrion/nestjs.test.ts +++ b/packages/server-utils/test/orchestrion/nestjs.test.ts @@ -16,7 +16,7 @@ import { setAsyncContextStrategy, spanToJSON, } from '@sentry/core'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { nestjsChannelIntegration } from '../../src/orchestrion'; import { CHANNELS } from '../../src/orchestrion/channels'; @@ -196,8 +196,8 @@ describe('nestjsChannelIntegration: request_context / request_handler', () => { // Drives `RouterExecutionContext.create` over the channel: the subscriber's // `start` wraps the callback arg, its `end` reassigns the returned handler on // `data.result`. `makeHandler` stands in for the real `create` body. Returns - // the effective return (the substituted `data.result`) and the wrapped - // callback (`data.arguments[1]`). + // the effective return (the substituted `data.result`) and the + // wrapped callback (`data.arguments[1]`). function driveCreate( instance: object, callback: AnyFn, @@ -243,7 +243,7 @@ describe('nestjsChannelIntegration: request_context / request_handler', () => { expect(contextSpanJson!.op).toBe('request_context.nestjs'); expect(contextSpanJson!.origin).toBe('auto.http.otel.nestjs'); expect(contextSpanJson!.data).toMatchObject({ - 'component': '@nestjs/core', + component: '@nestjs/core', 'nestjs.type': 'request_context', 'nestjs.controller': 'CatsController', 'nestjs.callback': 'getCats', @@ -280,7 +280,7 @@ describe('nestjsChannelIntegration: request_context / request_handler', () => { expect(handlerSpanJson!.op).toBe('handler.nestjs'); expect(handlerSpanJson!.origin).toBe('auto.http.otel.nestjs'); expect(handlerSpanJson!.data).toMatchObject({ - 'component': '@nestjs/core', + component: '@nestjs/core', 'nestjs.type': 'handler', 'nestjs.callback': 'getCats', 'nestjs.version': '10.4.1', @@ -315,3 +315,207 @@ describe('nestjsChannelIntegration: request_context / request_handler', () => { expect(handlerParentSpanId).toBe(contextSpanId); }); }); + +describe('nestjsChannelIntegration: @Injectable (middleware/guard/pipe/interceptor)', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + // Fire the @Injectable channel against `target` (as if its decorator arrow + // ran), so the subscriber's `start` patches `target.prototype`. + function applyInjectable(target: object): void { + tracingChannel<{ arguments: unknown[] }>(CHANNELS.NESTJS_INJECTABLE).traceSync(() => undefined, { + arguments: [target], + }); + } + + it('middleware: opens a span on `use`, ended when `next()` is called', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let spanInside: ReturnType; + class LoggerMiddleware { + public use(_req: unknown, _res: unknown, next: () => void): void { + spanInside = getActiveSpan(); + next(); + } + } + applyInjectable(LoggerMiddleware); + + const next = vi.fn(); + new LoggerMiddleware().use({ url: '/' }, {}, next); + + expect(next).toHaveBeenCalledTimes(1); + const json = spanToJSON(spanInside!); + expect(json.description).toBe('LoggerMiddleware'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.nestjs'); + // startSpanManual span ends when the proxied `next` is called. + expect(json.timestamp).toBeDefined(); + }); + + it('guard: wraps `canActivate` in a span and preserves its return value', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let spanInside: ReturnType; + class AuthGuard { + public canActivate(_ctx: unknown): boolean { + spanInside = getActiveSpan(); + return true; + } + } + applyInjectable(AuthGuard); + + expect(new AuthGuard().canActivate({ ctx: true })).toBe(true); + const json = spanToJSON(spanInside!); + expect(json.description).toBe('AuthGuard'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.nestjs.guard'); + }); + + it('pipe: wraps `transform` in a span and preserves its return value', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let spanInside: ReturnType; + class ParseIntPipe { + public transform(value: string, _metadata: unknown): number { + spanInside = getActiveSpan(); + return Number.parseInt(value, 10); + } + } + applyInjectable(ParseIntPipe); + + expect(new ParseIntPipe().transform('42', { type: 'param' })).toBe(42); + const json = spanToJSON(spanInside!); + expect(json.description).toBe('ParseIntPipe'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.nestjs.pipe'); + }); + + it('interceptor: opens a before-span (ended at next.handle) and instruments the returned observable', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + // Minimal rxjs-like observable whose subscription records teardown fns. + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class LoggingInterceptor { + public intercept(_context: unknown, next: { handle: () => unknown }): unknown { + beforeSpan = getActiveSpan(); + return next.handle(); + } + } + applyInjectable(LoggingInterceptor); + + const next = { handle: () => observable }; + const returned = new LoggingInterceptor().intercept({}, next) as typeof observable; + + // Passthrough: the same observable is returned (with `subscribe` proxied). + expect(returned).toBe(observable); + + const beforeJson = spanToJSON(beforeSpan!); + expect(beforeJson.description).toBe('LoggingInterceptor'); + expect(beforeJson.op).toBe('middleware.nestjs'); + expect(beforeJson.origin).toBe('auto.middleware.nestjs.interceptor'); + // before-span ends when `next.handle()` is called. + expect(beforeJson.timestamp).toBeDefined(); + + // The returned observable was instrumented: subscribing registers an + // after-span teardown (proving the after-span was created). + returned.subscribe(); + expect(teardowns).toHaveLength(1); + expect(() => teardowns.forEach(fn => fn())).not.toThrow(); + }); + + it('skips targets flagged __SENTRY_INTERNAL__', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + class InternalGuard { + public canActivate(_ctx: unknown): boolean { + return true; + } + } + (InternalGuard as unknown as { __SENTRY_INTERNAL__?: boolean }).__SENTRY_INTERNAL__ = true; + const original = InternalGuard.prototype.canActivate; + applyInjectable(InternalGuard); + + // Not patched: the prototype method is untouched. + expect(InternalGuard.prototype.canActivate).toBe(original); + }); +}); + +describe('nestjsChannelIntegration: @Catch (exception filter)', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + function applyCatch(target: object): void { + tracingChannel<{ arguments: unknown[] }>(CHANNELS.NESTJS_CATCH).traceSync(() => undefined, { + arguments: [target], + }); + } + + it('wraps `catch` in an exception_filter span and preserves its return value', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let spanInside: ReturnType; + class HttpExceptionFilter { + public catch(exception: unknown, _host: unknown): string { + spanInside = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + applyCatch(HttpExceptionFilter); + + const ret = new HttpExceptionFilter().catch('boom', { switchToHttp: () => ({}) }); + expect(ret).toBe('handled:boom'); + + const json = spanToJSON(spanInside!); + expect(json.description).toBe('HttpExceptionFilter'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.nestjs.exception_filter'); + }); + + it('does not open a span when exception or host is absent', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let spanInside: ReturnType = undefined; + class HttpExceptionFilter { + public catch(_exception: unknown, _host: unknown): string { + spanInside = getActiveSpan(); + return 'ok'; + } + } + applyCatch(HttpExceptionFilter); + + // Missing host -> guard short-circuits, no span opened. + new HttpExceptionFilter().catch('boom', undefined); + expect(spanInside).toBeUndefined(); + }); +}); From 2bc2db15a011f9746de5383e95dc0ad2f385666f Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 24 Jun 2026 16:38:49 -0700 Subject: [PATCH 06/18] feat(nest): final (still dormant) Orchestrion instrumentation Connect the `@Cron`/`@interval`/`@Timeout` (schedule), `@OnEvent` (event), and `@Processor` (bullmq) instrumentations in the orchestrion implementation. At this point, it's still not wired up by default into the SDK, but all of the functionality is there. Next step is the final wire-up and opt-in to swap out the OTel NestJS for this Orchestrion implementation. --- .../tracing-channel/nestjs-decorators.ts | 8 +- .../nestjs-handler-wrappers.ts | 264 ++++++++++++++++++ .../tracing-channel/nestjs-shared.ts | 30 ++ .../integrations/tracing-channel/nestjs.ts | 32 +-- .../src/orchestrion/config/nestjs.ts | 60 +++- .../test/orchestrion/nestjs.test.ts | 149 ++++++++++ 6 files changed, 511 insertions(+), 32 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts index 78294b97ecb3..e14ddfba5599 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts @@ -10,11 +10,7 @@ import { startSpanManual, withActiveSpan, } from '@sentry/core'; - -/** - * A function of unknown signature. - */ -export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; +import type { AnyFn } from './nestjs-shared'; const OP_MIDDLEWARE = 'middleware.nestjs'; const ORIGIN_MIDDLEWARE = 'auto.middleware.nestjs'; @@ -221,7 +217,7 @@ export function patchInjectableTarget(target: InjectableTarget, seenContexts: We return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => { const nextProxy = getNextProxy(next as AnyFn, span, prevSpan); const rest = (argsUse as unknown[]).slice(3); - return originalUse.apply(thisArgUse, [req, res, nextProxy, rest]); + return originalUse.apply(thisArgUse, [req, res, nextProxy, ...rest]); }); }, }); diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts new file mode 100644 index 000000000000..d812db3ea172 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts @@ -0,0 +1,264 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { + captureException, + isThenable, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, + withIsolationScope, +} from '@sentry/core'; +import { CHANNELS } from '../../orchestrion/channels'; +import type { AnyFn, ChannelContext } from './nestjs-shared'; +import { isWrapped, markWrapped } from './nestjs-shared'; + +const NOOP = (): void => {}; + +// Mechanism types for scheduled-handler error capture (no span) +// match vendored `SentryNestScheduleInstrumentation` +const MECHANISM_CRON = 'auto.function.nestjs.cron'; +const MECHANISM_INTERVAL = 'auto.function.nestjs.interval'; +const MECHANISM_TIMEOUT = 'auto.function.nestjs.timeout'; +const MECHANISM_EVENT = 'auto.event.nestjs'; +const MECHANISM_BULLMQ = 'auto.queue.nestjs.bullmq'; + +const EVENT_LISTENER_METADATA = 'EVENT_LISTENER_METADATA'; + +interface ReflectWithMetadata { + getMetadataKeys?: (target: object) => unknown[]; + getMetadata?: (key: unknown, target: object) => unknown; +} + +/** + * The class a `@Processor` decorator is applied to (a BullMQ queue processor). */ +interface ProcessorTarget { + __SENTRY_INTERNAL__?: boolean; + prototype?: { process?: AnyFn }; +} + +function captureHandlerError(error: unknown, mechanismType: string): void { + captureException(error, { mechanism: { handled: false, type: mechanismType } }); +} + +/** + * Wrap a scheduled handler (`@Cron`/`@Interval`/`@Timeout`): fork the + * isolation scope and capture errors. NOT async. Preserve the handler's sync + * return type, so sync and async errors are handled on separate paths + * matches vendored OTel implementation + */ +function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + return withIsolationScope(() => { + let result: unknown; + try { + result = handler.apply(this, args); + } catch (error) { + captureHandlerError(error, mechanismType); + throw error; + } + if (isThenable(result)) { + return result.then(undefined, (error: unknown) => { + captureHandlerError(error, mechanismType); + throw error; + }); + } + return result; + }); + }; +} + +function eventNameFromEvent(event: unknown): string { + if (typeof event === 'string') { + return event; + } + if (Array.isArray(event)) { + return event.map(eventNameFromEvent).join(','); + } + return String(event); +} + +/** + * Derive the event name(s) for an @OnEvent span. The wrapped handler carries + * `EVENT_LISTENER_METADATA` (set by the original decorator), which lists every + * event when multiple @OnEvent decorators are stacked; fall back to the event + * captured from the decorator factory. + */ +function deriveEventName(handler: AnyFn, fallbackEvent: unknown): string { + const R = Reflect as unknown as ReflectWithMetadata; + if (typeof R.getMetadataKeys === 'function' && typeof R.getMetadata === 'function') { + if (R.getMetadataKeys(handler)?.includes(EVENT_LISTENER_METADATA)) { + const eventData = R.getMetadata(EVENT_LISTENER_METADATA, handler); + if (Array.isArray(eventData)) { + return (eventData as unknown[]) + .map(entry => { + const event = entry && typeof entry === 'object' ? (entry as { event?: unknown }).event : undefined; + return event ? eventNameFromEvent(event) : ''; + }) + .reverse() // decorators evaluate bottom to top + .join('|'); + } + } + } + return eventNameFromEvent(fallbackEvent); +} + +/** + * Wrap an @OnEvent handler: fork the isolation scope, open an `event.nestjs` + * transaction, and capture errors. (event-handler errors bypass the global + * filter) + */ +function wrapEventHandler(handler: AnyFn, fallbackEvent: unknown): AnyFn { + const wrapped = async function (this: unknown, ...args: unknown[]): Promise { + const eventName = deriveEventName(wrapped, fallbackEvent); + return withIsolationScope(() => + startSpan( + { + name: `event ${eventName}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MECHANISM_EVENT, + }, + forceTransaction: true, + }, + async () => { + try { + return await handler.apply(this, args); + } catch (error) { + captureHandlerError(error, MECHANISM_EVENT); + throw error; + } + }, + ), + ); + }; + return wrapped; +} + +/** + * Wrap a BullMQ `process` method: fork the isolation scope, open a + * `queue.process` transaction, and capture errors. + */ +function wrapBullMQProcess(process: AnyFn, queueName: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + return withIsolationScope(() => + startSpan( + { + name: `${queueName} process`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MECHANISM_BULLMQ, + 'messaging.system': 'bullmq', + 'messaging.destination.name': queueName, + }, + forceTransaction: true, + }, + async () => { + try { + return await process.apply(this, args); + } catch (error) { + captureHandlerError(error, MECHANISM_BULLMQ); + throw error; + } + }, + ), + ); + }; +} + +/** + * Wrap a method decorator (the function the factory returns for + * `@Cron`/`@Interval`/`@Timeout`/`@OnEvent`) so it replaces + * `descriptor.value` with a wrapped handler before delegating to the + * original decorator (which then attaches its metadata to our wrapper). + */ +function makeMethodDecorator(original: AnyFn, wrapHandler: (handler: AnyFn) => AnyFn): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + const target = args[0] as { __SENTRY_INTERNAL__?: boolean } | undefined; + const propertyKey = args[1]; + const descriptor = args[2] as PropertyDescriptor | undefined; + const handler = descriptor?.value; + if (handler && typeof handler === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(handler as AnyFn)) { + const wrapped = wrapHandler(handler as AnyFn); + Object.defineProperty(wrapped, 'name', { + value: (handler as AnyFn).name || String(propertyKey), + configurable: true, + }); + markWrapped(wrapped); + descriptor.value = wrapped; + } + return original.apply(this, args); + }; +} + +/** + * Wrap the class decorator @Processor returns so it patches + * `target.prototype.process` before delegating to the original decorator. + */ +function makeProcessorDecorator(original: AnyFn, queueName: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + const target = args[0] as ProcessorTarget | undefined; + const process = target?.prototype?.process; + if (process && typeof process === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(process)) { + const wrapped = wrapBullMQProcess(process, queueName); + markWrapped(wrapped); + target.prototype!.process = wrapped; + } + return original.apply(this, args); + }; +} + +function extractQueueName(arg: unknown): string { + if (typeof arg === 'string') { + return arg; + } + if (arg && typeof arg === 'object' && 'name' in arg && typeof (arg as { name?: unknown }).name === 'string') { + return (arg as { name: string }).name; + } + return 'unknown'; +} + +/** + * Subscribe to a decorator-factory channel. `end` reassigns `data.result` (the + * decorator the factory returns) with a wrapped version -> `traceSync` returns + * whatever `end` leaves there. `wrap` receives the original decorator and the + * channel context (for the factory's args, e.g. the BullMQ queue name). + */ +function subscribeFactoryDecorator(channelName: string, wrap: (decorator: AnyFn, data: ChannelContext) => AnyFn): void { + diagnosticsChannel.tracingChannel(channelName).subscribe({ + start: NOOP, + end(data) { + const decorator = data.result; + if (typeof decorator === 'function' && !isWrapped(decorator as AnyFn)) { + const wrapped = wrap(decorator as AnyFn, data); + markWrapped(wrapped); + data.result = wrapped; + } + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +/** + * Subscribe the @Cron/@Interval/@Timeout (schedule), @OnEvent (event-emitter) + * and @Processor (bullmq) decorator channels. Each `end` handler reassigns the + * decorator the factory returns (via `data.result`) with one that wraps the + * user handler (schedule/event) or the `process` method (bullmq). + */ +export function subscribeNestHandlerDecorators(): void { + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_TIMEOUT, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_TIMEOUT)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_ONEVENT, (decorator, data) => + makeMethodDecorator(decorator, handler => wrapEventHandler(handler, data.arguments?.[0])), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_PROCESSOR, (decorator, data) => + makeProcessorDecorator(decorator, extractQueueName(data.arguments?.[0])), + ); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts new file mode 100644 index 000000000000..0b671fb774aa --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts @@ -0,0 +1,30 @@ +/** A function of unknown signature, matching the methods/handlers we wrap. */ +export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; + +/** + * The orchestrion tracing-channel context. `arguments` is the live call args + * array; `result` is the return value, which an `end` handler may reassign to + * substitute it (`traceSync`/`tracePromise` always return `ctx.result`). + */ +export interface ChannelContext { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +/** + * Marks a function as already wrapped so repeated subscriptions (eg a second + * `setupOnce`) or multiple decorators on one method don't double-wrap it. + */ +const SENTRY_WRAPPED = Symbol.for('sentry.orchestrion.nestjs.wrapped'); + +/** Whether `fn` has already been wrapped by this integration. */ +export function isWrapped(fn: AnyFn): boolean { + return !!(fn as AnyFn & Record)[SENTRY_WRAPPED]; +} + +/** Mark `fn` as wrapped (see {@link isWrapped}). */ +export function markWrapped(fn: AnyFn): void { + (fn as AnyFn & Record)[SENTRY_WRAPPED] = true; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts index 9c02ef67e6e5..2efba585c5e8 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts @@ -4,8 +4,11 @@ import { debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInacti import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; -import type { AnyFn, CatchTarget, InjectableTarget } from './nestjs-decorators'; +import type { CatchTarget, InjectableTarget } from './nestjs-decorators'; import { patchCatchTarget, patchInjectableTarget } from './nestjs-decorators'; +import { subscribeNestHandlerDecorators } from './nestjs-handler-wrappers'; +import type { AnyFn, ChannelContext } from './nestjs-shared'; +import { isWrapped, markWrapped } from './nestjs-shared'; // NOTE: this uses the same name as the OTel integration by design. // When enabled, the OTel 'Nest' integration is omitted from the default set. @@ -33,30 +36,6 @@ const TYPE_REQUEST_HANDLER = 'handler'; const NOOP = (): void => {}; -// Marks a function as already wrapped so repeated subscriptions (e.g. a second -// `setupOnce`) don't double-wrap a callback or returned handler. -const SENTRY_WRAPPED = Symbol.for('sentry.orchestrion.nestjs.wrapped'); - -function isWrapped(fn: AnyFn): boolean { - return !!(fn as AnyFn & Record)[SENTRY_WRAPPED]; -} - -function markWrapped(fn: AnyFn): void { - (fn as AnyFn & Record)[SENTRY_WRAPPED] = true; -} - -/** - * The orchestrion tracing-channel context. `arguments` is the live call args - * array; `result` is the return value, which an `end` handler may reassign to - * substitute it (`traceSync`/`tracePromise` always return `ctx.result`). - */ -interface ChannelContext { - arguments: unknown[]; - moduleVersion?: string; - result?: unknown; - error?: unknown; -} - /** Minimal request shape, across the express/fastify adapters. */ interface NestRequest { route?: { path?: string }; @@ -263,6 +242,9 @@ const _nestjsChannelIntegration = (() => { patchInjectableTarget(target, seenInterceptorContexts), ); subscribeDecoratorChannel(CHANNELS.NESTJS_CATCH, patchCatchTarget); + + // @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq). + subscribeNestHandlerDecorators(); }, }; }) satisfies IntegrationFn; diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index ca6a837aa164..274102bb89d0 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -71,7 +71,60 @@ export const nestjsConfig = [ astQuery: 'FunctionDeclaration[id.name="Catch"] ReturnStatement > ArrowFunctionExpression', functionQuery: { kind: 'Sync' }, }), - + // @nestjs/schedule @Cron/@Interval/@Timeout: `function Cron(...) { return + // applyDecorators(...); }` — the returned decorator has no inline arrow to + // target, so we match the factory function and reassign `data.result` in `end` + // to wrap the decorator it returns (which rewrites the user handler + // `descriptor.value` with isolation-scope + error capture). Mirrors + // `SentryNestScheduleInstrumentation`, whose supported range (`>=2.0.0`) we + // match so opting in doesn't drop coverage the OTel path had. The compiled + // `function Cron(...)` declaration is unchanged across 2.x–5.x. + { + channelName: 'cronDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/cron.decorator.js' }, + functionQuery: { functionName: 'Cron', kind: 'Sync' }, + }, + { + channelName: 'intervalDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/interval.decorator.js' }, + functionQuery: { functionName: 'Interval', kind: 'Sync' }, + }, + { + channelName: 'timeoutDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/timeout.decorator.js' }, + functionQuery: { functionName: 'Timeout', kind: 'Sync' }, + }, + { + // @nestjs/event-emitter @OnEvent: `const OnEvent = (event, options) => { + // const decoratorFactory = (t, k, d) => {…}; return decoratorFactory; }` + // `OnEvent` is an arrow assigned to a const, so `expressionName`. `end` + // reassigns `data.result` to wrap the returned decorator, which rewrites the + // handler to open an `event.nestjs` span. Mirrors + // `SentryNestEventInstrumentation` (`>=2.0.0`); the `const OnEvent = (…) =>` + // shape is unchanged across 2.x–3.x. + channelName: 'onEventDecorator', + module: { + name: '@nestjs/event-emitter', + versionRange: '>=2.0.0', + filePath: 'dist/decorators/on-event.decorator.js', + }, + functionQuery: { expressionName: 'OnEvent', kind: 'Sync' }, + }, + { + // @nestjs/bullmq @Processor: `function Processor(...) { return (target) => {…}; }` + // The factory arg carries the queue name, so we match the factory and reassign + // `data.result` in `end` to wrap the returned class decorator (which patches + // `target.prototype.process`). Mirrors `SentryNestBullMQInstrumentation` + // (`>=10.0.0`); the `function Processor(...)` declaration is unchanged across + // 10.x–11.x. + channelName: 'processorDecorator', + module: { + name: '@nestjs/bullmq', + versionRange: '>=10.0.0', + filePath: 'dist/decorators/processor.decorator.js', + }, + functionQuery: { functionName: 'Processor', kind: 'Sync' }, + }, ] satisfies InstrumentationConfig[]; export const nestjsChannels = { @@ -79,4 +132,9 @@ export const nestjsChannels = { NESTJS_ROUTER_CONTEXT: 'orchestrion:@nestjs/core:routerExecutionContextCreate', NESTJS_INJECTABLE: 'orchestrion:@nestjs/common:injectableDecorator', NESTJS_CATCH: 'orchestrion:@nestjs/common:catchDecorator', + NESTJS_SCHEDULE_CRON: 'orchestrion:@nestjs/schedule:cronDecorator', + NESTJS_SCHEDULE_INTERVAL: 'orchestrion:@nestjs/schedule:intervalDecorator', + NESTJS_SCHEDULE_TIMEOUT: 'orchestrion:@nestjs/schedule:timeoutDecorator', + NESTJS_ONEVENT: 'orchestrion:@nestjs/event-emitter:onEventDecorator', + NESTJS_PROCESSOR: 'orchestrion:@nestjs/bullmq:processorDecorator', } as const; diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts index 83c930dd0c4a..3b5715b89c1a 100644 --- a/packages/server-utils/test/orchestrion/nestjs.test.ts +++ b/packages/server-utils/test/orchestrion/nestjs.test.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { tracingChannel } from 'node:diagnostics_channel'; import type { Scope, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; import { _INTERNAL_setSpanForScope, Client, @@ -519,3 +520,151 @@ describe('nestjsChannelIntegration: @Catch (exception filter)', () => { expect(spanInside).toBeUndefined(); }); }); + +describe('nestjsChannelIntegration: schedule / event / bullmq', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + vi.restoreAllMocks(); + }); + + // Drive a decorator-factory channel: node's traceSync sets `data.result` to + // the factory's return (our `originalDecorator`), then the subscriber's `end` + // reassigns `data.result`. Returns the effective (wrapped) decorator. + function driveFactory(channelName: string, factoryArgs: unknown[], originalDecorator: AnyFn): AnyFn { + const data: { arguments: unknown[]; result?: unknown } = { arguments: factoryArgs }; + tracingChannel<{ arguments: unknown[]; result?: unknown }>(channelName).traceSync(() => originalDecorator, data); + return data.result as AnyFn; + } + + it('schedule @Cron: wraps the handler with isolation scope + error capture, preserving name', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + const captureSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + + let originalCalled = false; + const original: AnyFn = (_t, _k, descriptor) => { + originalCalled = true; + return descriptor; + }; + const wrappedDecorator = driveFactory(CHANNELS.NESTJS_SCHEDULE_CRON, ['*/5 * * * *'], original); + + const handler = function doCron(): void { + throw new Error('cron boom'); + }; + const descriptor: PropertyDescriptor = { value: handler, configurable: true }; + wrappedDecorator({}, 'doCron', descriptor); + + expect(originalCalled).toBe(true); + expect(descriptor.value).not.toBe(handler); + expect((descriptor.value as AnyFn).name).toBe('doCron'); + + expect(() => (descriptor.value as AnyFn)()).toThrow('cron boom'); + expect(captureSpy).toHaveBeenCalledWith(expect.any(Error), { + mechanism: { handled: false, type: 'auto.function.nestjs.cron' }, + }); + }); + + it('schedule @Interval: captures async (rejected) errors with the interval mechanism', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + const captureSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + + const wrappedDecorator = driveFactory(CHANNELS.NESTJS_SCHEDULE_INTERVAL, [1000], (_t, _k, d) => d); + const descriptor: PropertyDescriptor = { + value: async function doInterval(): Promise { + throw new Error('interval boom'); + }, + configurable: true, + }; + wrappedDecorator({}, 'doInterval', descriptor); + + await expect((descriptor.value as AnyFn)()).rejects.toThrow('interval boom'); + expect(captureSpy).toHaveBeenCalledWith(expect.any(Error), { + mechanism: { handled: false, type: 'auto.function.nestjs.interval' }, + }); + }); + + it('event @OnEvent: opens an event.nestjs transaction named from the event', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + const wrappedDecorator = driveFactory(CHANNELS.NESTJS_ONEVENT, ['user.created'], (_t, _k, d) => d); + + let spanInside: Span | undefined; + const descriptor: PropertyDescriptor = { + value: async function onUserCreated(): Promise { + spanInside = getActiveSpan(); + return 'ok'; + }, + configurable: true, + }; + wrappedDecorator({}, 'onUserCreated', descriptor); + + await (descriptor.value as AnyFn)(); + + const json = spanToJSON(spanInside!); + expect(json.description).toBe('event user.created'); + expect(json.op).toBe('event.nestjs'); + expect(json.origin).toBe('auto.event.nestjs'); + }); + + it('bullmq @Processor: patches `process` into a queue.process transaction (string queue name)', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let originalCalled = false; + const wrappedDecorator = driveFactory(CHANNELS.NESTJS_PROCESSOR, ['emails'], () => { + originalCalled = true; + }); + + let spanInside: Span | undefined; + class EmailProcessor { + public async process(_job: unknown): Promise { + spanInside = getActiveSpan(); + return 'done'; + } + } + const originalProcess = EmailProcessor.prototype.process; + wrappedDecorator(EmailProcessor); + + expect(originalCalled).toBe(true); + expect(EmailProcessor.prototype.process).not.toBe(originalProcess); + + await new EmailProcessor().process({}); + const json = spanToJSON(spanInside!); + expect(json.description).toBe('emails process'); + expect(json.op).toBe('queue.process'); + expect(json.origin).toBe('auto.queue.nestjs.bullmq'); + expect(json.data).toMatchObject({ + 'messaging.system': 'bullmq', + 'messaging.destination.name': 'emails', + }); + }); + + it('bullmq @Processor: derives the queue name from an options object', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + const wrappedDecorator = driveFactory(CHANNELS.NESTJS_PROCESSOR, [{ name: 'reports' }], () => undefined); + + let spanInside: Span | undefined; + class ReportsProcessor { + public async process(): Promise { + spanInside = getActiveSpan(); + } + } + wrappedDecorator(ReportsProcessor); + return new ReportsProcessor().process().then(() => { + expect(spanToJSON(spanInside!).description).toBe('reports process'); + }); + }); +}); From 417b27554fc4bf31f0ce9d62cc35fa6233ba6651 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 25 Jun 2026 07:17:51 -0700 Subject: [PATCH 07/18] feat(nest): wire up orchestrion instrumentation Add `'Nest'` to the set of integrations that are implemented using Orchestrion, and which override a prior OTel based integration. The integration swap is moved into the `_init` method in the Node SDK, because the NestJS SDK (and other framework SDKs) will pass in its own defaultIntegrations array, which would bypass the old swap location. Now the swap is uniform for every framework SDK based on Node init, and respects `defaultIntegrations: false`. A new unit test is added that proves the opt-out leaves the defaults untouched, and opt-in replaces the named OTel integration with channel integrations. --- packages/node/src/sdk/index.ts | 43 +++++---- .../sdk/diagnosticsChannelInjection.test.ts | 87 +++++++++++++++++++ .../src/orchestrion/config/nestjs.ts | 1 - .../server-utils/src/orchestrion/index.ts | 14 ++- 4 files changed, 118 insertions(+), 27 deletions(-) create mode 100644 packages/node/test/sdk/diagnosticsChannelInjection.test.ts diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 8c8d2e887541..95a1cc8fd7b7 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -30,7 +30,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { /** Get the default integrations for the Node SDK. */ export function getDefaultIntegrations(options: Options): Integration[] { - const integrations: Integration[] = [ + return [ ...getDefaultIntegrationsWithoutPerformance(), // We only add performance integrations if tracing is enabled // Note that this means that without tracing enabled, e.g. `expressIntegration()` will not be added @@ -38,24 +38,6 @@ export function getDefaultIntegrations(options: Options): Integration[] { // But `transactionName` will not be set automatically ...(hasSpansEnabled(options) ? getAutoPerformanceIntegrations() : []), ]; - - // When the app opted into diagnostics-channel injection (via - // `experimentalUseDiagnosticsChannelInjection()`) AND span recording is - // enabled, swap the channel-based integrations in place of OTel equivalents - // so the two don't both instrument the same library. - // - // Every channel-based integration we ship today is a 1:1 replacement for an - // OTel performance/tracing integration and produces nothing but spans (those - // only come from `getAutoPerformanceIntegrations()` above), so it's gated on - // span recording. - if (isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options)) { - const diagnosticsChannelInjection = resolveDiagnosticsChannelInjection(); - if (diagnosticsChannelInjection) { - const replaced = new Set(diagnosticsChannelInjection.replacedOtelIntegrationNames); - return [...integrations.filter(i => !replaced.has(i.name)), ...diagnosticsChannelInjection.integrations]; - } - } - return integrations; } /** @@ -77,8 +59,7 @@ function _init( // EXPERIMENTAL: diagnostics-channel injection, opted into via // `experimentalUseDiagnosticsChannelInjection()`. Gated on span recording to // match the OTel integrations it replaces. With tracing off there are no - // channel subscribers, so injecting is pointless work. `resolve...()` is - // memoized, so `getDefaultIntegrations()` (below) sees the same instance. + // channel subscribers, so injecting is pointless work. const diagnosticsChannelInjection = isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options) ? resolveDiagnosticsChannelInjection() @@ -90,10 +71,26 @@ function _init( diagnosticsChannelInjection.register(); } + // Only use Node SDK defaults if none provided. + let defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(options); + + // When opted into diagnostics-channel injection, swap the channel-based + // integrations in place of their OTel equivalents so the two don't both + // instrument the same library. Done here (rather than in + // `getDefaultIntegrations`) so it also covers framework SDKs (e.g. + // `@sentry/nestjs`) that pass their own `defaultIntegrations` array, and it + // respects `defaultIntegrations: false` (not an array -> left untouched). + if (diagnosticsChannelInjection && Array.isArray(defaultIntegrations)) { + const replaced = new Set(diagnosticsChannelInjection.replacedOtelIntegrationNames); + defaultIntegrations = [ + ...defaultIntegrations.filter(integration => !replaced.has(integration.name)), + ...diagnosticsChannelInjection.integrations, + ]; + } + const client = initNodeCore({ ...options, - // Only use Node SDK defaults if none provided - defaultIntegrations: options.defaultIntegrations ?? getDefaultIntegrationsImpl(options), + defaultIntegrations, }); // Add Node SDK specific OpenTelemetry setup diff --git a/packages/node/test/sdk/diagnosticsChannelInjection.test.ts b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts new file mode 100644 index 000000000000..29dd5f5e2e88 --- /dev/null +++ b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts @@ -0,0 +1,87 @@ +import type { Integration } from '@sentry/core'; +import { debug } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { init } from '../../src/sdk'; +import { setDiagnosticsChannelInjectionLoader } from '../../src/sdk/diagnosticsChannelInjection'; +import { cleanupOtel, resetGlobals } from '../helpers/mockSdkInit'; + +// eslint-disable-next-line no-var +declare var global: any; + +const PUBLIC_DSN = 'https://username@domain/123'; + +function mockIntegration(name: string): Integration { + return { name, setupOnce: vi.fn() }; +} + +// These tests run in definition order: the first runs before any loader is set +// (opt-out), the second sets it (opt-in). The module-level loader state is +// isolated per test file by vitest, so it doesn't leak elsewhere. +describe('diagnostics-channel injection integration swap', () => { + beforeEach(() => { + global.__SENTRY__ = {}; + vi.spyOn(debug, 'enable').mockImplementation(() => undefined); + }); + + afterEach(() => { + cleanupOtel(); + resetGlobals(); + vi.clearAllMocks(); + }); + + it('does not swap integrations when not opted in', () => { + // Distinct names from the opt-in test below: `@sentry/core` only runs + // `setupOnce` once per integration name per process, so reusing names across + // tests would suppress later calls. + const otelNest = mockIntegration('OptOutNest'); + const http = mockIntegration('OptOutHttp'); + + init({ + dsn: PUBLIC_DSN, + tracesSampleRate: 1, + skipOpenTelemetrySetup: true, + defaultIntegrations: [otelNest, http], + }); + + // No opt-in -> the supplied defaults are set up untouched. + expect(otelNest.setupOnce).toHaveBeenCalledTimes(1); + expect(http.setupOnce).toHaveBeenCalledTimes(1); + }); + + it('replaces the named OTel integrations with the channel integrations, even when defaultIntegrations are supplied by a framework SDK', () => { + const channelMysql = mockIntegration('Mysql'); + const channelNest = mockIntegration('Nest'); + const register = vi.fn(); + const detect = vi.fn(); + setDiagnosticsChannelInjectionLoader(() => ({ + integrations: [channelMysql, channelNest], + replacedOtelIntegrationNames: ['Mysql', 'Nest'], + register, + detect, + })); + + // Mimics `@sentry/nestjs`, which prepends its OTel `Nest` integration to + // its own `defaultIntegrations` array (so node's `getDefaultIntegrations` + // swap never sees it; swap must happen in `init`). + const otelNest = mockIntegration('Nest'); + const http = mockIntegration('Http'); + + init({ + dsn: PUBLIC_DSN, + tracesSampleRate: 1, + skipOpenTelemetrySetup: true, + defaultIntegrations: [otelNest, http], + }); + + // OTel 'Nest' filtered out, never set up. + expect(otelNest.setupOnce).not.toHaveBeenCalled(); + // Channel replacements set up instead. + expect(channelNest.setupOnce).toHaveBeenCalledTimes(1); + expect(channelMysql.setupOnce).toHaveBeenCalledTimes(1); + // Unrelated default preserved. + expect(http.setupOnce).toHaveBeenCalledTimes(1); + // Hooks installed and detection ran once. + expect(register).toHaveBeenCalledTimes(1); + expect(detect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index 274102bb89d0..a1c2112a0957 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -17,7 +17,6 @@ function astQueryInstrumentation(config: { return config as unknown as InstrumentationConfig; } - export const nestjsConfig = [ { // `@nestjs/core/nest-factory.js` exports `class NestFactoryStatic` with an diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index f95fc4aa82a7..0f08f4ca9269 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -5,6 +5,7 @@ import { ioredisChannelIntegration } from '../integrations/tracing-channel/iored import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; import { openaiChannelIntegration } from '../integrations/tracing-channel/openai'; +import { nestjsChannelIntegration } from '../integrations/tracing-channel/nestjs'; import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres'; import { postgresJsChannelIntegration } from '../integrations/tracing-channel/postgres-js'; import { vercelAiChannelIntegration } from '../integrations/tracing-channel/vercel-ai'; @@ -23,14 +24,12 @@ export { postgresJsChannelIntegration, vercelAiChannelIntegration, expressChannelIntegration, + nestjsChannelIntegration, }; export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../integrations/tracing-channel/ioredis'; export type { PostgresJsChannelIntegrationOptions } from '../integrations/tracing-channel/postgres-js'; export { redisChannelIntegration } from '../integrations/tracing-channel/redis'; export type { RedisChannelIntegrationOptions, RedisResponseHook } from '../integrations/tracing-channel/redis'; -// Not part of `channelIntegrations` below: `Nest` isn't a `@sentry/node` default integration (it's added -// by the standalone `@sentry/nestjs` SDK), so it's not swapped via the generic default-integration path. -export { nestjsChannelIntegration } from '../integrations/tracing-channel/nestjs'; /** * The canonical set of orchestrion diagnostics-channel integrations, keyed by their public @@ -44,6 +43,14 @@ export { nestjsChannelIntegration } from '../integrations/tracing-channel/nestjs * NOTE: `ioredisChannelIntegration` and `redisChannelIntegration` are intentionally NOT here. They * only partially replace the composite OTel `Redis` integration and need the node SDK's redis cache * `responseHook` (which can't live in `server-utils`), so `@sentry/node` wires them up separately. + * + * NOTE: `ioredisChannelIntegration` is intentionally NOT here. It only partially replaces the + * composite OTel `Redis` integration and needs the node SDK's redis cache `responseHook` (which + * can't live in `server-utils`), so `@sentry/node` wires it up separately. + * + * `Nest` is included even though it isn't a `@sentry/node` default integration: the swap runs in the Node + * SDK's `_init` over the *final* `defaultIntegrations`, so it also replaces the OTel `Nest` that + * `@sentry/nestjs` prepends to its own defaults. */ export const channelIntegrations = { postgresIntegration: postgresChannelIntegration, @@ -56,4 +63,5 @@ export const channelIntegrations = { vercelAiIntegration: vercelAiChannelIntegration, hapiIntegration: hapiChannelIntegration, expressIntegration: expressChannelIntegration, + nestIntegration: nestjsChannelIntegration, } as const; From 4572bfb4f5432ce121f64ef5cefcbd32123435cf Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 2 Jul 2026 16:47:05 -0700 Subject: [PATCH 08/18] test(nestjs): e2e test for nestjs orchestrion implementation --- .../nestjs-orchestrion/.gitignore | 56 +++++++++ .../nestjs-orchestrion/README.md | 31 +++++ .../nestjs-orchestrion/nest-cli.json | 8 ++ .../nestjs-orchestrion/package.json | 36 ++++++ .../nestjs-orchestrion/playwright.config.mjs | 7 ++ .../nestjs-orchestrion/src/app.controller.ts | 74 ++++++++++++ .../nestjs-orchestrion/src/app.module.ts | 31 +++++ .../nestjs-orchestrion/src/app.service.ts | 17 +++ .../nestjs-orchestrion/src/events.service.ts | 12 ++ .../src/example.exception.ts | 5 + .../nestjs-orchestrion/src/example.filter.ts | 12 ++ .../nestjs-orchestrion/src/example.guard.ts | 12 ++ .../src/example.interceptor.ts | 19 ++++ .../src/example.middleware.ts | 13 +++ .../nestjs-orchestrion/src/instrument.ts | 17 +++ .../nestjs-orchestrion/src/main.ts | 16 +++ .../src/schedule.service.ts | 33 ++++++ .../nestjs-orchestrion/start-event-proxy.mjs | 6 + .../nestjs-orchestrion/tests/events.test.ts | 17 +++ .../nestjs-orchestrion/tests/schedule.test.ts | 40 +++++++ .../tests/transactions.test.ts | 107 ++++++++++++++++++ .../nestjs-orchestrion/tsconfig.build.json | 4 + .../nestjs-orchestrion/tsconfig.json | 25 ++++ .../instrument-orchestrion.mjs | 28 +++++ .../tracing/nestjs-orchestrion/scenario.ts | 29 +++++ .../suites/tracing/nestjs-orchestrion/test.ts | 61 ++++++++++ .../node-integration-tests/tsconfig.json | 6 +- 27 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json create mode 100644 dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore new file mode 100644 index 000000000000..4b56acfbebf4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md new file mode 100644 index 000000000000..5e35268cd1fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md @@ -0,0 +1,31 @@ +# nestjs-orchestrion + +E2E test app for the **orchestrion** (diagnostics-channel +injection) NestJS instrumentation. It is a normal +`@sentry/nestjs` app whose only difference from `nestjs-basic` is +that `src/instrument.ts` calls +`Sentry.experimentalUseDiagnosticsChannelInjection()` before +`Sentry.init()`. That swaps the OTel `Nest` integration for the +orchestrion subscriber (`@sentry/server-utils/orchestrion`) and +injects the diagnostics channels into `@nestjs/*` at load time. + +The tests assert the **same** span tree the OTel path produces +(`nestjs-basic`), so this app is the opt-in side of an A/B +against that baseline: + +- `transactions.test.ts`: `app_creation`, `request_context`, + `handler`, and the + `middleware.nestjs[.guard|.pipe|.interceptor|.exception_filter]` + spans. +- `schedule.test.ts`: `@Cron`/`@Interval`/`@Timeout` error + mechanisms. +- `events.test.ts`: the `@OnEvent` `event.nestjs` transaction. + +The spans that reassign the value a decorator factory / route +handler returns (`request_context`, +`@Cron`/`@Interval`/`@Timeout`, `@OnEvent`) rely on the +transformer returning the (mutated) `ctx.result`. That is the +default for `Sync`/`Async` transforms as of +`@apm-js-collab/code-transformer` `0.16.0` (no `mutableResult` +opt-in), which `@sentry/node`/`@sentry/server-utils` depend on, +so this app runs against the published transformer. diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json new file mode 100644 index 000000000000..f9aa683b1ad5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json new file mode 100644 index 000000000000..f7d11be2e9ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json @@ -0,0 +1,36 @@ +{ + "name": "nestjs-orchestrion", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "start:prod": "node dist/main", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test": "playwright test", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/event-emitter": "^2.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^4.1.0", + "@sentry/nestjs": "file:../../packed/sentry-nestjs-packed.tgz", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@types/express": "^5.0.0", + "@types/node": "^18.19.1", + "typescript": "~5.5.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts new file mode 100644 index 000000000000..169a4aa03313 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Param, ParseIntPipe, UseGuards, UseInterceptors } from '@nestjs/common'; +import { AppService } from './app.service'; +import { ExampleException } from './example.exception'; +import { ExampleGuard } from './example.guard'; +import { ExampleInterceptor } from './example.interceptor'; +import { ScheduleService } from './schedule.service'; + +@Controller() +export class AppController { + public constructor( + private readonly appService: AppService, + private readonly scheduleService: ScheduleService, + ) {} + + @Get('test-transaction') + public testTransaction(): unknown { + return this.appService.testSpan(); + } + + @Get('test-middleware') + public testMiddleware(): unknown { + return this.appService.testSpan(); + } + + @Get('test-guard') + @UseGuards(ExampleGuard) + public testGuard(): unknown { + return {}; + } + + @Get('test-interceptor') + @UseInterceptors(ExampleInterceptor) + public testInterceptor(): unknown { + return this.appService.testSpan(); + } + + @Get('test-pipe/:id') + public testPipe(@Param('id', ParseIntPipe) id: number): unknown { + return { value: id }; + } + + @Get('test-exception') + public testException(): never { + throw new ExampleException(); + } + + @Get('test-event') + public testEvent(): unknown { + this.appService.emitEvent(); + return { message: 'emitted' }; + } + + // Triggers the `@Timeout`-decorated handler directly (its real delay is long + // so it never fires on its own during the test). + @Get('trigger-timeout-error') + public triggerTimeoutError(): unknown { + try { + this.scheduleService.handleTimeoutError(); + } catch { + // Swallow, the error is captured by the schedule instrumentation; the + // route itself should still succeed. + } + return { message: 'triggered' }; + } + + // Stop the auto-firing scheduled jobs so they don't keep throwing after the + // assertions have run. + @Get('kill-schedules') + public killSchedules(): unknown { + this.scheduleService.killCron('test-cron-error'); + this.scheduleService.killInterval('test-interval-error'); + return { message: 'killed' }; + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts new file mode 100644 index 000000000000..d29a0cc68d72 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.module.ts @@ -0,0 +1,31 @@ +import { MiddlewareConsumer, Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ScheduleModule } from '@nestjs/schedule'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { EventsService } from './events.service'; +import { ExampleExceptionFilter } from './example.filter'; +import { ExampleMiddleware } from './example.middleware'; +import { ScheduleService } from './schedule.service'; + +@Module({ + imports: [EventEmitterModule.forRoot(), ScheduleModule.forRoot()], + controllers: [AppController], + providers: [ + AppService, + EventsService, + ScheduleService, + // Global exception filter + // exercises the `@Catch` (exception_filter) instrumentation. + { + provide: APP_FILTER, + useClass: ExampleExceptionFilter, + }, + ], +}) +export class AppModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(ExampleMiddleware).forRoutes('test-middleware'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts new file mode 100644 index 000000000000..faafa5d28ddd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/app.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class AppService { + public constructor(private readonly eventEmitter: EventEmitter2) {} + + public testSpan(): void { + // A child span, to verify request handling nests under the nestjs spans. + Sentry.startSpan({ name: 'test-controller-span' }, () => undefined); + } + + public emitEvent(): void { + this.eventEmitter.emit('test.event', { hello: 'world' }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts new file mode 100644 index 000000000000..596de32724af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/events.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class EventsService { + // `@OnEvent` opens an `event.nestjs` transaction per handled event. + @OnEvent('test.event') + public handleTestEvent(): void { + Sentry.startSpan({ name: 'test-event-child-span' }, () => undefined); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts new file mode 100644 index 000000000000..36b7444fead6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.exception.ts @@ -0,0 +1,5 @@ +export class ExampleException extends Error { + public constructor() { + super('Example exception handled by the example filter'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts new file mode 100644 index 000000000000..1af3d1f28769 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.filter.ts @@ -0,0 +1,12 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; +import { Response } from 'express'; +import { ExampleException } from './example.exception'; + +// `@Catch` exercises the exception_filter instrumentation. +@Catch(ExampleException) +export class ExampleExceptionFilter implements ExceptionFilter { + public catch(_exception: ExampleException, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + response.status(400).json({ message: 'handled by example filter' }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts new file mode 100644 index 000000000000..a9069f4e6f9d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.guard.ts @@ -0,0 +1,12 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class ExampleGuard implements CanActivate { + public canActivate(_context: ExecutionContext): boolean { + // Child span + // should nest under the guard span (middleware.nestjs / .guard). + Sentry.startSpan({ name: 'test-guard-span' }, () => undefined); + return true; + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts new file mode 100644 index 000000000000..670ae0e0d3df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.interceptor.ts @@ -0,0 +1,19 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import { tap } from 'rxjs'; + +@Injectable() +export class ExampleInterceptor implements NestInterceptor { + public intercept(_context: ExecutionContext, next: CallHandler): ReturnType { + // Runs before `next.handle()` + // nests under the interceptor "before" span. + Sentry.startSpan({ name: 'test-interceptor-span-before' }, () => undefined); + return next.handle().pipe( + tap(() => { + // Runs after the route + // nests under the "Interceptors - After Route" span. + Sentry.startSpan({ name: 'test-interceptor-span-after' }, () => undefined); + }), + ); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts new file mode 100644 index 000000000000..c04904ef62ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/example.middleware.ts @@ -0,0 +1,13 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import { NextFunction, Request, Response } from 'express'; + +@Injectable() +export class ExampleMiddleware implements NestMiddleware { + public use(_req: Request, _res: Response, next: NextFunction): void { + // Child span + // should nest under the middleware span. + Sentry.startSpan({ name: 'test-middleware-span' }, () => undefined); + next(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts new file mode 100644 index 000000000000..4c784cacce09 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/instrument.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/nestjs'; + +// Opt into diagnostics-channel injection BEFORE `Sentry.init()`. This swaps +// the OTel `Nest` instrumentation for the orchestrion (diagnostics-channel) +// one and synchronously installs the module hooks that inject the channels +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1, + transportOptions: { + // We expect the app to send a lot of events in a short time + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts new file mode 100644 index 000000000000..b7a2a41921cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/main.ts @@ -0,0 +1,16 @@ +// Import this first. It opts into diagnostics-channel injection and installs +// the module hooks before any `@nestjs/*` module is loaded below. +import './instrument'; + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +const PORT = 3030; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule); + await app.listen(PORT); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts new file mode 100644 index 000000000000..a0efa7ef33cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/src/schedule.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, Interval, SchedulerRegistry, Timeout } from '@nestjs/schedule'; + +// Scheduled-handler instrumentation captures errors (no span) under +// `auto.function.nestjs.{cron,interval,timeout}`. +@Injectable() +export class ScheduleService { + public constructor(private readonly schedulerRegistry: SchedulerRegistry) {} + + @Cron('*/5 * * * * *', { name: 'test-cron-error' }) + public handleCronError(): void { + throw new Error('Test error from cron'); + } + + @Interval('test-interval-error', 2000) + public handleIntervalError(): void { + throw new Error('Test error from interval'); + } + + // Long delay so it never fires on its own; the test triggers it via HTTP. + @Timeout('test-timeout-error', 600000) + public handleTimeoutError(): void { + throw new Error('Test error from timeout'); + } + + public killCron(name: string): void { + this.schedulerRegistry.deleteCronJob(name); + } + + public killInterval(name: string): void { + this.schedulerRegistry.deleteInterval(name); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs new file mode 100644 index 000000000000..ba90624b2481 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nestjs-orchestrion', +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts new file mode 100644 index 000000000000..a4d018635a0a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const PROXY = 'nestjs-orchestrion'; + +// `@OnEvent` opens an `event.nestjs` transaction per handled event. +test('@OnEvent opens an event.nestjs transaction', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return transactionEvent?.transaction === 'event test.event'; + }); + + await fetch(`${baseURL}/test-event`); + const transactionEvent = await transactionPromise; + + expect(transactionEvent.contexts?.trace?.op).toBe('event.nestjs'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.event.nestjs'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts new file mode 100644 index 000000000000..0029cc0fea43 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/schedule.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const PROXY = 'nestjs-orchestrion'; + +// `@Cron`/`@Interval` auto-fire (every few seconds) and throw; the schedule +// instrumentation captures the error (no span) with the per-decorator mechanism. +test('@Cron error is captured with the cron mechanism', async () => { + const error = await waitForError(PROXY, event => { + return event.exception?.values?.[0]?.value === 'Test error from cron'; + }); + + expect(error.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.function.nestjs.cron', handled: false }), + ); +}); + +test('@Interval error is captured with the interval mechanism', async () => { + const error = await waitForError(PROXY, event => { + return event.exception?.values?.[0]?.value === 'Test error from interval'; + }); + + expect(error.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.function.nestjs.interval', handled: false }), + ); +}); + +// `@Timeout`'s real delay is long, so the route triggers the handler directly. +test('@Timeout error is captured with the timeout mechanism', async ({ baseURL }) => { + const errorPromise = waitForError(PROXY, event => { + return event.exception?.values?.[0]?.value === 'Test error from timeout'; + }); + + await fetch(`${baseURL}/trigger-timeout-error`); + const error = await errorPromise; + + expect(error.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.function.nestjs.timeout', handled: false }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts new file mode 100644 index 000000000000..14207e755ed7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem, waitForTransaction } from '@sentry-internal/test-utils'; + +const PROXY = 'nestjs-orchestrion'; + +// Find a child span by op + origin within a transaction event. +function findSpan( + transactionEvent: Awaited>, + op: string, + origin: string, +): { description?: string; op?: string; origin?: string; data?: Record } | undefined { + return (transactionEvent.spans ?? []).find(span => span.op === op && span.origin === origin); +} + +test('app_creation: emits a "Create Nest App" transaction at startup', async () => { + // Emitted once at startup (NestFactory.create), before any request, so look + // back through buffered envelopes rather than waiting for a new transaction. + const envelopeItem = await waitForEnvelopeItem( + PROXY, + item => item[0].type === 'transaction' && (item[1] as { transaction?: string }).transaction === 'Create Nest App', + 0, + ); + + const transaction = envelopeItem[1] as { + contexts: { trace: { op?: string; origin?: string; data?: Record } }; + }; + + expect(transaction.contexts.trace.op).toBe('app_creation.nestjs'); + expect(transaction.contexts.trace.origin).toBe('auto.http.otel.nestjs'); + expect(transaction.contexts.trace.data).toEqual( + expect.objectContaining({ + component: '@nestjs/core', + 'nestjs.type': 'app_creation', + 'nestjs.module': 'AppModule', + }), + ); +}); + +test('request_context + handler: a route transaction nests the nestjs spans', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + const transactionEvent = await transactionPromise; + + // request_context span, identified by its controller/callback attributes. Its description isn't asserted: + // the span carries `http.*` attributes, so the OTel span-name inference rewrites it to `GET /test-transaction` + // (same as the OTel `Nest` integration — this matches, rather than diverges from, the baseline). + const requestContext = findSpan(transactionEvent, 'request_context.nestjs', 'auto.http.otel.nestjs'); + expect(requestContext).toBeDefined(); + expect(requestContext?.data).toMatchObject({ + 'nestjs.type': 'request_context', + 'nestjs.controller': 'AppController', + 'nestjs.callback': 'testTransaction', + }); + + // request_handler span: wraps the controller method itself. + const handler = (transactionEvent.spans ?? []).find( + span => span.op === 'handler.nestjs' && span.description === 'testTransaction', + ); + expect(handler).toBeDefined(); +}); + +// op + origin produced by `@Injectable`/`@Catch` instrumentation, per component type. +const MIDDLEWARE_CASES = [ + { route: 'test-middleware', origin: 'auto.middleware.nestjs', description: 'ExampleMiddleware' }, + { route: 'test-guard', origin: 'auto.middleware.nestjs.guard', description: 'ExampleGuard' }, + { route: 'test-pipe/123', origin: 'auto.middleware.nestjs.pipe', description: 'ParseIntPipe' }, + { route: 'test-interceptor', origin: 'auto.middleware.nestjs.interceptor', description: 'ExampleInterceptor' }, +] as const; + +for (const { route, origin, description } of MIDDLEWARE_CASES) { + test(`middleware span: ${origin} (${description})`, async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === `GET /${route.replace('/123', '/:id')}` + ); + }); + + await fetch(`${baseURL}/${route}`); + const transactionEvent = await transactionPromise; + + const span = findSpan(transactionEvent, 'middleware.nestjs', origin); + expect(span, `expected a ${origin} span`).toBeDefined(); + expect(span?.description).toBe(description); + }); +} + +test('exception_filter span: a @Catch filter opens a middleware.nestjs span', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(PROXY, transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-exception' + ); + }); + + await fetch(`${baseURL}/test-exception`); + const transactionEvent = await transactionPromise; + + const span = findSpan(transactionEvent, 'middleware.nestjs', 'auto.middleware.nestjs.exception_filter'); + expect(span).toBeDefined(); + expect(span?.description).toBe('ExampleExceptionFilter'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json new file mode 100644 index 000000000000..26c30d4eddf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json new file mode 100644 index 000000000000..dd356751ac4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + // `Node16` (not `commonjs`) to match `moduleResolution: Node16` below — TS 5.5 (this app's pinned + // version) errors on the `commonjs` + `Node16` combo (TS5110). The package is CommonJS (no + // `"type": "module"`), so `Node16` still emits CJS. + "module": "Node16", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "moduleResolution": "Node16" + } +} diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs new file mode 100644 index 000000000000..00c4fcf6f653 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs @@ -0,0 +1,28 @@ +// Loaded via `--import` BEFORE the scenario module, so the channel-injection +// hooks are installed before `@nestjs/*` is imported. Opting in via +// `experimentalUseDiagnosticsChannelInjection()` (before `init`) is all +// that's needed. + +import { register } from 'node:module'; +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// The scenario is TypeScript (NestJS needs decorators + design-type metadata). +// Opting into orchestrion installs an ESM loader hook (Node's `Module.register` +// path), which routes the `.ts` entry through the ESM chain — where the runner's +// CJS `-r ts-node/register` doesn't apply. Register `ts-node/esm` so the ESM +// chain can transpile the scenario; it composes with the orchestrion hook (which +// only transforms `@nestjs/*`). +register('ts-node/esm', import.meta.url); + +// opt into the orchestrion implementation +Sentry.experimentalUseDiagnosticsChannelInjection(); + +// Because we opted in, `Sentry.init()` swaps the OTel `Nest` instrumentation +// for the diagnostics-channel one and synchronously installs the module hooks. +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts new file mode 100644 index 000000000000..2679c6817f01 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; +import { Controller, Get, Module } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; + +@Controller() +class AppController { + @Get('/test-transaction') + public testTransaction(): { ok: true } { + return { ok: true }; + } +} + +@Module({ controllers: [AppController] }) +class AppModule {} + +async function bootstrap(): Promise { + // `NestFactory.create` -> the `Create Nest App` (app_creation) span + // the route -> `request_context` + `handler` spans, all via the + // orchestrion subscriber + const app = await NestFactory.create(AppModule, { logger: false }); + await app.listen(0); + const address = app.getHttpServer().address(); + sendPortToRunner(typeof address === 'object' && address ? address.port : 0); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +bootstrap(); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts new file mode 100644 index 000000000000..37b417aaa85e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts @@ -0,0 +1,61 @@ +import { join } from 'path'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('nestjs orchestrion auto-instrumentation', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const INSTRUMENT = join(__dirname, 'instrument-orchestrion.mjs'); + + test('emits the app_creation transaction at startup', async () => { + await createRunner(__dirname, 'scenario.ts') + .withFlags('--import', INSTRUMENT) + .expect({ + transaction: transaction => { + expect(transaction.transaction).toBe('Create Nest App'); + expect(transaction.contexts?.trace?.op).toBe('app_creation.nestjs'); + expect(transaction.contexts?.trace?.origin).toBe('auto.http.otel.nestjs'); + expect(transaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + component: '@nestjs/core', + 'nestjs.type': 'app_creation', + 'nestjs.module': 'AppModule', + }), + ); + }, + }) + .start() + .completed(); + }); + + test('a route transaction nests request_context + handler spans', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .withFlags('--import', INSTRUMENT) + // The `app_creation` transaction is emitted at startup, before the request, so it arrives first. + .expect({ + transaction: transaction => { + expect(transaction.transaction).toBe('Create Nest App'); + }, + }) + .expect({ + transaction: transaction => { + expect(transaction.transaction).toBe('GET /test-transaction'); + const spans = transaction.spans ?? []; + expect( + spans.find(span => span.op === 'request_context.nestjs' && span.origin === 'auto.http.otel.nestjs'), + 'expected a request_context.nestjs span', + ).toBeDefined(); + expect( + spans.find(span => span.op === 'handler.nestjs'), + 'expected a handler.nestjs span', + ).toBeDefined(); + }, + }) + .start(); + + runner.makeRequest('get', '/test-transaction'); + await runner.completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/tsconfig.json b/dev-packages/node-integration-tests/tsconfig.json index a554f62a0fc6..be309bac5d11 100644 --- a/dev-packages/node-integration-tests/tsconfig.json +++ b/dev-packages/node-integration-tests/tsconfig.json @@ -9,6 +9,10 @@ "lib": ["DOM", "es2020"], // package-specific options "esModuleInterop": true, - "types": ["node"] + "types": ["node"], + // Needed for NestJS `.ts` scenarios (e.g. `suites/tracing/nestjs-orchestrion`) run via + // `ts-node/register`: NestJS relies on decorators + emitted design-type metadata for DI. + "experimentalDecorators": true, + "emitDecoratorMetadata": true } } From 470e0431833df5f8a549efe3844148615b1f190a Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 3 Jul 2026 10:28:07 -0700 Subject: [PATCH 09/18] fix(node): do not add back default integrations when list is empty --- packages/node/src/sdk/index.ts | 11 +++++-- .../sdk/diagnosticsChannelInjection.test.ts | 32 ++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 95a1cc8fd7b7..77c3c3bc6fcc 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -78,9 +78,14 @@ function _init( // integrations in place of their OTel equivalents so the two don't both // instrument the same library. Done here (rather than in // `getDefaultIntegrations`) so it also covers framework SDKs (e.g. - // `@sentry/nestjs`) that pass their own `defaultIntegrations` array, and it - // respects `defaultIntegrations: false` (not an array -> left untouched). - if (diagnosticsChannelInjection && Array.isArray(defaultIntegrations)) { + // `@sentry/nestjs`) that pass their own `defaultIntegrations` array. + // + // Only when there's a non-empty default set to swap: + // `defaultIntegrations: false` (not an array) and `[]` / + // `initWithoutDefaultIntegrations()` (explicitly no defaults) are left + // untouched, as appending channel integrations there would resurrect + // defaults the caller opted out of. + if (diagnosticsChannelInjection && Array.isArray(defaultIntegrations) && defaultIntegrations.length > 0) { const replaced = new Set(diagnosticsChannelInjection.replacedOtelIntegrationNames); defaultIntegrations = [ ...defaultIntegrations.filter(integration => !replaced.has(integration.name)), diff --git a/packages/node/test/sdk/diagnosticsChannelInjection.test.ts b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts index 29dd5f5e2e88..b1412904e33c 100644 --- a/packages/node/test/sdk/diagnosticsChannelInjection.test.ts +++ b/packages/node/test/sdk/diagnosticsChannelInjection.test.ts @@ -1,7 +1,7 @@ import type { Integration } from '@sentry/core'; import { debug } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { init } from '../../src/sdk'; +import { init, initWithoutDefaultIntegrations } from '../../src/sdk'; import { setDiagnosticsChannelInjectionLoader } from '../../src/sdk/diagnosticsChannelInjection'; import { cleanupOtel, resetGlobals } from '../helpers/mockSdkInit'; @@ -84,4 +84,34 @@ describe('diagnostics-channel injection integration swap', () => { expect(register).toHaveBeenCalledTimes(1); expect(detect).toHaveBeenCalledTimes(1); }); + + it('does not add channel integrations when defaults are explicitly empty', () => { + const channelEmptyMysql = mockIntegration('EmptyMysql'); + setDiagnosticsChannelInjectionLoader(() => ({ + integrations: [channelEmptyMysql], + replacedOtelIntegrationNames: ['EmptyMysql'], + register: vi.fn(), + detect: vi.fn(), + })); + + // `defaultIntegrations: []` opts out of all defaults; the swap must not + // resurrect them by appending the channel integrations. + init({ dsn: PUBLIC_DSN, tracesSampleRate: 1, skipOpenTelemetrySetup: true, defaultIntegrations: [] }); + + expect(channelEmptyMysql.setupOnce).not.toHaveBeenCalled(); + }); + + it('does not add channel integrations to initWithoutDefaultIntegrations()', () => { + const channelNoDefaults = mockIntegration('NoDefaultsMysql'); + setDiagnosticsChannelInjectionLoader(() => ({ + integrations: [channelNoDefaults], + replacedOtelIntegrationNames: ['NoDefaultsMysql'], + register: vi.fn(), + detect: vi.fn(), + })); + + initWithoutDefaultIntegrations({ dsn: PUBLIC_DSN, tracesSampleRate: 1, skipOpenTelemetrySetup: true }); + + expect(channelNoDefaults.setupOnce).not.toHaveBeenCalled(); + }); }); From a790dfbab186d74268e39669427bb9aec79eed88 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 3 Jul 2026 10:32:06 -0700 Subject: [PATCH 10/18] fix(nestjs): use waitForTracingChannelBinding properly --- .../integrations/tracing-channel/nestjs.ts | 61 +++++++++++++------ 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts index 2efba585c5e8..bf6cf7dee59e 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts @@ -1,6 +1,13 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; -import { debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, startSpan } from '@sentry/core'; +import { + debug, + defineIntegration, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, + startSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; import { bindTracingChannelToSpan } from '../../tracing-channel'; @@ -164,6 +171,11 @@ const _nestjsChannelIntegration = (() => { 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:nestjs] subscribing to @nestjs channels'); // App-creation span: `bindTracingChannelToSpan` opens the span on @@ -172,24 +184,35 @@ const _nestjsChannelIntegration = (() => { // // `captureError: false` a failed bootstrap surfaces to the caller. // We just annotate the span. - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), - data => { - const moduleCls = data.arguments?.[0] as { name?: string } | undefined; - return startInactiveSpan({ - name: 'Create Nest App', - op: `${TYPE_APP_CREATION}.nestjs`, - attributes: { - [ATTR_COMPONENT]: NESTJS_COMPONENT, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, - [ATTR_NESTJS_TYPE]: TYPE_APP_CREATION, - ...(data.moduleVersion ? { [ATTR_NESTJS_VERSION]: data.moduleVersion } : {}), - ...(moduleCls?.name ? { [ATTR_NESTJS_MODULE]: moduleCls.name } : {}), - }, - }); - }, - { captureError: false }, - ); + // + // `bindTracingChannelToSpan` uses `bindStore`, which needs the + // async-context binding registered after integration `setupOnce`; + // defer until it's available (matches the other channel subscribers). + // Only this bind is deferred: it fires at `NestFactory.create` + // (bootstrap), so a retry tick is fine. The plain `.subscribe` calls + // below stay synchronous. The decorator channels fire at module-load / + // decoration time (right after init), which a deferred subscription + // could miss. + waitForTracingChannelBinding(() => { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), + data => { + const moduleCls = data.arguments?.[0] as { name?: string } | undefined; + return startInactiveSpan({ + name: 'Create Nest App', + op: `${TYPE_APP_CREATION}.nestjs`, + attributes: { + [ATTR_COMPONENT]: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, + [ATTR_NESTJS_TYPE]: TYPE_APP_CREATION, + ...(data.moduleVersion ? { [ATTR_NESTJS_VERSION]: data.moduleVersion } : {}), + ...(moduleCls?.name ? { [ATTR_NESTJS_MODULE]: moduleCls.name } : {}), + }, + }); + }, + { captureError: false }, + ); + }); // request_context + request_handler. `RouterExecutionContext.create` // runs once per route at setup: it receives `(instance, callback, ...)` From 8380475e28d1d8b8dfa5a04ecece0dc685fa9eeb Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 6 Jul 2026 18:19:20 -0700 Subject: [PATCH 11/18] fix(nestjs): note orchestrion on span origins --- .../nestjs-orchestrion/tests/events.test.ts | 2 +- .../tests/transactions.test.ts | 18 ++++++++++------- .../suites/tracing/nestjs-orchestrion/test.ts | 4 ++-- .../tracing-channel/nestjs-decorators.ts | 2 +- .../nestjs-handler-wrappers.ts | 16 +++++++++++---- .../integrations/tracing-channel/nestjs.ts | 2 +- .../test/orchestrion/nestjs.test.ts | 20 +++++++++---------- 7 files changed, 38 insertions(+), 26 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts index a4d018635a0a..6796c439506a 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/events.test.ts @@ -13,5 +13,5 @@ test('@OnEvent opens an event.nestjs transaction', async ({ baseURL }) => { const transactionEvent = await transactionPromise; expect(transactionEvent.contexts?.trace?.op).toBe('event.nestjs'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.event.nestjs'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.event.orchestrion.nestjs'); }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts index 14207e755ed7..856e60dde4bd 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts @@ -26,7 +26,7 @@ test('app_creation: emits a "Create Nest App" transaction at startup', async () }; expect(transaction.contexts.trace.op).toBe('app_creation.nestjs'); - expect(transaction.contexts.trace.origin).toBe('auto.http.otel.nestjs'); + expect(transaction.contexts.trace.origin).toBe('auto.http.orchestrion.nestjs'); expect(transaction.contexts.trace.data).toEqual( expect.objectContaining({ component: '@nestjs/core', @@ -50,7 +50,7 @@ test('request_context + handler: a route transaction nests the nestjs spans', as // request_context span, identified by its controller/callback attributes. Its description isn't asserted: // the span carries `http.*` attributes, so the OTel span-name inference rewrites it to `GET /test-transaction` // (same as the OTel `Nest` integration — this matches, rather than diverges from, the baseline). - const requestContext = findSpan(transactionEvent, 'request_context.nestjs', 'auto.http.otel.nestjs'); + const requestContext = findSpan(transactionEvent, 'request_context.nestjs', 'auto.http.orchestrion.nestjs'); expect(requestContext).toBeDefined(); expect(requestContext?.data).toMatchObject({ 'nestjs.type': 'request_context', @@ -67,10 +67,14 @@ test('request_context + handler: a route transaction nests the nestjs spans', as // op + origin produced by `@Injectable`/`@Catch` instrumentation, per component type. const MIDDLEWARE_CASES = [ - { route: 'test-middleware', origin: 'auto.middleware.nestjs', description: 'ExampleMiddleware' }, - { route: 'test-guard', origin: 'auto.middleware.nestjs.guard', description: 'ExampleGuard' }, - { route: 'test-pipe/123', origin: 'auto.middleware.nestjs.pipe', description: 'ParseIntPipe' }, - { route: 'test-interceptor', origin: 'auto.middleware.nestjs.interceptor', description: 'ExampleInterceptor' }, + { route: 'test-middleware', origin: 'auto.middleware.orchestrion.nestjs', description: 'ExampleMiddleware' }, + { route: 'test-guard', origin: 'auto.middleware.orchestrion.nestjs.guard', description: 'ExampleGuard' }, + { route: 'test-pipe/123', origin: 'auto.middleware.orchestrion.nestjs.pipe', description: 'ParseIntPipe' }, + { + route: 'test-interceptor', + origin: 'auto.middleware.orchestrion.nestjs.interceptor', + description: 'ExampleInterceptor', + }, ] as const; for (const { route, origin, description } of MIDDLEWARE_CASES) { @@ -101,7 +105,7 @@ test('exception_filter span: a @Catch filter opens a middleware.nestjs span', as await fetch(`${baseURL}/test-exception`); const transactionEvent = await transactionPromise; - const span = findSpan(transactionEvent, 'middleware.nestjs', 'auto.middleware.nestjs.exception_filter'); + const span = findSpan(transactionEvent, 'middleware.nestjs', 'auto.middleware.orchestrion.nestjs.exception_filter'); expect(span).toBeDefined(); expect(span?.description).toBe('ExampleExceptionFilter'); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts index 37b417aaa85e..002b71fcf207 100644 --- a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts @@ -16,7 +16,7 @@ describe('nestjs orchestrion auto-instrumentation', () => { transaction: transaction => { expect(transaction.transaction).toBe('Create Nest App'); expect(transaction.contexts?.trace?.op).toBe('app_creation.nestjs'); - expect(transaction.contexts?.trace?.origin).toBe('auto.http.otel.nestjs'); + expect(transaction.contexts?.trace?.origin).toBe('auto.http.orchestrion.nestjs'); expect(transaction.contexts?.trace?.data).toEqual( expect.objectContaining({ component: '@nestjs/core', @@ -44,7 +44,7 @@ describe('nestjs orchestrion auto-instrumentation', () => { expect(transaction.transaction).toBe('GET /test-transaction'); const spans = transaction.spans ?? []; expect( - spans.find(span => span.op === 'request_context.nestjs' && span.origin === 'auto.http.otel.nestjs'), + spans.find(span => span.op === 'request_context.nestjs' && span.origin === 'auto.http.orchestrion.nestjs'), 'expected a request_context.nestjs span', ).toBeDefined(); expect( diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts index e14ddfba5599..0bc2a231dca1 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts @@ -13,7 +13,7 @@ import { import type { AnyFn } from './nestjs-shared'; const OP_MIDDLEWARE = 'middleware.nestjs'; -const ORIGIN_MIDDLEWARE = 'auto.middleware.nestjs'; +const ORIGIN_MIDDLEWARE = 'auto.middleware.orchestrion.nestjs'; /** The class an `@Injectable` decorator is applied to (`ctx.arguments[0]`). */ export interface InjectableTarget { diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts index d812db3ea172..e9e533f0c9e3 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts @@ -13,14 +13,22 @@ import { isWrapped, markWrapped } from './nestjs-shared'; const NOOP = (): void => {}; -// Mechanism types for scheduled-handler error capture (no span) -// match vendored `SentryNestScheduleInstrumentation` +// Error-capture mechanism types. Kept identical to the vendored OTel +// instrumentations (`SentryNest{Schedule,Event,Bullmq}Instrumentation`) so +// captured errors attribute and group the same regardless of which +// instrumentation path (OTel vs orchestrion) caught them. const MECHANISM_CRON = 'auto.function.nestjs.cron'; const MECHANISM_INTERVAL = 'auto.function.nestjs.interval'; const MECHANISM_TIMEOUT = 'auto.function.nestjs.timeout'; const MECHANISM_EVENT = 'auto.event.nestjs'; const MECHANISM_BULLMQ = 'auto.queue.nestjs.bullmq'; +// Span origins for the orchestrion path. Unlike the mechanism types above, +// these carry the `orchestrion` segment so orchestrion-created spans are +// distinguishable from OTel +const ORIGIN_EVENT = 'auto.event.orchestrion.nestjs'; +const ORIGIN_BULLMQ = 'auto.queue.orchestrion.nestjs.bullmq'; + const EVENT_LISTENER_METADATA = 'EVENT_LISTENER_METADATA'; interface ReflectWithMetadata { @@ -115,7 +123,7 @@ function wrapEventHandler(handler: AnyFn, fallbackEvent: unknown): AnyFn { name: `event ${eventName}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MECHANISM_EVENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_EVENT, }, forceTransaction: true, }, @@ -145,7 +153,7 @@ function wrapBullMQProcess(process: AnyFn, queueName: string): AnyFn { name: `${queueName} process`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MECHANISM_BULLMQ, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_BULLMQ, 'messaging.system': 'bullmq', 'messaging.destination.name': queueName, }, diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts index bf6cf7dee59e..c18f98397a99 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts @@ -27,7 +27,7 @@ const INTEGRATION_NAME = 'Nest'; // `@sentry/nestjs` because that package depends on this one, not vice versa. // Orchestrion's whole point is to keep this surface free of OTel. const NESTJS_COMPONENT = '@nestjs/core'; -const ORIGIN_NESTJS = 'auto.http.otel.nestjs'; +const ORIGIN_NESTJS = 'auto.http.orchestrion.nestjs'; const ATTR_COMPONENT = 'component'; const ATTR_NESTJS_TYPE = 'nestjs.type'; const ATTR_NESTJS_VERSION = 'nestjs.version'; diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts index 3b5715b89c1a..fce6c21d54c1 100644 --- a/packages/server-utils/test/orchestrion/nestjs.test.ts +++ b/packages/server-utils/test/orchestrion/nestjs.test.ts @@ -148,7 +148,7 @@ describe('nestjsChannelIntegration: app_creation', () => { const json = spanToJSON(span!); expect(json.description).toBe('Create Nest App'); expect(json.op).toBe('app_creation.nestjs'); - expect(json.origin).toBe('auto.http.otel.nestjs'); + expect(json.origin).toBe('auto.http.orchestrion.nestjs'); expect(json.data).toMatchObject({ component: '@nestjs/core', 'nestjs.type': 'app_creation', @@ -242,7 +242,7 @@ describe('nestjsChannelIntegration: request_context / request_handler', () => { expect(contextSpanJson).toBeDefined(); expect(contextSpanJson!.description).toBe('CatsController.getCats'); expect(contextSpanJson!.op).toBe('request_context.nestjs'); - expect(contextSpanJson!.origin).toBe('auto.http.otel.nestjs'); + expect(contextSpanJson!.origin).toBe('auto.http.orchestrion.nestjs'); expect(contextSpanJson!.data).toMatchObject({ component: '@nestjs/core', 'nestjs.type': 'request_context', @@ -279,7 +279,7 @@ describe('nestjsChannelIntegration: request_context / request_handler', () => { expect(handlerSpanJson).toBeDefined(); expect(handlerSpanJson!.description).toBe('getCats'); expect(handlerSpanJson!.op).toBe('handler.nestjs'); - expect(handlerSpanJson!.origin).toBe('auto.http.otel.nestjs'); + expect(handlerSpanJson!.origin).toBe('auto.http.orchestrion.nestjs'); expect(handlerSpanJson!.data).toMatchObject({ component: '@nestjs/core', 'nestjs.type': 'handler', @@ -355,7 +355,7 @@ describe('nestjsChannelIntegration: @Injectable (middleware/guard/pipe/intercept const json = spanToJSON(spanInside!); expect(json.description).toBe('LoggerMiddleware'); expect(json.op).toBe('middleware.nestjs'); - expect(json.origin).toBe('auto.middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs'); // startSpanManual span ends when the proxied `next` is called. expect(json.timestamp).toBeDefined(); }); @@ -378,7 +378,7 @@ describe('nestjsChannelIntegration: @Injectable (middleware/guard/pipe/intercept const json = spanToJSON(spanInside!); expect(json.description).toBe('AuthGuard'); expect(json.op).toBe('middleware.nestjs'); - expect(json.origin).toBe('auto.middleware.nestjs.guard'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.guard'); }); it('pipe: wraps `transform` in a span and preserves its return value', () => { @@ -399,7 +399,7 @@ describe('nestjsChannelIntegration: @Injectable (middleware/guard/pipe/intercept const json = spanToJSON(spanInside!); expect(json.description).toBe('ParseIntPipe'); expect(json.op).toBe('middleware.nestjs'); - expect(json.origin).toBe('auto.middleware.nestjs.pipe'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.pipe'); }); it('interceptor: opens a before-span (ended at next.handle) and instruments the returned observable', () => { @@ -433,7 +433,7 @@ describe('nestjsChannelIntegration: @Injectable (middleware/guard/pipe/intercept const beforeJson = spanToJSON(beforeSpan!); expect(beforeJson.description).toBe('LoggingInterceptor'); expect(beforeJson.op).toBe('middleware.nestjs'); - expect(beforeJson.origin).toBe('auto.middleware.nestjs.interceptor'); + expect(beforeJson.origin).toBe('auto.middleware.orchestrion.nestjs.interceptor'); // before-span ends when `next.handle()` is called. expect(beforeJson.timestamp).toBeDefined(); @@ -498,7 +498,7 @@ describe('nestjsChannelIntegration: @Catch (exception filter)', () => { const json = spanToJSON(spanInside!); expect(json.description).toBe('HttpExceptionFilter'); expect(json.op).toBe('middleware.nestjs'); - expect(json.origin).toBe('auto.middleware.nestjs.exception_filter'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); }); it('does not open a span when exception or host is absent', () => { @@ -612,7 +612,7 @@ describe('nestjsChannelIntegration: schedule / event / bullmq', () => { const json = spanToJSON(spanInside!); expect(json.description).toBe('event user.created'); expect(json.op).toBe('event.nestjs'); - expect(json.origin).toBe('auto.event.nestjs'); + expect(json.origin).toBe('auto.event.orchestrion.nestjs'); }); it('bullmq @Processor: patches `process` into a queue.process transaction (string queue name)', async () => { @@ -642,7 +642,7 @@ describe('nestjsChannelIntegration: schedule / event / bullmq', () => { const json = spanToJSON(spanInside!); expect(json.description).toBe('emails process'); expect(json.op).toBe('queue.process'); - expect(json.origin).toBe('auto.queue.nestjs.bullmq'); + expect(json.origin).toBe('auto.queue.orchestrion.nestjs.bullmq'); expect(json.data).toMatchObject({ 'messaging.system': 'bullmq', 'messaging.destination.name': 'emails', From dfc12d599f08757463f49b23c8804ba29a596a7a Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 6 Jul 2026 18:22:54 -0700 Subject: [PATCH 12/18] test: remove unnecessary nestjs integration test --- .../instrument-orchestrion.mjs | 28 --------- .../tracing/nestjs-orchestrion/scenario.ts | 29 --------- .../suites/tracing/nestjs-orchestrion/test.ts | 61 ------------------- .../node-integration-tests/tsconfig.json | 6 +- 4 files changed, 1 insertion(+), 123 deletions(-) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts delete mode 100644 dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs deleted file mode 100644 index 00c4fcf6f653..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs +++ /dev/null @@ -1,28 +0,0 @@ -// Loaded via `--import` BEFORE the scenario module, so the channel-injection -// hooks are installed before `@nestjs/*` is imported. Opting in via -// `experimentalUseDiagnosticsChannelInjection()` (before `init`) is all -// that's needed. - -import { register } from 'node:module'; -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -// The scenario is TypeScript (NestJS needs decorators + design-type metadata). -// Opting into orchestrion installs an ESM loader hook (Node's `Module.register` -// path), which routes the `.ts` entry through the ESM chain — where the runner's -// CJS `-r ts-node/register` doesn't apply. Register `ts-node/esm` so the ESM -// chain can transpile the scenario; it composes with the orchestrion hook (which -// only transforms `@nestjs/*`). -register('ts-node/esm', import.meta.url); - -// opt into the orchestrion implementation -Sentry.experimentalUseDiagnosticsChannelInjection(); - -// Because we opted in, `Sentry.init()` swaps the OTel `Nest` instrumentation -// for the diagnostics-channel one and synchronously installs the module hooks. -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts deleted file mode 100644 index 2679c6817f01..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/scenario.ts +++ /dev/null @@ -1,29 +0,0 @@ -import 'reflect-metadata'; -import { Controller, Get, Module } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; -import type { NestExpressApplication } from '@nestjs/platform-express'; -import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; - -@Controller() -class AppController { - @Get('/test-transaction') - public testTransaction(): { ok: true } { - return { ok: true }; - } -} - -@Module({ controllers: [AppController] }) -class AppModule {} - -async function bootstrap(): Promise { - // `NestFactory.create` -> the `Create Nest App` (app_creation) span - // the route -> `request_context` + `handler` spans, all via the - // orchestrion subscriber - const app = await NestFactory.create(AppModule, { logger: false }); - await app.listen(0); - const address = app.getHttpServer().address(); - sendPortToRunner(typeof address === 'object' && address ? address.port : 0); -} - -// eslint-disable-next-line @typescript-eslint/no-floating-promises -bootstrap(); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts deleted file mode 100644 index 002b71fcf207..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { join } from 'path'; -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; - -describe('nestjs orchestrion auto-instrumentation', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - const INSTRUMENT = join(__dirname, 'instrument-orchestrion.mjs'); - - test('emits the app_creation transaction at startup', async () => { - await createRunner(__dirname, 'scenario.ts') - .withFlags('--import', INSTRUMENT) - .expect({ - transaction: transaction => { - expect(transaction.transaction).toBe('Create Nest App'); - expect(transaction.contexts?.trace?.op).toBe('app_creation.nestjs'); - expect(transaction.contexts?.trace?.origin).toBe('auto.http.orchestrion.nestjs'); - expect(transaction.contexts?.trace?.data).toEqual( - expect.objectContaining({ - component: '@nestjs/core', - 'nestjs.type': 'app_creation', - 'nestjs.module': 'AppModule', - }), - ); - }, - }) - .start() - .completed(); - }); - - test('a route transaction nests request_context + handler spans', async () => { - const runner = createRunner(__dirname, 'scenario.ts') - .withFlags('--import', INSTRUMENT) - // The `app_creation` transaction is emitted at startup, before the request, so it arrives first. - .expect({ - transaction: transaction => { - expect(transaction.transaction).toBe('Create Nest App'); - }, - }) - .expect({ - transaction: transaction => { - expect(transaction.transaction).toBe('GET /test-transaction'); - const spans = transaction.spans ?? []; - expect( - spans.find(span => span.op === 'request_context.nestjs' && span.origin === 'auto.http.orchestrion.nestjs'), - 'expected a request_context.nestjs span', - ).toBeDefined(); - expect( - spans.find(span => span.op === 'handler.nestjs'), - 'expected a handler.nestjs span', - ).toBeDefined(); - }, - }) - .start(); - - runner.makeRequest('get', '/test-transaction'); - await runner.completed(); - }); -}); diff --git a/dev-packages/node-integration-tests/tsconfig.json b/dev-packages/node-integration-tests/tsconfig.json index be309bac5d11..a554f62a0fc6 100644 --- a/dev-packages/node-integration-tests/tsconfig.json +++ b/dev-packages/node-integration-tests/tsconfig.json @@ -9,10 +9,6 @@ "lib": ["DOM", "es2020"], // package-specific options "esModuleInterop": true, - "types": ["node"], - // Needed for NestJS `.ts` scenarios (e.g. `suites/tracing/nestjs-orchestrion`) run via - // `ts-node/register`: NestJS relies on decorators + emitted design-type metadata for DI. - "experimentalDecorators": true, - "emitDecoratorMetadata": true + "types": ["node"] } } From e9e7a1d3deee8e72d20f0d47a3cd5db8e6d771f2 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 6 Jul 2026 18:44:46 -0700 Subject: [PATCH 13/18] fix(nestjs): wrap @Catch and @Injectable with 2 flags --- .../tracing-channel/nestjs-decorators.ts | 29 ++++-- .../test/orchestrion/nestjs.test.ts | 98 +++++++++++++++++++ 2 files changed, 118 insertions(+), 9 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts index 0bc2a231dca1..ac04dbd45e0c 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts @@ -18,7 +18,7 @@ const ORIGIN_MIDDLEWARE = 'auto.middleware.orchestrion.nestjs'; /** The class an `@Injectable` decorator is applied to (`ctx.arguments[0]`). */ export interface InjectableTarget { name?: string; - sentryPatched?: boolean; + sentryPatchedInjectable?: boolean; __SENTRY_INTERNAL__?: boolean; prototype: { use?: AnyFn; @@ -31,7 +31,7 @@ export interface InjectableTarget { /** The class a `@Catch` decorator is applied to (an exception filter). */ export interface CatchTarget { name?: string; - sentryPatched?: boolean; + sentryPatchedCatch?: boolean; __SENTRY_INTERNAL__?: boolean; prototype: { catch?: AnyFn }; } @@ -49,14 +49,20 @@ interface ObservableLike { } /** - * Mark a target class as patched so it's instrumented only once (mirrors the - * vendored `isPatched`). Also give idempotency across repeated subscriptions. + * Mark a target class as patched (for the given pass) so it's instrumented only + * once, and to stay idempotent across repeated subscriptions. + * + * The `@Injectable` and `@Catch` passes use *separate* flags on purpose: they + * wrap disjoint method sets (use/canActivate/transform/intercept vs catch), and + * a class can be decorated with both (an exception filter that also uses DI). + * A single shared flag would let whichever channel fired first latch it and + * block the other pass, dropping that pass's spans regardless of ordering. */ -function isTargetPatched(target: { sentryPatched?: boolean }): boolean { - if (target.sentryPatched) { +function isTargetPatched(target: object, flag: 'sentryPatchedInjectable' | 'sentryPatchedCatch'): boolean { + if ((target as Record)[flag]) { return true; } - addNonEnumerableProperty(target as object, 'sentryPatched', true); + addNonEnumerableProperty(target, flag, true); return false; } @@ -201,7 +207,7 @@ function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContex */ export function patchInjectableTarget(target: InjectableTarget, seenContexts: WeakSet): void { const proto = target?.prototype; - if (!proto || target.__SENTRY_INTERNAL__ || isTargetPatched(target)) { + if (!proto || target.__SENTRY_INTERNAL__ || isTargetPatched(target, 'sentryPatchedInjectable')) { return; } @@ -264,7 +270,12 @@ export function patchInjectableTarget(target: InjectableTarget, seenContexts: We */ export function patchCatchTarget(target: CatchTarget): void { const proto = target?.prototype; - if (!proto || typeof proto.catch !== 'function' || target.__SENTRY_INTERNAL__ || isTargetPatched(target)) { + if ( + !proto || + typeof proto.catch !== 'function' || + target.__SENTRY_INTERNAL__ || + isTargetPatched(target, 'sentryPatchedCatch') + ) { return; } proto.catch = new Proxy(proto.catch, { diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts index fce6c21d54c1..49fa5163c665 100644 --- a/packages/server-utils/test/orchestrion/nestjs.test.ts +++ b/packages/server-utils/test/orchestrion/nestjs.test.ts @@ -519,6 +519,104 @@ describe('nestjsChannelIntegration: @Catch (exception filter)', () => { new HttpExceptionFilter().catch('boom', undefined); expect(spanInside).toBeUndefined(); }); + + // A class can be decorated with both `@Injectable` and `@Catch` (an exception + // filter that uses DI). Which channel fires first depends on decorator + // stacking order (decorators apply inner-first): `@Catch` over `@Injectable` + // fires @Injectable first; `@Injectable` over `@Catch` fires @Catch first. + // Because the two passes use separate patched-flags, both must wrap their own + // methods regardless of which channel fires first. + function fireInjectable(target: object): void { + tracingChannel<{ arguments: unknown[] }>(CHANNELS.NESTJS_INJECTABLE).traceSync(() => undefined, { + arguments: [target], + }); + } + + it('still wraps `catch` when the @Injectable channel fired first (dual @Injectable @Catch filter)', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let spanInside: ReturnType; + class HttpExceptionFilter { + public catch(exception: unknown, _host: unknown): string { + spanInside = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + fireInjectable(HttpExceptionFilter); + applyCatch(HttpExceptionFilter); + + const ret = new HttpExceptionFilter().catch('boom', { switchToHttp: () => ({}) }); + expect(ret).toBe('handled:boom'); + + const json = spanToJSON(spanInside!); + expect(json.description).toBe('HttpExceptionFilter'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); + }); + + it('still wraps `catch` when the @Catch channel fired first (dual @Injectable @Catch filter)', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let spanInside: ReturnType; + class HttpExceptionFilter { + public catch(exception: unknown, _host: unknown): string { + spanInside = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + applyCatch(HttpExceptionFilter); + fireInjectable(HttpExceptionFilter); + + const ret = new HttpExceptionFilter().catch('boom', { switchToHttp: () => ({}) }); + expect(ret).toBe('handled:boom'); + + const json = spanToJSON(spanInside!); + expect(json.description).toBe('HttpExceptionFilter'); + expect(json.op).toBe('middleware.nestjs'); + expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); + }); + + // A (contrived) class that is BOTH a guard (`canActivate`) and an exception + // filter (`catch`) proves the two passes are independent: neither ordering may + // let one pass's patched-flag block the other. Both spans must appear either way. + for (const order of ['injectable-first', 'catch-first'] as const) { + it(`wraps BOTH canActivate and catch when the ${order} channel fired first`, () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + let guardSpan: ReturnType; + let filterSpan: ReturnType; + class GuardAndFilter { + public canActivate(_ctx: unknown): boolean { + guardSpan = getActiveSpan(); + return true; + } + public catch(exception: unknown, _host: unknown): string { + filterSpan = getActiveSpan(); + return `handled:${String(exception)}`; + } + } + + if (order === 'injectable-first') { + fireInjectable(GuardAndFilter); + applyCatch(GuardAndFilter); + } else { + applyCatch(GuardAndFilter); + fireInjectable(GuardAndFilter); + } + + expect(new GuardAndFilter().canActivate({ ctx: true })).toBe(true); + expect(new GuardAndFilter().catch('boom', { switchToHttp: () => ({}) })).toBe('handled:boom'); + + expect(spanToJSON(guardSpan!).origin).toBe('auto.middleware.orchestrion.nestjs.guard'); + expect(spanToJSON(filterSpan!).origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter'); + }); + } }); describe('nestjsChannelIntegration: schedule / event / bullmq', () => { From 60016b1eca51a1fabd58ef128c7320b92ef26247 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 7 Jul 2026 11:20:06 -0700 Subject: [PATCH 14/18] fix(nestjs): do not dangle spans for async iterators --- .../tracing-channel/nestjs-decorators.ts | 18 +++-- .../test/orchestrion/nestjs.test.ts | 70 +++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts index ac04dbd45e0c..26d2a35cacf7 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts @@ -167,15 +167,17 @@ function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContex throw e; } - if (!afterSpan) { - return returned; - } - // async interceptor: returns a Promise if (isThenable(returned)) { return returned.then( (observable: unknown) => { - instrumentObservable(observable as ObservableLike, afterSpan ?? parentSpan); + if (afterSpan) { + instrumentObservable(observable as ObservableLike, afterSpan); + } else { + // `next.handle()` was never called, so nothing ended the + // before-span (its `handle` proxy never ran); close it here. + beforeSpan.end(); + } return observable; }, (e: unknown) => { @@ -186,6 +188,12 @@ function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContex ); } + // Sync interceptor: `next.handle()` (if it was going to be called) has + // already run synchronously, so `afterSpan` is settled. + if (!afterSpan) { + return returned; + } + // sync interceptor: returns an Observable if (typeof (returned as ObservableLike).subscribe === 'function') { instrumentObservable(returned as ObservableLike, afterSpan); diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts index 49fa5163c665..48cd3c5010f8 100644 --- a/packages/server-utils/test/orchestrion/nestjs.test.ts +++ b/packages/server-utils/test/orchestrion/nestjs.test.ts @@ -444,6 +444,76 @@ describe('nestjsChannelIntegration: @Injectable (middleware/guard/pipe/intercept expect(() => teardowns.forEach(fn => fn())).not.toThrow(); }); + it('async interceptor that awaits before next.handle(): still instruments the after-span', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class AsyncInterceptor { + // Awaits *before* calling `next.handle()`, so `intercept` returns a + // pending Promise while the after-span does not yet exist. + public async intercept(_context: unknown, next: { handle: () => unknown }): Promise { + beforeSpan = getActiveSpan(); + await Promise.resolve(); + return next.handle(); + } + } + applyInjectable(AsyncInterceptor); + + const next = { handle: () => observable }; + const returned = (await new AsyncInterceptor().intercept({}, next)) as typeof observable; + + expect(returned).toBe(observable); + // before-span ended (when `next.handle()` ran, post-await) + expect(spanToJSON(beforeSpan!).timestamp).toBeDefined(); + // after-span was created AND the observable instrumented despite the await + returned.subscribe(); + expect(teardowns).toHaveLength(1); + expect(() => teardowns.forEach(fn => fn())).not.toThrow(); + }); + + it('async interceptor that never calls next.handle(): ends the before-span, no after-span', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class ShortCircuitInterceptor { + public async intercept(_context: unknown, _next: { handle: () => unknown }): Promise { + beforeSpan = getActiveSpan(); + await Promise.resolve(); + return observable; // short-circuits without calling `next.handle()` + } + } + applyInjectable(ShortCircuitInterceptor); + + const next = { handle: vi.fn() }; + const returned = (await new ShortCircuitInterceptor().intercept({}, next)) as typeof observable; + + expect(returned).toBe(observable); + expect(next.handle).not.toHaveBeenCalled(); + // before-span is closed even though `next.handle()` (which normally ends it) never ran + expect(spanToJSON(beforeSpan!).timestamp).toBeDefined(); + // no after-span, so the observable is left un-instrumented + returned.subscribe(); + expect(teardowns).toHaveLength(0); + }); + it('skips targets flagged __SENTRY_INTERNAL__', () => { installTestAsyncContextStrategy(); initTestClient(); From 2c114008131f1dfa3cf578060671040c4e48f830 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 7 Jul 2026 11:36:35 -0700 Subject: [PATCH 15/18] fix(nestjs): handle sync interceptor that short-circuits span end --- .../tracing-channel/nestjs-decorators.ts | 4 +++ .../test/orchestrion/nestjs.test.ts | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts index 26d2a35cacf7..0838ddc55d32 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts @@ -191,6 +191,10 @@ function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContex // Sync interceptor: `next.handle()` (if it was going to be called) has // already run synchronously, so `afterSpan` is settled. if (!afterSpan) { + // `next.handle()` was never called (e.g. the interceptor + // short-circuited for a cache/validation hit), so its `handle` proxy + // never ended the before-span; close it here. + beforeSpan.end(); return returned; } diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts index 48cd3c5010f8..a54d39d0cf21 100644 --- a/packages/server-utils/test/orchestrion/nestjs.test.ts +++ b/packages/server-utils/test/orchestrion/nestjs.test.ts @@ -514,6 +514,41 @@ describe('nestjsChannelIntegration: @Injectable (middleware/guard/pipe/intercept expect(teardowns).toHaveLength(0); }); + it('sync interceptor that short-circuits without next.handle(): ends the before-span', () => { + installTestAsyncContextStrategy(); + initTestClient(); + nestjsChannelIntegration().setupOnce!(); + + const teardowns: Array<() => void> = []; + const observable = { + subscribe(): { add: (fn: () => void) => void } { + return { add: (fn: () => void) => void teardowns.push(fn) }; + }, + }; + + let beforeSpan: ReturnType; + class CachingInterceptor { + // Synchronously returns an Observable without calling `next.handle()` + // (a cache/validation short-circuit). + public intercept(_context: unknown, _next: { handle: () => unknown }): unknown { + beforeSpan = getActiveSpan(); + return observable; + } + } + applyInjectable(CachingInterceptor); + + const next = { handle: vi.fn() }; + const returned = new CachingInterceptor().intercept({}, next) as typeof observable; + + expect(returned).toBe(observable); + expect(next.handle).not.toHaveBeenCalled(); + // before-span is closed even though `next.handle()` (which normally ends it) never ran + expect(spanToJSON(beforeSpan!).timestamp).toBeDefined(); + // no after-span, so the observable is left un-instrumented + returned.subscribe(); + expect(teardowns).toHaveLength(0); + }); + it('skips targets flagged __SENTRY_INTERNAL__', () => { installTestAsyncContextStrategy(); initTestClient(); From 0997aa2ec5fdaa97003557481d2c2b7f7f7e002a Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 8 Jul 2026 13:05:33 -0700 Subject: [PATCH 16/18] ref(server-utils, nestjs): move NestJS orchestrion configs out of server-utils Introduce a dependency-injection approach to setting orchestrion configuration from a standalone SDK that is not a part of the "normal" tracing integrations reasonable to put in server-utils. This also allows for better DRY sharing of reusable components between the OTel and Orchestrion implementations of the NestJS integration. The major change is that certain items like the list of Orchestrion instrumentations, need to be fetched on demand with a method, rather than being a static `as const` array. Dependency cycle is still avoided, but all NestJS code is now in packages/nest where it belongs. --- packages/bun/src/plugin.ts | 18 +- packages/bun/src/sdk.ts | 8 +- packages/nestjs/package.json | 8 +- packages/nestjs/rollup.npm.config.mjs | 30 +- packages/nestjs/src/debug-build.ts | 8 + packages/nestjs/src/import.mjs | 20 ++ packages/nestjs/src/integrations/helpers.ts | 81 +++-- .../sentry-nest-bullmq-instrumentation.ts | 48 +-- .../sentry-nest-event-instrumentation.ts | 87 +----- .../sentry-nest-instrumentation.ts | 225 +------------- .../sentry-nest-schedule-instrumentation.ts | 150 +++------- packages/nestjs/src/integrations/types.ts | 8 +- .../integrations/vendored/instrumentation.ts | 99 +----- .../src/integrations/wrap-components.ts} | 166 ++--------- .../nestjs/src/integrations/wrap-handlers.ts | 183 ++++++++++++ .../nestjs/src/integrations/wrap-route.ts | 133 +++++++++ .../src/orchestrion/config.ts} | 39 ++- packages/nestjs/src/orchestrion/index.ts | 21 ++ packages/nestjs/src/orchestrion/subscriber.ts | 227 ++++++++++++++ packages/nestjs/src/sdk.ts | 9 + .../nestjs/test/integrations/nest.test.ts | 14 +- .../test/orchestrion/nestjs.test.ts | 14 +- ...erimentalUseDiagnosticsChannelInjection.ts | 7 +- .../nestjs-handler-wrappers.ts | 272 ----------------- .../tracing-channel/nestjs-shared.ts | 30 -- .../integrations/tracing-channel/nestjs.ts | 282 ------------------ .../src/orchestrion/bundler/vite.ts | 20 +- .../server-utils/src/orchestrion/channels.ts | 2 - .../src/orchestrion/config/index.ts | 39 ++- .../server-utils/src/orchestrion/detect.ts | 5 +- .../server-utils/src/orchestrion/index.ts | 21 +- .../server-utils/src/orchestrion/registry.ts | 55 ++++ .../src/orchestrion/runtime/register.ts | 13 +- 33 files changed, 996 insertions(+), 1346 deletions(-) create mode 100644 packages/nestjs/src/debug-build.ts create mode 100644 packages/nestjs/src/import.mjs rename packages/{server-utils/src/integrations/tracing-channel/nestjs-decorators.ts => nestjs/src/integrations/wrap-components.ts} (55%) create mode 100644 packages/nestjs/src/integrations/wrap-handlers.ts create mode 100644 packages/nestjs/src/integrations/wrap-route.ts rename packages/{server-utils/src/orchestrion/config/nestjs.ts => nestjs/src/orchestrion/config.ts} (86%) create mode 100644 packages/nestjs/src/orchestrion/index.ts create mode 100644 packages/nestjs/src/orchestrion/subscriber.ts rename packages/{server-utils => nestjs}/test/orchestrion/nestjs.test.ts (98%) delete mode 100644 packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts delete mode 100644 packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts delete mode 100644 packages/server-utils/src/integrations/tracing-channel/nestjs.ts create mode 100644 packages/server-utils/src/orchestrion/registry.ts diff --git a/packages/bun/src/plugin.ts b/packages/bun/src/plugin.ts index c0c9ac73fa79..5e4b00a055e0 100644 --- a/packages/bun/src/plugin.ts +++ b/packages/bun/src/plugin.ts @@ -38,10 +38,11 @@ type UnknownPlugin = any; // module system. import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/bun'; import { - INSTRUMENTED_MODULE_NAMES, + instrumentedModuleNames, SENTRY_INSTRUMENTATIONS, withoutInstrumentedExternals, } from '@sentry/server-utils/orchestrion/config'; +import type { OrchestrionInstrumentation } from '@sentry/server-utils/orchestrion'; const BUNDLER_MARKER_BANNER = ';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=true;'; @@ -61,18 +62,25 @@ interface BunPluginBuilder { * * Pass the result to `Bun.build({ plugins: [...] })`. * + * A framework SDK that owns its own instrumentation can inject its + * `OrchestrionInstrumentation` descriptor via the `instrumentations` option, merged with the + * built-in configs. + * * @example * ```ts * import { sentryBunPlugin } from '@sentry/bun/plugin'; * await Bun.build({ entrypoints: ['./app.ts'], plugins: [sentryBunPlugin()] }); * ``` */ -export function sentryBunPlugin(): UnknownPlugin { +export function sentryBunPlugin(options?: { instrumentations?: OrchestrionInstrumentation[] }): UnknownPlugin { + const instrumentations = [...SENTRY_INSTRUMENTATIONS, ...(options?.instrumentations ?? []).flatMap(i => i.configs)]; + const moduleNames = instrumentedModuleNames(instrumentations); + // Typed upstream as an esbuild `Plugin`, but Bun passes its own // `PluginBuilder` (which has the `onLoad` the transform uses) to `setup`. // Cast to the Bun-compatible shape so we can forward Bun's builder to its // `setup`. - const transformer = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }) as unknown as { + const transformer = codeTransformer({ instrumentations }) as unknown as { setup: (build: BunPluginBuilder) => void; }; @@ -91,7 +99,7 @@ export function sentryBunPlugin(): UnknownPlugin { // the transform's `onLoad`, so its diagnostics_channel calls would // be silently never injected. Bun has no runtime fallback here, so // bundling is the only injection path. - build.config.external = withoutInstrumentedExternals(build.config.external); + build.config.external = withoutInstrumentedExternals(build.config.external, moduleNames); // A blanket externalization strategy like `packages: 'external'` or // `'*'` in `external` externalizes instrumented packages too, and @@ -113,7 +121,7 @@ export function sentryBunPlugin(): UnknownPlugin { console.warn( `[Sentry] This Bun build externalizes all dependencies (${blanketExternal}), so Sentry ` + 'cannot instrument bundled libraries. Instrumentation will be missing for any of ' + - `these packages your app uses: ${INSTRUMENTED_MODULE_NAMES.join(', ')}. To instrument them, ` + + `these packages your app uses: ${moduleNames.join(', ')}. To instrument them, ` + 'externalize only the specific packages you need external instead of all of them.', ); } diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index ae27a986c352..9b82eb604750 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -22,7 +22,7 @@ import { onUnhandledRejectionIntegration, processSessionIntegration, } from '@sentry/node'; -import { channelIntegrations, isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; +import { getChannelIntegrations, isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; import { bunServerIntegration } from './integrations/bunserver'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; @@ -31,8 +31,8 @@ import type { BunOptions } from './types'; * The orchestrion channel-subscriber integrations, listening on the diagnostics * channels that `@sentry/bun/plugin` injects at build time. */ -function getChannelIntegrations(): Integration[] { - return Object.values(channelIntegrations).map(integrationFactory => integrationFactory()); +function getChannelIntegrationInstances(): Integration[] { + return getChannelIntegrations().map(integrationFactory => integrationFactory()); } /** @@ -53,7 +53,7 @@ function getPerformanceIntegrations(options: Options): Integration[] { return autoPerformanceIntegrations; } - const channelIntegrationInstances = getChannelIntegrations(); + const channelIntegrationInstances = getChannelIntegrationInstances(); // The OTel integrations these channel subscribers replace, keyed by the name they share with them. const replacedOtelIntegrationNames = new Set(channelIntegrationInstances.map(integration => integration.name)); diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index e638532a3aea..2e4a51c3f579 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -38,6 +38,11 @@ "types": "./setup.d.ts", "default": "./build/cjs/setup.js" } + }, + "./import": { + "import": { + "default": "./build/import.mjs" + } } }, "publishConfig": { @@ -48,7 +53,8 @@ "@opentelemetry/instrumentation": "^0.220.0", "@sentry/conventions": "^0.15.1", "@sentry/core": "10.64.0", - "@sentry/node": "10.64.0" + "@sentry/node": "10.64.0", + "@sentry/server-utils": "10.64.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", diff --git a/packages/nestjs/rollup.npm.config.mjs b/packages/nestjs/rollup.npm.config.mjs index 0ce71546935c..1b18447a64e9 100644 --- a/packages/nestjs/rollup.npm.config.mjs +++ b/packages/nestjs/rollup.npm.config.mjs @@ -1,7 +1,29 @@ +import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants( - makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/setup.ts'], +// EXPERIMENTAL: NestJS orchestrion `--import` runtime hook. A hand-written +// `.mjs` shim referenced via `node --import @sentry/nestjs/import`. We pass it +// through rollup only to copy it into `build/import.mjs` at the path the +// package.json `exports` map expects; `external: /.*/` keeps every import (e.g. +// `@sentry/nestjs/orchestrion`) as a runtime resolution against the installed +// package. +const orchestrionRuntimeHooks = [ + defineConfig({ + input: 'src/import.mjs', + external: /.*/, + output: { format: 'esm', file: 'build/import.mjs' }, }), -); +]; + +export default [ + ...orchestrionRuntimeHooks, + ...makeNPMConfigVariants( + makeBaseNPMConfig({ + // `src/orchestrion/index.ts` is internal (NOT a public export). It's listed + // as an entrypoint only to guarantee a stable `build/esm/orchestrion/index.js` + // output path, which the `build/import.mjs` `--import` hook references via a + // relative import to register the `nestjsOrchestrion` descriptor. + entrypoints: ['src/index.ts', 'src/setup.ts', 'src/orchestrion/index.ts'], + }), + ), +]; diff --git a/packages/nestjs/src/debug-build.ts b/packages/nestjs/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/nestjs/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/nestjs/src/import.mjs b/packages/nestjs/src/import.mjs new file mode 100644 index 000000000000..bd2053db10a6 --- /dev/null +++ b/packages/nestjs/src/import.mjs @@ -0,0 +1,20 @@ +// EXPERIMENTAL: NestJS diagnostics-channel injection runtime hook. The +// side-effecting `--import` entry (e.g. `node --import @sentry/nestjs/import app.js`) +// that injects the @nestjs channels unconditionally before the app loads. +// +// Order matters and static ESM imports are hoisted: we import the register +// FUNCTION (not a side-effecting base hook) and call it AFTER registering the +// NestJS instrumentation, so the transform config includes the @nestjs configs. +// The `nestjsOrchestrion` descriptor is an internal detail (not a public export), +// so we reach it via a relative path into this package's own build output; +// importing it has no side effects. It only defines the descriptor. +// +// This file ships verbatim to `build/import.mjs`; the relative import below +// resolves to `build/esm/orchestrion/index.js` at runtime. + +import { registerOrchestrionInstrumentation } from '@sentry/server-utils/orchestrion'; +import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; +import { nestjsOrchestrion } from './esm/orchestrion/index.js'; + +registerOrchestrionInstrumentation(nestjsOrchestrion); +registerDiagnosticsChannelInjection(); diff --git a/packages/nestjs/src/integrations/helpers.ts b/packages/nestjs/src/integrations/helpers.ts index d8b50957f979..24af563d1e92 100644 --- a/packages/nestjs/src/integrations/helpers.ts +++ b/packages/nestjs/src/integrations/helpers.ts @@ -5,42 +5,87 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, withActiveSpan, } from '@sentry/core'; +import { isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types'; -const sentryPatched = 'sentryPatched'; +/** A function of unknown signature, matching the methods/handlers we wrap. */ +export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; /** - * Helper checking if a concrete target class is already patched. + * Marks a function as already wrapped so repeated subscriptions/decoration + * don't double-wrap it. Shared by the OTel and orchestrion paths. + */ +const SENTRY_WRAPPED = Symbol.for('sentry.nestjs.wrapped'); + +/** Whether `fn` has already been wrapped by this integration. */ +export function isWrapped(fn: AnyFn): boolean { + return !!(fn as AnyFn & Record)[SENTRY_WRAPPED]; +} + +/** Mark `fn` as wrapped (see {@link isWrapped}). */ +export function markWrapped(fn: AnyFn): void { + (fn as AnyFn & Record)[SENTRY_WRAPPED] = true; +} + +/** + * Mark a target class as patched (for the given pass) so it's instrumented only + * once, and to stay idempotent across repeated subscriptions/decoration. * - * We already guard duplicate patching with isWrapped. However, isWrapped checks whether a file has been patched, whereas we use this check for concrete target classes. - * This check might not be necessary, but better to play it safe. + * The `@Injectable` and `@Catch` passes use *separate* flags on purpose: they + * wrap disjoint method sets (use/canActivate/transform/intercept vs catch), and + * a class can be decorated with both (an exception filter that also uses DI). + * A single shared flag would let whichever pass fired first latch it and block + * the other, dropping that pass's spans regardless of ordering. */ -export function isPatched(target: InjectableTarget | CatchTarget): boolean { - if (target.sentryPatched) { +export function isTargetPatched(target: object, flag: 'sentryPatchedInjectable' | 'sentryPatchedCatch'): boolean { + if ((target as Record)[flag]) { return true; } - - addNonEnumerableProperty(target, sentryPatched, true); + addNonEnumerableProperty(target, flag, true); return false; } +// The instrumentation path is reflected in the span origin: orchestrion-created +// spans carry an `orchestrion` segment so they're distinguishable from OTel. +// Only one path is ever live in a process (the OTel `Nest` integration is +// swapped out whenever orchestrion is injected), so the global injection flag +// reliably selects the right origin. Everything else about the span is identical. + +/** Origin for middleware/guard/pipe/interceptor/exception_filter spans. */ +function middlewareOrigin(componentType?: string): string { + const base = isOrchestrionInjected() ? 'auto.middleware.orchestrion.nestjs' : 'auto.middleware.nestjs'; + return componentType ? `${base}.${componentType}` : base; +} + +/** Origin for the app-creation / request-context / request-handler HTTP spans. */ +export function httpOrigin(): string { + return isOrchestrionInjected() ? 'auto.http.orchestrion.nestjs' : 'auto.http.otel.nestjs'; +} + +/** Origin for `@OnEvent` spans. */ +function eventOrigin(): string { + return isOrchestrionInjected() ? 'auto.event.orchestrion.nestjs' : 'auto.event.nestjs'; +} + +/** Origin for BullMQ `@Processor` `process` spans. */ +function bullmqOrigin(): string { + return isOrchestrionInjected() ? 'auto.queue.orchestrion.nestjs.bullmq' : 'auto.queue.nestjs.bullmq'; +} + /** * Returns span options for nest middleware spans. + * name = provided name or class name. */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function getMiddlewareSpanOptions( - target: InjectableTarget | CatchTarget, + target: InjectableTarget | CatchTarget | { name?: string }, name: string | undefined = undefined, componentType: string | undefined = undefined, -) { - const span_name = name ?? target.name; // fallback to class name if no name is provided - const origin = componentType ? `auto.middleware.nestjs.${componentType}` : 'auto.middleware.nestjs'; - +): { name: string; attributes: Record } { return { - name: span_name, + name: name ?? target.name ?? 'unknown', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: middlewareOrigin(componentType), }, }; } @@ -57,7 +102,7 @@ export function getEventSpanOptions(event: string): { name: `event ${event}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: eventOrigin(), }, forceTransaction: true, }; @@ -75,7 +120,7 @@ export function getBullMQProcessSpanOptions(queueName: string): { name: `${queueName} process`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.nestjs.bullmq', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: bullmqOrigin(), 'messaging.system': 'bullmq', 'messaging.destination.name': queueName, }, diff --git a/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts index b18bab1dc07c..6ab8faab15df 100644 --- a/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts @@ -5,9 +5,9 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core'; -import { getBullMQProcessSpanOptions } from './helpers'; +import { SDK_VERSION } from '@sentry/core'; import type { ProcessorDecoratorTarget } from './types'; +import { extractQueueName, patchProcessorTarget } from './wrap-handlers'; const supportedVersions = ['>=10.0.0']; const COMPONENT = '@nestjs/bullmq'; @@ -18,6 +18,9 @@ const COMPONENT = '@nestjs/bullmq'; * This hooks into the `@Processor` class decorator, which is applied on queue processor classes. * It wraps the `process` method on the decorated class to fork the isolation scope for each job * invocation, create a span, and capture errors. + * + * The `process`-wrapping logic lives in `./wrappers` and is shared with the orchestrion + * (diagnostics-channel) path. */ export class SentryNestBullMQInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -62,50 +65,13 @@ export class SentryNestBullMQInstrumentation extends InstrumentationBase { return function wrapProcessor(original: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrappedProcessor(...decoratorArgs: any[]) { - // Extract queue name from decorator args - // @Processor('queueName') or @Processor({ name: 'queueName' }) - const queueName = - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - typeof decoratorArgs[0] === 'string' ? decoratorArgs[0] : decoratorArgs[0]?.name || 'unknown'; + const queueName = extractQueueName(decoratorArgs[0]); - // Get the original class decorator // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const classDecorator = original(...decoratorArgs); - // Return a new class decorator that wraps the process method return function (target: ProcessorDecoratorTarget) { - const originalProcess = target.prototype.process; - - if ( - originalProcess && - typeof originalProcess === 'function' && - !target.__SENTRY_INTERNAL__ && - !originalProcess.__SENTRY_INSTRUMENTED__ - ) { - target.prototype.process = new Proxy(originalProcess, { - apply: (originalProcessFn, thisArg, args) => { - return withIsolationScope(() => { - return startSpan(getBullMQProcessSpanOptions(queueName), async () => { - try { - return await originalProcessFn.apply(thisArg, args); - } catch (error) { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.queue.nestjs.bullmq', - }, - }); - throw error; - } - }); - }); - }, - }); - - target.prototype.process.__SENTRY_INSTRUMENTED__ = true; - } - - // Apply the original class decorator + patchProcessorTarget(target, queueName); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return classDecorator(target); }; diff --git a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts index b4f8784eea05..7918f2cb34e2 100644 --- a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts @@ -5,9 +5,10 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core'; -import { getEventSpanOptions } from './helpers'; +import { SDK_VERSION } from '@sentry/core'; +import type { AnyFn } from './helpers'; import type { OnEventTarget } from './types'; +import { patchMethodDescriptor, wrapEventHandler } from './wrap-handlers'; const supportedVersions = ['>=2.0.0']; const COMPONENT = '@nestjs/event-emitter'; @@ -19,6 +20,9 @@ const COMPONENT = '@nestjs/event-emitter'; * Wrapped handlers run inside a forked isolation scope to ensure event-scoped data * (breadcrumbs, tags, etc.) does not leak between concurrent event invocations * or into subsequent HTTP requests. + * + * The handler-wrapping logic lives in `./wrappers` and is shared with the orchestrion + * (diagnostics-channel) path. */ export class SentryNestEventInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -62,87 +66,10 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrapOnEvent(original: any) { return function wrappedOnEvent(event: unknown, options?: unknown) { - // Get the original decorator result const decoratorResult = original(event, options); - // Return a new decorator function that wraps the handler return (target: OnEventTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - if ( - !descriptor.value || - typeof descriptor.value !== 'function' || - target.__SENTRY_INTERNAL__ || - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ - ) { - return decoratorResult(target, propertyKey, descriptor); - } - - function eventNameFromEvent(event: unknown): string { - if (typeof event === 'string') { - return event; - } else if (Array.isArray(event)) { - return event.map(eventNameFromEvent).join(','); - } else return String(event); - } - - const originalHandler = descriptor.value; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const handlerName = originalHandler.name || propertyKey; - let eventName = eventNameFromEvent(event); - - // Instrument the actual handler - descriptor.value = async function (...args: unknown[]) { - // When multiple @OnEvent decorators are used on a single method, we need to get all event names - // from the reflector metadata as there is no information during execution which event triggered it - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect - if (Reflect.getMetadataKeys(descriptor.value).includes('EVENT_LISTENER_METADATA')) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect - const eventData = Reflect.getMetadata('EVENT_LISTENER_METADATA', descriptor.value); - if (Array.isArray(eventData)) { - eventName = eventData - .map((data: unknown) => { - if (data && typeof data === 'object' && 'event' in data && data.event) { - return eventNameFromEvent(data.event); - } - return ''; - }) - .reverse() // decorators are evaluated bottom to top - .join('|'); - } - } - - return withIsolationScope(() => { - return startSpan(getEventSpanOptions(eventName), async () => { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const result = await originalHandler.apply(this, args); - return result; - } catch (error) { - // exceptions from event handlers are not caught by global error filter - captureException(error, { - mechanism: { - handled: false, - type: 'auto.event.nestjs', - }, - }); - throw error; - } - }); - }); - }; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ = true; - - // Preserve the original function name - Object.defineProperty(descriptor.value, 'name', { - value: handlerName, - configurable: true, - }); - - // Apply the original decorator + patchMethodDescriptor(target, propertyKey, descriptor, (handler: AnyFn) => wrapEventHandler(handler, event)); return decoratorResult(target, propertyKey, descriptor); }; }; diff --git a/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts index e1d7fa978020..b0cc582a728a 100644 --- a/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts @@ -5,18 +5,9 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import type { Span } from '@sentry/core'; -import { - getActiveSpan, - isThenable, - SDK_VERSION, - startInactiveSpan, - startSpan, - startSpanManual, - withActiveSpan, -} from '@sentry/core'; -import { getMiddlewareSpanOptions, getNextProxy, instrumentObservable, isPatched } from './helpers'; -import type { CallHandler, CatchTarget, InjectableTarget, MinimalNestJsExecutionContext, Observable } from './types'; +import { SDK_VERSION } from '@sentry/core'; +import { patchCatchTarget, patchInjectableTarget } from './wrap-components'; +import type { CatchTarget, InjectableTarget } from './types'; const supportedVersions = ['>=8.0.0 <12']; const COMPONENT = '@nestjs/common'; @@ -27,6 +18,10 @@ const COMPONENT = '@nestjs/common'; * This hooks into * 1. @Injectable decorator, which is applied on class middleware, interceptors and guards. * 2. @Catch decorator, which is applied on exception filters. + * + * The span-emitting logic lives in `./wrappers` and is shared with the orchestrion + * (diagnostics-channel) path; this class only wraps the decorators to feed the + * decorated classes into it. */ export class SentryNestInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -87,191 +82,18 @@ export class SentryNestInstrumentation extends InstrumentationBase { } /** - * Creates a wrapper function for the @Injectable decorator. + * Creates a wrapper function for the @Injectable decorator. It patches the + * decorated class (via the shared `patchInjectableTarget`) and delegates to + * the original decorator. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private _createWrapInjectable(): (original: any) => (options?: unknown) => (target: InjectableTarget) => any { - const SeenNestjsContextSet = new WeakSet(); + const seenContexts = new WeakSet(); // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrapInjectable(original: any) { return function wrappedInjectable(options?: unknown) { return function (target: InjectableTarget) { - // patch middleware - if (typeof target.prototype.use === 'function' && !target.__SENTRY_INTERNAL__) { - // patch only once - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.use = new Proxy(target.prototype.use, { - apply: (originalUse, thisArgUse, argsUse) => { - const [req, res, next, ...args] = argsUse; - - // Check that we can reasonably assume that the target is a middleware. - // Without these guards, instrumentation will fail if a function named 'use' on a service, which is - // decorated with @Injectable, is called. - if (!req || !res || !next || typeof next !== 'function') { - return originalUse.apply(thisArgUse, argsUse); - } - - const prevSpan = getActiveSpan(); - - return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => { - // proxy next to end span on call - const nextProxy = getNextProxy(next, span, prevSpan); - return originalUse.apply(thisArgUse, [req, res, nextProxy, args]); - }); - }, - }); - } - - // patch guards - if (typeof target.prototype.canActivate === 'function' && !target.__SENTRY_INTERNAL__) { - // patch only once - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.canActivate = new Proxy(target.prototype.canActivate, { - apply: (originalCanActivate, thisArgCanActivate, argsCanActivate) => { - const context = argsCanActivate[0]; - - if (!context) { - return originalCanActivate.apply(thisArgCanActivate, argsCanActivate); - } - - return startSpan(getMiddlewareSpanOptions(target, undefined, 'guard'), () => { - return originalCanActivate.apply(thisArgCanActivate, argsCanActivate); - }); - }, - }); - } - - // patch pipes - if (typeof target.prototype.transform === 'function' && !target.__SENTRY_INTERNAL__) { - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.transform = new Proxy(target.prototype.transform, { - apply: (originalTransform, thisArgTransform, argsTransform) => { - const value = argsTransform[0]; - const metadata = argsTransform[1]; - - if (!value || !metadata) { - return originalTransform.apply(thisArgTransform, argsTransform); - } - - return startSpan(getMiddlewareSpanOptions(target, undefined, 'pipe'), () => { - return originalTransform.apply(thisArgTransform, argsTransform); - }); - }, - }); - } - - // patch interceptors - if (typeof target.prototype.intercept === 'function' && !target.__SENTRY_INTERNAL__) { - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.intercept = new Proxy(target.prototype.intercept, { - apply: (originalIntercept, thisArgIntercept, argsIntercept) => { - const context = argsIntercept[0] as MinimalNestJsExecutionContext | undefined; - const next = argsIntercept[1] as CallHandler | undefined; - - const parentSpan = getActiveSpan(); - let afterSpan: Span | undefined; - - if ( - !context || - !next || - typeof next.handle !== 'function' || // Check that we can reasonably assume that the target is an interceptor. - target.name === 'SentryTracingInterceptor' // We don't want to trace this internal interceptor - ) { - return originalIntercept.apply(thisArgIntercept, argsIntercept); - } - - return startSpanManual( - getMiddlewareSpanOptions(target, undefined, 'interceptor'), - (beforeSpan: Span) => { - // eslint-disable-next-line @typescript-eslint/unbound-method - next.handle = new Proxy(next.handle, { - apply: (originalHandle, thisArgHandle, argsHandle) => { - beforeSpan.end(); - - if (parentSpan) { - return withActiveSpan(parentSpan, () => { - const handleReturnObservable = Reflect.apply(originalHandle, thisArgHandle, argsHandle); - - if (!SeenNestjsContextSet.has(context)) { - SeenNestjsContextSet.add(context); - afterSpan = startInactiveSpan( - getMiddlewareSpanOptions(target, 'Interceptors - After Route', 'interceptor'), - ); - } - - return handleReturnObservable; - }); - } else { - const handleReturnObservable = Reflect.apply(originalHandle, thisArgHandle, argsHandle); - - if (!SeenNestjsContextSet.has(context)) { - SeenNestjsContextSet.add(context); - afterSpan = startInactiveSpan( - getMiddlewareSpanOptions(target, 'Interceptors - After Route', 'interceptor'), - ); - } - - return handleReturnObservable; - } - }, - }); - - let returnedObservableInterceptMaybePromise: Observable | Promise>; - - try { - returnedObservableInterceptMaybePromise = originalIntercept.apply( - thisArgIntercept, - argsIntercept, - ); - } catch (e) { - beforeSpan.end(); - afterSpan?.end(); - throw e; - } - - if (!afterSpan) { - return returnedObservableInterceptMaybePromise; - } - - // handle async interceptor - if (isThenable(returnedObservableInterceptMaybePromise)) { - return returnedObservableInterceptMaybePromise.then( - observable => { - instrumentObservable(observable, afterSpan ?? parentSpan); - return observable; - }, - e => { - beforeSpan.end(); - afterSpan?.end(); - throw e; - }, - ); - } - - // handle sync interceptor - if (typeof returnedObservableInterceptMaybePromise.subscribe === 'function') { - instrumentObservable(returnedObservableInterceptMaybePromise, afterSpan); - } - - return returnedObservableInterceptMaybePromise; - }, - ); - }, - }); - } - + patchInjectableTarget(target, seenContexts); return original(options)(target); }; }; @@ -286,28 +108,7 @@ export class SentryNestInstrumentation extends InstrumentationBase { return function wrapCatch(original: any) { return function wrappedCatch(...exceptions: unknown[]) { return function (target: CatchTarget) { - if (typeof target.prototype.catch === 'function' && !target.__SENTRY_INTERNAL__) { - // patch only once - if (isPatched(target)) { - return original(...exceptions)(target); - } - - target.prototype.catch = new Proxy(target.prototype.catch, { - apply: (originalCatch, thisArgCatch, argsCatch) => { - const exception = argsCatch[0]; - const host = argsCatch[1]; - - if (!exception || !host) { - return originalCatch.apply(thisArgCatch, argsCatch); - } - - return startSpan(getMiddlewareSpanOptions(target, undefined, 'exception_filter'), () => { - return originalCatch.apply(thisArgCatch, argsCatch); - }); - }, - }); - } - + patchCatchTarget(target); return original(...exceptions)(target); }; }; diff --git a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts index ea0261164f9c..97ae4f8548ff 100644 --- a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts @@ -5,8 +5,16 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { captureException, isThenable, SDK_VERSION, withIsolationScope } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; +import type { AnyFn } from './helpers'; import type { ScheduleDecoratorTarget } from './types'; +import { + MECHANISM_CRON, + MECHANISM_INTERVAL, + MECHANISM_TIMEOUT, + patchMethodDescriptor, + wrapScheduleHandler, +} from './wrap-handlers'; const supportedVersions = ['>=2.0.0']; const COMPONENT = '@nestjs/schedule'; @@ -16,6 +24,9 @@ const COMPONENT = '@nestjs/schedule'; * * This hooks into the `@Cron`, `@Interval`, and `@Timeout` decorators, which are applied on scheduled task handlers. * It forks the isolation scope for each handler invocation, preventing data leakage to subsequent HTTP requests. + * + * The handler-wrapping logic lives in `./wrappers` and is shared with the orchestrion + * (diagnostics-channel) path. */ export class SentryNestScheduleInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -28,68 +39,37 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { public init(): InstrumentationNodeModuleDefinition { const moduleDef = new InstrumentationNodeModuleDefinition(COMPONENT, supportedVersions); - moduleDef.files.push(this._getCronFileInstrumentation(supportedVersions)); - moduleDef.files.push(this._getIntervalFileInstrumentation(supportedVersions)); - moduleDef.files.push(this._getTimeoutFileInstrumentation(supportedVersions)); - return moduleDef; - } - - /** - * Wraps the @Cron decorator. - */ - private _getCronFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { - return new InstrumentationNodeModuleFile( - '@nestjs/schedule/dist/decorators/cron.decorator.js', - versions, - (moduleExports: { Cron: ScheduleDecoratorTarget }) => { - if (isWrapped(moduleExports.Cron)) { - this._unwrap(moduleExports, 'Cron'); - } - this._wrap(moduleExports, 'Cron', this._createWrapDecorator('auto.function.nestjs.cron')); - return moduleExports; - }, - (moduleExports: { Cron: ScheduleDecoratorTarget }) => { - this._unwrap(moduleExports, 'Cron'); - }, + moduleDef.files.push(this._getDecoratorFileInstrumentation('Cron', 'cron', MECHANISM_CRON, supportedVersions)); + moduleDef.files.push( + this._getDecoratorFileInstrumentation('Interval', 'interval', MECHANISM_INTERVAL, supportedVersions), ); - } - - /** - * Wraps the @Interval decorator. - */ - private _getIntervalFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { - return new InstrumentationNodeModuleFile( - '@nestjs/schedule/dist/decorators/interval.decorator.js', - versions, - (moduleExports: { Interval: ScheduleDecoratorTarget }) => { - if (isWrapped(moduleExports.Interval)) { - this._unwrap(moduleExports, 'Interval'); - } - this._wrap(moduleExports, 'Interval', this._createWrapDecorator('auto.function.nestjs.interval')); - return moduleExports; - }, - (moduleExports: { Interval: ScheduleDecoratorTarget }) => { - this._unwrap(moduleExports, 'Interval'); - }, + moduleDef.files.push( + this._getDecoratorFileInstrumentation('Timeout', 'timeout', MECHANISM_TIMEOUT, supportedVersions), ); + return moduleDef; } /** - * Wraps the @Timeout decorator. + * Wraps a schedule decorator (`@Cron`/`@Interval`/`@Timeout`). */ - private _getTimeoutFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { + private _getDecoratorFileInstrumentation( + exportName: 'Cron' | 'Interval' | 'Timeout', + fileName: string, + mechanismType: string, + versions: string[], + ): InstrumentationNodeModuleFile { return new InstrumentationNodeModuleFile( - '@nestjs/schedule/dist/decorators/timeout.decorator.js', + `@nestjs/schedule/dist/decorators/${fileName}.decorator.js`, versions, - (moduleExports: { Timeout: ScheduleDecoratorTarget }) => { - if (isWrapped(moduleExports.Timeout)) { - this._unwrap(moduleExports, 'Timeout'); + (moduleExports: Record) => { + if (isWrapped(moduleExports[exportName])) { + this._unwrap(moduleExports, exportName); } - this._wrap(moduleExports, 'Timeout', this._createWrapDecorator('auto.function.nestjs.timeout')); + this._wrap(moduleExports, exportName, this._createWrapDecorator(mechanismType)); return moduleExports; }, - (moduleExports: { Timeout: ScheduleDecoratorTarget }) => { - this._unwrap(moduleExports, 'Timeout'); + (moduleExports: Record) => { + this._unwrap(moduleExports, exportName); }, ); } @@ -102,72 +82,12 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { return function wrapDecorator(original: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrappedDecorator(...decoratorArgs: any[]) { - // Get the original decorator result const decoratorResult = original(...decoratorArgs); - // Return a new decorator function that wraps the handler return (target: ScheduleDecoratorTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - if ( - !descriptor.value || - typeof descriptor.value !== 'function' || - target.__SENTRY_INTERNAL__ || - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ - ) { - return decoratorResult(target, propertyKey, descriptor); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const originalHandler: (...handlerArgs: unknown[]) => unknown = descriptor.value; - const handlerName = originalHandler.name || propertyKey; - - // Not using async/await here to avoid changing the return type of sync handlers. - // This means we need to handle sync and async errors separately. - descriptor.value = function (...args: unknown[]) { - return withIsolationScope(() => { - let result; - try { - // Catches errors from sync handlers - result = originalHandler.apply(this, args); - } catch (error) { - captureException(error, { - mechanism: { - handled: false, - type: mechanismType, - }, - }); - throw error; - } - - // Catches errors from async handlers (rejected promises bypass try/catch) - if (isThenable(result)) { - return result.then(undefined, (error: unknown) => { - captureException(error, { - mechanism: { - handled: false, - type: mechanismType, - }, - }); - throw error; - }); - } - - return result; - }); - }; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ = true; - - // Preserve the original function name - Object.defineProperty(descriptor.value, 'name', { - value: handlerName, - configurable: true, - enumerable: true, - writable: true, - }); - - // Apply the original decorator + patchMethodDescriptor(target, propertyKey, descriptor, (handler: AnyFn) => + wrapScheduleHandler(handler, mechanismType), + ); return decoratorResult(target, propertyKey, descriptor); }; }; diff --git a/packages/nestjs/src/integrations/types.ts b/packages/nestjs/src/integrations/types.ts index 6dd00caa8cc1..553708c37bcb 100644 --- a/packages/nestjs/src/integrations/types.ts +++ b/packages/nestjs/src/integrations/types.ts @@ -63,8 +63,8 @@ export interface CallHandler { * Represents an injectable target class in NestJS. */ export interface InjectableTarget { - name: string; - sentryPatched?: boolean; + name?: string; + sentryPatchedInjectable?: boolean; __SENTRY_INTERNAL__?: boolean; prototype: { use?: (req: unknown, res: unknown, next: () => void, ...args: any[]) => void; @@ -78,8 +78,8 @@ export interface InjectableTarget { * Represents a target class in NestJS annotated with @Catch. */ export interface CatchTarget { - name: string; - sentryPatched?: boolean; + name?: string; + sentryPatchedCatch?: boolean; __SENTRY_INTERNAL__?: boolean; prototype: { catch?: (...args: any[]) => any; diff --git a/packages/nestjs/src/integrations/vendored/instrumentation.ts b/packages/nestjs/src/integrations/vendored/instrumentation.ts index 670d7b32f813..bb9574df3752 100644 --- a/packages/nestjs/src/integrations/vendored/instrumentation.ts +++ b/packages/nestjs/src/integrations/vendored/instrumentation.ts @@ -6,6 +6,10 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-nestjs-core * - Upstream version: @opentelemetry/instrumentation-nestjs-core@0.64.0 * - Some types vendored from @nestjs/core and @nestjs/common with simplifications + * - The span-emitting logic (app-creation / request-context / request-handler + * spans) has been extracted to `../wrappers` and is shared with the + * orchestrion (diagnostics-channel) path; this file only wraps the + * `NestFactory.create` / `RouterExecutionContext.create` methods to feed into it. */ import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { @@ -14,15 +18,12 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { HTTP_ROUTE } from '@sentry/conventions/attributes'; -import type { SpanAttributes } from '@sentry/core'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; -import { AttributeNames, NestType } from './enums'; +import { SDK_VERSION, startSpan } from '@sentry/core'; +import type { AnyFn } from '../helpers'; +import { getAppCreationSpanOptions, wrapRequestContextHandler, wrapRouteHandler } from '../wrap-route'; const PACKAGE_NAME = '@sentry/instrumentation-nestjs-core'; -type AnyFn = (this: unknown, ...args: unknown[]) => unknown; - type Controller = object; declare const NestFactory: { @@ -33,28 +34,10 @@ interface RouterExecutionContext { create(instance: Controller, callback: (...args: unknown[]) => unknown, ...args: unknown[]): unknown; } -interface NestRequest { - route?: { path?: string }; - routeOptions?: { url?: string }; - routerPath?: string; - method?: string; - originalUrl?: string; - url?: string; -} - -declare namespace Reflect { - function getMetadataKeys(target: unknown): unknown[]; - function getMetadata(metadataKey: unknown, target: unknown): unknown; - function defineMetadata(metadataKey: unknown, metadataValue: unknown, target: unknown): void; -} - const supportedVersions = ['>=4.0.0 <12']; export class NestInstrumentation extends InstrumentationBase { static readonly COMPONENT = '@nestjs/core'; - static readonly COMMON_ATTRIBUTES = { - component: NestInstrumentation.COMPONENT, - }; constructor(config: InstrumentationConfig = {}) { super(PACKAGE_NAME, SDK_VERSION, config); @@ -123,20 +106,7 @@ function createWrapNestFactoryCreate(moduleVersion?: string) { return function wrapCreate(original: typeof NestFactory.create): typeof NestFactory.create { return function createWithTrace(this: typeof NestFactory, ...args: unknown[]) { const nestModule = args[0] as { name?: string }; - return startSpan( - { - name: 'Create Nest App', - op: `${NestType.APP_CREATION}.nestjs`, - attributes: { - ...NestInstrumentation.COMMON_ATTRIBUTES, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs', - [AttributeNames.TYPE]: NestType.APP_CREATION, - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.MODULE]: nestModule.name, - }, - }, - () => original.apply(this, args), - ); + return startSpan(getAppCreationSpanOptions(moduleVersion, nestModule?.name), () => original.apply(this, args)); }; }; } @@ -146,56 +116,11 @@ function createWrapCreateHandler(moduleVersion: string | undefined) { return function createHandlerWithTrace(this: RouterExecutionContext, ...args: unknown[]) { const instance = args[0] as { constructor?: { name?: string } }; const callback = args[1] as AnyFn; - args[1] = createWrapHandler(moduleVersion, callback); + const instanceName = instance?.constructor?.name || 'UnnamedInstance'; + const callbackName = typeof callback === 'function' ? callback.name : ''; + args[1] = wrapRouteHandler(callback, moduleVersion); const handler = original.apply(this, args) as AnyFn; - const callbackName = callback.name; - const instanceName = instance.constructor?.name || 'UnnamedInstance'; - const spanName = callbackName ? `${instanceName}.${callbackName}` : instanceName; - - return function (this: unknown, ...handlerArgs: unknown[]) { - const req = handlerArgs[0] as NestRequest; - const attributes: SpanAttributes = { - ...NestInstrumentation.COMMON_ATTRIBUTES, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs', - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.TYPE]: NestType.REQUEST_CONTEXT, - [HTTP_ROUTE]: req.route?.path || req.routeOptions?.url || req.routerPath, - [AttributeNames.CONTROLLER]: instanceName, - [AttributeNames.CALLBACK]: callbackName, - }; - attributes['http.method'] = req.method; - attributes['http.url'] = req.originalUrl || req.url; - return startSpan({ name: spanName, op: `${NestType.REQUEST_CONTEXT}.nestjs`, attributes }, () => - handler.apply(this, handlerArgs), - ); - }; + return wrapRequestContextHandler(handler, instanceName, callbackName, moduleVersion); }; }; } - -function createWrapHandler(moduleVersion: string | undefined, handler: AnyFn): AnyFn { - const spanName = handler.name || 'anonymous nest handler'; - const attributes: SpanAttributes = { - ...NestInstrumentation.COMMON_ATTRIBUTES, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs', - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.TYPE]: NestType.REQUEST_HANDLER, - [AttributeNames.CALLBACK]: handler.name, - }; - const wrappedHandler = function (this: unknown, ...args: unknown[]) { - return startSpan({ name: spanName, op: `${NestType.REQUEST_HANDLER}.nestjs`, attributes }, () => - handler.apply(this, args), - ); - }; - - if (handler.name) { - Object.defineProperty(wrappedHandler, 'name', { value: handler.name }); - } - - // Get the current metadata and set onto the wrapper to ensure other decorators ( ie: NestJS EventPattern / RolesGuard ) - // won't be affected by the use of this instrumentation - Reflect.getMetadataKeys(handler).forEach(metadataKey => { - Reflect.defineMetadata(metadataKey, Reflect.getMetadata(metadataKey, handler), wrappedHandler); - }); - return wrappedHandler; -} diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts b/packages/nestjs/src/integrations/wrap-components.ts similarity index 55% rename from packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts rename to packages/nestjs/src/integrations/wrap-components.ts index 0838ddc55d32..aebf21c1b4c7 100644 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts +++ b/packages/nestjs/src/integrations/wrap-components.ts @@ -1,130 +1,21 @@ -import type { Span, SpanAttributes } from '@sentry/core'; -import { - addNonEnumerableProperty, - getActiveSpan, - isThenable, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startInactiveSpan, - startSpan, - startSpanManual, - withActiveSpan, -} from '@sentry/core'; -import type { AnyFn } from './nestjs-shared'; - -const OP_MIDDLEWARE = 'middleware.nestjs'; -const ORIGIN_MIDDLEWARE = 'auto.middleware.orchestrion.nestjs'; - -/** The class an `@Injectable` decorator is applied to (`ctx.arguments[0]`). */ -export interface InjectableTarget { - name?: string; - sentryPatchedInjectable?: boolean; - __SENTRY_INTERNAL__?: boolean; - prototype: { - use?: AnyFn; - canActivate?: AnyFn; - transform?: AnyFn; - intercept?: AnyFn; - }; -} - -/** The class a `@Catch` decorator is applied to (an exception filter). */ -export interface CatchTarget { - name?: string; - sentryPatchedCatch?: boolean; - __SENTRY_INTERNAL__?: boolean; - prototype: { catch?: AnyFn }; -} - -interface NestCallHandler { - handle: AnyFn; -} - -interface SubscriptionLike { - add: (teardown: () => void) => void; -} - -interface ObservableLike { - subscribe: AnyFn; -} +import type { Span } from '@sentry/core'; +import { getActiveSpan, isThenable, startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from '@sentry/core'; +import type { AnyFn } from './helpers'; +import { getMiddlewareSpanOptions, getNextProxy, instrumentObservable, isTargetPatched } from './helpers'; +import type { CallHandler, CatchTarget, InjectableTarget, MinimalNestJsExecutionContext, Observable } from './types'; /** - * Mark a target class as patched (for the given pass) so it's instrumented only - * once, and to stay idempotent across repeated subscriptions. - * - * The `@Injectable` and `@Catch` passes use *separate* flags on purpose: they - * wrap disjoint method sets (use/canActivate/transform/intercept vs catch), and - * a class can be decorated with both (an exception filter that also uses DI). - * A single shared flag would let whichever channel fired first latch it and - * block the other pass, dropping that pass's spans regardless of ordering. + * Shared span-emitting logic for `@Injectable` (middleware/guard/pipe/interceptor) + * and `@Catch` (exception filter) classes. Used by both the OTel decorator wraps + * (`SentryNestInstrumentation`) and the orchestrion channel subscriber; only the + * span origin differs (via `isOrchestrionInjected()` in `./helpers`). */ -function isTargetPatched(target: object, flag: 'sentryPatchedInjectable' | 'sentryPatchedCatch'): boolean { - if ((target as Record)[flag]) { - return true; - } - addNonEnumerableProperty(target, flag, true); - return false; -} - -/** - * Span options for middleware/guard/pipe/interceptor spans - * name = provided name or class name. - */ -function getMiddlewareSpanOptions( - target: { name?: string }, - name?: string, - componentType?: string, -): { name: string; attributes: SpanAttributes } { - return { - name: name ?? target.name ?? 'unknown', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: OP_MIDDLEWARE, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: componentType ? `${ORIGIN_MIDDLEWARE}.${componentType}` : ORIGIN_MIDDLEWARE, - }, - }; -} - -/** - * Proxy a middleware `next()` so the span ends when `next` is called, then - * restore the previous active span for the continuation. - */ -function getNextProxy(next: AnyFn, span: Span, prevSpan: Span | undefined): AnyFn { - return new Proxy(next, { - apply: (originalNext, thisArgNext, argsNext) => { - span.end(); - if (prevSpan) { - return withActiveSpan(prevSpan, () => Reflect.apply(originalNext, thisArgNext, argsNext)); - } - return Reflect.apply(originalNext, thisArgNext, argsNext); - }, - }); -} - -/** - * End the given span when the interceptor's returned observable is - * unsubscribed (i.e. the response is sent), keeping it active across the - * subscription. - */ -function instrumentObservable(observable: ObservableLike, activeSpan: Span | undefined): void { - if (!activeSpan) { - return; - } - observable.subscribe = new Proxy(observable.subscribe, { - apply: (originalSubscribe, thisArgSubscribe, argsSubscribe) => { - return withActiveSpan(activeSpan, () => { - const subscription = originalSubscribe.apply(thisArgSubscribe, argsSubscribe) as SubscriptionLike; - subscription.add(() => activeSpan.end()); - return subscription; - }); - }, - }); -} function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContexts: WeakSet): AnyFn { return new Proxy(intercept, { apply: (originalIntercept, thisArg, argsIntercept) => { - const context = argsIntercept[0] as object | undefined; - const next = argsIntercept[1] as NestCallHandler | undefined; + const context = argsIntercept[0] as MinimalNestJsExecutionContext | undefined; + const next = argsIntercept[1] as CallHandler | undefined; const parentSpan = getActiveSpan(); let afterSpan: Span | undefined; @@ -141,6 +32,7 @@ function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContex // `next.handle()` is the boundary between the "before" and "after" // interceptor work: end the before-span and open the after-span (once // per execution context), which `instrumentObservable` later closes. + // eslint-disable-next-line @typescript-eslint/unbound-method next.handle = new Proxy(next.handle, { apply: (originalHandle, thisArgHandle, argsHandle) => { beforeSpan.end(); @@ -172,7 +64,7 @@ function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContex return returned.then( (observable: unknown) => { if (afterSpan) { - instrumentObservable(observable as ObservableLike, afterSpan); + instrumentObservable(observable as Observable, afterSpan); } else { // `next.handle()` was never called, so nothing ended the // before-span (its `handle` proxy never ran); close it here. @@ -199,8 +91,8 @@ function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContex } // sync interceptor: returns an Observable - if (typeof (returned as ObservableLike).subscribe === 'function') { - instrumentObservable(returned as ObservableLike, afterSpan); + if (typeof (returned as Observable).subscribe === 'function') { + instrumentObservable(returned as Observable, afterSpan); } return returned; @@ -210,12 +102,10 @@ function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContex } /** - * Port the vendored `@Injectable` instrumentation - * patch the decorated class's prototype methods so each runtime + * Patch an `@Injectable`-decorated class's prototype methods so each runtime * invocation opens the corresponding middleware/guard/pipe/interceptor span. * The runtime guards (req/res/next, context, value+metadata) avoid false - * positives on non-middleware classes that happen to expose a same-named - * method. + * positives on non-middleware classes that happen to expose a same-named method. */ export function patchInjectableTarget(target: InjectableTarget, seenContexts: WeakSet): void { const proto = target?.prototype; @@ -235,10 +125,10 @@ export function patchInjectableTarget(target: InjectableTarget, seenContexts: We return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => { const nextProxy = getNextProxy(next as AnyFn, span, prevSpan); const rest = (argsUse as unknown[]).slice(3); - return originalUse.apply(thisArgUse, [req, res, nextProxy, ...rest]); + return (originalUse as AnyFn).apply(thisArgUse, [req, res, nextProxy, ...rest]); }); }, - }); + }) as InjectableTarget['prototype']['use']; } // guards @@ -252,7 +142,7 @@ export function patchInjectableTarget(target: InjectableTarget, seenContexts: We originalCanActivate.apply(thisArg, args), ); }, - }); + }) as InjectableTarget['prototype']['canActivate']; } // pipes @@ -266,19 +156,23 @@ export function patchInjectableTarget(target: InjectableTarget, seenContexts: We originalTransform.apply(thisArg, args), ); }, - }); + }) as InjectableTarget['prototype']['transform']; } // interceptors if (typeof proto.intercept === 'function') { - proto.intercept = patchInterceptor(target, proto.intercept, seenContexts); + proto.intercept = patchInterceptor( + target, + proto.intercept as AnyFn, + seenContexts, + ) as InjectableTarget['prototype']['intercept']; } } /** - * Port the vendored `@Catch` instrumentation. Patch the exception filter's - * prototype `catch` so each invocation opens an `exception_filter` span. The - * runtime guard (exception + host present) avoids false positives. + * Patch an exception filter's prototype `catch` so each invocation opens an + * `exception_filter` span. The runtime guard (exception + host present) avoids + * false positives. */ export function patchCatchTarget(target: CatchTarget): void { const proto = target?.prototype; @@ -300,5 +194,5 @@ export function patchCatchTarget(target: CatchTarget): void { originalCatch.apply(thisArg, args), ); }, - }); + }) as CatchTarget['prototype']['catch']; } diff --git a/packages/nestjs/src/integrations/wrap-handlers.ts b/packages/nestjs/src/integrations/wrap-handlers.ts new file mode 100644 index 000000000000..82b39fbe89b7 --- /dev/null +++ b/packages/nestjs/src/integrations/wrap-handlers.ts @@ -0,0 +1,183 @@ +import { captureException, isThenable, startSpan, withIsolationScope } from '@sentry/core'; +import type { AnyFn } from './helpers'; +import { getBullMQProcessSpanOptions, getEventSpanOptions, isWrapped, markWrapped } from './helpers'; + +/** + * Shared span-emitting / error-capturing logic for the `@Cron`/`@Interval`/ + * `@Timeout` (schedule), `@OnEvent` (event), and `@Processor` (bullmq) handlers. + * Used by both the OTel decorator wraps and the orchestrion channel subscriber; + * only the span origin differs (via `isOrchestrionInjected()` in `./helpers`). + */ + +// Error-capture mechanism types. These do NOT carry an `orchestrion` segment. +// They're identical across both paths so captured errors attribute and group +// the same regardless of which instrumentation caught them. +export const MECHANISM_CRON = 'auto.function.nestjs.cron'; +export const MECHANISM_INTERVAL = 'auto.function.nestjs.interval'; +export const MECHANISM_TIMEOUT = 'auto.function.nestjs.timeout'; +export const MECHANISM_EVENT = 'auto.event.nestjs'; +export const MECHANISM_BULLMQ = 'auto.queue.nestjs.bullmq'; + +const EVENT_LISTENER_METADATA = 'EVENT_LISTENER_METADATA'; + +interface ReflectWithMetadata { + getMetadataKeys?: (target: object) => unknown[]; + getMetadata?: (key: unknown, target: object) => unknown; +} + +interface ProcessorTarget { + __SENTRY_INTERNAL__?: boolean; + prototype?: { process?: AnyFn }; +} + +function captureHandlerError(error: unknown, mechanismType: string): void { + captureException(error, { mechanism: { handled: false, type: mechanismType } }); +} + +/** + * Wrap a scheduled handler (`@Cron`/`@Interval`/`@Timeout`): fork the isolation + * scope and capture errors. NOT async. Preserve the handler's sync return type, + * so sync and async errors are handled on separate paths. + */ +export function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + return withIsolationScope(() => { + let result: unknown; + try { + result = handler.apply(this, args); + } catch (error) { + captureHandlerError(error, mechanismType); + throw error; + } + if (isThenable(result)) { + return result.then(undefined, (error: unknown) => { + captureHandlerError(error, mechanismType); + throw error; + }); + } + return result; + }); + }; +} + +function eventNameFromEvent(event: unknown): string { + if (typeof event === 'string') { + return event; + } + if (Array.isArray(event)) { + return event.map(eventNameFromEvent).join(','); + } + return String(event); +} + +/** + * Derive the event name(s) for an @OnEvent span. The wrapped handler carries + * `EVENT_LISTENER_METADATA` (set by the original decorator), which lists every + * event when multiple @OnEvent decorators are stacked; fall back to the event + * captured from the decorator factory. + */ +function deriveEventName(handler: AnyFn, fallbackEvent: unknown): string { + const R = Reflect as unknown as ReflectWithMetadata; + if (typeof R.getMetadataKeys === 'function' && typeof R.getMetadata === 'function') { + if (R.getMetadataKeys(handler)?.includes(EVENT_LISTENER_METADATA)) { + const eventData = R.getMetadata(EVENT_LISTENER_METADATA, handler); + if (Array.isArray(eventData)) { + return (eventData as unknown[]) + .map(entry => { + const event = entry && typeof entry === 'object' ? (entry as { event?: unknown }).event : undefined; + return event ? eventNameFromEvent(event) : ''; + }) + .reverse() // decorators evaluate bottom to top + .join('|'); + } + } + } + return eventNameFromEvent(fallbackEvent); +} + +/** + * Wrap an @OnEvent handler: fork the isolation scope, open an `event.nestjs` + * transaction, and capture errors (event-handler errors bypass the global filter). + */ +export function wrapEventHandler(handler: AnyFn, fallbackEvent: unknown): AnyFn { + const wrapped = async function (this: unknown, ...args: unknown[]): Promise { + const eventName = deriveEventName(wrapped, fallbackEvent); + return withIsolationScope(() => + startSpan(getEventSpanOptions(eventName), async () => { + try { + return await handler.apply(this, args); + } catch (error) { + captureHandlerError(error, MECHANISM_EVENT); + throw error; + } + }), + ); + }; + return wrapped; +} + +/** + * Wrap a BullMQ `process` method: fork the isolation scope, open a + * `queue.process` transaction, and capture errors. + */ +export function wrapBullMQProcess(process: AnyFn, queueName: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + return withIsolationScope(() => + startSpan(getBullMQProcessSpanOptions(queueName), async () => { + try { + return await process.apply(this, args); + } catch (error) { + captureHandlerError(error, MECHANISM_BULLMQ); + throw error; + } + }), + ); + }; +} + +/** + * Replace a method decorator's `descriptor.value` with a wrapped handler (via + * `wrapHandler`), preserving the handler name and marking it wrapped. Shared by + * the OTel schedule/event decorator wraps and the orchestrion factory subscriber. + */ +export function patchMethodDescriptor( + target: { __SENTRY_INTERNAL__?: boolean } | undefined, + propertyKey: string | symbol | undefined, + descriptor: PropertyDescriptor | undefined, + wrapHandler: (handler: AnyFn) => AnyFn, +): void { + const handler = descriptor?.value as AnyFn | undefined; + if (descriptor && handler && typeof handler === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(handler)) { + const wrapped = wrapHandler(handler); + Object.defineProperty(wrapped, 'name', { + value: handler.name || String(propertyKey), + configurable: true, + }); + markWrapped(wrapped); + descriptor.value = wrapped; + } +} + +/** Extract the queue name from `@Processor('name')` or `@Processor({ name })`. */ +export function extractQueueName(arg: unknown): string { + if (typeof arg === 'string') { + return arg; + } + if (arg && typeof arg === 'object' && 'name' in arg && typeof (arg as { name?: unknown }).name === 'string') { + return (arg as { name: string }).name; + } + return 'unknown'; +} + +/** + * Patch a `@Processor`-decorated class's `prototype.process` with a wrapped + * version. Shared by the OTel `@Processor` wrap and the orchestrion subscriber. + */ +export function patchProcessorTarget(target: ProcessorTarget | undefined, queueName: string): void { + const process = target?.prototype?.process; + if (process && typeof process === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(process)) { + const wrapped = wrapBullMQProcess(process, queueName); + markWrapped(wrapped); + target.prototype!.process = wrapped; + } +} diff --git a/packages/nestjs/src/integrations/wrap-route.ts b/packages/nestjs/src/integrations/wrap-route.ts new file mode 100644 index 000000000000..d4b78ea2c7a9 --- /dev/null +++ b/packages/nestjs/src/integrations/wrap-route.ts @@ -0,0 +1,133 @@ +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; +import type { SpanAttributes } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import type { AnyFn } from './helpers'; +import { httpOrigin, isWrapped, markWrapped } from './helpers'; +import { AttributeNames, NestType } from './vendored/enums'; + +/** + * Shared span-emitting logic for the NestJS route/controller spans + * (app-creation / request-context / request-handler). Used by both the OTel + * `NestFactory.create` / `RouterExecutionContext.create` wraps (`./vendored`) and + * the orchestrion channel subscriber; only the span origin differs (via + * `isOrchestrionInjected()` in `./helpers`). + */ + +const NESTJS_COMPONENT = '@nestjs/core'; + +/** Minimal request shape, across the express/fastify adapters. */ +interface NestRequest { + route?: { path?: string }; + routeOptions?: { url?: string }; + routerPath?: string; + method?: string; + originalUrl?: string; + url?: string; +} + +interface ReflectWithMetadata { + getMetadataKeys?: (target: object) => unknown[]; + getMetadata?: (key: unknown, target: object) => unknown; + defineMetadata?: (key: unknown, value: unknown, target: object) => void; +} + +/** + * Copy NestJS reflect-metadata from the original handler onto the wrapper so + * other decorators (param decorators, guards, `@EventPattern`, ...) that read + * it keep working. No-op when `reflect-metadata` isn't loaded. + */ +export function copyReflectMetadata(from: object, to: object): void { + const R = Reflect as unknown as ReflectWithMetadata; + if ( + typeof R.getMetadataKeys !== 'function' || + typeof R.getMetadata !== 'function' || + typeof R.defineMetadata !== 'function' + ) { + return; + } + for (const key of R.getMetadataKeys(from)) { + R.defineMetadata(key, R.getMetadata(key, from), to); + } +} + +/** Span options for the `Create Nest App` (app_creation) span. */ +export function getAppCreationSpanOptions( + moduleVersion?: string, + moduleName?: string, +): { name: string; op: string; attributes: SpanAttributes } { + return { + name: 'Create Nest App', + op: `${NestType.APP_CREATION}.nestjs`, + attributes: { + component: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: httpOrigin(), + [AttributeNames.TYPE]: NestType.APP_CREATION, + ...(moduleVersion ? { [AttributeNames.VERSION]: moduleVersion } : {}), + ...(moduleName ? { [AttributeNames.MODULE]: moduleName } : {}), + }, + }; +} + +/** + * Wrap the route-handler callback so each invocation opens the `handler.nestjs` + * span (REQUEST_HANDLER). Preserve the original `.name` and reflect-metadata so + * NestJS reflection is unaffected. + */ +export function wrapRouteHandler(callback: AnyFn, moduleVersion?: string): AnyFn { + if (isWrapped(callback)) { + return callback; + } + const spanName = callback.name || 'anonymous nest handler'; + const attributes: SpanAttributes = { + component: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: httpOrigin(), + [AttributeNames.TYPE]: NestType.REQUEST_HANDLER, + [AttributeNames.CALLBACK]: callback.name, + ...(moduleVersion ? { [AttributeNames.VERSION]: moduleVersion } : {}), + }; + const wrapped = function (this: unknown, ...args: unknown[]): unknown { + return startSpan({ name: spanName, op: `${NestType.REQUEST_HANDLER}.nestjs`, attributes }, () => + callback.apply(this, args), + ); + }; + if (callback.name) { + Object.defineProperty(wrapped, 'name', { value: callback.name }); + } + copyReflectMetadata(callback, wrapped); + markWrapped(wrapped); + return wrapped; +} + +/** + * Wrap the per-request handler that `RouterExecutionContext.create` returns so + * each request opens the `request_context.nestjs` span (REQUEST_CONTEXT), + * carrying controller/callback names plus the per-request http.* attributes. + */ +export function wrapRequestContextHandler( + handler: AnyFn, + instanceName: string, + callbackName: string, + moduleVersion?: string, +): AnyFn { + const spanName = callbackName ? `${instanceName}.${callbackName}` : instanceName; + const wrapped = function (this: unknown, ...handlerArgs: unknown[]): unknown { + const req = (handlerArgs[0] || {}) as NestRequest; + const httpRoute = req.route?.path || req.routeOptions?.url || req.routerPath; + const attributes: SpanAttributes = { + component: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: httpOrigin(), + [AttributeNames.TYPE]: NestType.REQUEST_CONTEXT, + [AttributeNames.CONTROLLER]: instanceName, + [AttributeNames.CALLBACK]: callbackName, + ...(moduleVersion ? { [AttributeNames.VERSION]: moduleVersion } : {}), + ...(httpRoute ? { [HTTP_ROUTE]: httpRoute } : {}), + ...(req.method ? { ['http.method']: req.method } : {}), + ...(req.originalUrl || req.url ? { ['http.url']: req.originalUrl || req.url } : {}), + }; + return startSpan({ name: spanName, op: `${NestType.REQUEST_CONTEXT}.nestjs`, attributes }, () => + handler.apply(this, handlerArgs), + ); + }; + markWrapped(wrapped); + return wrapped; +} diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/nestjs/src/orchestrion/config.ts similarity index 86% rename from packages/server-utils/src/orchestrion/config/nestjs.ts rename to packages/nestjs/src/orchestrion/config.ts index a1c2112a0957..b274d6c6e8d0 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/nestjs/src/orchestrion/config.ts @@ -1,4 +1,4 @@ -import type { FunctionKind, InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { FunctionKind, InstrumentationConfig } from '@sentry/server-utils/orchestrion'; /** * Wrap an instrumentation that targets nodes via a raw esquery selector @@ -29,16 +29,18 @@ export const nestjsConfig = [ module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'nest-factory.js' }, functionQuery: { className: 'NestFactoryStatic', methodName: 'create', kind: 'Async' }, }, + { // `@nestjs/core/router/router-execution-context.js` exports // `class RouterExecutionContext` with a synchronous `create(instance, // callback, ...)` that RETURNS the per-request handler. The subscriber - // wraps the `callback` arg (-> one handler span) and reassigns the returned - // handler (-> request_context span). + // wraps the `callback` arg (-> one handler span) and reassigns the + // returned handler (-> request_context span). channelName: 'routerExecutionContextCreate', module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'router/router-execution-context.js' }, functionQuery: { className: 'RouterExecutionContext', methodName: 'create', kind: 'Sync' }, }, + astQueryInstrumentation({ // `@nestjs/common/decorators/core/injectable.decorator.js`: // `function Injectable(options) { return (target) => { ... }; }` @@ -57,6 +59,7 @@ export const nestjsConfig = [ astQuery: 'FunctionDeclaration[id.name="Injectable"] ReturnStatement > ArrowFunctionExpression', functionQuery: { kind: 'Sync' }, }), + astQueryInstrumentation({ // `@nestjs/common/decorators/core/catch.decorator.js`: // `function Catch(...exceptions) { return (target) => { ... }; }` @@ -70,14 +73,17 @@ export const nestjsConfig = [ astQuery: 'FunctionDeclaration[id.name="Catch"] ReturnStatement > ArrowFunctionExpression', functionQuery: { kind: 'Sync' }, }), - // @nestjs/schedule @Cron/@Interval/@Timeout: `function Cron(...) { return - // applyDecorators(...); }` — the returned decorator has no inline arrow to - // target, so we match the factory function and reassign `data.result` in `end` + + // @nestjs/schedule @Cron/@Interval/@Timeout: + // `function Cron(...) { return applyDecorators(...); }` + // + // The returned decorator has no inline arrow to target, so we match the + // factory function and reassign `data.result` in `end` // to wrap the decorator it returns (which rewrites the user handler // `descriptor.value` with isolation-scope + error capture). Mirrors - // `SentryNestScheduleInstrumentation`, whose supported range (`>=2.0.0`) we - // match so opting in doesn't drop coverage the OTel path had. The compiled - // `function Cron(...)` declaration is unchanged across 2.x–5.x. + // `SentryNestScheduleInstrumentation`, whose supported range (`>=2.0.0`) + // we match so opting in doesn't drop coverage the OTel path had. The + // compiled `function Cron(...)` declaration is unchanged across 2.x–5.x. { channelName: 'cronDecorator', module: { name: '@nestjs/schedule', versionRange: '>=2.0.0', filePath: 'dist/decorators/cron.decorator.js' }, @@ -109,13 +115,16 @@ export const nestjsConfig = [ }, functionQuery: { expressionName: 'OnEvent', kind: 'Sync' }, }, + { - // @nestjs/bullmq @Processor: `function Processor(...) { return (target) => {…}; }` - // The factory arg carries the queue name, so we match the factory and reassign - // `data.result` in `end` to wrap the returned class decorator (which patches - // `target.prototype.process`). Mirrors `SentryNestBullMQInstrumentation` - // (`>=10.0.0`); the `function Processor(...)` declaration is unchanged across - // 10.x–11.x. + // @nestjs/bullmq @Processor: + // `function Processor(...) { return (target) => {…}; }` + // The factory arg carries the queue name, so we match the factory and + // reassign `data.result` in `end` to wrap the returned class decorator + // (which patches `target.prototype.process`). + // + // Mirrors `SentryNestBullMQInstrumentation` (`>=10.0.0`); the + // `function Processor(...)` declaration is unchanged across 10.x–11.x. channelName: 'processorDecorator', module: { name: '@nestjs/bullmq', diff --git a/packages/nestjs/src/orchestrion/index.ts b/packages/nestjs/src/orchestrion/index.ts new file mode 100644 index 000000000000..5975f0eed3de --- /dev/null +++ b/packages/nestjs/src/orchestrion/index.ts @@ -0,0 +1,21 @@ +import type { OrchestrionInstrumentation } from '@sentry/server-utils/orchestrion'; +import { nestjsConfig } from './config'; +import { nestjsChannelIntegration } from './subscriber'; + +export { nestjsChannelIntegration } from './subscriber'; + +/** + * The NestJS orchestrion instrumentation descriptor. + * + * Inject it into the diagnostics-channel assembly so `@sentry/server-utils` + * never has to depend on `@sentry/nestjs`: + * - RUNTIME: `@sentry/nestjs` `init()` (and the `@sentry/nestjs/import` + * preload entry) register via `registerOrchestrionInstrumentation`. + * - BUILD: pass it to the bundler plugin's `instrumentations` option, e.g. + * `sentryBunPlugin({ instrumentations: [nestjsOrchestrion] })`. + */ +export const nestjsOrchestrion: OrchestrionInstrumentation = { + name: 'nestjs', + configs: nestjsConfig, + integration: nestjsChannelIntegration, +}; diff --git a/packages/nestjs/src/orchestrion/subscriber.ts b/packages/nestjs/src/orchestrion/subscriber.ts new file mode 100644 index 000000000000..5f69c26b0541 --- /dev/null +++ b/packages/nestjs/src/orchestrion/subscriber.ts @@ -0,0 +1,227 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, startInactiveSpan, waitForTracingChannelBinding } from '@sentry/core'; +import { bindTracingChannelToSpan } from '@sentry/server-utils'; +import { DEBUG_BUILD } from '../debug-build'; +import type { AnyFn } from '../integrations/helpers'; +import { isWrapped, markWrapped } from '../integrations/helpers'; +import type { CatchTarget, InjectableTarget } from '../integrations/types'; +import { patchCatchTarget, patchInjectableTarget } from '../integrations/wrap-components'; +import { + extractQueueName, + MECHANISM_CRON, + MECHANISM_INTERVAL, + MECHANISM_TIMEOUT, + patchMethodDescriptor, + patchProcessorTarget, + wrapEventHandler, + wrapScheduleHandler, +} from '../integrations/wrap-handlers'; +import { getAppCreationSpanOptions, wrapRequestContextHandler, wrapRouteHandler } from '../integrations/wrap-route'; +import { nestjsChannels as CHANNELS } from './config'; + +// NOTE: this uses the same name as the OTel integration by design. +// When enabled, the OTel 'Nest' integration is omitted from the default set. +const INTEGRATION_NAME = 'Nest'; + +const NOOP = (): void => {}; + +/** + * The orchestrion tracing-channel context. `arguments` is the live call args + * array; `result` is the return value, which an `end` handler may reassign to + * substitute it (`traceSync`/`tracePromise` always return `ctx.result`). + */ +interface ChannelContext { + arguments: unknown[]; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +/** + * Subscribe to a decorator channel (`Injectable`/`Catch`). + * + * The orchestrion transform targets the decorator's inner arrow, so `start` + * receives the decorated class as `arguments[0]`. There is no span around the + * decorator itself; `patch` installs the prototype-method proxies (the shared + * span-emitting logic) that open spans later. + */ +function subscribeDecoratorChannel(channelName: string, patch: (target: T) => void): void { + diagnosticsChannel.tracingChannel(channelName).subscribe({ + start(data) { + const target = data.arguments?.[0] as T | undefined; + if (target) { + patch(target); + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +/** + * Wrap the method decorator the factory returns (`@Cron`/`@Interval`/`@Timeout`/ + * `@OnEvent`) so it replaces `descriptor.value` with a wrapped handler (shared + * logic) before delegating to the original decorator. + */ +function makeMethodDecorator(original: AnyFn, wrapHandler: (handler: AnyFn) => AnyFn): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + patchMethodDescriptor( + args[0] as { __SENTRY_INTERNAL__?: boolean } | undefined, + args[1] as string | symbol | undefined, + args[2] as PropertyDescriptor | undefined, + wrapHandler, + ); + return original.apply(this, args); + }; +} + +/** + * Wrap the class decorator `@Processor` returns so it patches + * `target.prototype.process` (shared logic) before delegating. + */ +function makeProcessorDecorator(original: AnyFn, queueName: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + patchProcessorTarget(args[0] as { __SENTRY_INTERNAL__?: boolean; prototype?: { process?: AnyFn } }, queueName); + return original.apply(this, args); + }; +} + +/** + * Subscribe to a decorator-factory channel. `end` reassigns `data.result` (the + * decorator the factory returns) with a wrapped version -> `traceSync` returns + * whatever `end` leaves there. `wrap` receives the original decorator and the + * channel context (for the factory's args, e.g. the BullMQ queue name). + */ +function subscribeFactoryDecorator(channelName: string, wrap: (decorator: AnyFn, data: ChannelContext) => AnyFn): void { + diagnosticsChannel.tracingChannel(channelName).subscribe({ + start: NOOP, + end(data) { + const decorator = data.result; + if (typeof decorator === 'function' && !isWrapped(decorator as AnyFn)) { + const wrapped = wrap(decorator as AnyFn, data); + markWrapped(wrapped); + data.result = wrapped; + } + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +const _nestjsChannelIntegration = (() => { + 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:nestjs] subscribing to @nestjs channels'); + + // App-creation span: `bindTracingChannelToSpan` opens the span on + // `start`, makes it the active context for the bootstrap, and ends it + // on `asyncEnd` (or `end` if `create` throws synchronously). + // + // `captureError: false`: a failed bootstrap surfaces to the caller. + // We just annotate the span. + // + // `bindTracingChannelToSpan` uses `bindStore`, which needs the + // async-context binding registered after integration `setupOnce`; defer + // until it's available. Only this bind is deferred (it fires at + // `NestFactory.create`, so a retry tick is fine); the plain `.subscribe` + // calls below stay synchronous because the decorator channels fire at + // module-load time, which a deferred subscription could miss. + waitForTracingChannelBinding(() => { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), + data => { + const moduleCls = data.arguments?.[0] as { name?: string } | undefined; + return startInactiveSpan(getAppCreationSpanOptions(data.moduleVersion, moduleCls?.name)); + }, + { captureError: false }, + ); + }); + + // request_context + request_handler. `RouterExecutionContext.create` + // runs once per route at setup: it receives `(instance, callback, ...)` + // and RETURNS the per-request handler. `start` wraps the callback arg + // (-> handler span per call) and `end` reassigns `data.result` to + // replace the returned handler (-> request_context span per request). + const routerMeta = new WeakMap(); + diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_ROUTER_CONTEXT).subscribe({ + start(data) { + const instance = data.arguments?.[0] as { constructor?: { name?: string } } | undefined; + const callback = data.arguments?.[1]; + routerMeta.set(data, { + instanceName: instance?.constructor?.name || 'UnnamedInstance', + callbackName: typeof callback === 'function' ? callback.name : '', + moduleVersion: data.moduleVersion, + }); + if (typeof callback === 'function') { + data.arguments[1] = wrapRouteHandler(callback as AnyFn, data.moduleVersion); + } + }, + end(data) { + const handler = data.result; + const meta = routerMeta.get(data); + if (typeof handler === 'function' && meta && !isWrapped(handler as AnyFn)) { + data.result = wrapRequestContextHandler( + handler as AnyFn, + meta.instanceName, + meta.callbackName, + meta.moduleVersion, + ); + } + routerMeta.delete(data); + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error(data) { + routerMeta.delete(data); + }, + }); + + // @Injectable (middleware/guard/pipe/interceptor) and @Catch + // (exception filter): both decorators share the + // `(target) => {...}` inner-arrow shape. + const seenInterceptorContexts = new WeakSet(); + subscribeDecoratorChannel(CHANNELS.NESTJS_INJECTABLE, target => + patchInjectableTarget(target, seenInterceptorContexts), + ); + subscribeDecoratorChannel(CHANNELS.NESTJS_CATCH, patchCatchTarget); + + // @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq). + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_TIMEOUT, decorator => + makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_TIMEOUT)), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_ONEVENT, (decorator, data) => + makeMethodDecorator(decorator, handler => wrapEventHandler(handler, data.arguments?.[0])), + ); + subscribeFactoryDecorator(CHANNELS.NESTJS_PROCESSOR, (decorator, data) => + makeProcessorDecorator(decorator, extractQueueName(data.arguments?.[0])), + ); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL orchestrion-driven NestJS integration. + * + * Subscribes to the diagnostics_channels the orchestrion code transform + * injects into `@nestjs/*` (see `./config`). Requires the orchestrion runtime + * hook or bundler plugin to be active. Shares all span-emitting logic with + * the OTel path (`../integrations/wrappers`) + */ +export const nestjsChannelIntegration = defineIntegration(_nestjsChannelIntegration); diff --git a/packages/nestjs/src/sdk.ts b/packages/nestjs/src/sdk.ts index deba80d4fbba..1d02c3e61bfa 100644 --- a/packages/nestjs/src/sdk.ts +++ b/packages/nestjs/src/sdk.ts @@ -2,12 +2,21 @@ import type { Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit } from '@sentry/node'; +import { registerOrchestrionInstrumentation } from '@sentry/server-utils/orchestrion'; import { nestIntegration } from './integrations/nest'; +import { nestjsOrchestrion } from './orchestrion'; /** * Initializes the NestJS SDK */ export function init(options: NodeOptions | undefined = {}): NodeClient | undefined { + // Inject the NestJS orchestrion instrumentation into the shared diagnostics-channel + // assembly BEFORE `nodeInit()` runs — that's where the opt-in helper builds its + // channel-integration list and the runtime hook registers the transform config. + // A no-op unless the user opted into diagnostics-channel injection. On the + // `--import` preload path, `@sentry/nestjs/import` registers it even earlier. + registerOrchestrionInstrumentation(nestjsOrchestrion); + const opts: NodeOptions = { defaultIntegrations: getDefaultIntegrations(options), ...options, diff --git a/packages/nestjs/test/integrations/nest.test.ts b/packages/nestjs/test/integrations/nest.test.ts index 6b758d44c982..a369060ece3d 100644 --- a/packages/nestjs/test/integrations/nest.test.ts +++ b/packages/nestjs/test/integrations/nest.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { isPatched } from '../../src/integrations/helpers'; +import { isTargetPatched } from '../../src/integrations/helpers'; import type { InjectableTarget } from '../../src/integrations/types'; describe('Nest', () => { - describe('isPatched', () => { + describe('isTargetPatched', () => { it('should return true if target is already patched', () => { - const target = { name: 'TestTarget', sentryPatched: true, prototype: {} }; - expect(isPatched(target)).toBe(true); + const target = { name: 'TestTarget', sentryPatchedInjectable: true, prototype: {} }; + expect(isTargetPatched(target, 'sentryPatchedInjectable')).toBe(true); }); - it('should add the sentryPatched property and return false if target is not patched', () => { + it('should add the patch flag and return false if target is not patched', () => { const target: InjectableTarget = { name: 'TestTarget', prototype: {} }; - expect(isPatched(target)).toBe(false); - expect(target.sentryPatched).toBe(true); + expect(isTargetPatched(target, 'sentryPatchedInjectable')).toBe(false); + expect(target.sentryPatchedInjectable).toBe(true); }); }); }); diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/nestjs/test/orchestrion/nestjs.test.ts similarity index 98% rename from packages/server-utils/test/orchestrion/nestjs.test.ts rename to packages/nestjs/test/orchestrion/nestjs.test.ts index a54d39d0cf21..9cacace8f249 100644 --- a/packages/server-utils/test/orchestrion/nestjs.test.ts +++ b/packages/nestjs/test/orchestrion/nestjs.test.ts @@ -17,9 +17,19 @@ import { setAsyncContextStrategy, spanToJSON, } from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { nestjsChannelIntegration } from '../../src/orchestrion'; -import { CHANNELS } from '../../src/orchestrion/channels'; +import { nestjsChannels as CHANNELS } from '../../src/orchestrion/config'; + +// The subscriber only ever runs when orchestrion has injected the channels. +// `isOrchestrionInjected()` selects the `orchestrion` span origins that the +// assertions below expect, and so must be true for these tests. +beforeEach(() => { + (globalThis as { __SENTRY_ORCHESTRION__?: unknown }).__SENTRY_ORCHESTRION__ = { runtime: true }; +}); +afterEach(() => { + delete (globalThis as { __SENTRY_ORCHESTRION__?: unknown }).__SENTRY_ORCHESTRION__; +}); // Mirrors harness in `tracing-channel.test.ts`: `bindTracingChannelToSpan` // only creates/ends spans when an async-context binding is available, so the diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 08d24479480f..4cfdd8cd5f4c 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,5 +1,6 @@ import { channelIntegrations, + getChannelIntegrations, ioredisChannelIntegration, redisChannelIntegration, detectOrchestrionSetup, @@ -47,8 +48,10 @@ export function diagnosticsChannelInjectionIntegrations(): typeof channelIntegra */ export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { - // The registry integrations 1:1 replace the OTel integration of the same name. - const integrations = Object.values(channelIntegrations).map(createIntegration => createIntegration()); + // The registry integrations 1:1 replace the OTel integration of the same name. `getChannelIntegrations()` + // includes any externally-injected ones (e.g. `@sentry/nestjs`'s `Nest`), which must have registered + // before `init()` runs this loader — `@sentry/nestjs`'s `init()` does so before calling `nodeInit()`. + const integrations = getChannelIntegrations().map(createIntegration => createIntegration()); const replacedOtelIntegrationNames = integrations.map(i => i.name); return { diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts deleted file mode 100644 index e9e533f0c9e3..000000000000 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts +++ /dev/null @@ -1,272 +0,0 @@ -import * as diagnosticsChannel from 'node:diagnostics_channel'; -import { - captureException, - isThenable, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startSpan, - withIsolationScope, -} from '@sentry/core'; -import { CHANNELS } from '../../orchestrion/channels'; -import type { AnyFn, ChannelContext } from './nestjs-shared'; -import { isWrapped, markWrapped } from './nestjs-shared'; - -const NOOP = (): void => {}; - -// Error-capture mechanism types. Kept identical to the vendored OTel -// instrumentations (`SentryNest{Schedule,Event,Bullmq}Instrumentation`) so -// captured errors attribute and group the same regardless of which -// instrumentation path (OTel vs orchestrion) caught them. -const MECHANISM_CRON = 'auto.function.nestjs.cron'; -const MECHANISM_INTERVAL = 'auto.function.nestjs.interval'; -const MECHANISM_TIMEOUT = 'auto.function.nestjs.timeout'; -const MECHANISM_EVENT = 'auto.event.nestjs'; -const MECHANISM_BULLMQ = 'auto.queue.nestjs.bullmq'; - -// Span origins for the orchestrion path. Unlike the mechanism types above, -// these carry the `orchestrion` segment so orchestrion-created spans are -// distinguishable from OTel -const ORIGIN_EVENT = 'auto.event.orchestrion.nestjs'; -const ORIGIN_BULLMQ = 'auto.queue.orchestrion.nestjs.bullmq'; - -const EVENT_LISTENER_METADATA = 'EVENT_LISTENER_METADATA'; - -interface ReflectWithMetadata { - getMetadataKeys?: (target: object) => unknown[]; - getMetadata?: (key: unknown, target: object) => unknown; -} - -/** - * The class a `@Processor` decorator is applied to (a BullMQ queue processor). */ -interface ProcessorTarget { - __SENTRY_INTERNAL__?: boolean; - prototype?: { process?: AnyFn }; -} - -function captureHandlerError(error: unknown, mechanismType: string): void { - captureException(error, { mechanism: { handled: false, type: mechanismType } }); -} - -/** - * Wrap a scheduled handler (`@Cron`/`@Interval`/`@Timeout`): fork the - * isolation scope and capture errors. NOT async. Preserve the handler's sync - * return type, so sync and async errors are handled on separate paths - * matches vendored OTel implementation - */ -function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyFn { - return function (this: unknown, ...args: unknown[]): unknown { - return withIsolationScope(() => { - let result: unknown; - try { - result = handler.apply(this, args); - } catch (error) { - captureHandlerError(error, mechanismType); - throw error; - } - if (isThenable(result)) { - return result.then(undefined, (error: unknown) => { - captureHandlerError(error, mechanismType); - throw error; - }); - } - return result; - }); - }; -} - -function eventNameFromEvent(event: unknown): string { - if (typeof event === 'string') { - return event; - } - if (Array.isArray(event)) { - return event.map(eventNameFromEvent).join(','); - } - return String(event); -} - -/** - * Derive the event name(s) for an @OnEvent span. The wrapped handler carries - * `EVENT_LISTENER_METADATA` (set by the original decorator), which lists every - * event when multiple @OnEvent decorators are stacked; fall back to the event - * captured from the decorator factory. - */ -function deriveEventName(handler: AnyFn, fallbackEvent: unknown): string { - const R = Reflect as unknown as ReflectWithMetadata; - if (typeof R.getMetadataKeys === 'function' && typeof R.getMetadata === 'function') { - if (R.getMetadataKeys(handler)?.includes(EVENT_LISTENER_METADATA)) { - const eventData = R.getMetadata(EVENT_LISTENER_METADATA, handler); - if (Array.isArray(eventData)) { - return (eventData as unknown[]) - .map(entry => { - const event = entry && typeof entry === 'object' ? (entry as { event?: unknown }).event : undefined; - return event ? eventNameFromEvent(event) : ''; - }) - .reverse() // decorators evaluate bottom to top - .join('|'); - } - } - } - return eventNameFromEvent(fallbackEvent); -} - -/** - * Wrap an @OnEvent handler: fork the isolation scope, open an `event.nestjs` - * transaction, and capture errors. (event-handler errors bypass the global - * filter) - */ -function wrapEventHandler(handler: AnyFn, fallbackEvent: unknown): AnyFn { - const wrapped = async function (this: unknown, ...args: unknown[]): Promise { - const eventName = deriveEventName(wrapped, fallbackEvent); - return withIsolationScope(() => - startSpan( - { - name: `event ${eventName}`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_EVENT, - }, - forceTransaction: true, - }, - async () => { - try { - return await handler.apply(this, args); - } catch (error) { - captureHandlerError(error, MECHANISM_EVENT); - throw error; - } - }, - ), - ); - }; - return wrapped; -} - -/** - * Wrap a BullMQ `process` method: fork the isolation scope, open a - * `queue.process` transaction, and capture errors. - */ -function wrapBullMQProcess(process: AnyFn, queueName: string): AnyFn { - return function (this: unknown, ...args: unknown[]): unknown { - return withIsolationScope(() => - startSpan( - { - name: `${queueName} process`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_BULLMQ, - 'messaging.system': 'bullmq', - 'messaging.destination.name': queueName, - }, - forceTransaction: true, - }, - async () => { - try { - return await process.apply(this, args); - } catch (error) { - captureHandlerError(error, MECHANISM_BULLMQ); - throw error; - } - }, - ), - ); - }; -} - -/** - * Wrap a method decorator (the function the factory returns for - * `@Cron`/`@Interval`/`@Timeout`/`@OnEvent`) so it replaces - * `descriptor.value` with a wrapped handler before delegating to the - * original decorator (which then attaches its metadata to our wrapper). - */ -function makeMethodDecorator(original: AnyFn, wrapHandler: (handler: AnyFn) => AnyFn): AnyFn { - return function (this: unknown, ...args: unknown[]): unknown { - const target = args[0] as { __SENTRY_INTERNAL__?: boolean } | undefined; - const propertyKey = args[1]; - const descriptor = args[2] as PropertyDescriptor | undefined; - const handler = descriptor?.value; - if (handler && typeof handler === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(handler as AnyFn)) { - const wrapped = wrapHandler(handler as AnyFn); - Object.defineProperty(wrapped, 'name', { - value: (handler as AnyFn).name || String(propertyKey), - configurable: true, - }); - markWrapped(wrapped); - descriptor.value = wrapped; - } - return original.apply(this, args); - }; -} - -/** - * Wrap the class decorator @Processor returns so it patches - * `target.prototype.process` before delegating to the original decorator. - */ -function makeProcessorDecorator(original: AnyFn, queueName: string): AnyFn { - return function (this: unknown, ...args: unknown[]): unknown { - const target = args[0] as ProcessorTarget | undefined; - const process = target?.prototype?.process; - if (process && typeof process === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(process)) { - const wrapped = wrapBullMQProcess(process, queueName); - markWrapped(wrapped); - target.prototype!.process = wrapped; - } - return original.apply(this, args); - }; -} - -function extractQueueName(arg: unknown): string { - if (typeof arg === 'string') { - return arg; - } - if (arg && typeof arg === 'object' && 'name' in arg && typeof (arg as { name?: unknown }).name === 'string') { - return (arg as { name: string }).name; - } - return 'unknown'; -} - -/** - * Subscribe to a decorator-factory channel. `end` reassigns `data.result` (the - * decorator the factory returns) with a wrapped version -> `traceSync` returns - * whatever `end` leaves there. `wrap` receives the original decorator and the - * channel context (for the factory's args, e.g. the BullMQ queue name). - */ -function subscribeFactoryDecorator(channelName: string, wrap: (decorator: AnyFn, data: ChannelContext) => AnyFn): void { - diagnosticsChannel.tracingChannel(channelName).subscribe({ - start: NOOP, - end(data) { - const decorator = data.result; - if (typeof decorator === 'function' && !isWrapped(decorator as AnyFn)) { - const wrapped = wrap(decorator as AnyFn, data); - markWrapped(wrapped); - data.result = wrapped; - } - }, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, - }); -} - -/** - * Subscribe the @Cron/@Interval/@Timeout (schedule), @OnEvent (event-emitter) - * and @Processor (bullmq) decorator channels. Each `end` handler reassigns the - * decorator the factory returns (via `data.result`) with one that wraps the - * user handler (schedule/event) or the `process` method (bullmq). - */ -export function subscribeNestHandlerDecorators(): void { - subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, decorator => - makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON)), - ); - subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator => - makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)), - ); - subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_TIMEOUT, decorator => - makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_TIMEOUT)), - ); - subscribeFactoryDecorator(CHANNELS.NESTJS_ONEVENT, (decorator, data) => - makeMethodDecorator(decorator, handler => wrapEventHandler(handler, data.arguments?.[0])), - ); - subscribeFactoryDecorator(CHANNELS.NESTJS_PROCESSOR, (decorator, data) => - makeProcessorDecorator(decorator, extractQueueName(data.arguments?.[0])), - ); -} diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts deleted file mode 100644 index 0b671fb774aa..000000000000 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** A function of unknown signature, matching the methods/handlers we wrap. */ -export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; - -/** - * The orchestrion tracing-channel context. `arguments` is the live call args - * array; `result` is the return value, which an `end` handler may reassign to - * substitute it (`traceSync`/`tracePromise` always return `ctx.result`). - */ -export interface ChannelContext { - arguments: unknown[]; - moduleVersion?: string; - result?: unknown; - error?: unknown; -} - -/** - * Marks a function as already wrapped so repeated subscriptions (eg a second - * `setupOnce`) or multiple decorators on one method don't double-wrap it. - */ -const SENTRY_WRAPPED = Symbol.for('sentry.orchestrion.nestjs.wrapped'); - -/** Whether `fn` has already been wrapped by this integration. */ -export function isWrapped(fn: AnyFn): boolean { - return !!(fn as AnyFn & Record)[SENTRY_WRAPPED]; -} - -/** Mark `fn` as wrapped (see {@link isWrapped}). */ -export function markWrapped(fn: AnyFn): void { - (fn as AnyFn & Record)[SENTRY_WRAPPED] = true; -} diff --git a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts b/packages/server-utils/src/integrations/tracing-channel/nestjs.ts deleted file mode 100644 index c18f98397a99..000000000000 --- a/packages/server-utils/src/integrations/tracing-channel/nestjs.ts +++ /dev/null @@ -1,282 +0,0 @@ -import * as diagnosticsChannel from 'node:diagnostics_channel'; -import type { IntegrationFn, SpanAttributes } from '@sentry/core'; -import { - debug, - defineIntegration, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startInactiveSpan, - startSpan, - waitForTracingChannelBinding, -} from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; -import { CHANNELS } from '../../orchestrion/channels'; -import { bindTracingChannelToSpan } from '../../tracing-channel'; -import type { CatchTarget, InjectableTarget } from './nestjs-decorators'; -import { patchCatchTarget, patchInjectableTarget } from './nestjs-decorators'; -import { subscribeNestHandlerDecorators } from './nestjs-handler-wrappers'; -import type { AnyFn, ChannelContext } from './nestjs-shared'; -import { isWrapped, markWrapped } from './nestjs-shared'; - -// NOTE: this uses the same name as the OTel integration by design. -// When enabled, the OTel 'Nest' integration is omitted from the default set. -const INTEGRATION_NAME = 'Nest'; - -// Span op/origin/attribute values inlined to match the vendored -// `@opentelemetry/instrumentation-nestjs-core` output exactly (the -// `@sentry/nestjs` e2e suite asserts these). They are NOT imported from -// `@sentry/nestjs` because that package depends on this one, not vice versa. -// Orchestrion's whole point is to keep this surface free of OTel. -const NESTJS_COMPONENT = '@nestjs/core'; -const ORIGIN_NESTJS = 'auto.http.orchestrion.nestjs'; -const ATTR_COMPONENT = 'component'; -const ATTR_NESTJS_TYPE = 'nestjs.type'; -const ATTR_NESTJS_VERSION = 'nestjs.version'; -const ATTR_NESTJS_MODULE = 'nestjs.module'; -const ATTR_NESTJS_CONTROLLER = 'nestjs.controller'; -const ATTR_NESTJS_CALLBACK = 'nestjs.callback'; -const ATTR_HTTP_ROUTE = 'http.route'; -const ATTR_HTTP_METHOD = 'http.method'; -const ATTR_HTTP_URL = 'http.url'; -const TYPE_APP_CREATION = 'app_creation'; -const TYPE_REQUEST_CONTEXT = 'request_context'; -const TYPE_REQUEST_HANDLER = 'handler'; - -const NOOP = (): void => {}; - -/** Minimal request shape, across the express/fastify adapters. */ -interface NestRequest { - route?: { path?: string }; - routeOptions?: { url?: string }; - routerPath?: string; - method?: string; - originalUrl?: string; - url?: string; -} - -interface ReflectWithMetadata { - getMetadataKeys?: (target: object) => unknown[]; - getMetadata?: (key: unknown, target: object) => unknown; - defineMetadata?: (key: unknown, value: unknown, target: object) => void; -} - -/** - * Copy NestJS reflect-metadata from the original handler onto the wrapper so - * other decorators (param decorators, guards, `@EventPattern`, ...) that - * read it keep working. No-op when `reflect-metadata` isn't loaded. Mirrors - * vendored `@opentelemetry/instrumentation-nestjs-core` behaviour. - */ -function copyReflectMetadata(from: object, to: object): void { - const R = Reflect as unknown as ReflectWithMetadata; - if ( - typeof R.getMetadataKeys !== 'function' || - typeof R.getMetadata !== 'function' || - typeof R.defineMetadata !== 'function' - ) { - return; - } - for (const key of R.getMetadataKeys(from)) { - R.defineMetadata(key, R.getMetadata(key, from), to); - } -} - -/** - * Wrap the route-handler callback (`create`'s `arguments[1]`) so each - * invocation opens the `handler.nestjs` span (REQUEST_HANDLER). Preserve the - * original `.name` and reflect-metadata so NestJS reflection is unaffected. - */ -function wrapRouteHandler(callback: AnyFn, moduleVersion?: string): AnyFn { - if (isWrapped(callback)) { - return callback; - } - const spanName = callback.name || 'anonymous nest handler'; - const attributes: SpanAttributes = { - [ATTR_COMPONENT]: NESTJS_COMPONENT, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, - [ATTR_NESTJS_TYPE]: TYPE_REQUEST_HANDLER, - [ATTR_NESTJS_CALLBACK]: callback.name, - ...(moduleVersion ? { [ATTR_NESTJS_VERSION]: moduleVersion } : {}), - }; - const wrapped = function (this: unknown, ...args: unknown[]): unknown { - return startSpan({ name: spanName, op: `${TYPE_REQUEST_HANDLER}.nestjs`, attributes }, () => - callback.apply(this, args), - ); - }; - if (callback.name) { - Object.defineProperty(wrapped, 'name', { value: callback.name }); - } - copyReflectMetadata(callback, wrapped); - markWrapped(wrapped); - return wrapped; -} - -/** - * Wrap per-request handler `create` returns so each request opens the - * `request_context.nestjs` span (REQUEST_CONTEXT), carrying the controller / - * callback names captured at setup plus the per-request http.* attributes. - */ -function wrapRequestContextHandler( - handler: AnyFn, - instanceName: string, - callbackName: string, - moduleVersion?: string, -): AnyFn { - const spanName = callbackName ? `${instanceName}.${callbackName}` : instanceName; - const wrapped = function (this: unknown, ...handlerArgs: unknown[]): unknown { - const req = (handlerArgs[0] || {}) as NestRequest; - const httpRoute = req.route?.path || req.routeOptions?.url || req.routerPath; - const attributes: SpanAttributes = { - [ATTR_COMPONENT]: NESTJS_COMPONENT, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, - [ATTR_NESTJS_TYPE]: TYPE_REQUEST_CONTEXT, - [ATTR_NESTJS_CONTROLLER]: instanceName, - [ATTR_NESTJS_CALLBACK]: callbackName, - ...(moduleVersion ? { [ATTR_NESTJS_VERSION]: moduleVersion } : {}), - ...(httpRoute ? { [ATTR_HTTP_ROUTE]: httpRoute } : {}), - ...(req.method ? { [ATTR_HTTP_METHOD]: req.method } : {}), - ...(req.originalUrl || req.url ? { [ATTR_HTTP_URL]: req.originalUrl || req.url } : {}), - }; - return startSpan({ name: spanName, op: `${TYPE_REQUEST_CONTEXT}.nestjs`, attributes }, () => - handler.apply(this, handlerArgs), - ); - }; - markWrapped(wrapped); - return wrapped; -} - -/** - * Subscribe to a decorator channel (`Injectable`/`Catch`) - * - * The orchestrion transform targets the decorator's inner arrow, so `start` - * receives the decorated class as `arguments[0]`. There is no span around the - * decorator itself. - * - * `patch` method installs the prototype-method proxies that open spans later. - */ -function subscribeDecoratorChannel(channelName: string, patch: (target: T) => void): void { - diagnosticsChannel.tracingChannel(channelName).subscribe({ - start(data) { - const target = data.arguments?.[0] as T | undefined; - if (target) { - patch(target); - } - }, - end: NOOP, - asyncStart: NOOP, - asyncEnd: NOOP, - error: NOOP, - }); -} - -const _nestjsChannelIntegration = (() => { - 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:nestjs] subscribing to @nestjs channels'); - - // App-creation span: `bindTracingChannelToSpan` opens the span on - // `start`, makes it the active context for the bootstrap, and ends it - // on `asyncEnd` (or `end` if `create` throws synchronously). - // - // `captureError: false` a failed bootstrap surfaces to the caller. - // We just annotate the span. - // - // `bindTracingChannelToSpan` uses `bindStore`, which needs the - // async-context binding registered after integration `setupOnce`; - // defer until it's available (matches the other channel subscribers). - // Only this bind is deferred: it fires at `NestFactory.create` - // (bootstrap), so a retry tick is fine. The plain `.subscribe` calls - // below stay synchronous. The decorator channels fire at module-load / - // decoration time (right after init), which a deferred subscription - // could miss. - waitForTracingChannelBinding(() => { - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), - data => { - const moduleCls = data.arguments?.[0] as { name?: string } | undefined; - return startInactiveSpan({ - name: 'Create Nest App', - op: `${TYPE_APP_CREATION}.nestjs`, - attributes: { - [ATTR_COMPONENT]: NESTJS_COMPONENT, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN_NESTJS, - [ATTR_NESTJS_TYPE]: TYPE_APP_CREATION, - ...(data.moduleVersion ? { [ATTR_NESTJS_VERSION]: data.moduleVersion } : {}), - ...(moduleCls?.name ? { [ATTR_NESTJS_MODULE]: moduleCls.name } : {}), - }, - }); - }, - { captureError: false }, - ); - }); - - // request_context + request_handler. `RouterExecutionContext.create` - // runs once per route at setup: it receives `(instance, callback, ...)` - // and RETURNS the per-request handler. We don't span `create` itself. - // `start` wraps the callback arg (-> handler span per call) and `end` - // reassigns `data.result` to replace the returned handler (-> request_context - // span per request); `traceSync` always returns whatever `end` leaves there. - // - // Both wrappers open their span at invoke time, inside the live - // request context, so they parent correctly. - const routerMeta = new WeakMap(); - diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_ROUTER_CONTEXT).subscribe({ - start(data) { - const instance = data.arguments?.[0] as { constructor?: { name?: string } } | undefined; - const callback = data.arguments?.[1]; - routerMeta.set(data, { - instanceName: instance?.constructor?.name || 'UnnamedInstance', - callbackName: typeof callback === 'function' ? callback.name : '', - moduleVersion: data.moduleVersion, - }); - if (typeof callback === 'function') { - data.arguments[1] = wrapRouteHandler(callback as AnyFn, data.moduleVersion); - } - }, - end(data) { - const handler = data.result; - const meta = routerMeta.get(data); - if (typeof handler === 'function' && meta && !isWrapped(handler as AnyFn)) { - data.result = wrapRequestContextHandler( - handler as AnyFn, - meta.instanceName, - meta.callbackName, - meta.moduleVersion, - ); - } - routerMeta.delete(data); - }, - asyncStart: NOOP, - asyncEnd: NOOP, - error(data) { - routerMeta.delete(data); - }, - }); - - // @Injectable (middleware/guard/pipe/interceptor) and @Catch (exception - // filter): both decorators share the `(target) => {...}` - // inner-arrow shape. - const seenInterceptorContexts = new WeakSet(); - subscribeDecoratorChannel(CHANNELS.NESTJS_INJECTABLE, target => - patchInjectableTarget(target, seenInterceptorContexts), - ); - subscribeDecoratorChannel(CHANNELS.NESTJS_CATCH, patchCatchTarget); - - // @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq). - subscribeNestHandlerDecorators(); - }, - }; -}) satisfies IntegrationFn; - -/** - * EXPERIMENTAL orchestrion-driven NestJS integration. - * - * Subscribes to the diagnostics_channels the orchestrion code transform injects - * into `@nestjs/core` and `@nestjs/common` (see `orchestrion/config.ts`). - * Requires the orchestrion runtime hook or bundler plugin to be active. - */ -export const nestjsChannelIntegration = defineIntegration(_nestjsChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 4cadc7b40925..273e9772c47c 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -15,7 +15,8 @@ type UnknownPlugin = any; import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; import MagicString from 'magic-string'; -import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; +import { instrumentedModuleNames, SENTRY_INSTRUMENTATIONS } from '../config'; +import type { OrchestrionInstrumentation } from '../registry'; // `vite` types live in the package's ESM-only subpath; under Node16 module // resolution with TS treating @sentry/server-utils as CJS, importing them produces a @@ -42,6 +43,10 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * 2. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` * plugin, fed our central `SENTRY_INSTRUMENTATIONS` config. * + * A framework SDK that owns its own instrumentation can inject its + * `OrchestrionInstrumentation` descriptor via the `instrumentations` option, which is merged + * with the built-in configs. + * * @example * ```ts * // vite.config.ts @@ -49,15 +54,18 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * export default { plugins: [sentryOrchestrionPlugin()] }; * ``` */ -export function sentryOrchestrionPlugin(): UnknownPlugin[] { - const codeTransformerPlugins = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); +export function sentryOrchestrionPlugin(options?: { + instrumentations?: OrchestrionInstrumentation[]; +}): UnknownPlugin[] { + const instrumentations = [...SENTRY_INSTRUMENTATIONS, ...(options?.instrumentations ?? []).flatMap(i => i.configs)]; + const codeTransformerPlugins = codeTransformer({ instrumentations }); const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) ? codeTransformerPlugins : [codeTransformerPlugins]; - return [bundlerMarkerPlugin(), ...codeTransformerArray]; + return [bundlerMarkerPlugin(instrumentedModuleNames(instrumentations)), ...codeTransformerArray]; } -function bundlerMarkerPlugin(): UnknownPlugin { +function bundlerMarkerPlugin(moduleNames: string[]): UnknownPlugin { const banner = [ 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', @@ -75,7 +83,7 @@ function bundlerMarkerPlugin(): UnknownPlugin { // diagnostics_channel calls never get injected. Vite merges array // `noExternal` entries with the user's config, so we don't overwrite // their additions. - return { ssr: { noExternal: INSTRUMENTED_MODULE_NAMES } }; + return { ssr: { noExternal: moduleNames } }; }, renderChunk(code: string, chunk: { isEntry: boolean }): { code: string; map: unknown } | null { if (!chunk.isEntry) return null; diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 23a08b2279c6..2f681a061238 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -10,7 +10,6 @@ import { vercelAiChannels } from './config/vercel-ai'; import { hapiChannels } from './config/hapi'; import { redisChannels } from './config/redis'; import { expressChannels } from './config/express'; -import { nestjsChannels } from './config/nestjs'; /** * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. @@ -38,7 +37,6 @@ export const CHANNELS = { ...hapiChannels, ...redisChannels, ...expressChannels, - ...nestjsChannels, } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index b859273f844f..02a86d236739 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -11,8 +11,16 @@ import { vercelAiConfig } from './vercel-ai'; import { hapiConfig } from './hapi'; import { redisConfig } from './redis'; import { expressConfig } from './express'; -import { nestjsConfig } from './nestjs'; +import { getInjectedOrchestrionInstrumentations } from '../registry'; +/** + * The built-in orchestrion code-transform configs shipped by `@sentry/server-utils`. + * + * Framework packages that own their own instrumentation (e.g. `@sentry/nestjs`) + * are NOT here — they inject via the registry (runtime) or the bundler plugin's + * `instrumentations` option (build time). Use {@link getSentryInstrumentations} + * to get the built-ins merged with any injected ones. + */ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, ...lruMemoizerConfig, @@ -26,19 +34,31 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...hapiConfig, ...redisConfig, ...expressConfig, - ...nestjsConfig, ]; /** - * The unique set of package names instrumented by `SENTRY_INSTRUMENTATIONS` - * (e.g. `['mysql']`). + * The built-in configs merged with any externally-injected ones (see the + * registry). This is the list the runtime hook feeds to the code transform. + */ +export function getSentryInstrumentations(): InstrumentationConfig[] { + return [...SENTRY_INSTRUMENTATIONS, ...getInjectedOrchestrionInstrumentations().flatMap(i => i.configs)]; +} + +/** The unique set of instrumented package names for the given configs. */ +export function instrumentedModuleNames(configs: InstrumentationConfig[]): string[] { + return Array.from(new Set(configs.map(i => i.module.name))); +} + +/** + * The unique set of package names instrumented by the built-in + * `SENTRY_INSTRUMENTATIONS` (e.g. `['mysql']`). * * Bundler plugins MUST ensure these are actually bundled rather than * externalized: an externalized dependency is resolved from `node_modules` at * runtime and never passes through the code transform's `onLoad`, so its * diagnostics_channel calls are silently never injected. */ -export const INSTRUMENTED_MODULE_NAMES: string[] = Array.from(new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name))); +export const INSTRUMENTED_MODULE_NAMES: string[] = instrumentedModuleNames(SENTRY_INSTRUMENTATIONS); /** * Returns `external` with any instrumented packages removed, so a bundler that @@ -50,11 +70,12 @@ export const INSTRUMENTED_MODULE_NAMES: string[] = Array.from(new Set(SENTRY_INS * (Vite uses an `ssr.noExternal` allowlist instead, so it consumes * `INSTRUMENTED_MODULE_NAMES` directly rather than this helper.) */ -export function withoutInstrumentedExternals(external: readonly string[] | undefined): string[] | undefined { +export function withoutInstrumentedExternals( + external: readonly string[] | undefined, + names: string[] = INSTRUMENTED_MODULE_NAMES, +): string[] | undefined { if (!external) { return undefined; } - return external.filter( - entry => !INSTRUMENTED_MODULE_NAMES.some(name => entry === name || entry.startsWith(`${name}/`)), - ); + return external.filter(entry => !names.some(name => entry === name || entry.startsWith(`${name}/`))); } diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index 9dfa88bf427d..8ccb779ebb0a 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -1,9 +1,12 @@ import { debug } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import type { OrchestrionInstrumentation } from './registry'; declare global { // eslint-disable-next-line no-var - var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; + var __SENTRY_ORCHESTRION__: + | { runtime?: boolean; bundler?: boolean; registry?: OrchestrionInstrumentation[] } + | undefined; } /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 0f08f4ca9269..7ff39f2d77ad 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,3 +1,5 @@ +import type { IntegrationFn } from '@sentry/core'; +import { getInjectedOrchestrionInstrumentations } from './registry'; import { anthropicChannelIntegration } from '../integrations/tracing-channel/anthropic'; import { googleGenAIChannelIntegration } from '../integrations/tracing-channel/google-genai'; import { hapiChannelIntegration } from '../integrations/tracing-channel/hapi'; @@ -5,13 +7,14 @@ import { ioredisChannelIntegration } from '../integrations/tracing-channel/iored import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; import { openaiChannelIntegration } from '../integrations/tracing-channel/openai'; -import { nestjsChannelIntegration } from '../integrations/tracing-channel/nestjs'; import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres'; import { postgresJsChannelIntegration } from '../integrations/tracing-channel/postgres-js'; import { vercelAiChannelIntegration } from '../integrations/tracing-channel/vercel-ai'; import { expressChannelIntegration } from '../integrations/tracing-channel/express'; export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; +export { registerOrchestrionInstrumentation, getInjectedOrchestrionInstrumentations } from './registry'; +export type { OrchestrionInstrumentation, InstrumentationConfig, FunctionKind } from './registry'; export { anthropicChannelIntegration, googleGenAIChannelIntegration, @@ -24,7 +27,6 @@ export { postgresJsChannelIntegration, vercelAiChannelIntegration, expressChannelIntegration, - nestjsChannelIntegration, }; export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../integrations/tracing-channel/ioredis'; export type { PostgresJsChannelIntegrationOptions } from '../integrations/tracing-channel/postgres-js'; @@ -48,9 +50,8 @@ export type { RedisChannelIntegrationOptions, RedisResponseHook } from '../integ * composite OTel `Redis` integration and needs the node SDK's redis cache `responseHook` (which * can't live in `server-utils`), so `@sentry/node` wires it up separately. * - * `Nest` is included even though it isn't a `@sentry/node` default integration: the swap runs in the Node - * SDK's `_init` over the *final* `defaultIntegrations`, so it also replaces the OTel `Nest` that - * `@sentry/nestjs` prepends to its own defaults. + * Framework packages that own their own channel integration (e.g. `@sentry/nestjs`'s `Nest`) are + * NOT here either: they inject via the registry, and {@link getChannelIntegrations} merges them in. */ export const channelIntegrations = { postgresIntegration: postgresChannelIntegration, @@ -63,5 +64,13 @@ export const channelIntegrations = { vercelAiIntegration: vercelAiChannelIntegration, hapiIntegration: hapiChannelIntegration, expressIntegration: expressChannelIntegration, - nestIntegration: nestjsChannelIntegration, } as const; + +/** + * The built-in channel-integration factories merged with any externally-injected ones (see the + * registry). Each 1:1 replaces the OTel integration of the same `name`. This is the list the Node + * SDK's opt-in helper instantiates and swaps in for the matching OTel integrations. + */ +export function getChannelIntegrations(): IntegrationFn[] { + return [...Object.values(channelIntegrations), ...getInjectedOrchestrionInstrumentations().map(i => i.integration)]; +} diff --git a/packages/server-utils/src/orchestrion/registry.ts b/packages/server-utils/src/orchestrion/registry.ts new file mode 100644 index 000000000000..4482997a4ca0 --- /dev/null +++ b/packages/server-utils/src/orchestrion/registry.ts @@ -0,0 +1,55 @@ +import type { FunctionKind, InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { IntegrationFn } from '@sentry/core'; + +export type { FunctionKind, InstrumentationConfig }; + +declare global { + // eslint-disable-next-line no-var + var __SENTRY_ORCHESTRION__: + | { runtime?: boolean; bundler?: boolean; registry?: OrchestrionInstrumentation[] } + | undefined; +} + +/** + * A self-contained orchestrion instrumentation contributed by an SDK package. + * + * Bundles the three pieces a diagnostics-channel instrumentation needs: + * - `configs`: the code-transform configs (consumed at startup/bundler time), + * - `integration`: the channel-subscriber span-emitting integration factory. + * + * A package that owns its own instrumentation (e.g. `@sentry/nestjs`) defines + * one of these and injects it via {@link registerOrchestrionInstrumentation} + * (runtime) and by passing it to the bundler plugin's `instrumentations` + * option (build time), so `@sentry/server-utils` never has to depend on that + * package. + */ +export interface OrchestrionInstrumentation { + name: string; + configs: InstrumentationConfig[]; + integration: IntegrationFn; +} + +/** + * Inject an externally-defined orchestrion instrumentation into the shared + * assembly. Must be called before the runtime hook registers (see + * `registerDiagnosticsChannelInjection`) and before the Node SDK builds its + * channel-integration list. + * + * Backed by `globalThis` (not a module-scoped array) so the CJS copy of this + * module that `Sentry.init()` `require()`s synchronously and the ESM copy an + * injecting package imports share one store. Idempotent per `name`. + */ +export function registerOrchestrionInstrumentation(instrumentation: OrchestrionInstrumentation): void { + const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); + const registry = (g.registry ??= []); + if (!registry.some(existing => existing.name === instrumentation.name)) { + registry.push(instrumentation); + } +} + +/** +* The externally-injected orchestrion instrumentations, in registration order. +*/ +export function getInjectedOrchestrionInstrumentations(): OrchestrionInstrumentation[] { + return globalThis.__SENTRY_ORCHESTRION__?.registry ?? []; +} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 195464f7375f..9c281f028333 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -3,11 +3,14 @@ import { createRequire } from 'node:module'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; import { DEBUG_BUILD } from '../../debug-build'; -import { SENTRY_INSTRUMENTATIONS } from '../config'; +import { getSentryInstrumentations } from '../config'; +import type { OrchestrionInstrumentation } from '../registry'; declare global { // eslint-disable-next-line no-var - var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; + var __SENTRY_ORCHESTRION__: + | { runtime?: boolean; bundler?: boolean; registry?: OrchestrionInstrumentation[] } + | undefined; } /** @@ -76,7 +79,7 @@ export function registerDiagnosticsChannelInjection(): void { resolve: unknown; load: unknown; }; - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); + initialize({ instrumentations: getSentryInstrumentations() }); mod.registerHooks({ resolve, load }); DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.registerHooks()'); } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { @@ -93,7 +96,7 @@ export function registerDiagnosticsChannelInjection(): void { mod.register('@apm-js-collab/tracing-hooks/hook.mjs', { parentURL, - data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + data: { instrumentations: getSentryInstrumentations() }, }); // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM @@ -104,7 +107,7 @@ export function registerDiagnosticsChannelInjection(): void { const ModulePatch = nodeRequire('@apm-js-collab/tracing-hooks') as new (opts: { instrumentations: unknown }) => { patch: () => void; }; - new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + new ModulePatch({ instrumentations: getSentryInstrumentations() }).patch(); DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.register()'); } else { DEBUG_BUILD && From 9eec1cc5e1e2c82d59d410428c40b367539dfdc5 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 8 Jul 2026 15:35:01 -0700 Subject: [PATCH 17/18] chore: size limit increase --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index c0c4a2c25a32..6fc6cad995b7 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -400,7 +400,7 @@ module.exports = [ import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '135 KB', + limit: '140 KB', disablePlugins: ['@size-limit/esbuild'], }, { From b34d60e2f37e5d2da2f2507051c541db675665b2 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 8 Jul 2026 15:54:47 -0700 Subject: [PATCH 18/18] fixup! ref(server-utils, nestjs): move NestJS orchestrion configs out of server-utils --- packages/server-utils/src/orchestrion/registry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/orchestrion/registry.ts b/packages/server-utils/src/orchestrion/registry.ts index 4482997a4ca0..5d450077ffda 100644 --- a/packages/server-utils/src/orchestrion/registry.ts +++ b/packages/server-utils/src/orchestrion/registry.ts @@ -48,8 +48,8 @@ export function registerOrchestrionInstrumentation(instrumentation: OrchestrionI } /** -* The externally-injected orchestrion instrumentations, in registration order. -*/ + * The externally-injected orchestrion instrumentations, in registration order. + */ export function getInjectedOrchestrionInstrumentations(): OrchestrionInstrumentation[] { return globalThis.__SENTRY_ORCHESTRION__?.registry ?? []; }