From c6a07ed7087df46e63998cdeffd325a7e2728369 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 24 Jun 2026 11:56:43 -0700 Subject: [PATCH 1/6] feat(nestjs): initial orchestrion span for app creation --- .../integrations/tracing-channel/nestjs.ts | 77 ++++++++ .../server-utils/src/orchestrion/channels.ts | 1 + .../server-utils/src/orchestrion/config.ts | 11 ++ .../server-utils/src/orchestrion/index.ts | 1 + .../test/orchestrion/nestjs.test.ts | 175 ++++++++++++++++++ 5 files changed, 265 insertions(+) create mode 100644 packages/server-utils/src/integrations/tracing-channel/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 28dcf0c33468..0c36f091d7e8 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -13,6 +13,7 @@ */ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', + NESTJS_APP_CREATION: 'orchestrion:@nestjs/core:nestFactoryCreate', } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 35b326fb8eb1..dda7cb891b87 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -32,6 +32,17 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ // attach `'end'`/`'error'` listeners that finish the span. functionQuery: { expressionName: 'query', kind: 'Auto' }, }, + { + // `@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' }, + }, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index dd3ecd0f8f19..97126d93f1dc 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,2 +1,3 @@ export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; +export { nestjsChannelIntegration } from '../integrations/tracing-channel/nestjs'; 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 322bd5ded57da9ed7671c3d7ffa7b4aee9bfeda6 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 24 Jun 2026 13:09:47 -0700 Subject: [PATCH 2/6] 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 | 189 +++++++++++++++++- .../server-utils/src/orchestrion/channels.ts | 1 + .../server-utils/src/orchestrion/config.ts | 13 ++ .../test/orchestrion/nestjs.test.ts | 142 +++++++++++++ 4 files changed, 342 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..a5a25aeeece6 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` (+ `mutableResult`) 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,55 @@ 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, because the + // config sets `mutableResult: true`, `end` replaces the returned + // handler (-> one request_context span per request). + // + // 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/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 0c36f091d7e8..718b2cafbd9e 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -14,6 +14,7 @@ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', NESTJS_APP_CREATION: 'orchestrion:@nestjs/core:nestFactoryCreate', + NESTJS_ROUTER_CONTEXT: 'orchestrion:@nestjs/core:routerExecutionContextCreate', } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index dda7cb891b87..09e349b8a966 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -43,6 +43,19 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ 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, via + // `mutableResult: true`, replaces the returned handler + // (-> request_context span). So `kind: 'Sync'` + `mutableResult: true`. + // Mirrors vendored `@opentelemetry/instrumentation-nestjs-core` + // `RouterExecutionContext.create` wrap. + channelName: 'routerExecutionContextCreate', + module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'router/router-execution-context.js' }, + functionQuery: { className: 'RouterExecutionContext', methodName: 'create', kind: 'Sync', mutableResult: true }, + }, ]; /** diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts index 16d220e312d6..e89497830bf4 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` replaces the returned handler + // (mutableResult). `makeHandler` stands in for the real `create` body. Returns + // the effective return (post-mutableResult, i.e. `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 bb33217f87e9ae5eae8ec779d1f8f51bfb899367 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 24 Jun 2026 14:53:41 -0700 Subject: [PATCH 3/6] 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 | 146 +++++---- .../server-utils/src/orchestrion/channels.ts | 2 + .../server-utils/src/orchestrion/config.ts | 32 ++ .../test/orchestrion/nestjs.test.ts | 210 ++++++++++++- 5 files changed, 607 insertions(+), 68 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 a5a25aeeece6..e12be9bcb3ee 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,10 @@ 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 (sync) return value when `mutableResult` is set. */ -interface NestFactoryCreateData { - arguments: unknown[]; - moduleVersion?: string; - result?: unknown; - error?: unknown; -} - -/** - * The shape orchestrion's `traceSync` (+ `mutableResult`) 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 +72,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 +92,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 +122,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 +156,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,27 +211,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 + // because the config sets `mutableResult`, `end` replaces the returned + // handler (-> request_context span per request). // - // `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, because the - // config sets `mutableResult: true`, `end` replaces the returned - // handler (-> one request_context span per request). - // - // 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); } @@ -236,16 +247,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; @@ -253,8 +269,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/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 718b2cafbd9e..cd97cd56d0e2 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -15,6 +15,8 @@ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', 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; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 09e349b8a966..207b5ce2e8e0 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -56,6 +56,38 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ module: { name: '@nestjs/core', versionRange: '>=8.0.0 <12', filePath: 'router/router-execution-context.js' }, functionQuery: { className: 'RouterExecutionContext', methodName: 'create', kind: 'Sync', mutableResult: true }, }, + { + // `@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'` with no + // `mutableResult`. + 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' }, + }, + { + // `@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' }, + }, ]; /** diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts index e89497830bf4..04dd9308d82e 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'; @@ -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 037db731067e5323695642793fa080a00bb2dc43 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 24 Jun 2026 16:38:49 -0700 Subject: [PATCH 4/6] 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 | 6 +- .../nestjs-handler-wrappers.ts | 265 ++++++++++++++++++ .../tracing-channel/nestjs-shared.ts | 29 ++ .../integrations/tracing-channel/nestjs.ts | 31 +- .../server-utils/src/orchestrion/channels.ts | 5 + .../server-utils/src/orchestrion/config.ts | 48 ++++ .../test/orchestrion/nestjs.test.ts | 151 +++++++++- 7 files changed, 505 insertions(+), 30 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..4e0879a95984 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'; 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..37b92a2ecea8 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-handler-wrappers.ts @@ -0,0 +1,265 @@ +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. The factory is matched with + * `mutableResult`, so `end` can replace `data.result` (the decorator the + * factory returns) with a wrapped version. `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 factory is matched with + * `mutableResult`; we replace the decorator it returns 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..2302beda23d5 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/nestjs-shared.ts @@ -0,0 +1,29 @@ +/** 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 (sync) return value, mutable when `mutableResult` is set. + */ +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 e12be9bcb3ee..aff54eac37f4 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,29 +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 (sync) return value when `mutableResult` is set. - */ -interface ChannelContext { - arguments: unknown[]; - moduleVersion?: string; - result?: unknown; - error?: unknown; -} - /** Minimal request shape, across the express/fastify adapters. */ interface NestRequest { route?: { path?: string }; @@ -262,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/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index cd97cd56d0e2..cffc617ae26b 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -17,6 +17,11 @@ export const CHANNELS = { 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; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 207b5ce2e8e0..b3c827b6dfa6 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -88,6 +88,54 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ 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 use `mutableResult` to wrap the + // decorator it returns (which rewrites the user handler `descriptor.value` with + // isolation-scope + error capture). Mirrors `SentryNestScheduleInstrumentation`. + // Version range scoped to the verified compiled shape (4.x). + { + channelName: 'cronDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=4.0.0 <5', filePath: 'dist/decorators/cron.decorator.js' }, + functionQuery: { functionName: 'Cron', kind: 'Sync', mutableResult: true }, + }, + { + channelName: 'intervalDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=4.0.0 <5', filePath: 'dist/decorators/interval.decorator.js' }, + functionQuery: { functionName: 'Interval', kind: 'Sync', mutableResult: true }, + }, + { + channelName: 'timeoutDecorator', + module: { name: '@nestjs/schedule', versionRange: '>=4.0.0 <5', filePath: 'dist/decorators/timeout.decorator.js' }, + functionQuery: { functionName: 'Timeout', kind: 'Sync', mutableResult: true }, + }, + { + // @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`. + // `mutableResult` wraps the returned decorator, which rewrites the handler to + // open an `event.nestjs` span. Mirrors `SentryNestEventInstrumentation`. + channelName: 'onEventDecorator', + module: { + name: '@nestjs/event-emitter', + versionRange: '>=2.0.0 <3', + filePath: 'dist/decorators/on-event.decorator.js', + }, + functionQuery: { expressionName: 'OnEvent', kind: 'Sync', mutableResult: true }, + }, + { + // @nestjs/bullmq @Processor: `function Processor(...) { return (target) => {…}; }` + // The factory arg carries the queue name, so we match the factory and use + // `mutableResult` to wrap the returned class decorator (which patches + // `target.prototype.process`). Mirrors `SentryNestBullMQInstrumentation`. + channelName: 'processorDecorator', + module: { + name: '@nestjs/bullmq', + versionRange: '>=10.0.0 <12', + filePath: 'dist/decorators/processor.decorator.js', + }, + functionQuery: { functionName: 'Processor', kind: 'Sync', mutableResult: true }, + }, ]; /** diff --git a/packages/server-utils/test/orchestrion/nestjs.test.ts b/packages/server-utils/test/orchestrion/nestjs.test.ts index 04dd9308d82e..d61c27df74b0 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, @@ -514,8 +515,156 @@ describe('nestjsChannelIntegration: @Catch (exception filter)', () => { } applyCatch(HttpExceptionFilter); - // Missing host → guard short-circuits, no span opened. + // Missing host -> guard short-circuits, no span opened. new HttpExceptionFilter().catch('boom', undefined); 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` + // (mutableResult) replaces it. 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 25cb29ec6356b34e0b0452496f82d6f6612ae2e5 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 25 Jun 2026 07:17:51 -0700 Subject: [PATCH 5/6] 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. Full E2E testing is deferred until `@apm-js-collab/code-transformer` updates are published, because this depends on changes upstream, and E2E tests will pull in the published version. --- ...erimentalUseDiagnosticsChannelInjection.ts | 10 ++- packages/node/src/sdk/index.ts | 39 ++++----- .../sdk/diagnosticsChannelInjection.test.ts | 87 +++++++++++++++++++ 3 files changed, 112 insertions(+), 24 deletions(-) create mode 100644 packages/node/test/sdk/diagnosticsChannelInjection.test.ts diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index d51f2d86a610..7efddb33febf 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,4 +1,8 @@ -import { mysqlChannelIntegration, detectOrchestrionSetup } from '@sentry/server-utils/orchestrion'; +import { + detectOrchestrionSetup, + mysqlChannelIntegration, + nestjsChannelIntegration, +} from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection'; @@ -38,8 +42,8 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader( (): DiagnosticsChannelInjection => ({ - integrations: [mysqlChannelIntegration()], - replacedOtelIntegrationNames: ['Mysql'], + integrations: [mysqlChannelIntegration(), nestjsChannelIntegration()], + replacedOtelIntegrationNames: ['Mysql', 'Nest'], register: registerDiagnosticsChannelInjection, detect: detectOrchestrionSetup, }), diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 8c8d2e887541..2c447f75eda2 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; } /** @@ -90,10 +72,25 @@ 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. + 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); + }); +}); From 7fafc689299608532c7d2ee07e5600ef1efa3fb3 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 26 Jun 2026 09:18:53 -0700 Subject: [PATCH 6/6] test: scaffolding tests for nestjs-orchestrion This is not yet runnable in CI (or even, most dev envs), because we're waiting on several upstream PRs to land and be published to the npm registry. If that ends up being delayed further, we can of course bundle it, but that would be a lot of complexity for only a very temporary benefit, most likely. --- .../nestjs-orchestrion/.gitignore | 56 ++++++++++ .../nestjs-orchestrion/README.md | 45 ++++++++ .../nestjs-orchestrion/nest-cli.json | 8 ++ .../nestjs-orchestrion/package.json | 40 +++++++ .../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 | 104 ++++++++++++++++++ .../nestjs-orchestrion/tsconfig.build.json | 4 + .../nestjs-orchestrion/tsconfig.json | 22 ++++ .../tracing/nestjs-orchestrion/README.md | 39 +++++++ .../instrument-orchestrion.mjs | 19 ++++ .../tracing/nestjs-orchestrion/scenario.ts | 29 +++++ .../suites/tracing/nestjs-orchestrion/test.ts | 57 ++++++++++ 27 files changed, 754 insertions(+) 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/README.md 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..631b1834c9fe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/README.md @@ -0,0 +1,45 @@ +# 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. + +> [!WARNING] +> +> ## ⚠️ Not yet runnable in CI +> +> This app installs `@apm-js-collab/code-transformer` from npm (a +> transitive dep of `@sentry/node`/`@sentry/server-utils`). +> Several spans depend on the `mutableResult` transform option, +> which is **not in the published version yet**: +> +> - `request_context` (wraps the handler returned by +> `RouterExecutionContext.create`) +> - `@Cron`/`@Interval`/`@Timeout`, `@OnEvent` (wrap the +> decorator the factory returns) +> +> `app_creation`, `request_handler`, and the +> `@Injectable`/`@Catch` spans only need `astQuery` + argument +> mutation (already published), so they should pass first. +> +> **Enable in CI once** the `@apm-js-collab/code-transformer` +> changes (`mutableResult` + documented `astQuery`) are published +> and pulled in. Until then keep this app out of the e2e run +> list. 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..c822d5cb5c76 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/package.json @@ -0,0 +1,40 @@ +{ + "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" + }, + "sentryTest": { + "// skip": "Keep out of CI until @apm-js-collab/code-transformer (mutableResult + astQuery) is published. See README.md.", + "skip": true + } +} 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..cdbac674e795 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tests/transactions.test.ts @@ -0,0 +1,104 @@ +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 } | 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: `{Controller}.{handler}`, carries http + controller/callback attrs. + const requestContext = findSpan(transactionEvent, 'request_context.nestjs', 'auto.http.otel.nestjs'); + expect(requestContext).toBeDefined(); + expect(requestContext?.description).toBe('AppController.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..cf79f029c781 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-orchestrion/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "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/README.md b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/README.md new file mode 100644 index 000000000000..b78cf0b51bfd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/README.md @@ -0,0 +1,39 @@ +# nestjs-orchestrion integration test + +In-process verification of the orchestrion (diagnostics-channel) +NestJS instrumentation. Unlike the e2e app +(`e2e-tests/test-applications/nestjs-orchestrion`, which installs +the **published** `@apm-js-collab/code-transformer`), this +harness resolves dependencies from the repo root `node_modules`, +where the code-transformer is **symlinked to the local +checkout**, so it can validate the `mutableResult`-dependent +spans (`request_context`, schedule, event) **before** the +upstream npm publish. + +- `instrument-orchestrion.mjs`: `--import`ed before the scenario; + opts in + inits. +- `scenario.ts`: a minimal NestJS app (`NestFactory.create` + one + route). +- `test.ts`: `createRunner` asserts the `app_creation`, + `request_context` and `handler` spans. **Currently + `describe.skip`.** + +> [!WARNING] +> +> ## ⚠️ Currently not runnable in CI - Prerequesitest to un-skip +> +> 1. `@apm-js-collab/code-transformer` with `mutableResult` +> available. (The local checkout is symlinked into root +> `node_modules`, so this is satisfied as soon as that work is +> built. No npm publish needed for this test). +> 2. Add `rxjs` and `reflect-metadata` to this package's +> `devDependencies`. NestJS cannot load without them. +> (`@nestjs/common`/`core`/`platform-express` are already +> present.) +> 3. Ensure `scenario.ts` compiles with `experimentalDecorators` +> and `emitDecoratorMetadata` (NestJS dependency injection +> needs them). `scenario.ts` runs via `ts-node/register`, +> which uses this package's tsconfig; add a suite-local +> tsconfig if the shared one lacks those options. +> +> Then remove `.skip` in `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 new file mode 100644 index 000000000000..de142b4eb678 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/instrument-orchestrion.mjs @@ -0,0 +1,19 @@ +// 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 * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// 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..928cc48371cc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-orchestrion/test.ts @@ -0,0 +1,57 @@ +import { join } from 'path'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +// See ./README.md in this test folder for details about requirements +// to un-skip this test. +describe.skip('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) + .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(); + }); +});