Skip to content

Commit

Permalink
feat(nestjs): Automatic instrumentation of nestjs interceptors before…
Browse files Browse the repository at this point in the history
… route execution (#13153)

Adds automatic instrumentation of interceptors to `@sentry/nestjs`.
Interceptors in nest have a `@Injectable` decorator and implement a
`intercept` function. So we can simply extend the existing
instrumentation to add a proxy for `intercept`.

Remark: Interceptors allow users to add functionality before and after a
route handler is called. This PR adds tracing to whatever happens before
the route is executed. I am still figuring out how to trace any
instructions after the route was executed. Will do that in a separate
PR.
  • Loading branch information
nicohrubec authored Aug 2, 2024
1 parent 98160a5 commit 964d050
Show file tree
Hide file tree
Showing 10 changed files with 288 additions and 56 deletions.
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,7 +14,7 @@ export class AppController {

@Get('test-middleware-instrumentation')
testMiddlewareInstrumentation() {
return this.appService.testMiddleware();
return this.appService.testSpan();
}

@Get('test-guard-instrumentation')
Expand All @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }, () => {});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,7 +14,7 @@ export class AppController {

@Get('test-middleware-instrumentation')
testMiddlewareInstrumentation() {
return this.appService.testMiddleware();
return this.appService.testSpan();
}

@Get('test-guard-instrumentation')
Expand All @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }, () => {});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
3 changes: 3 additions & 0 deletions packages/nestjs/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading

0 comments on commit 964d050

Please sign in to comment.