From 28b5141001d82e4e9237e26be3154f4192fcee7e Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 9 Jul 2026 10:45:16 -0700 Subject: [PATCH] ref(nestjs): extract shared span helpers Pull the span-emitting/patching logic out of the OTel `InstrumentationBase` classes into `wrap-components`, `wrap-handlers`, and `wrap-route` so it can be reused by the diagnostics-channel integration. Refactor only. There is still only the OTel implementation, which still emits the same spans as before. However, this will be needed when the next implementation is added. --- packages/nestjs/src/integrations/helpers.ts | 71 ++++-- .../sentry-nest-bullmq-instrumentation.ts | 50 +--- .../sentry-nest-event-instrumentation.ts | 87 +------ .../sentry-nest-instrumentation.ts | 224 +----------------- .../sentry-nest-schedule-instrumentation.ts | 153 +++--------- packages/nestjs/src/integrations/types.ts | 10 +- .../integrations/vendored/instrumentation.ts | 99 +------- .../src/integrations/wrap-components.ts | 198 ++++++++++++++++ .../nestjs/src/integrations/wrap-handlers.ts | 187 +++++++++++++++ .../nestjs/src/integrations/wrap-route.ts | 133 +++++++++++ .../nestjs/test/integrations/nest.test.ts | 14 +- 11 files changed, 650 insertions(+), 576 deletions(-) create mode 100644 packages/nestjs/src/integrations/wrap-components.ts create mode 100644 packages/nestjs/src/integrations/wrap-handlers.ts create mode 100644 packages/nestjs/src/integrations/wrap-route.ts diff --git a/packages/nestjs/src/integrations/helpers.ts b/packages/nestjs/src/integrations/helpers.ts index d8b50957f979..ab4b0084dbf6 100644 --- a/packages/nestjs/src/integrations/helpers.ts +++ b/packages/nestjs/src/integrations/helpers.ts @@ -7,40 +7,73 @@ import { } from '@sentry/core'; import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types'; -const sentryPatched = 'sentryPatched'; +/** A function of unknown signature, matching the methods/handlers we wrap. */ +export type AnyFn = (this: unknown, ...args: unknown[]) => unknown; /** - * Helper checking if a concrete target class is already patched. - * - * We already guard duplicate patching with isWrapped. However, isWrapped checks whether a file has been patched, whereas we use this check for concrete target classes. - * This check might not be necessary, but better to play it safe. + * Marks a function as already wrapped so repeated subscriptions/decoration + * don't double-wrap it. */ -export function isPatched(target: InjectableTarget | CatchTarget): boolean { - if (target.sentryPatched) { +const SENTRY_WRAPPED = Symbol.for('sentry.nestjs.wrapped'); + +/** Whether `fn` has already been wrapped by this integration. */ +export function isWrapped(fn: AnyFn): boolean { + return !!(fn as AnyFn & Record)[SENTRY_WRAPPED]; +} + +/** Mark `fn` as wrapped (see {@link isWrapped}). */ +export function markWrapped(fn: AnyFn): void { + (fn as AnyFn & Record)[SENTRY_WRAPPED] = true; +} + +/** + * Mark a target class as patched (for the given pass) so it's instrumented + * only once, and to stay idempotent across repeated subscriptions/decoration. + */ +export function isTargetPatched(target: object, flag: 'sentryPatchedInjectable' | 'sentryPatchedCatch'): boolean { + if ((target as Record)[flag]) { return true; } - - addNonEnumerableProperty(target, sentryPatched, true); + addNonEnumerableProperty(target, flag, true); return false; } +/** Origin for middleware/guard/pipe/interceptor/exception_filter spans. */ +function middlewareOrigin(componentType?: string): string { + return componentType ? `auto.middleware.nestjs.${componentType}` : 'auto.middleware.nestjs'; +} + +/** + * Origin for the app-creation / request-context / request-handler HTTP spans. + */ +export function httpOrigin(): string { + return 'auto.http.otel.nestjs'; +} + +/** Origin for `@OnEvent` spans. */ +function eventOrigin(): string { + return 'auto.event.nestjs'; +} + +/** Origin for BullMQ `@Processor` `process` spans. */ +function bullmqOrigin(): string { + return 'auto.queue.nestjs.bullmq'; +} + /** * Returns span options for nest middleware spans. + * name = provided name or class name. */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function getMiddlewareSpanOptions( - target: InjectableTarget | CatchTarget, + target: InjectableTarget | CatchTarget | { name?: string }, name: string | undefined = undefined, componentType: string | undefined = undefined, -) { - const span_name = name ?? target.name; // fallback to class name if no name is provided - const origin = componentType ? `auto.middleware.nestjs.${componentType}` : 'auto.middleware.nestjs'; - +): { name: string; attributes: Record } { return { - name: span_name, + name: name ?? target.name ?? 'unknown', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: middlewareOrigin(componentType), }, }; } @@ -57,7 +90,7 @@ export function getEventSpanOptions(event: string): { name: `event ${event}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: eventOrigin(), }, forceTransaction: true, }; @@ -75,7 +108,7 @@ export function getBullMQProcessSpanOptions(queueName: string): { name: `${queueName} process`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.nestjs.bullmq', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: bullmqOrigin(), 'messaging.system': 'bullmq', 'messaging.destination.name': queueName, }, diff --git a/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts index b18bab1dc07c..bf40bd70db8c 100644 --- a/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts @@ -5,9 +5,9 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core'; -import { getBullMQProcessSpanOptions } from './helpers'; +import { SDK_VERSION } from '@sentry/core'; import type { ProcessorDecoratorTarget } from './types'; +import { extractQueueName, patchProcessorTarget } from './wrap-handlers'; const supportedVersions = ['>=10.0.0']; const COMPONENT = '@nestjs/bullmq'; @@ -15,9 +15,8 @@ const COMPONENT = '@nestjs/bullmq'; /** * Custom instrumentation for nestjs bullmq module. * - * This hooks into the `@Processor` class decorator, which is applied on queue processor classes. - * It wraps the `process` method on the decorated class to fork the isolation scope for each job - * invocation, create a span, and capture errors. + * This hooks into the `@Processor` class decorator, which is applied on queue + * processor classes. */ export class SentryNestBullMQInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -62,50 +61,13 @@ export class SentryNestBullMQInstrumentation extends InstrumentationBase { return function wrapProcessor(original: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrappedProcessor(...decoratorArgs: any[]) { - // Extract queue name from decorator args - // @Processor('queueName') or @Processor({ name: 'queueName' }) - const queueName = - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - typeof decoratorArgs[0] === 'string' ? decoratorArgs[0] : decoratorArgs[0]?.name || 'unknown'; + const queueName = extractQueueName(decoratorArgs[0]); - // Get the original class decorator // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const classDecorator = original(...decoratorArgs); - // Return a new class decorator that wraps the process method return function (target: ProcessorDecoratorTarget) { - const originalProcess = target.prototype.process; - - if ( - originalProcess && - typeof originalProcess === 'function' && - !target.__SENTRY_INTERNAL__ && - !originalProcess.__SENTRY_INSTRUMENTED__ - ) { - target.prototype.process = new Proxy(originalProcess, { - apply: (originalProcessFn, thisArg, args) => { - return withIsolationScope(() => { - return startSpan(getBullMQProcessSpanOptions(queueName), async () => { - try { - return await originalProcessFn.apply(thisArg, args); - } catch (error) { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.queue.nestjs.bullmq', - }, - }); - throw error; - } - }); - }); - }, - }); - - target.prototype.process.__SENTRY_INSTRUMENTED__ = true; - } - - // Apply the original class decorator + patchProcessorTarget(target, queueName); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return classDecorator(target); }; diff --git a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts index 28255e075741..7bb2967cc453 100644 --- a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts @@ -5,9 +5,10 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { isObjectLike, captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core'; -import { getEventSpanOptions } from './helpers'; +import { SDK_VERSION } from '@sentry/core'; +import type { AnyFn } from './helpers'; import type { OnEventTarget } from './types'; +import { patchMethodDescriptor, wrapEventHandler } from './wrap-handlers'; const supportedVersions = ['>=2.0.0']; const COMPONENT = '@nestjs/event-emitter'; @@ -16,9 +17,6 @@ const COMPONENT = '@nestjs/event-emitter'; * Custom instrumentation for nestjs event-emitter * * This hooks into the `OnEvent` decorator, which is applied on event handlers. - * Wrapped handlers run inside a forked isolation scope to ensure event-scoped data - * (breadcrumbs, tags, etc.) does not leak between concurrent event invocations - * or into subsequent HTTP requests. */ export class SentryNestEventInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -62,87 +60,10 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrapOnEvent(original: any) { return function wrappedOnEvent(event: unknown, options?: unknown) { - // Get the original decorator result const decoratorResult = original(event, options); - // Return a new decorator function that wraps the handler return (target: OnEventTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - if ( - !descriptor.value || - typeof descriptor.value !== 'function' || - target.__SENTRY_INTERNAL__ || - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ - ) { - return decoratorResult(target, propertyKey, descriptor); - } - - function eventNameFromEvent(event: unknown): string { - if (typeof event === 'string') { - return event; - } else if (Array.isArray(event)) { - return event.map(eventNameFromEvent).join(','); - } else return String(event); - } - - const originalHandler = descriptor.value; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const handlerName = originalHandler.name || propertyKey; - let eventName = eventNameFromEvent(event); - - // Instrument the actual handler - descriptor.value = async function (...args: unknown[]) { - // When multiple @OnEvent decorators are used on a single method, we need to get all event names - // from the reflector metadata as there is no information during execution which event triggered it - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect - if (Reflect.getMetadataKeys(descriptor.value).includes('EVENT_LISTENER_METADATA')) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect - const eventData = Reflect.getMetadata('EVENT_LISTENER_METADATA', descriptor.value); - if (Array.isArray(eventData)) { - eventName = eventData - .map((data: unknown) => { - if (isObjectLike(data) && 'event' in data && data.event) { - return eventNameFromEvent(data.event); - } - return ''; - }) - .reverse() // decorators are evaluated bottom to top - .join('|'); - } - } - - return withIsolationScope(() => { - return startSpan(getEventSpanOptions(eventName), async () => { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const result = await originalHandler.apply(this, args); - return result; - } catch (error) { - // exceptions from event handlers are not caught by global error filter - captureException(error, { - mechanism: { - handled: false, - type: 'auto.event.nestjs', - }, - }); - throw error; - } - }); - }); - }; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ = true; - - // Preserve the original function name - Object.defineProperty(descriptor.value, 'name', { - value: handlerName, - configurable: true, - }); - - // Apply the original decorator + patchMethodDescriptor(target, propertyKey, descriptor, (handler: AnyFn) => wrapEventHandler(handler, event)); return decoratorResult(target, propertyKey, descriptor); }; }; diff --git a/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts index e1d7fa978020..7f3a94f13a86 100644 --- a/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts @@ -5,18 +5,9 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import type { Span } from '@sentry/core'; -import { - getActiveSpan, - isThenable, - SDK_VERSION, - startInactiveSpan, - startSpan, - startSpanManual, - withActiveSpan, -} from '@sentry/core'; -import { getMiddlewareSpanOptions, getNextProxy, instrumentObservable, isPatched } from './helpers'; -import type { CallHandler, CatchTarget, InjectableTarget, MinimalNestJsExecutionContext, Observable } from './types'; +import { SDK_VERSION } from '@sentry/core'; +import { patchCatchTarget, patchInjectableTarget } from './wrap-components'; +import type { CatchTarget, InjectableTarget } from './types'; const supportedVersions = ['>=8.0.0 <12']; const COMPONENT = '@nestjs/common'; @@ -25,7 +16,8 @@ const COMPONENT = '@nestjs/common'; * Custom instrumentation for nestjs. * * This hooks into - * 1. @Injectable decorator, which is applied on class middleware, interceptors and guards. + * 1. @Injectable decorator, which is applied on class middleware, + * interceptors and guards. * 2. @Catch decorator, which is applied on exception filters. */ export class SentryNestInstrumentation extends InstrumentationBase { @@ -87,191 +79,18 @@ export class SentryNestInstrumentation extends InstrumentationBase { } /** - * Creates a wrapper function for the @Injectable decorator. + * Creates a wrapper function for the @Injectable decorator. It patches the + * decorated class (via `patchInjectableTarget`) and delegates to the + * original decorator. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private _createWrapInjectable(): (original: any) => (options?: unknown) => (target: InjectableTarget) => any { - const SeenNestjsContextSet = new WeakSet(); + const seenContexts = new WeakSet(); // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrapInjectable(original: any) { return function wrappedInjectable(options?: unknown) { return function (target: InjectableTarget) { - // patch middleware - if (typeof target.prototype.use === 'function' && !target.__SENTRY_INTERNAL__) { - // patch only once - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.use = new Proxy(target.prototype.use, { - apply: (originalUse, thisArgUse, argsUse) => { - const [req, res, next, ...args] = argsUse; - - // Check that we can reasonably assume that the target is a middleware. - // Without these guards, instrumentation will fail if a function named 'use' on a service, which is - // decorated with @Injectable, is called. - if (!req || !res || !next || typeof next !== 'function') { - return originalUse.apply(thisArgUse, argsUse); - } - - const prevSpan = getActiveSpan(); - - return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => { - // proxy next to end span on call - const nextProxy = getNextProxy(next, span, prevSpan); - return originalUse.apply(thisArgUse, [req, res, nextProxy, args]); - }); - }, - }); - } - - // patch guards - if (typeof target.prototype.canActivate === 'function' && !target.__SENTRY_INTERNAL__) { - // patch only once - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.canActivate = new Proxy(target.prototype.canActivate, { - apply: (originalCanActivate, thisArgCanActivate, argsCanActivate) => { - const context = argsCanActivate[0]; - - if (!context) { - return originalCanActivate.apply(thisArgCanActivate, argsCanActivate); - } - - return startSpan(getMiddlewareSpanOptions(target, undefined, 'guard'), () => { - return originalCanActivate.apply(thisArgCanActivate, argsCanActivate); - }); - }, - }); - } - - // patch pipes - if (typeof target.prototype.transform === 'function' && !target.__SENTRY_INTERNAL__) { - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.transform = new Proxy(target.prototype.transform, { - apply: (originalTransform, thisArgTransform, argsTransform) => { - const value = argsTransform[0]; - const metadata = argsTransform[1]; - - if (!value || !metadata) { - return originalTransform.apply(thisArgTransform, argsTransform); - } - - return startSpan(getMiddlewareSpanOptions(target, undefined, 'pipe'), () => { - return originalTransform.apply(thisArgTransform, argsTransform); - }); - }, - }); - } - - // patch interceptors - if (typeof target.prototype.intercept === 'function' && !target.__SENTRY_INTERNAL__) { - if (isPatched(target)) { - return original(options)(target); - } - - target.prototype.intercept = new Proxy(target.prototype.intercept, { - apply: (originalIntercept, thisArgIntercept, argsIntercept) => { - const context = argsIntercept[0] as MinimalNestJsExecutionContext | undefined; - const next = argsIntercept[1] as CallHandler | undefined; - - const parentSpan = getActiveSpan(); - let afterSpan: Span | undefined; - - if ( - !context || - !next || - typeof next.handle !== 'function' || // Check that we can reasonably assume that the target is an interceptor. - target.name === 'SentryTracingInterceptor' // We don't want to trace this internal interceptor - ) { - return originalIntercept.apply(thisArgIntercept, argsIntercept); - } - - return startSpanManual( - getMiddlewareSpanOptions(target, undefined, 'interceptor'), - (beforeSpan: Span) => { - // eslint-disable-next-line @typescript-eslint/unbound-method - next.handle = new Proxy(next.handle, { - apply: (originalHandle, thisArgHandle, argsHandle) => { - beforeSpan.end(); - - if (parentSpan) { - return withActiveSpan(parentSpan, () => { - const handleReturnObservable = Reflect.apply(originalHandle, thisArgHandle, argsHandle); - - if (!SeenNestjsContextSet.has(context)) { - SeenNestjsContextSet.add(context); - afterSpan = startInactiveSpan( - getMiddlewareSpanOptions(target, 'Interceptors - After Route', 'interceptor'), - ); - } - - return handleReturnObservable; - }); - } else { - const handleReturnObservable = Reflect.apply(originalHandle, thisArgHandle, argsHandle); - - if (!SeenNestjsContextSet.has(context)) { - SeenNestjsContextSet.add(context); - afterSpan = startInactiveSpan( - getMiddlewareSpanOptions(target, 'Interceptors - After Route', 'interceptor'), - ); - } - - return handleReturnObservable; - } - }, - }); - - let returnedObservableInterceptMaybePromise: Observable | Promise>; - - try { - returnedObservableInterceptMaybePromise = originalIntercept.apply( - thisArgIntercept, - argsIntercept, - ); - } catch (e) { - beforeSpan.end(); - afterSpan?.end(); - throw e; - } - - if (!afterSpan) { - return returnedObservableInterceptMaybePromise; - } - - // handle async interceptor - if (isThenable(returnedObservableInterceptMaybePromise)) { - return returnedObservableInterceptMaybePromise.then( - observable => { - instrumentObservable(observable, afterSpan ?? parentSpan); - return observable; - }, - e => { - beforeSpan.end(); - afterSpan?.end(); - throw e; - }, - ); - } - - // handle sync interceptor - if (typeof returnedObservableInterceptMaybePromise.subscribe === 'function') { - instrumentObservable(returnedObservableInterceptMaybePromise, afterSpan); - } - - return returnedObservableInterceptMaybePromise; - }, - ); - }, - }); - } - + patchInjectableTarget(target, seenContexts); return original(options)(target); }; }; @@ -286,28 +105,7 @@ export class SentryNestInstrumentation extends InstrumentationBase { return function wrapCatch(original: any) { return function wrappedCatch(...exceptions: unknown[]) { return function (target: CatchTarget) { - if (typeof target.prototype.catch === 'function' && !target.__SENTRY_INTERNAL__) { - // patch only once - if (isPatched(target)) { - return original(...exceptions)(target); - } - - target.prototype.catch = new Proxy(target.prototype.catch, { - apply: (originalCatch, thisArgCatch, argsCatch) => { - const exception = argsCatch[0]; - const host = argsCatch[1]; - - if (!exception || !host) { - return originalCatch.apply(thisArgCatch, argsCatch); - } - - return startSpan(getMiddlewareSpanOptions(target, undefined, 'exception_filter'), () => { - return originalCatch.apply(thisArgCatch, argsCatch); - }); - }, - }); - } - + patchCatchTarget(target); return original(...exceptions)(target); }; }; diff --git a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts index ea0261164f9c..c846659175bc 100644 --- a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts @@ -5,8 +5,16 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { captureException, isThenable, SDK_VERSION, withIsolationScope } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; +import type { AnyFn } from './helpers'; import type { ScheduleDecoratorTarget } from './types'; +import { + MECHANISM_CRON, + MECHANISM_INTERVAL, + MECHANISM_TIMEOUT, + patchMethodDescriptor, + wrapScheduleHandler, +} from './wrap-handlers'; const supportedVersions = ['>=2.0.0']; const COMPONENT = '@nestjs/schedule'; @@ -14,8 +22,8 @@ const COMPONENT = '@nestjs/schedule'; /** * Custom instrumentation for nestjs schedule module. * - * This hooks into the `@Cron`, `@Interval`, and `@Timeout` decorators, which are applied on scheduled task handlers. - * It forks the isolation scope for each handler invocation, preventing data leakage to subsequent HTTP requests. + * This hooks into the `@Cron`, `@Interval`, and `@Timeout` decorators, which + * are applied on scheduled task handlers. */ export class SentryNestScheduleInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -28,146 +36,55 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { public init(): InstrumentationNodeModuleDefinition { const moduleDef = new InstrumentationNodeModuleDefinition(COMPONENT, supportedVersions); - moduleDef.files.push(this._getCronFileInstrumentation(supportedVersions)); - moduleDef.files.push(this._getIntervalFileInstrumentation(supportedVersions)); - moduleDef.files.push(this._getTimeoutFileInstrumentation(supportedVersions)); - return moduleDef; - } - - /** - * Wraps the @Cron decorator. - */ - private _getCronFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { - return new InstrumentationNodeModuleFile( - '@nestjs/schedule/dist/decorators/cron.decorator.js', - versions, - (moduleExports: { Cron: ScheduleDecoratorTarget }) => { - if (isWrapped(moduleExports.Cron)) { - this._unwrap(moduleExports, 'Cron'); - } - this._wrap(moduleExports, 'Cron', this._createWrapDecorator('auto.function.nestjs.cron')); - return moduleExports; - }, - (moduleExports: { Cron: ScheduleDecoratorTarget }) => { - this._unwrap(moduleExports, 'Cron'); - }, + moduleDef.files.push(this._getDecoratorFileInstrumentation('Cron', 'cron', MECHANISM_CRON, supportedVersions)); + moduleDef.files.push( + this._getDecoratorFileInstrumentation('Interval', 'interval', MECHANISM_INTERVAL, supportedVersions), ); - } - - /** - * Wraps the @Interval decorator. - */ - private _getIntervalFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { - return new InstrumentationNodeModuleFile( - '@nestjs/schedule/dist/decorators/interval.decorator.js', - versions, - (moduleExports: { Interval: ScheduleDecoratorTarget }) => { - if (isWrapped(moduleExports.Interval)) { - this._unwrap(moduleExports, 'Interval'); - } - this._wrap(moduleExports, 'Interval', this._createWrapDecorator('auto.function.nestjs.interval')); - return moduleExports; - }, - (moduleExports: { Interval: ScheduleDecoratorTarget }) => { - this._unwrap(moduleExports, 'Interval'); - }, + moduleDef.files.push( + this._getDecoratorFileInstrumentation('Timeout', 'timeout', MECHANISM_TIMEOUT, supportedVersions), ); + return moduleDef; } /** - * Wraps the @Timeout decorator. + * Wraps a schedule decorator (`@Cron`/`@Interval`/`@Timeout`). */ - private _getTimeoutFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { + private _getDecoratorFileInstrumentation( + exportName: 'Cron' | 'Interval' | 'Timeout', + fileName: string, + mechanismType: string, + versions: string[], + ): InstrumentationNodeModuleFile { return new InstrumentationNodeModuleFile( - '@nestjs/schedule/dist/decorators/timeout.decorator.js', + `@nestjs/schedule/dist/decorators/${fileName}.decorator.js`, versions, - (moduleExports: { Timeout: ScheduleDecoratorTarget }) => { - if (isWrapped(moduleExports.Timeout)) { - this._unwrap(moduleExports, 'Timeout'); + (moduleExports: Record) => { + if (isWrapped(moduleExports[exportName])) { + this._unwrap(moduleExports, exportName); } - this._wrap(moduleExports, 'Timeout', this._createWrapDecorator('auto.function.nestjs.timeout')); + this._wrap(moduleExports, exportName, this._createWrapDecorator(mechanismType)); return moduleExports; }, - (moduleExports: { Timeout: ScheduleDecoratorTarget }) => { - this._unwrap(moduleExports, 'Timeout'); + (moduleExports: Record) => { + this._unwrap(moduleExports, exportName); }, ); } /** - * Creates a wrapper function for a schedule decorator (@Cron, @Interval, or @Timeout). + * Creates a wrapper function for a schedule decorator. */ private _createWrapDecorator(mechanismType: string) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrapDecorator(original: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrappedDecorator(...decoratorArgs: any[]) { - // Get the original decorator result const decoratorResult = original(...decoratorArgs); - // Return a new decorator function that wraps the handler return (target: ScheduleDecoratorTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - if ( - !descriptor.value || - typeof descriptor.value !== 'function' || - target.__SENTRY_INTERNAL__ || - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ - ) { - return decoratorResult(target, propertyKey, descriptor); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const originalHandler: (...handlerArgs: unknown[]) => unknown = descriptor.value; - const handlerName = originalHandler.name || propertyKey; - - // Not using async/await here to avoid changing the return type of sync handlers. - // This means we need to handle sync and async errors separately. - descriptor.value = function (...args: unknown[]) { - return withIsolationScope(() => { - let result; - try { - // Catches errors from sync handlers - result = originalHandler.apply(this, args); - } catch (error) { - captureException(error, { - mechanism: { - handled: false, - type: mechanismType, - }, - }); - throw error; - } - - // Catches errors from async handlers (rejected promises bypass try/catch) - if (isThenable(result)) { - return result.then(undefined, (error: unknown) => { - captureException(error, { - mechanism: { - handled: false, - type: mechanismType, - }, - }); - throw error; - }); - } - - return result; - }); - }; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - descriptor.value.__SENTRY_INSTRUMENTED__ = true; - - // Preserve the original function name - Object.defineProperty(descriptor.value, 'name', { - value: handlerName, - configurable: true, - enumerable: true, - writable: true, - }); - - // Apply the original decorator + patchMethodDescriptor(target, propertyKey, descriptor, (handler: AnyFn) => + wrapScheduleHandler(handler, mechanismType), + ); return decoratorResult(target, propertyKey, descriptor); }; }; diff --git a/packages/nestjs/src/integrations/types.ts b/packages/nestjs/src/integrations/types.ts index 6dd00caa8cc1..626cdbafc1f6 100644 --- a/packages/nestjs/src/integrations/types.ts +++ b/packages/nestjs/src/integrations/types.ts @@ -63,8 +63,8 @@ export interface CallHandler { * Represents an injectable target class in NestJS. */ export interface InjectableTarget { - name: string; - sentryPatched?: boolean; + name?: string; + sentryPatchedInjectable?: boolean; __SENTRY_INTERNAL__?: boolean; prototype: { use?: (req: unknown, res: unknown, next: () => void, ...args: any[]) => void; @@ -78,8 +78,8 @@ export interface InjectableTarget { * Represents a target class in NestJS annotated with @Catch. */ export interface CatchTarget { - name: string; - sentryPatched?: boolean; + name?: string; + sentryPatchedCatch?: boolean; __SENTRY_INTERNAL__?: boolean; prototype: { catch?: (...args: any[]) => any; @@ -109,7 +109,7 @@ export interface ProcessorDecoratorTarget { name: string; __SENTRY_INTERNAL__?: boolean; prototype: { - process?: ((...args: any[]) => Promise) & { __SENTRY_INSTRUMENTED__?: boolean }; + process?: (...args: any[]) => Promise; }; } diff --git a/packages/nestjs/src/integrations/vendored/instrumentation.ts b/packages/nestjs/src/integrations/vendored/instrumentation.ts index 670d7b32f813..b40aa98f768c 100644 --- a/packages/nestjs/src/integrations/vendored/instrumentation.ts +++ b/packages/nestjs/src/integrations/vendored/instrumentation.ts @@ -6,6 +6,10 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-nestjs-core * - Upstream version: @opentelemetry/instrumentation-nestjs-core@0.64.0 * - Some types vendored from @nestjs/core and @nestjs/common with simplifications + * - The span-emitting logic (app-creation / request-context / request-handler + * spans) has been extracted to `../wrap-route` and is shared with the + * orchestrion (diagnostics-channel) path; this file only wraps the + * `NestFactory.create` / `RouterExecutionContext.create` methods to feed into it. */ import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { @@ -14,15 +18,12 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { HTTP_ROUTE } from '@sentry/conventions/attributes'; -import type { SpanAttributes } from '@sentry/core'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; -import { AttributeNames, NestType } from './enums'; +import { SDK_VERSION, startSpan } from '@sentry/core'; +import type { AnyFn } from '../helpers'; +import { getAppCreationSpanOptions, wrapRequestContextHandler, wrapRouteHandler } from '../wrap-route'; const PACKAGE_NAME = '@sentry/instrumentation-nestjs-core'; -type AnyFn = (this: unknown, ...args: unknown[]) => unknown; - type Controller = object; declare const NestFactory: { @@ -33,28 +34,10 @@ interface RouterExecutionContext { create(instance: Controller, callback: (...args: unknown[]) => unknown, ...args: unknown[]): unknown; } -interface NestRequest { - route?: { path?: string }; - routeOptions?: { url?: string }; - routerPath?: string; - method?: string; - originalUrl?: string; - url?: string; -} - -declare namespace Reflect { - function getMetadataKeys(target: unknown): unknown[]; - function getMetadata(metadataKey: unknown, target: unknown): unknown; - function defineMetadata(metadataKey: unknown, metadataValue: unknown, target: unknown): void; -} - const supportedVersions = ['>=4.0.0 <12']; export class NestInstrumentation extends InstrumentationBase { static readonly COMPONENT = '@nestjs/core'; - static readonly COMMON_ATTRIBUTES = { - component: NestInstrumentation.COMPONENT, - }; constructor(config: InstrumentationConfig = {}) { super(PACKAGE_NAME, SDK_VERSION, config); @@ -123,20 +106,7 @@ function createWrapNestFactoryCreate(moduleVersion?: string) { return function wrapCreate(original: typeof NestFactory.create): typeof NestFactory.create { return function createWithTrace(this: typeof NestFactory, ...args: unknown[]) { const nestModule = args[0] as { name?: string }; - return startSpan( - { - name: 'Create Nest App', - op: `${NestType.APP_CREATION}.nestjs`, - attributes: { - ...NestInstrumentation.COMMON_ATTRIBUTES, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs', - [AttributeNames.TYPE]: NestType.APP_CREATION, - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.MODULE]: nestModule.name, - }, - }, - () => original.apply(this, args), - ); + return startSpan(getAppCreationSpanOptions(moduleVersion, nestModule?.name), () => original.apply(this, args)); }; }; } @@ -146,56 +116,11 @@ function createWrapCreateHandler(moduleVersion: string | undefined) { return function createHandlerWithTrace(this: RouterExecutionContext, ...args: unknown[]) { const instance = args[0] as { constructor?: { name?: string } }; const callback = args[1] as AnyFn; - args[1] = createWrapHandler(moduleVersion, callback); + const instanceName = instance?.constructor?.name || 'UnnamedInstance'; + const callbackName = typeof callback === 'function' ? callback.name : ''; + args[1] = wrapRouteHandler(callback, moduleVersion); const handler = original.apply(this, args) as AnyFn; - const callbackName = callback.name; - const instanceName = instance.constructor?.name || 'UnnamedInstance'; - const spanName = callbackName ? `${instanceName}.${callbackName}` : instanceName; - - return function (this: unknown, ...handlerArgs: unknown[]) { - const req = handlerArgs[0] as NestRequest; - const attributes: SpanAttributes = { - ...NestInstrumentation.COMMON_ATTRIBUTES, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs', - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.TYPE]: NestType.REQUEST_CONTEXT, - [HTTP_ROUTE]: req.route?.path || req.routeOptions?.url || req.routerPath, - [AttributeNames.CONTROLLER]: instanceName, - [AttributeNames.CALLBACK]: callbackName, - }; - attributes['http.method'] = req.method; - attributes['http.url'] = req.originalUrl || req.url; - return startSpan({ name: spanName, op: `${NestType.REQUEST_CONTEXT}.nestjs`, attributes }, () => - handler.apply(this, handlerArgs), - ); - }; + return wrapRequestContextHandler(handler, instanceName, callbackName, moduleVersion); }; }; } - -function createWrapHandler(moduleVersion: string | undefined, handler: AnyFn): AnyFn { - const spanName = handler.name || 'anonymous nest handler'; - const attributes: SpanAttributes = { - ...NestInstrumentation.COMMON_ATTRIBUTES, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs', - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.TYPE]: NestType.REQUEST_HANDLER, - [AttributeNames.CALLBACK]: handler.name, - }; - const wrappedHandler = function (this: unknown, ...args: unknown[]) { - return startSpan({ name: spanName, op: `${NestType.REQUEST_HANDLER}.nestjs`, attributes }, () => - handler.apply(this, args), - ); - }; - - if (handler.name) { - Object.defineProperty(wrappedHandler, 'name', { value: handler.name }); - } - - // Get the current metadata and set onto the wrapper to ensure other decorators ( ie: NestJS EventPattern / RolesGuard ) - // won't be affected by the use of this instrumentation - Reflect.getMetadataKeys(handler).forEach(metadataKey => { - Reflect.defineMetadata(metadataKey, Reflect.getMetadata(metadataKey, handler), wrappedHandler); - }); - return wrappedHandler; -} diff --git a/packages/nestjs/src/integrations/wrap-components.ts b/packages/nestjs/src/integrations/wrap-components.ts new file mode 100644 index 000000000000..5210012632dd --- /dev/null +++ b/packages/nestjs/src/integrations/wrap-components.ts @@ -0,0 +1,198 @@ +import type { Span } from '@sentry/core'; +import { getActiveSpan, isThenable, startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from '@sentry/core'; +import type { AnyFn } from './helpers'; +import { getMiddlewareSpanOptions, getNextProxy, instrumentObservable, isTargetPatched } from './helpers'; +import type { CallHandler, CatchTarget, InjectableTarget, MinimalNestJsExecutionContext, Observable } from './types'; + +/** + * Shared span-emitting logic for `@Injectable` (middleware/guard/pipe/interceptor) + * and `@Catch` (exception filter) classes. Used by both the OTel decorator wraps + * (`SentryNestInstrumentation`) and the orchestrion channel subscriber; only the + * span origin differs (see the origin helpers in `./helpers`). + */ + +function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContexts: WeakSet): AnyFn { + return new Proxy(intercept, { + apply: (originalIntercept, thisArg, argsIntercept) => { + const context = argsIntercept[0] as MinimalNestJsExecutionContext | undefined; + const next = argsIntercept[1] as CallHandler | undefined; + const parentSpan = getActiveSpan(); + let afterSpan: Span | undefined; + + if ( + !context || + !next || + typeof next.handle !== 'function' || + 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. + // eslint-disable-next-line @typescript-eslint/unbound-method + 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; + } + + // async interceptor: returns a Promise + if (isThenable(returned)) { + return returned.then( + (observable: unknown) => { + if (afterSpan) { + instrumentObservable(observable as Observable, afterSpan); + } else { + // `next.handle()` was never called, so nothing ended the + // before-span (its `handle` proxy never ran); close it here. + beforeSpan.end(); + } + return observable; + }, + (e: unknown) => { + beforeSpan.end(); + afterSpan?.end(); + throw e; + }, + ); + } + + // Sync interceptor: `next.handle()` (if it was going to be called) has + // already run synchronously, so `afterSpan` is settled. + if (!afterSpan) { + // `next.handle()` was never called (e.g. the interceptor + // short-circuited for a cache/validation hit), so its `handle` proxy + // never ended the before-span; close it here. + beforeSpan.end(); + return returned; + } + + // sync interceptor: returns an Observable + if (typeof (returned as Observable).subscribe === 'function') { + instrumentObservable(returned as Observable, afterSpan); + } + + return returned; + }); + }, + }); +} + +/** + * Patch an `@Injectable`-decorated class's prototype methods so each runtime + * invocation opens the corresponding middleware/guard/pipe/interceptor span. + * The runtime guards (req/res/next, context, value+metadata) avoid false + * positives on non-middleware classes that happen to expose a same-named method. + */ +export function patchInjectableTarget(target: InjectableTarget, seenContexts: WeakSet): void { + const proto = target?.prototype; + if (!proto || target.__SENTRY_INTERNAL__ || isTargetPatched(target, 'sentryPatchedInjectable')) { + 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 as AnyFn).apply(thisArgUse, [req, res, nextProxy, ...rest]); + }); + }, + }) as InjectableTarget['prototype']['use']; + } + + // 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), + ); + }, + }) as InjectableTarget['prototype']['canActivate']; + } + + // 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), + ); + }, + }) as InjectableTarget['prototype']['transform']; + } + + // interceptors + if (typeof proto.intercept === 'function') { + proto.intercept = patchInterceptor( + target, + proto.intercept as AnyFn, + seenContexts, + ) as InjectableTarget['prototype']['intercept']; + } +} + +/** + * Patch an exception filter's prototype `catch` so each invocation opens an + * `exception_filter` span. The runtime guard (exception + host present) avoids + * false positives. + */ +export function patchCatchTarget(target: CatchTarget): void { + const proto = target?.prototype; + if ( + !proto || + typeof proto.catch !== 'function' || + target.__SENTRY_INTERNAL__ || + isTargetPatched(target, 'sentryPatchedCatch') + ) { + 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), + ); + }, + }) as CatchTarget['prototype']['catch']; +} diff --git a/packages/nestjs/src/integrations/wrap-handlers.ts b/packages/nestjs/src/integrations/wrap-handlers.ts new file mode 100644 index 000000000000..da3a5dd15a25 --- /dev/null +++ b/packages/nestjs/src/integrations/wrap-handlers.ts @@ -0,0 +1,187 @@ +import { captureException, isObjectLike, isThenable, startSpan, withIsolationScope } from '@sentry/core'; +import type { AnyFn } from './helpers'; +import { getBullMQProcessSpanOptions, getEventSpanOptions, isWrapped, markWrapped } from './helpers'; + +/** + * Span-emitting / error-capturing logic for the `@Cron`/`@Interval`/ + * `@Timeout` (schedule), `@OnEvent` (event), and `@Processor` (bullmq) + * handlers. + * + * @module + */ + +// Error-capture mechanism types. These do NOT carry an `orchestrion` segment. +// They're identical across both paths so captured errors attribute and group +// the same regardless of which instrumentation caught them. +export const MECHANISM_CRON = 'auto.function.nestjs.cron'; +export const MECHANISM_INTERVAL = 'auto.function.nestjs.interval'; +export const MECHANISM_TIMEOUT = 'auto.function.nestjs.timeout'; +export const MECHANISM_EVENT = 'auto.event.nestjs'; +export const MECHANISM_BULLMQ = 'auto.queue.nestjs.bullmq'; + +const EVENT_LISTENER_METADATA = 'EVENT_LISTENER_METADATA'; + +interface ReflectWithMetadata { + getMetadataKeys?: (target: object) => unknown[]; + getMetadata?: (key: unknown, target: object) => unknown; +} + +interface ProcessorTarget { + __SENTRY_INTERNAL__?: boolean; + prototype?: { process?: AnyFn }; +} + +function captureHandlerError(error: unknown, mechanismType: string): void { + captureException(error, { mechanism: { handled: false, type: mechanismType } }); +} + +/** + * Wrap a scheduled handler (`@Cron`/`@Interval`/`@Timeout`): fork the isolation + * scope and capture errors. NOT async. Preserve the handler's sync return type, + * so sync and async errors are handled on separate paths. + */ +export function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + return withIsolationScope(() => { + let result: unknown; + try { + result = handler.apply(this, args); + } catch (error) { + captureHandlerError(error, mechanismType); + throw error; + } + if (isThenable(result)) { + return result.then(undefined, (error: unknown) => { + captureHandlerError(error, mechanismType); + throw error; + }); + } + return result; + }); + }; +} + +function eventNameFromEvent(event: unknown): string { + if (typeof event === 'string') { + return event; + } + if (Array.isArray(event)) { + return event.map(eventNameFromEvent).join(','); + } + return String(event); +} + +/** + * Derive the event name(s) for an @OnEvent span. The wrapped handler carries + * `EVENT_LISTENER_METADATA` (set by the original decorator), which lists every + * event when multiple @OnEvent decorators are stacked; fall back to the event + * captured from the decorator factory. + */ +function deriveEventName(handler: AnyFn, fallbackEvent: unknown): string { + const R = Reflect as unknown as ReflectWithMetadata; + if (typeof R.getMetadataKeys === 'function' && typeof R.getMetadata === 'function') { + if (R.getMetadataKeys(handler)?.includes(EVENT_LISTENER_METADATA)) { + const eventData = R.getMetadata(EVENT_LISTENER_METADATA, handler); + if (Array.isArray(eventData)) { + return (eventData as unknown[]) + .map(entry => { + const event = isObjectLike(entry) ? (entry as { event?: unknown }).event : undefined; + return event ? eventNameFromEvent(event) : ''; + }) + .reverse() // decorators evaluate bottom to top + .join('|'); + } + } + } + return eventNameFromEvent(fallbackEvent); +} + +/** + * Wrap an @OnEvent handler: fork the isolation scope, open an `event.nestjs` + * transaction, and capture errors (event-handler errors bypass the global filter). + */ +export function wrapEventHandler(handler: AnyFn, fallbackEvent: unknown): AnyFn { + const wrapped = async function (this: unknown, ...args: unknown[]): Promise { + const eventName = deriveEventName(wrapped, fallbackEvent); + return withIsolationScope(() => + startSpan(getEventSpanOptions(eventName), async () => { + try { + return await handler.apply(this, args); + } catch (error) { + captureHandlerError(error, MECHANISM_EVENT); + throw error; + } + }), + ); + }; + return wrapped; +} + +/** + * Wrap a BullMQ `process` method: fork the isolation scope, open a + * `queue.process` transaction, and capture errors. + */ +export function wrapBullMQProcess(process: AnyFn, queueName: string): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + return withIsolationScope(() => + startSpan(getBullMQProcessSpanOptions(queueName), async () => { + try { + return await process.apply(this, args); + } catch (error) { + captureHandlerError(error, MECHANISM_BULLMQ); + throw error; + } + }), + ); + }; +} + +/** + * Replace a method decorator's `descriptor.value` with a wrapped handler (via + * `wrapHandler`), preserving the handler name and marking it wrapped. Shared + * by the OTel schedule/event decorator wraps and the orchestrion factory + * subscriber. + */ +export function patchMethodDescriptor( + target: { __SENTRY_INTERNAL__?: boolean } | undefined, + propertyKey: string | symbol | undefined, + descriptor: PropertyDescriptor | undefined, + wrapHandler: (handler: AnyFn) => AnyFn, +): void { + const handler = descriptor?.value as AnyFn | undefined; + if (descriptor && handler && typeof handler === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(handler)) { + const wrapped = wrapHandler(handler); + Object.defineProperty(wrapped, 'name', { + value: handler.name || String(propertyKey), + configurable: true, + }); + markWrapped(wrapped); + descriptor.value = wrapped; + } +} + +/** + * Extract the queue name from `@Processor('name')` or `@Processor({ name })`. + */ +export function extractQueueName(arg: unknown): string { + if (typeof arg === 'string') { + return arg; + } + if (arg && typeof arg === 'object' && 'name' in arg && typeof (arg as { name?: unknown }).name === 'string') { + return (arg as { name: string }).name; + } + return 'unknown'; +} + +/** + * Patch a `@Processor`-decorated class's `prototype.process` with a wrapped + * version. + */ +export function patchProcessorTarget(target: ProcessorTarget | undefined, queueName: string): void { + const process = target?.prototype?.process; + if (process && typeof process === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(process)) { + const wrapped = wrapBullMQProcess(process, queueName); + markWrapped(wrapped); + target.prototype!.process = wrapped; + } +} diff --git a/packages/nestjs/src/integrations/wrap-route.ts b/packages/nestjs/src/integrations/wrap-route.ts new file mode 100644 index 000000000000..0ba621708f2a --- /dev/null +++ b/packages/nestjs/src/integrations/wrap-route.ts @@ -0,0 +1,133 @@ +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; +import type { SpanAttributes } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import type { AnyFn } from './helpers'; +import { httpOrigin, isWrapped, markWrapped } from './helpers'; +import { AttributeNames, NestType } from './vendored/enums'; + +/** + * Shared span-emitting logic for the NestJS route/controller spans + * (app-creation / request-context / request-handler). Used by both the OTel + * `NestFactory.create` / `RouterExecutionContext.create` wraps (`./vendored`) and + * the orchestrion channel subscriber; only the span origin differs (see the + * origin helpers in `./helpers`). + */ + +const NESTJS_COMPONENT = '@nestjs/core'; + +/** Minimal request shape, across the express/fastify adapters. */ +interface NestRequest { + route?: { path?: string }; + routeOptions?: { url?: string }; + routerPath?: string; + method?: string; + originalUrl?: string; + url?: string; +} + +interface ReflectWithMetadata { + getMetadataKeys?: (target: object) => unknown[]; + getMetadata?: (key: unknown, target: object) => unknown; + defineMetadata?: (key: unknown, value: unknown, target: object) => void; +} + +/** + * Copy NestJS reflect-metadata from the original handler onto the wrapper so + * other decorators (param decorators, guards, `@EventPattern`, ...) that read + * it keep working. No-op when `reflect-metadata` isn't loaded. + */ +export function copyReflectMetadata(from: object, to: object): void { + const R = Reflect as unknown as ReflectWithMetadata; + if ( + typeof R.getMetadataKeys !== 'function' || + typeof R.getMetadata !== 'function' || + typeof R.defineMetadata !== 'function' + ) { + return; + } + for (const key of R.getMetadataKeys(from)) { + R.defineMetadata(key, R.getMetadata(key, from), to); + } +} + +/** Span options for the `Create Nest App` (app_creation) span. */ +export function getAppCreationSpanOptions( + moduleVersion?: string, + moduleName?: string, +): { name: string; op: string; attributes: SpanAttributes } { + return { + name: 'Create Nest App', + op: `${NestType.APP_CREATION}.nestjs`, + attributes: { + component: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: httpOrigin(), + [AttributeNames.TYPE]: NestType.APP_CREATION, + ...(moduleVersion ? { [AttributeNames.VERSION]: moduleVersion } : {}), + ...(moduleName ? { [AttributeNames.MODULE]: moduleName } : {}), + }, + }; +} + +/** + * Wrap the route-handler callback so each invocation opens the `handler.nestjs` + * span (REQUEST_HANDLER). Preserve the original `.name` and reflect-metadata so + * NestJS reflection is unaffected. + */ +export function wrapRouteHandler(callback: AnyFn, moduleVersion?: string): AnyFn { + if (isWrapped(callback)) { + return callback; + } + const spanName = callback.name || 'anonymous nest handler'; + const attributes: SpanAttributes = { + component: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: httpOrigin(), + [AttributeNames.TYPE]: NestType.REQUEST_HANDLER, + [AttributeNames.CALLBACK]: callback.name, + ...(moduleVersion ? { [AttributeNames.VERSION]: moduleVersion } : {}), + }; + const wrapped = function (this: unknown, ...args: unknown[]): unknown { + return startSpan({ name: spanName, op: `${NestType.REQUEST_HANDLER}.nestjs`, attributes }, () => + callback.apply(this, args), + ); + }; + if (callback.name) { + Object.defineProperty(wrapped, 'name', { value: callback.name }); + } + copyReflectMetadata(callback, wrapped); + markWrapped(wrapped); + return wrapped; +} + +/** + * Wrap the per-request handler that `RouterExecutionContext.create` returns so + * each request opens the `request_context.nestjs` span (REQUEST_CONTEXT), + * carrying controller/callback names plus the per-request http.* attributes. + */ +export function wrapRequestContextHandler( + handler: AnyFn, + instanceName: string, + callbackName: string, + moduleVersion?: string, +): AnyFn { + const spanName = callbackName ? `${instanceName}.${callbackName}` : instanceName; + const wrapped = function (this: unknown, ...handlerArgs: unknown[]): unknown { + const req = (handlerArgs[0] || {}) as NestRequest; + const httpRoute = req.route?.path || req.routeOptions?.url || req.routerPath; + const attributes: SpanAttributes = { + component: NESTJS_COMPONENT, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: httpOrigin(), + [AttributeNames.TYPE]: NestType.REQUEST_CONTEXT, + [AttributeNames.CONTROLLER]: instanceName, + [AttributeNames.CALLBACK]: callbackName, + ...(moduleVersion ? { [AttributeNames.VERSION]: moduleVersion } : {}), + ...(httpRoute ? { [HTTP_ROUTE]: httpRoute } : {}), + ...(req.method ? { ['http.method']: req.method } : {}), + ...(req.originalUrl || req.url ? { ['http.url']: req.originalUrl || req.url } : {}), + }; + return startSpan({ name: spanName, op: `${NestType.REQUEST_CONTEXT}.nestjs`, attributes }, () => + handler.apply(this, handlerArgs), + ); + }; + markWrapped(wrapped); + return wrapped; +} diff --git a/packages/nestjs/test/integrations/nest.test.ts b/packages/nestjs/test/integrations/nest.test.ts index 6b758d44c982..a369060ece3d 100644 --- a/packages/nestjs/test/integrations/nest.test.ts +++ b/packages/nestjs/test/integrations/nest.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { isPatched } from '../../src/integrations/helpers'; +import { isTargetPatched } from '../../src/integrations/helpers'; import type { InjectableTarget } from '../../src/integrations/types'; describe('Nest', () => { - describe('isPatched', () => { + describe('isTargetPatched', () => { it('should return true if target is already patched', () => { - const target = { name: 'TestTarget', sentryPatched: true, prototype: {} }; - expect(isPatched(target)).toBe(true); + const target = { name: 'TestTarget', sentryPatchedInjectable: true, prototype: {} }; + expect(isTargetPatched(target, 'sentryPatchedInjectable')).toBe(true); }); - it('should add the sentryPatched property and return false if target is not patched', () => { + it('should add the patch flag and return false if target is not patched', () => { const target: InjectableTarget = { name: 'TestTarget', prototype: {} }; - expect(isPatched(target)).toBe(false); - expect(target.sentryPatched).toBe(true); + expect(isTargetPatched(target, 'sentryPatchedInjectable')).toBe(false); + expect(target.sentryPatchedInjectable).toBe(true); }); }); });