Skip to content

Commit b71d0fd

Browse files
authored
feat(nestjs): Automatic instrumentation of nestjs exception filters (#13230)
Adds automatic instrumentation of exception filters to `@sentry/nestjs`. Exception filters in nest have a `@Catch` decorator and implement a `catch` function. So we can use that to attach an instrumentation proxy.
1 parent 061042a commit b71d0fd

File tree

5 files changed

+147
-7
lines changed

5 files changed

+147
-7
lines changed

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,77 @@ test('Sends an API route transaction from module', async ({ baseURL }) => {
121121
}),
122122
);
123123
});
124+
125+
test('API route transaction includes exception filter span for global filter', async ({ baseURL }) => {
126+
const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => {
127+
return (
128+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
129+
transactionEvent?.transaction === 'GET /example-module/expected-exception' &&
130+
transactionEvent?.request?.url?.includes('/example-module/expected-exception')
131+
);
132+
});
133+
134+
const response = await fetch(`${baseURL}/example-module/expected-exception`);
135+
expect(response.status).toBe(400);
136+
137+
const transactionEvent = await transactionEventPromise;
138+
139+
expect(transactionEvent).toEqual(
140+
expect.objectContaining({
141+
spans: expect.arrayContaining([
142+
{
143+
span_id: expect.any(String),
144+
trace_id: expect.any(String),
145+
data: {
146+
'sentry.op': 'middleware.nestjs',
147+
'sentry.origin': 'auto.middleware.nestjs',
148+
},
149+
description: 'ExampleExceptionFilter',
150+
parent_span_id: expect.any(String),
151+
start_timestamp: expect.any(Number),
152+
timestamp: expect.any(Number),
153+
status: 'ok',
154+
op: 'middleware.nestjs',
155+
origin: 'auto.middleware.nestjs',
156+
},
157+
]),
158+
}),
159+
);
160+
});
161+
162+
test('API route transaction includes exception filter span for local filter', async ({ baseURL }) => {
163+
const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => {
164+
return (
165+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
166+
transactionEvent?.transaction === 'GET /example-module-local-filter/expected-exception' &&
167+
transactionEvent?.request?.url?.includes('/example-module-local-filter/expected-exception')
168+
);
169+
});
170+
171+
const response = await fetch(`${baseURL}/example-module-local-filter/expected-exception`);
172+
expect(response.status).toBe(400);
173+
174+
const transactionEvent = await transactionEventPromise;
175+
176+
expect(transactionEvent).toEqual(
177+
expect.objectContaining({
178+
spans: expect.arrayContaining([
179+
{
180+
span_id: expect.any(String),
181+
trace_id: expect.any(String),
182+
data: {
183+
'sentry.op': 'middleware.nestjs',
184+
'sentry.origin': 'auto.middleware.nestjs',
185+
},
186+
description: 'LocalExampleExceptionFilter',
187+
parent_span_id: expect.any(String),
188+
start_timestamp: expect.any(Number),
189+
timestamp: expect.any(Number),
190+
status: 'ok',
191+
op: 'middleware.nestjs',
192+
origin: 'auto.middleware.nestjs',
193+
},
194+
]),
195+
}),
196+
);
197+
});

packages/nestjs/src/setup.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export { SentryTracingInterceptor };
6464
* Global filter to handle exceptions and report them to Sentry.
6565
*/
6666
class SentryGlobalFilter extends BaseExceptionFilter {
67+
public static readonly __SENTRY_INTERNAL__ = true;
68+
6769
/**
6870
* Catches exceptions and reports them to Sentry unless they are expected errors.
6971
*/
@@ -84,6 +86,8 @@ export { SentryGlobalFilter };
8486
* Service to set up Sentry performance tracing for Nest.js applications.
8587
*/
8688
class SentryService implements OnModuleInit {
89+
public static readonly __SENTRY_INTERNAL__ = true;
90+
8791
/**
8892
* Initializes the Sentry service and registers span attributes.
8993
*/

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
22
import { addNonEnumerableProperty } from '@sentry/utils';
3-
import type { InjectableTarget } from './types';
3+
import type { CatchTarget, InjectableTarget } from './types';
44

55
const sentryPatched = 'sentryPatched';
66

@@ -10,7 +10,7 @@ const sentryPatched = 'sentryPatched';
1010
* We already guard duplicate patching with isWrapped. However, isWrapped checks whether a file has been patched, whereas we use this check for concrete target classes.
1111
* This check might not be necessary, but better to play it safe.
1212
*/
13-
export function isPatched(target: InjectableTarget): boolean {
13+
export function isPatched(target: InjectableTarget | CatchTarget): boolean {
1414
if (target.sentryPatched) {
1515
return true;
1616
}
@@ -23,7 +23,7 @@ export function isPatched(target: InjectableTarget): boolean {
2323
* Returns span options for nest middleware spans.
2424
*/
2525
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
26-
export function getMiddlewareSpanOptions(target: InjectableTarget) {
26+
export function getMiddlewareSpanOptions(target: InjectableTarget | CatchTarget) {
2727
return {
2828
name: target.name,
2929
attributes: {

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

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getActiveSpan, startSpan, startSpanManual, withActiveSpan } from '@sent
99
import type { Span } from '@sentry/types';
1010
import { SDK_VERSION } from '@sentry/utils';
1111
import { getMiddlewareSpanOptions, isPatched } from './helpers';
12-
import type { InjectableTarget } from './types';
12+
import type { CatchTarget, InjectableTarget } from './types';
1313

1414
const supportedVersions = ['>=8.0.0 <11'];
1515

@@ -34,7 +34,10 @@ export class SentryNestInstrumentation extends InstrumentationBase {
3434
public init(): InstrumentationNodeModuleDefinition {
3535
const moduleDef = new InstrumentationNodeModuleDefinition(SentryNestInstrumentation.COMPONENT, supportedVersions);
3636

37-
moduleDef.files.push(this._getInjectableFileInstrumentation(supportedVersions));
37+
moduleDef.files.push(
38+
this._getInjectableFileInstrumentation(supportedVersions),
39+
this._getCatchFileInstrumentation(supportedVersions),
40+
);
3841
return moduleDef;
3942
}
4043

@@ -58,10 +61,28 @@ export class SentryNestInstrumentation extends InstrumentationBase {
5861
);
5962
}
6063

64+
/**
65+
* Wraps the @Catch decorator.
66+
*/
67+
private _getCatchFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile {
68+
return new InstrumentationNodeModuleFile(
69+
'@nestjs/common/decorators/core/catch.decorator.js',
70+
versions,
71+
(moduleExports: { Catch: CatchTarget }) => {
72+
if (isWrapped(moduleExports.Catch)) {
73+
this._unwrap(moduleExports, 'Catch');
74+
}
75+
this._wrap(moduleExports, 'Catch', this._createWrapCatch());
76+
return moduleExports;
77+
},
78+
(moduleExports: { Catch: CatchTarget }) => {
79+
this._unwrap(moduleExports, 'Catch');
80+
},
81+
);
82+
}
83+
6184
/**
6285
* Creates a wrapper function for the @Injectable decorator.
63-
*
64-
* Wraps the use method to instrument nest class middleware.
6586
*/
6687
private _createWrapInjectable() {
6788
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -177,4 +198,33 @@ export class SentryNestInstrumentation extends InstrumentationBase {
177198
};
178199
};
179200
}
201+
202+
/**
203+
* Creates a wrapper function for the @Catch decorator. Used to instrument exception filters.
204+
*/
205+
private _createWrapCatch() {
206+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
207+
return function wrapCatch(original: any) {
208+
return function wrappedCatch(...exceptions: unknown[]) {
209+
return function (target: CatchTarget) {
210+
if (typeof target.prototype.catch === 'function' && !target.__SENTRY_INTERNAL__) {
211+
// patch only once
212+
if (isPatched(target)) {
213+
return original(...exceptions)(target);
214+
}
215+
216+
target.prototype.catch = new Proxy(target.prototype.catch, {
217+
apply: (originalCatch, thisArgCatch, argsCatch) => {
218+
return startSpan(getMiddlewareSpanOptions(target), () => {
219+
return originalCatch.apply(thisArgCatch, argsCatch);
220+
});
221+
},
222+
});
223+
}
224+
225+
return original(...exceptions)(target);
226+
};
227+
};
228+
};
229+
}
180230
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,15 @@ export interface InjectableTarget {
5555
intercept?: (context: unknown, next: CallHandler, ...args: any[]) => Observable<any>;
5656
};
5757
}
58+
59+
/**
60+
* Represents a target class in NestJS annotated with @Catch.
61+
*/
62+
export interface CatchTarget {
63+
name: string;
64+
sentryPatched?: boolean;
65+
__SENTRY_INTERNAL__?: boolean;
66+
prototype: {
67+
catch?: (...args: any[]) => any;
68+
};
69+
}

0 commit comments

Comments
 (0)