Skip to content

Commit

Permalink
feat(nestjs): Automatic instrumentation of nestjs middleware (#13065)
Browse files Browse the repository at this point in the history
Adds middleware instrumentation to the `@sentry/nestjs`. The
implementation lives in `@sentry/node` so that both users using
`@sentry/nestjs` directly as well as users still on `@sentry/node`
benefit. The instrumentation is automatic without requiring any
additional setup. The idea is to hook into the Injectable decorator
(every class middleware is annotated with `@Injectable` and patch the
`use` method if it is implemented.

Caveat: This implementation only works for class middleware, which
implements the `use` method, which seems to be the standard for
implementing middleware in nest. However, nest also provides functional
middleware, for which this implementation does not work.
  • Loading branch information
nicohrubec authored Jul 30, 2024
1 parent b7e62c4 commit e3af1ce
Show file tree
Hide file tree
Showing 12 changed files with 394 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export class AppController {
return this.appService.testTransaction();
}

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

@Get('test-exception/:id')
async testException(@Param('id') id: string) {
return this.appService.testException(id);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { SentryModule } from '@sentry/nestjs/setup';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ExampleMiddleware } from './example.middleware';

@Module({
imports: [SentryModule.forRoot(), ScheduleModule.forRoot()],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
export class AppModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(ExampleMiddleware).forRoutes('test-middleware-instrumentation');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export class AppService {
});
}

testMiddleware() {
// span that should not be a child span of the middleware span
Sentry.startSpan({ name: 'test-controller-span' }, () => {});
}

testException(id: string) {
throw new Error(`This is an exception with id ${id}`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import * as Sentry from '@sentry/nestjs';
import { NextFunction, Request, Response } from 'express';

@Injectable()
export class ExampleMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// span that should be a child span of the middleware span
Sentry.startSpan({ name: 'test-middleware-span' }, () => {});
next();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,82 @@ test('Sends an API route transaction', async ({ baseURL }) => {
}),
);
});

test('API route transaction includes nest middleware span. Spans created in and after middleware are nested correctly', async ({
baseURL,
}) => {
const pageloadTransactionEventPromise = waitForTransaction('nestjs', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
transactionEvent?.transaction === 'GET /test-middleware-instrumentation'
);
});

await fetch(`${baseURL}/test-middleware-instrumentation`);

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: 'ExampleMiddleware',
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 exampleMiddlewareSpan = transactionEvent.spans.find(span => span.description === 'ExampleMiddleware');
const exampleMiddlewareSpanId = exampleMiddlewareSpan?.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-middleware-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 testMiddlewareSpan = transactionEvent.spans.find(span => span.description === 'test-middleware-span');
const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span');

// 'ExampleMiddleware' is the parent of 'test-middleware-span'
expect(testMiddlewareSpan.parent_span_id).toBe(exampleMiddlewareSpanId);

// 'ExampleMiddleware' is NOT the parent of 'test-controller-span'
expect(testControllerSpan.parent_span_id).not.toBe(exampleMiddlewareSpanId);
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export class AppController {
return this.appService.testTransaction();
}

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

@Get('test-exception/:id')
async testException(@Param('id') id: string) {
return this.appService.testException(id);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ExampleMiddleware } from './example.middleware';

@Module({
imports: [ScheduleModule.forRoot()],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
export class AppModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(ExampleMiddleware).forRoutes('test-middleware-instrumentation');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export class AppService {
});
}

testMiddleware() {
// span that should not be a child span of the middleware span
Sentry.startSpan({ name: 'test-controller-span' }, () => {});
}

testException(id: string) {
throw new Error(`This is an exception with id ${id}`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import * as Sentry from '@sentry/nestjs';
import { NextFunction, Request, Response } from 'express';

@Injectable()
export class ExampleMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// span that should be a child span of the middleware span
Sentry.startSpan({ name: 'test-middleware-span' }, () => {});
next();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,82 @@ test('Sends an API route transaction', async ({ baseURL }) => {
}),
);
});

test('API route transaction includes nest middleware span. Spans created in and after middleware are nested correctly', async ({
baseURL,
}) => {
const pageloadTransactionEventPromise = waitForTransaction('nestjs', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
transactionEvent?.transaction === 'GET /test-middleware-instrumentation'
);
});

await fetch(`${baseURL}/test-middleware-instrumentation`);

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: 'ExampleMiddleware',
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 exampleMiddlewareSpan = transactionEvent.spans.find(span => span.description === 'ExampleMiddleware');
const exampleMiddlewareSpanId = exampleMiddlewareSpan?.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-middleware-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 testMiddlewareSpan = transactionEvent.spans.find(span => span.description === 'test-middleware-span');
const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span');

// 'ExampleMiddleware' is the parent of 'test-middleware-span'
expect(testMiddlewareSpan.parent_span_id).toBe(exampleMiddlewareSpanId);

// 'ExampleMiddleware' is NOT the parent of 'test-controller-span'
expect(testControllerSpan.parent_span_id).not.toBe(exampleMiddlewareSpanId);
});
Loading

0 comments on commit e3af1ce

Please sign in to comment.