Skip to content

Commit e2668f8

Browse files
authored
feat(nestjs): Automatic instrumentation of nestjs guards (#13129)
Adds automatic instrumentation to `@sentry/nestjs`. Guards in nest have a `@Injectable` decorator and implement a `canActivate` function. So we can simply extend the existing instrumentation to add a proxy for `canActivate`. Also fixed a mistake with the middleware instrumentation (missing return).
1 parent 4972604 commit e2668f8

File tree

7 files changed

+207
-11
lines changed

7 files changed

+207
-11
lines changed

Diff for: dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Controller, Get, Param } from '@nestjs/common';
1+
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
22
import { AppService } from './app.service';
3+
import { ExampleGuard } from './example.guard';
34

45
@Controller()
56
export class AppController {
@@ -15,6 +16,12 @@ export class AppController {
1516
return this.appService.testMiddleware();
1617
}
1718

19+
@Get('test-guard-instrumentation')
20+
@UseGuards(ExampleGuard)
21+
testGuardInstrumentation() {
22+
return {};
23+
}
24+
1825
@Get('test-exception/:id')
1926
async testException(@Param('id') id: string) {
2027
return this.appService.testException(id);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
2+
import * as Sentry from '@sentry/nestjs';
3+
4+
@Injectable()
5+
export class ExampleGuard implements CanActivate {
6+
canActivate(context: ExecutionContext): boolean {
7+
Sentry.startSpan({ name: 'test-guard-span' }, () => {});
8+
return true;
9+
}
10+
}

Diff for: dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts

+67-1
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ test('API route transaction includes nest middleware span. Spans created in and
132132
);
133133
});
134134

135-
await fetch(`${baseURL}/test-middleware-instrumentation`);
135+
const response = await fetch(`${baseURL}/test-middleware-instrumentation`);
136+
expect(response.status).toBe(200);
136137

137138
const transactionEvent = await pageloadTransactionEventPromise;
138139

@@ -200,3 +201,68 @@ test('API route transaction includes nest middleware span. Spans created in and
200201
// 'ExampleMiddleware' is NOT the parent of 'test-controller-span'
201202
expect(testControllerSpan.parent_span_id).not.toBe(exampleMiddlewareSpanId);
202203
});
204+
205+
test('API route transaction includes nest guard span and span started in guard is nested correctly', async ({
206+
baseURL,
207+
}) => {
208+
const transactionEventPromise = waitForTransaction('nestjs', transactionEvent => {
209+
return (
210+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
211+
transactionEvent?.transaction === 'GET /test-guard-instrumentation'
212+
);
213+
});
214+
215+
const response = await fetch(`${baseURL}/test-guard-instrumentation`);
216+
expect(response.status).toBe(200);
217+
218+
const transactionEvent = await transactionEventPromise;
219+
220+
expect(transactionEvent).toEqual(
221+
expect.objectContaining({
222+
spans: expect.arrayContaining([
223+
{
224+
span_id: expect.any(String),
225+
trace_id: expect.any(String),
226+
data: {
227+
'sentry.op': 'middleware.nestjs',
228+
'sentry.origin': 'auto.middleware.nestjs',
229+
},
230+
description: 'ExampleGuard',
231+
parent_span_id: expect.any(String),
232+
start_timestamp: expect.any(Number),
233+
timestamp: expect.any(Number),
234+
status: 'ok',
235+
op: 'middleware.nestjs',
236+
origin: 'auto.middleware.nestjs',
237+
},
238+
]),
239+
}),
240+
);
241+
242+
const exampleGuardSpan = transactionEvent.spans.find(span => span.description === 'ExampleGuard');
243+
const exampleGuardSpanId = exampleGuardSpan?.span_id;
244+
245+
expect(transactionEvent).toEqual(
246+
expect.objectContaining({
247+
spans: expect.arrayContaining([
248+
{
249+
span_id: expect.any(String),
250+
trace_id: expect.any(String),
251+
data: expect.any(Object),
252+
description: 'test-guard-span',
253+
parent_span_id: expect.any(String),
254+
start_timestamp: expect.any(Number),
255+
timestamp: expect.any(Number),
256+
status: 'ok',
257+
origin: 'manual',
258+
},
259+
]),
260+
}),
261+
);
262+
263+
// verify correct span parent-child relationships
264+
const testGuardSpan = transactionEvent.spans.find(span => span.description === 'test-guard-span');
265+
266+
// 'ExampleGuard' is the parent of 'test-guard-span'
267+
expect(testGuardSpan.parent_span_id).toBe(exampleGuardSpanId);
268+
});

Diff for: dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.controller.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Controller, Get, Param } from '@nestjs/common';
1+
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
22
import { AppService } from './app.service';
3+
import { ExampleGuard } from './example.guard';
34

45
@Controller()
56
export class AppController {
@@ -15,6 +16,12 @@ export class AppController {
1516
return this.appService.testMiddleware();
1617
}
1718

19+
@Get('test-guard-instrumentation')
20+
@UseGuards(ExampleGuard)
21+
testGuardInstrumentation() {
22+
return {};
23+
}
24+
1825
@Get('test-exception/:id')
1926
async testException(@Param('id') id: string) {
2027
return this.appService.testException(id);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
2+
import * as Sentry from '@sentry/nestjs';
3+
4+
@Injectable()
5+
export class ExampleGuard implements CanActivate {
6+
canActivate(context: ExecutionContext): boolean {
7+
Sentry.startSpan({ name: 'test-guard-span' }, () => {});
8+
return true;
9+
}
10+
}

Diff for: dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/transactions.test.ts

+69-3
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,17 @@ test('Sends an API route transaction', async ({ baseURL }) => {
125125
test('API route transaction includes nest middleware span. Spans created in and after middleware are nested correctly', async ({
126126
baseURL,
127127
}) => {
128-
const pageloadTransactionEventPromise = waitForTransaction('nestjs', transactionEvent => {
128+
const transactionEventPromise = waitForTransaction('nestjs', transactionEvent => {
129129
return (
130130
transactionEvent?.contexts?.trace?.op === 'http.server' &&
131131
transactionEvent?.transaction === 'GET /test-middleware-instrumentation'
132132
);
133133
});
134134

135-
await fetch(`${baseURL}/test-middleware-instrumentation`);
135+
const response = await fetch(`${baseURL}/test-middleware-instrumentation`);
136+
expect(response.status).toBe(200);
136137

137-
const transactionEvent = await pageloadTransactionEventPromise;
138+
const transactionEvent = await transactionEventPromise;
138139

139140
expect(transactionEvent).toEqual(
140141
expect.objectContaining({
@@ -200,3 +201,68 @@ test('API route transaction includes nest middleware span. Spans created in and
200201
// 'ExampleMiddleware' is NOT the parent of 'test-controller-span'
201202
expect(testControllerSpan.parent_span_id).not.toBe(exampleMiddlewareSpanId);
202203
});
204+
205+
test('API route transaction includes nest guard span and span started in guard is nested correctly', async ({
206+
baseURL,
207+
}) => {
208+
const transactionEventPromise = waitForTransaction('nestjs', transactionEvent => {
209+
return (
210+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
211+
transactionEvent?.transaction === 'GET /test-guard-instrumentation'
212+
);
213+
});
214+
215+
const response = await fetch(`${baseURL}/test-guard-instrumentation`);
216+
expect(response.status).toBe(200);
217+
218+
const transactionEvent = await transactionEventPromise;
219+
220+
expect(transactionEvent).toEqual(
221+
expect.objectContaining({
222+
spans: expect.arrayContaining([
223+
{
224+
span_id: expect.any(String),
225+
trace_id: expect.any(String),
226+
data: {
227+
'sentry.op': 'middleware.nestjs',
228+
'sentry.origin': 'auto.middleware.nestjs',
229+
},
230+
description: 'ExampleGuard',
231+
parent_span_id: expect.any(String),
232+
start_timestamp: expect.any(Number),
233+
timestamp: expect.any(Number),
234+
status: 'ok',
235+
op: 'middleware.nestjs',
236+
origin: 'auto.middleware.nestjs',
237+
},
238+
]),
239+
}),
240+
);
241+
242+
const exampleGuardSpan = transactionEvent.spans.find(span => span.description === 'ExampleGuard');
243+
const exampleGuardSpanId = exampleGuardSpan?.span_id;
244+
245+
expect(transactionEvent).toEqual(
246+
expect.objectContaining({
247+
spans: expect.arrayContaining([
248+
{
249+
span_id: expect.any(String),
250+
trace_id: expect.any(String),
251+
data: expect.any(Object),
252+
description: 'test-guard-span',
253+
parent_span_id: expect.any(String),
254+
start_timestamp: expect.any(Number),
255+
timestamp: expect.any(Number),
256+
status: 'ok',
257+
origin: 'manual',
258+
},
259+
]),
260+
}),
261+
);
262+
263+
// verify correct span parent-child relationships
264+
const testGuardSpan = transactionEvent.spans.find(span => span.description === 'test-guard-span');
265+
266+
// 'ExampleGuard' is the parent of 'test-guard-span'
267+
expect(testGuardSpan.parent_span_id).toBe(exampleGuardSpanId);
268+
});

Diff for: packages/node/src/integrations/tracing/nest.ts

+35-5
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ import {
1717
getDefaultIsolationScope,
1818
getIsolationScope,
1919
spanToJSON,
20+
startSpan,
2021
startSpanManual,
2122
withActiveSpan,
2223
} from '@sentry/core';
2324
import type { IntegrationFn, Span } from '@sentry/types';
2425
import { addNonEnumerableProperty, logger } from '@sentry/utils';
26+
import type { Observable } from 'rxjs';
2527
import { generateInstrumentOnce } from '../../otel/instrument';
2628

2729
interface MinimalNestJsExecutionContext {
@@ -66,7 +68,10 @@ export interface InjectableTarget {
6668
name: string;
6769
sentryPatched?: boolean;
6870
prototype: {
69-
use?: (req: unknown, res: unknown, next: () => void) => void;
71+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
72+
use?: (req: unknown, res: unknown, next: () => void, ...args: any[]) => void;
73+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
74+
canActivate?: (...args: any[]) => boolean | Promise<boolean> | Observable<boolean>;
7075
};
7176
}
7277

@@ -152,7 +157,7 @@ export class SentryNestInstrumentation extends InstrumentationBase {
152157
const [req, res, next, ...args] = argsUse;
153158
const prevSpan = getActiveSpan();
154159

155-
startSpanManual(
160+
return startSpanManual(
156161
{
157162
name: target.name,
158163
attributes: {
@@ -167,15 +172,40 @@ export class SentryNestInstrumentation extends InstrumentationBase {
167172

168173
if (prevSpan) {
169174
withActiveSpan(prevSpan, () => {
170-
Reflect.apply(originalNext, thisArgNext, argsNext);
175+
return Reflect.apply(originalNext, thisArgNext, argsNext);
171176
});
172177
} else {
173-
Reflect.apply(originalNext, thisArgNext, argsNext);
178+
return Reflect.apply(originalNext, thisArgNext, argsNext);
174179
}
175180
},
176181
});
177182

178-
originalUse.apply(thisArgUse, [req, res, nextProxy, args]);
183+
return originalUse.apply(thisArgUse, [req, res, nextProxy, args]);
184+
},
185+
);
186+
},
187+
});
188+
}
189+
190+
// patch guards
191+
if (typeof target.prototype.canActivate === 'function') {
192+
// patch only once
193+
if (isPatched(target)) {
194+
return original(options)(target);
195+
}
196+
197+
target.prototype.canActivate = new Proxy(target.prototype.canActivate, {
198+
apply: (originalCanActivate, thisArgCanActivate, argsCanActivate) => {
199+
return startSpan(
200+
{
201+
name: target.name,
202+
attributes: {
203+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs',
204+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.middleware.nestjs',
205+
},
206+
},
207+
() => {
208+
return originalCanActivate.apply(thisArgCanActivate, argsCanActivate);
179209
},
180210
);
181211
},

0 commit comments

Comments
 (0)