Skip to content

Commit 00a45a4

Browse files
authored
fix(nestjs): Check arguments before instrumenting with @Injectable (#13544)
Before this fix, calling a `use` method on a service that does not implement the `NestMiddleware` interface resulted in an error. This is because we try to proxy the third argument to the function, which in middleware is an express `NextFunction`, but in other classes can be anything, potentially even undefined. This fix introduces further guards to early-return in non-middleware targets. Added a test to verify that services with `use` work fine now. Also added additional tests to verify that this behavior does not occur for `canActivate` (guard), `intercept` (interceptor) and `transform` (pipe) methods.
1 parent 86aeba8 commit 00a45a4

File tree

9 files changed

+173
-16
lines changed

9 files changed

+173
-16
lines changed

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

+20
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,24 @@ export class AppController {
9696
async exampleExceptionLocalFilter() {
9797
throw new ExampleExceptionLocalFilter();
9898
}
99+
100+
@Get('test-service-use')
101+
testServiceWithUseMethod() {
102+
return this.appService.use();
103+
}
104+
105+
@Get('test-service-transform')
106+
testServiceWithTransform() {
107+
return this.appService.transform();
108+
}
109+
110+
@Get('test-service-intercept')
111+
testServiceWithIntercept() {
112+
return this.appService.intercept();
113+
}
114+
115+
@Get('test-service-canActivate')
116+
testServiceWithCanActivate() {
117+
return this.appService.canActivate();
118+
}
99119
}

dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts

+16
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,20 @@ export class AppService {
7878
async killTestCron() {
7979
this.schedulerRegistry.deleteCronJob('test-cron-job');
8080
}
81+
82+
use() {
83+
console.log('Test use!');
84+
}
85+
86+
transform() {
87+
console.log('Test transform!');
88+
}
89+
90+
intercept() {
91+
console.log('Test intercept!');
92+
}
93+
94+
canActivate() {
95+
console.log('Test canActivate!');
96+
}
8197
}

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

+20
Original file line numberDiff line numberDiff line change
@@ -707,3 +707,23 @@ test('API route transaction includes exactly one nest async interceptor span aft
707707
// 'Interceptor - After Route' is NOT the parent of 'test-controller-span'
708708
expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptorSpanAfterRouteId);
709709
});
710+
711+
test('Calling use method on service with Injectable decorator returns 200', async ({ baseURL }) => {
712+
const response = await fetch(`${baseURL}/test-service-use`);
713+
expect(response.status).toBe(200);
714+
});
715+
716+
test('Calling transform method on service with Injectable decorator returns 200', async ({ baseURL }) => {
717+
const response = await fetch(`${baseURL}/test-service-transform`);
718+
expect(response.status).toBe(200);
719+
});
720+
721+
test('Calling intercept method on service with Injectable decorator returns 200', async ({ baseURL }) => {
722+
const response = await fetch(`${baseURL}/test-service-intercept`);
723+
expect(response.status).toBe(200);
724+
});
725+
726+
test('Calling canActivate method on service with Injectable decorator returns 200', async ({ baseURL }) => {
727+
const response = await fetch(`${baseURL}/test-service-canActivate`);
728+
expect(response.status).toBe(200);
729+
});

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

+20
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,24 @@ export class AppController {
8282
async flush() {
8383
await flush();
8484
}
85+
86+
@Get('test-service-use')
87+
testServiceWithUseMethod() {
88+
return this.appService.use();
89+
}
90+
91+
@Get('test-service-transform')
92+
testServiceWithTransform() {
93+
return this.appService.transform();
94+
}
95+
96+
@Get('test-service-intercept')
97+
testServiceWithIntercept() {
98+
return this.appService.intercept();
99+
}
100+
101+
@Get('test-service-canActivate')
102+
testServiceWithCanActivate() {
103+
return this.appService.canActivate();
104+
}
85105
}

dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.service.ts

+16
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,20 @@ export class AppService {
7878
async killTestCron() {
7979
this.schedulerRegistry.deleteCronJob('test-cron-job');
8080
}
81+
82+
use() {
83+
console.log('Test use!');
84+
}
85+
86+
transform() {
87+
console.log('Test transform!');
88+
}
89+
90+
intercept() {
91+
console.log('Test intercept!');
92+
}
93+
94+
canActivate() {
95+
console.log('Test canActivate!');
96+
}
8197
}

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

+20
Original file line numberDiff line numberDiff line change
@@ -705,3 +705,23 @@ test('API route transaction includes exactly one nest async interceptor span aft
705705
// 'Interceptor - After Route' is NOT the parent of 'test-controller-span'
706706
expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptorSpanAfterRouteId);
707707
});
708+
709+
test('Calling use method on service with Injectable decorator returns 200', async ({ baseURL }) => {
710+
const response = await fetch(`${baseURL}/test-service-use`);
711+
expect(response.status).toBe(200);
712+
});
713+
714+
test('Calling transform method on service with Injectable decorator returns 200', async ({ baseURL }) => {
715+
const response = await fetch(`${baseURL}/test-service-transform`);
716+
expect(response.status).toBe(200);
717+
});
718+
719+
test('Calling intercept method on service with Injectable decorator returns 200', async ({ baseURL }) => {
720+
const response = await fetch(`${baseURL}/test-service-intercept`);
721+
expect(response.status).toBe(200);
722+
});
723+
724+
test('Calling canActivate method on service with Injectable decorator returns 200', async ({ baseURL }) => {
725+
const response = await fetch(`${baseURL}/test-service-canActivate`);
726+
expect(response.status).toBe(200);
727+
});

packages/node/src/integrations/tracing/nest/helpers.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, withActiveSpan } from '@sentry/core';
22
import type { Span } from '@sentry/types';
33
import { addNonEnumerableProperty } from '@sentry/utils';
4-
import type { CatchTarget, InjectableTarget, Observable, Subscription } from './types';
4+
import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types';
55

66
const sentryPatched = 'sentryPatched';
77

@@ -53,3 +53,22 @@ export function instrumentObservable(observable: Observable<unknown>, activeSpan
5353
});
5454
}
5555
}
56+
57+
/**
58+
* Proxies the next() call in a nestjs middleware to end the span when it is called.
59+
*/
60+
export function getNextProxy(next: NextFunction, span: Span, prevSpan: undefined | Span): NextFunction {
61+
return new Proxy(next, {
62+
apply: (originalNext, thisArgNext, argsNext) => {
63+
span.end();
64+
65+
if (prevSpan) {
66+
return withActiveSpan(prevSpan, () => {
67+
return Reflect.apply(originalNext, thisArgNext, argsNext);
68+
});
69+
} else {
70+
return Reflect.apply(originalNext, thisArgNext, argsNext);
71+
}
72+
},
73+
});
74+
}

packages/node/src/integrations/tracing/nest/sentry-nest-instrumentation.ts

+36-15
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { getActiveSpan, startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from '@sentry/core';
99
import type { Span } from '@sentry/types';
1010
import { SDK_VERSION, addNonEnumerableProperty, isThenable } from '@sentry/utils';
11-
import { getMiddlewareSpanOptions, instrumentObservable, isPatched } from './helpers';
11+
import { getMiddlewareSpanOptions, getNextProxy, instrumentObservable, isPatched } from './helpers';
1212
import type { CallHandler, CatchTarget, InjectableTarget, MinimalNestJsExecutionContext, Observable } from './types';
1313

1414
const supportedVersions = ['>=8.0.0 <11'];
@@ -101,23 +101,19 @@ export class SentryNestInstrumentation extends InstrumentationBase {
101101
target.prototype.use = new Proxy(target.prototype.use, {
102102
apply: (originalUse, thisArgUse, argsUse) => {
103103
const [req, res, next, ...args] = argsUse;
104-
const prevSpan = getActiveSpan();
105104

106-
return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => {
107-
const nextProxy = new Proxy(next, {
108-
apply: (originalNext, thisArgNext, argsNext) => {
109-
span.end();
105+
// Check that we can reasonably assume that the target is a middleware.
106+
// Without these guards, instrumentation will fail if a function named 'use' on a service, which is
107+
// decorated with @Injectable, is called.
108+
if (!req || !res || !next || typeof next !== 'function') {
109+
return originalUse.apply(thisArgUse, argsUse);
110+
}
110111

111-
if (prevSpan) {
112-
return withActiveSpan(prevSpan, () => {
113-
return Reflect.apply(originalNext, thisArgNext, argsNext);
114-
});
115-
} else {
116-
return Reflect.apply(originalNext, thisArgNext, argsNext);
117-
}
118-
},
119-
});
112+
const prevSpan = getActiveSpan();
120113

114+
return startSpanManual(getMiddlewareSpanOptions(target), (span: Span) => {
115+
// proxy next to end span on call
116+
const nextProxy = getNextProxy(next, span, prevSpan);
121117
return originalUse.apply(thisArgUse, [req, res, nextProxy, args]);
122118
});
123119
},
@@ -133,6 +129,12 @@ export class SentryNestInstrumentation extends InstrumentationBase {
133129

134130
target.prototype.canActivate = new Proxy(target.prototype.canActivate, {
135131
apply: (originalCanActivate, thisArgCanActivate, argsCanActivate) => {
132+
const context: MinimalNestJsExecutionContext = argsCanActivate[0];
133+
134+
if (!context) {
135+
return originalCanActivate.apply(thisArgCanActivate, argsCanActivate);
136+
}
137+
136138
return startSpan(getMiddlewareSpanOptions(target), () => {
137139
return originalCanActivate.apply(thisArgCanActivate, argsCanActivate);
138140
});
@@ -148,6 +150,13 @@ export class SentryNestInstrumentation extends InstrumentationBase {
148150

149151
target.prototype.transform = new Proxy(target.prototype.transform, {
150152
apply: (originalTransform, thisArgTransform, argsTransform) => {
153+
const value = argsTransform[0];
154+
const metadata = argsTransform[1];
155+
156+
if (!value || !metadata) {
157+
return originalTransform.apply(thisArgTransform, argsTransform);
158+
}
159+
151160
return startSpan(getMiddlewareSpanOptions(target), () => {
152161
return originalTransform.apply(thisArgTransform, argsTransform);
153162
});
@@ -169,6 +178,11 @@ export class SentryNestInstrumentation extends InstrumentationBase {
169178
const parentSpan = getActiveSpan();
170179
let afterSpan: Span;
171180

181+
// Check that we can reasonably assume that the target is an interceptor.
182+
if (!context || !next || typeof next.handle !== 'function') {
183+
return originalIntercept.apply(thisArgIntercept, argsIntercept);
184+
}
185+
172186
return startSpanManual(getMiddlewareSpanOptions(target), (beforeSpan: Span) => {
173187
// eslint-disable-next-line @typescript-eslint/unbound-method
174188
next.handle = new Proxy(next.handle, {
@@ -263,6 +277,13 @@ export class SentryNestInstrumentation extends InstrumentationBase {
263277

264278
target.prototype.catch = new Proxy(target.prototype.catch, {
265279
apply: (originalCatch, thisArgCatch, argsCatch) => {
280+
const exception = argsCatch[0];
281+
const host = argsCatch[1];
282+
283+
if (!exception || !host) {
284+
return originalCatch.apply(thisArgCatch, argsCatch);
285+
}
286+
266287
return startSpan(getMiddlewareSpanOptions(target), () => {
267288
return originalCatch.apply(thisArgCatch, argsCatch);
268289
});

packages/node/src/integrations/tracing/nest/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,8 @@ export interface CatchTarget {
7373
catch?: (...args: any[]) => any;
7474
};
7575
}
76+
77+
/**
78+
* Represents an express NextFunction.
79+
*/
80+
export type NextFunction = (err?: any) => void;

0 commit comments

Comments
 (0)