diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts index 40ee93adaa90..c04fd5613e95 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Param, ParseIntPipe, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, ParseIntPipe, UseGuards, UseInterceptors } from '@nestjs/common'; import { AppService } from './app.service'; import { ExampleGuard } from './example.guard'; +import { ExampleInterceptor } from './example.interceptor'; @Controller() export class AppController { @@ -13,7 +14,7 @@ export class AppController { @Get('test-middleware-instrumentation') testMiddlewareInstrumentation() { - return this.appService.testMiddleware(); + return this.appService.testSpan(); } @Get('test-guard-instrumentation') @@ -22,6 +23,12 @@ export class AppController { return {}; } + @Get('test-interceptor-instrumentation') + @UseInterceptors(ExampleInterceptor) + testInterceptorInstrumentation() { + return this.appService.testSpan(); + } + @Get('test-pipe-instrumentation/:id') testPipeInstrumentation(@Param('id', ParseIntPipe) id: number) { return { value: id }; diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts index 1ae4c50d8901..b2dadbb0a269 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts @@ -21,7 +21,7 @@ export class AppService { }); } - testMiddleware() { + testSpan() { // span that should not be a child span of the middleware span Sentry.startSpan({ name: 'test-controller-span' }, () => {}); } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/example.interceptor.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/example.interceptor.ts new file mode 100644 index 000000000000..75c301b4cffc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/example.interceptor.ts @@ -0,0 +1,10 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class ExampleInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler) { + Sentry.startSpan({ name: 'test-interceptor-span' }, () => {}); + return next.handle().pipe(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts index 33e56cd5695e..78b3e0d3102a 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts @@ -338,3 +338,83 @@ test('API route transaction includes nest pipe span for invalid request', async }), ); }); + +test('API route transaction includes nest interceptor span. Spans created in and after interceptor are nested correctly', async ({ + baseURL, +}) => { + const pageloadTransactionEventPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-interceptor-instrumentation' + ); + }); + + const response = await fetch(`${baseURL}/test-interceptor-instrumentation`); + expect(response.status).toBe(200); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + spans: expect.arrayContaining([ + { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'middleware.nestjs', + 'sentry.origin': 'auto.middleware.nestjs', + }, + description: 'ExampleInterceptor', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'middleware.nestjs', + origin: 'auto.middleware.nestjs', + }, + ]), + }), + ); + + const exampleInterceptorSpan = transactionEvent.spans.find(span => span.description === 'ExampleInterceptor'); + const exampleInterceptorSpanId = exampleInterceptorSpan?.span_id; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + spans: expect.arrayContaining([ + { + span_id: expect.any(String), + trace_id: expect.any(String), + data: expect.any(Object), + description: 'test-controller-span', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + origin: 'manual', + }, + { + span_id: expect.any(String), + trace_id: expect.any(String), + data: expect.any(Object), + description: 'test-interceptor-span', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + origin: 'manual', + }, + ]), + }), + ); + + // verify correct span parent-child relationships + const testInterceptorSpan = transactionEvent.spans.find(span => span.description === 'test-interceptor-span'); + const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span'); + + // 'ExampleInterceptor' is the parent of 'test-interceptor-span' + expect(testInterceptorSpan.parent_span_id).toBe(exampleInterceptorSpanId); + + // 'ExampleInterceptor' is NOT the parent of 'test-controller-span' + expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptorSpanId); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.controller.ts index 40ee93adaa90..c04fd5613e95 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.controller.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Param, ParseIntPipe, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, ParseIntPipe, UseGuards, UseInterceptors } from '@nestjs/common'; import { AppService } from './app.service'; import { ExampleGuard } from './example.guard'; +import { ExampleInterceptor } from './example.interceptor'; @Controller() export class AppController { @@ -13,7 +14,7 @@ export class AppController { @Get('test-middleware-instrumentation') testMiddlewareInstrumentation() { - return this.appService.testMiddleware(); + return this.appService.testSpan(); } @Get('test-guard-instrumentation') @@ -22,6 +23,12 @@ export class AppController { return {}; } + @Get('test-interceptor-instrumentation') + @UseInterceptors(ExampleInterceptor) + testInterceptorInstrumentation() { + return this.appService.testSpan(); + } + @Get('test-pipe-instrumentation/:id') testPipeInstrumentation(@Param('id', ParseIntPipe) id: number) { return { value: id }; diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.service.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.service.ts index 1ae4c50d8901..b2dadbb0a269 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.service.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.service.ts @@ -21,7 +21,7 @@ export class AppService { }); } - testMiddleware() { + testSpan() { // span that should not be a child span of the middleware span Sentry.startSpan({ name: 'test-controller-span' }, () => {}); } diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/example.interceptor.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/example.interceptor.ts new file mode 100644 index 000000000000..75c301b4cffc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/example.interceptor.ts @@ -0,0 +1,10 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class ExampleInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler) { + Sentry.startSpan({ name: 'test-interceptor-span' }, () => {}); + return next.handle().pipe(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/transactions.test.ts index 754d545979e5..62c882eb7f4b 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/transactions.test.ts @@ -338,3 +338,83 @@ test('API route transaction includes nest pipe span for invalid request', async }), ); }); + +test('API route transaction includes nest interceptor span. Spans created in and after interceptor are nested correctly', async ({ + baseURL, +}) => { + const pageloadTransactionEventPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-interceptor-instrumentation' + ); + }); + + const response = await fetch(`${baseURL}/test-interceptor-instrumentation`); + expect(response.status).toBe(200); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + spans: expect.arrayContaining([ + { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'middleware.nestjs', + 'sentry.origin': 'auto.middleware.nestjs', + }, + description: 'ExampleInterceptor', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'middleware.nestjs', + origin: 'auto.middleware.nestjs', + }, + ]), + }), + ); + + const exampleInterceptorSpan = transactionEvent.spans.find(span => span.description === 'ExampleInterceptor'); + const exampleInterceptorSpanId = exampleInterceptorSpan?.span_id; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + spans: expect.arrayContaining([ + { + span_id: expect.any(String), + trace_id: expect.any(String), + data: expect.any(Object), + description: 'test-controller-span', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + origin: 'manual', + }, + { + span_id: expect.any(String), + trace_id: expect.any(String), + data: expect.any(Object), + description: 'test-interceptor-span', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + origin: 'manual', + }, + ]), + }), + ); + + // verify correct span parent-child relationships + const testInterceptorSpan = transactionEvent.spans.find(span => span.description === 'test-interceptor-span'); + const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span'); + + // 'ExampleInterceptor' is the parent of 'test-interceptor-span' + expect(testInterceptorSpan.parent_span_id).toBe(exampleInterceptorSpanId); + + // 'ExampleInterceptor' is NOT the parent of 'test-controller-span' + expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptorSpanId); +}); diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index 7402d3f374f0..f788ccb9b67c 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -32,6 +32,9 @@ import type { Observable } from 'rxjs'; * Interceptor to add Sentry tracing capabilities to Nest.js applications. */ class SentryTracingInterceptor implements NestInterceptor { + // used to exclude this class from being auto-instrumented + public static readonly __SENTRY_INTERNAL__ = true; + /** * Intercepts HTTP requests to set the transaction name for Sentry tracing. */ diff --git a/packages/node/src/integrations/tracing/nest.ts b/packages/node/src/integrations/tracing/nest.ts index cb3097b06228..6b452ab3add3 100644 --- a/packages/node/src/integrations/tracing/nest.ts +++ b/packages/node/src/integrations/tracing/nest.ts @@ -61,12 +61,21 @@ const supportedVersions = ['>=8.0.0 <11']; const sentryPatched = 'sentryPatched'; +/** + * A NestJS call handler. Used in interceptors to start the route execution. + */ +export interface CallHandler { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handle(...args: any[]): Observable; +} + /** * Represents an injectable target class in NestJS. */ export interface InjectableTarget { name: string; sentryPatched?: boolean; + __SENTRY_INTERNAL__?: boolean; prototype: { // eslint-disable-next-line @typescript-eslint/no-explicit-any use?: (req: unknown, res: unknown, next: () => void, ...args: any[]) => void; @@ -74,6 +83,8 @@ export interface InjectableTarget { canActivate?: (...args: any[]) => boolean | Promise | Observable; // eslint-disable-next-line @typescript-eslint/no-explicit-any transform?: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + intercept?: (context: unknown, next: CallHandler, ...args: any[]) => Observable; }; } @@ -92,6 +103,17 @@ export function isPatched(target: InjectableTarget): boolean { return false; } +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function getMiddlewareSpanOptions(target: InjectableTarget) { + return { + name: target.name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.middleware.nestjs', + }, + }; +} + /** * Custom instrumentation for nestjs. * @@ -148,7 +170,7 @@ export class SentryNestInstrumentation extends InstrumentationBase { return function wrappedInjectable(options?: unknown) { return function (target: InjectableTarget) { // patch middleware - if (typeof target.prototype.use === 'function') { + if (typeof target.prototype.use === 'function' && !target.__SENTRY_INTERNAL__) { // patch only once if (isPatched(target)) { return original(options)(target); @@ -159,38 +181,29 @@ export class SentryNestInstrumentation extends InstrumentationBase { const [req, res, next, ...args] = argsUse; const prevSpan = getActiveSpan(); - return startSpanManual( - { - name: target.name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.middleware.nestjs', - }, - }, - (span: Span) => { - const nextProxy = new Proxy(next, { - apply: (originalNext, thisArgNext, argsNext) => { - span.end(); - - if (prevSpan) { - withActiveSpan(prevSpan, () => { - return Reflect.apply(originalNext, thisArgNext, argsNext); - }); - } else { + return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => { + const nextProxy = new Proxy(next, { + apply: (originalNext, thisArgNext, argsNext) => { + span.end(); + + if (prevSpan) { + return withActiveSpan(prevSpan, () => { return Reflect.apply(originalNext, thisArgNext, argsNext); - } - }, - }); + }); + } else { + return Reflect.apply(originalNext, thisArgNext, argsNext); + } + }, + }); - return originalUse.apply(thisArgUse, [req, res, nextProxy, args]); - }, - ); + return originalUse.apply(thisArgUse, [req, res, nextProxy, args]); + }); }, }); } // patch guards - if (typeof target.prototype.canActivate === 'function') { + if (typeof target.prototype.canActivate === 'function' && !target.__SENTRY_INTERNAL__) { // patch only once if (isPatched(target)) { return original(options)(target); @@ -198,42 +211,64 @@ export class SentryNestInstrumentation extends InstrumentationBase { target.prototype.canActivate = new Proxy(target.prototype.canActivate, { apply: (originalCanActivate, thisArgCanActivate, argsCanActivate) => { - return startSpan( - { - name: target.name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.middleware.nestjs', - }, - }, - () => { - return originalCanActivate.apply(thisArgCanActivate, argsCanActivate); - }, - ); + return startSpan(getMiddlewareSpanOptions(target), () => { + return originalCanActivate.apply(thisArgCanActivate, argsCanActivate); + }); }, }); } // patch pipes - if (typeof target.prototype.transform === 'function') { + 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) => { - return startSpan( - { - name: target.name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.middleware.nestjs', + return startSpan(getMiddlewareSpanOptions(target), () => { + 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 [executionContext, next, args] = argsIntercept; + const prevSpan = getActiveSpan(); + + return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => { + const nextProxy = new Proxy(next, { + get: (thisArgNext, property, receiver) => { + if (property === 'handle') { + const originalHandle = Reflect.get(thisArgNext, property, receiver); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (...args: any[]) => { + span.end(); + + if (prevSpan) { + return withActiveSpan(prevSpan, () => { + return Reflect.apply(originalHandle, thisArgNext, args); + }); + } else { + return Reflect.apply(originalHandle, thisArgNext, args); + } + }; + } + + return Reflect.get(target, property, receiver); }, - }, - () => { - return originalTransform.apply(thisArgTransform, argsTransform); - }, - ); + }); + + return originalIntercept.apply(thisArgIntercept, [executionContext, nextProxy, args]); + }); }, }); }