Skip to content

Commit

Permalink
feat(nestjs): Automatic instrumentation of nestjs exception filters (#…
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
nicohrubec authored Aug 7, 2024
1 parent 061042a commit b71d0fd
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,77 @@ test('Sends an API route transaction from module', async ({ baseURL }) => {
}),
);
});

test('API route transaction includes exception filter span for global filter', async ({ baseURL }) => {
const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
transactionEvent?.transaction === 'GET /example-module/expected-exception' &&
transactionEvent?.request?.url?.includes('/example-module/expected-exception')
);
});

const response = await fetch(`${baseURL}/example-module/expected-exception`);
expect(response.status).toBe(400);

const transactionEvent = await transactionEventPromise;

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: 'ExampleExceptionFilter',
parent_span_id: expect.any(String),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
status: 'ok',
op: 'middleware.nestjs',
origin: 'auto.middleware.nestjs',
},
]),
}),
);
});

test('API route transaction includes exception filter span for local filter', async ({ baseURL }) => {
const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
transactionEvent?.transaction === 'GET /example-module-local-filter/expected-exception' &&
transactionEvent?.request?.url?.includes('/example-module-local-filter/expected-exception')
);
});

const response = await fetch(`${baseURL}/example-module-local-filter/expected-exception`);
expect(response.status).toBe(400);

const transactionEvent = await transactionEventPromise;

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: 'LocalExampleExceptionFilter',
parent_span_id: expect.any(String),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
status: 'ok',
op: 'middleware.nestjs',
origin: 'auto.middleware.nestjs',
},
]),
}),
);
});
4 changes: 4 additions & 0 deletions packages/nestjs/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export { SentryTracingInterceptor };
* Global filter to handle exceptions and report them to Sentry.
*/
class SentryGlobalFilter extends BaseExceptionFilter {
public static readonly __SENTRY_INTERNAL__ = true;

/**
* Catches exceptions and reports them to Sentry unless they are expected errors.
*/
Expand All @@ -84,6 +86,8 @@ export { SentryGlobalFilter };
* Service to set up Sentry performance tracing for Nest.js applications.
*/
class SentryService implements OnModuleInit {
public static readonly __SENTRY_INTERNAL__ = true;

/**
* Initializes the Sentry service and registers span attributes.
*/
Expand Down
6 changes: 3 additions & 3 deletions packages/node/src/integrations/tracing/nest/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
import { addNonEnumerableProperty } from '@sentry/utils';
import type { InjectableTarget } from './types';
import type { CatchTarget, InjectableTarget } from './types';

const sentryPatched = 'sentryPatched';

Expand All @@ -10,7 +10,7 @@ const sentryPatched = 'sentryPatched';
* 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.
* This check might not be necessary, but better to play it safe.
*/
export function isPatched(target: InjectableTarget): boolean {
export function isPatched(target: InjectableTarget | CatchTarget): boolean {
if (target.sentryPatched) {
return true;
}
Expand All @@ -23,7 +23,7 @@ export function isPatched(target: InjectableTarget): boolean {
* Returns span options for nest middleware spans.
*/
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function getMiddlewareSpanOptions(target: InjectableTarget) {
export function getMiddlewareSpanOptions(target: InjectableTarget | CatchTarget) {
return {
name: target.name,
attributes: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getActiveSpan, startSpan, startSpanManual, withActiveSpan } from '@sent
import type { Span } from '@sentry/types';
import { SDK_VERSION } from '@sentry/utils';
import { getMiddlewareSpanOptions, isPatched } from './helpers';
import type { InjectableTarget } from './types';
import type { CatchTarget, InjectableTarget } from './types';

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

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

moduleDef.files.push(this._getInjectableFileInstrumentation(supportedVersions));
moduleDef.files.push(
this._getInjectableFileInstrumentation(supportedVersions),
this._getCatchFileInstrumentation(supportedVersions),
);
return moduleDef;
}

Expand All @@ -58,10 +61,28 @@ export class SentryNestInstrumentation extends InstrumentationBase {
);
}

/**
* Wraps the @Catch decorator.
*/
private _getCatchFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile {
return new InstrumentationNodeModuleFile(
'@nestjs/common/decorators/core/catch.decorator.js',
versions,
(moduleExports: { Catch: CatchTarget }) => {
if (isWrapped(moduleExports.Catch)) {
this._unwrap(moduleExports, 'Catch');
}
this._wrap(moduleExports, 'Catch', this._createWrapCatch());
return moduleExports;
},
(moduleExports: { Catch: CatchTarget }) => {
this._unwrap(moduleExports, 'Catch');
},
);
}

/**
* Creates a wrapper function for the @Injectable decorator.
*
* Wraps the use method to instrument nest class middleware.
*/
private _createWrapInjectable() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -177,4 +198,33 @@ export class SentryNestInstrumentation extends InstrumentationBase {
};
};
}

/**
* Creates a wrapper function for the @Catch decorator. Used to instrument exception filters.
*/
private _createWrapCatch() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function wrapCatch(original: any) {
return function wrappedCatch(...exceptions: unknown[]) {
return function (target: CatchTarget) {
if (typeof target.prototype.catch === 'function' && !target.__SENTRY_INTERNAL__) {
// patch only once
if (isPatched(target)) {
return original(...exceptions)(target);
}

target.prototype.catch = new Proxy(target.prototype.catch, {
apply: (originalCatch, thisArgCatch, argsCatch) => {
return startSpan(getMiddlewareSpanOptions(target), () => {
return originalCatch.apply(thisArgCatch, argsCatch);
});
},
});
}

return original(...exceptions)(target);
};
};
};
}
}
12 changes: 12 additions & 0 deletions packages/node/src/integrations/tracing/nest/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,15 @@ export interface InjectableTarget {
intercept?: (context: unknown, next: CallHandler, ...args: any[]) => Observable<any>;
};
}

/**
* Represents a target class in NestJS annotated with @Catch.
*/
export interface CatchTarget {
name: string;
sentryPatched?: boolean;
__SENTRY_INTERNAL__?: boolean;
prototype: {
catch?: (...args: any[]) => any;
};
}

0 comments on commit b71d0fd

Please sign in to comment.