From 95786689dd8448c3a5bd9d812ab037e215978ab9 Mon Sep 17 00:00:00 2001 From: Sebastian Alex Date: Wed, 2 Oct 2024 17:21:57 +0200 Subject: [PATCH 1/5] nestjs: extract interceptor exception handling logic to handler --- packages/nestjs/src/backtrace.handler.ts | 208 +++++++ packages/nestjs/src/backtrace.interceptor.ts | 208 ++----- packages/nestjs/src/backtrace.module.ts | 35 +- packages/nestjs/src/index.ts | 1 + .../nestjs/tests/backtrace.handler.spec.ts | 521 ++++++++++++++++++ ...c.ts => backtrace.interceptor.e2e.spec.ts} | 87 ++- .../tests/backtrace.interceptor.spec.ts | 352 +----------- 7 files changed, 906 insertions(+), 506 deletions(-) create mode 100644 packages/nestjs/src/backtrace.handler.ts create mode 100644 packages/nestjs/tests/backtrace.handler.spec.ts rename packages/nestjs/tests/{e2e.spec.ts => backtrace.interceptor.e2e.spec.ts} (60%) diff --git a/packages/nestjs/src/backtrace.handler.ts b/packages/nestjs/src/backtrace.handler.ts new file mode 100644 index 00000000..68a62bc7 --- /dev/null +++ b/packages/nestjs/src/backtrace.handler.ts @@ -0,0 +1,208 @@ +import { BacktraceClient } from '@backtrace/node'; +import { HttpException, Injectable, Optional } from '@nestjs/common'; +import { + ArgumentsHost, + HttpArgumentsHost, + RpcArgumentsHost, + WsArgumentsHost, +} from '@nestjs/common/interfaces/index.js'; +import { type Request as ExpressRequest } from 'express'; + +type ExceptionTypeFilter = (new (...args: never[]) => unknown)[] | ((err: unknown) => boolean); + +export interface BacktraceExceptionHandlerOptions { + /** + * If specified, only matching errors will be sent. + * + * Can be an array of error types, or a function returning `boolean`. + * The error is passed to the function as first parameter. + * + * @example + * // Include only InternalServerErrorException or errors deriving from it + * { + * includeExceptionTypes: [InternalServerErrorException] + * } + * + * // Include only errors that are instanceof InternalServerErrorException + * { + * includeExceptionTypes: (error) => error instanceof InternalServerErrorException + * } + */ + readonly includeExceptionTypes?: ExceptionTypeFilter; + + /** + * If specified, matching errors will not be sent. + * Can be an array of error types, or a function returning `boolean`. + * The error is passed to the function as first parameter. + * + * @example + * // Exclude BadRequestException or errors deriving from it + * { + * excludeExceptionTypes: [BadRequestException] + * } + * + * // Exclude errors that are instanceof BadRequestException + * { + * excludeExceptionTypes: (error) => error instanceof BadRequestException + * } + */ + readonly excludeExceptionTypes?: ExceptionTypeFilter; + + /** + * Will not throw on initialization if `true` and the `BacktraceClient` instance is `undefined`. + * + * If this is `true` and the client instance is not available, the interceptor will not be run. + * + * @default false + */ + readonly skipIfClientUndefined?: boolean; + + /** + * This method will be called before sending the report. + * Use this to build attributes that will be attached to the report. + * + * Note that this will overwrite the attributes. To add you own and keep the defaults, use `defaultAttributes`. + * @param host Execution host. + * @param defaultAttributes Attributes created by default by the interceptor. + * @returns Attribute dictionary. + * + * @example + * buildAttributes: (host, defaultAttributes) => ({ + * ...defaultAttributes, + * 'request.body': host.switchToHttp().getRequest().body + * }) + */ + readonly buildAttributes?: (host: Context, defaultAttributes: Record) => Record; +} + +@Injectable() +export class BacktraceExceptionHandler { + private readonly _client?: BacktraceClient; + private readonly _options: BacktraceExceptionHandlerOptions; + + /** + * Creates handler with the global client instance. + * + * The instance will be resolved while intercepting the request. + * If the instance is not available, an error will be thrown. + */ + constructor(options?: BacktraceExceptionHandlerOptions); + /** + * Creates handler with the provided client instance. + */ + constructor(options: BacktraceExceptionHandlerOptions | undefined, client: BacktraceClient); + constructor(options: BacktraceExceptionHandlerOptions | undefined, @Optional() client?: BacktraceClient) { + this._options = { + ...BacktraceExceptionHandler.getDefaultOptions(), + ...options, + }; + this._client = client; + } + + public handleException(err: unknown, host: Context) { + const client = this._client ?? BacktraceClient.instance; + if (!client) { + if (this._options.skipIfClientUndefined) { + return false; + } + + throw new Error('Backtrace instance is unavailable. Initialize the client first.'); + } + + if (!this.shouldSend(err)) { + return false; + } + + let attributes: Record = { + ...this.getBaseAttributes(host), + ...this.getTypeAttributes(host), + }; + + if (this._options.buildAttributes) { + attributes = this._options.buildAttributes(host, attributes); + } + + if (typeof err !== 'string' && !(err instanceof Error)) { + client.send(String(err), attributes); + } else { + client.send(err, attributes); + } + + return true; + } + + private shouldSend(error: unknown) { + if (this._options.includeExceptionTypes && this.filterException(error, this._options.includeExceptionTypes)) { + return true; + } + + if (this._options.excludeExceptionTypes && this.filterException(error, this._options.excludeExceptionTypes)) { + return false; + } + + return true; + } + + private getBaseAttributes(host: ArgumentsHost) { + const contextType = host.getType(); + + return { + 'request.contextType': contextType, + }; + } + + private getTypeAttributes(host: ArgumentsHost) { + const type = host.getType(); + switch (type) { + case 'http': + return this.getHttpAttributes(host.switchToHttp()); + case 'rpc': + return this.getRpcAttributes(host.switchToRpc()); + case 'ws': + return this.getWsAttributes(host.switchToWs()); + default: + return {}; + } + } + + private getHttpAttributes(http: HttpArgumentsHost) { + const request = http.getRequest(); + const expressRequest = request as ExpressRequest; + return { + 'request.url': expressRequest.url, + 'request.baseUrl': expressRequest.baseUrl, + 'request.method': expressRequest.method, + 'request.originalUrl': expressRequest.originalUrl, + 'request.protocol': expressRequest.protocol, + 'request.hostname': expressRequest.hostname, + 'request.httpVersion': expressRequest.httpVersion, + }; + } + + private getRpcAttributes(rpc: RpcArgumentsHost) { + return { + ['rpc.data']: rpc.getData(), + }; + } + + private getWsAttributes(ws: WsArgumentsHost) { + return { + ['ws.data']: ws.getData(), + }; + } + + private filterException(exception: unknown, filter: ExceptionTypeFilter): boolean { + if (Array.isArray(filter)) { + return filter.some((f) => exception instanceof f); + } + + return filter(exception); + } + + private static getDefaultOptions(): BacktraceExceptionHandlerOptions { + return { + excludeExceptionTypes: (error) => error instanceof HttpException && error.getStatus() < 500, + skipIfClientUndefined: false, + }; + } +} diff --git a/packages/nestjs/src/backtrace.interceptor.ts b/packages/nestjs/src/backtrace.interceptor.ts index 36105982..e8c7f8b5 100644 --- a/packages/nestjs/src/backtrace.interceptor.ts +++ b/packages/nestjs/src/backtrace.interceptor.ts @@ -1,85 +1,16 @@ import { BacktraceClient } from '@backtrace/node'; -import { CallHandler, ExecutionContext, HttpException, Injectable, NestInterceptor, Optional } from '@nestjs/common'; -import { HttpArgumentsHost, RpcArgumentsHost, WsArgumentsHost } from '@nestjs/common/interfaces'; -import { type Request as ExpressRequest } from 'express'; +import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor, Optional } from '@nestjs/common'; import { Observable, catchError, throwError } from 'rxjs'; +import { BacktraceExceptionHandler, BacktraceExceptionHandlerOptions } from './backtrace.handler.js'; -type ExceptionTypeFilter = (new (...args: never[]) => unknown)[] | ((err: unknown) => boolean); - -export interface BacktraceInterceptorOptions { - /** - * If specified, only matching errors will be sent. - * - * Can be an array of error types, or a function returning `boolean`. - * The error is passed to the function as first parameter. - * - * @example - * // Include only InternalServerErrorException or errors deriving from it - * { - * includeExceptionTypes: [InternalServerErrorException] - * } - * - * // Include only errors that are instanceof InternalServerErrorException - * { - * includeExceptionTypes: (error) => error instanceof InternalServerErrorException - * } - */ - readonly includeExceptionTypes?: ExceptionTypeFilter; - - /** - * If specified, matching errors will not be sent. - * Can be an array of error types, or a function returning `boolean`. - * The error is passed to the function as first parameter. - * - * @example - * // Exclude BadRequestException or errors deriving from it - * { - * excludeExceptionTypes: [BadRequestException] - * } - * - * // Exclude errors that are instanceof BadRequestException - * { - * excludeExceptionTypes: (error) => error instanceof BadRequestException - * } - */ - readonly excludeExceptionTypes?: ExceptionTypeFilter; - - /** - * Will not throw on initialization if `true` and the `BacktraceClient` instance is `undefined`. - * - * If this is `true` and the client instance is not available, the interceptor will not be run. - * - * @default false - */ - readonly skipIfClientUndefined?: boolean; - - /** - * This method will be called before sending the report. - * Use this to build attributes that will be attached to the report. - * - * Note that this will overwrite the attributes. To add you own and keep the defaults, use `defaultAttributes`. - * @param context Execution context. - * @param defaultAttributes Attributes created by default by the interceptor. - * @returns Attribute dictionary. - * - * @example - * buildAttributes: (context, defaultAttributes) => ({ - * ...defaultAttributes, - * 'request.body': context.switchToHttp().getRequest().body - * }) - */ - readonly buildAttributes?: ( - context: ExecutionContext, - defaultAttributes: Record, - ) => Record; -} +export type BacktraceInterceptorOptions = BacktraceExceptionHandlerOptions; /** * Intercepts errors and sends them to Backtrace. */ @Injectable() export class BacktraceInterceptor implements NestInterceptor { - private readonly _client?: BacktraceClient; + private readonly _handler: BacktraceExceptionHandler; /** * Creates an interceptor with the global client instance. @@ -92,124 +23,55 @@ export class BacktraceInterceptor implements NestInterceptor { * Creates an interceptor with the provided client instance. */ constructor(options: BacktraceInterceptorOptions | undefined, client: BacktraceClient); + constructor(handler: BacktraceExceptionHandler); constructor( - private readonly _options = BacktraceInterceptor.getDefaultOptions(), + @Inject(BacktraceExceptionHandler) + handlerOrOptions: BacktraceInterceptorOptions | BacktraceExceptionHandler | undefined, @Optional() client?: BacktraceClient, ) { - this._client = client; - } + if (handlerOrOptions instanceof BacktraceExceptionHandler) { + this._handler = handlerOrOptions; + return; + } - public intercept(context: ExecutionContext, next: CallHandler): Observable { - const client = this._client ?? BacktraceClient.instance; - if (!client) { - if (this._options.skipIfClientUndefined) { - return next.handle(); - } + const options = handlerOrOptions; + const handlerOptions: BacktraceInterceptorOptions = { + ...options, + buildAttributes: BacktraceInterceptor.extendBuildAttributes(options?.buildAttributes), + }; - throw new Error('Backtrace instance is unavailable. Initialize the client first.'); + if (client) { + this._handler = new BacktraceExceptionHandler(handlerOptions, client); + } else { + this._handler = new BacktraceExceptionHandler(handlerOptions); } + } + public intercept(context: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( catchError((err) => { - if (!this.shouldSend(err)) { - return throwError(() => err); - } - - let attributes: Record = { - ...this.getBaseAttributes(context), - ...this.getTypeAttributes(context), - }; - - if (this._options.buildAttributes) { - attributes = this._options.buildAttributes(context, attributes); - } - - if (typeof err !== 'string' && !(err instanceof Error)) { - client.send(String(err), attributes); - } else { - client.send(err, attributes); - } - + this._handler.handleException(err, context); return throwError(() => err); }), ); } - private shouldSend(error: unknown) { - if (this._options.includeExceptionTypes && !this.filterException(error, this._options.includeExceptionTypes)) { - return false; - } - - if (this._options.excludeExceptionTypes && this.filterException(error, this._options.excludeExceptionTypes)) { - return false; - } - - return true; - } - - private getBaseAttributes(context: ExecutionContext) { - const controller = context.getClass().name; - const contextType = context.getType(); + private static extendBuildAttributes( + buildAttributes?: BacktraceInterceptorOptions['buildAttributes'], + ): BacktraceInterceptorOptions['buildAttributes'] { + const getAttributes: BacktraceInterceptorOptions['buildAttributes'] = (context, attributes) => { + const controller = context.getClass().name; - return { - 'request.controller': controller, - 'request.contextType': contextType, + return { + ...attributes, + 'request.controller': controller, + }; }; - } - private getTypeAttributes(context: ExecutionContext) { - const type = context.getType(); - switch (type) { - case 'http': - return this.getHttpAttributes(context.switchToHttp()); - case 'rpc': - return this.getRpcAttributes(context.switchToRpc()); - case 'ws': - return this.getWsAttributes(context.switchToWs()); - default: - return {}; + if (!buildAttributes) { + return getAttributes; } - } - private getHttpAttributes(http: HttpArgumentsHost) { - const request = http.getRequest(); - const expressRequest = request as ExpressRequest; - return { - 'request.url': expressRequest.url, - 'request.baseUrl': expressRequest.baseUrl, - 'request.method': expressRequest.method, - 'request.originalUrl': expressRequest.originalUrl, - 'request.protocol': expressRequest.protocol, - 'request.hostname': expressRequest.hostname, - 'request.httpVersion': expressRequest.httpVersion, - }; - } - - private getRpcAttributes(rpc: RpcArgumentsHost) { - return { - ['rpc.data']: rpc.getData(), - }; - } - - private getWsAttributes(ws: WsArgumentsHost) { - return { - ['ws.data']: ws.getData(), - }; - } - - private filterException(exception: unknown, filter: ExceptionTypeFilter): boolean { - if (Array.isArray(filter)) { - return filter.some((f) => exception instanceof f); - } - - return filter(exception); - } - - private static getDefaultOptions(): BacktraceInterceptorOptions { - return { - includeExceptionTypes: [Error], - excludeExceptionTypes: (error) => error instanceof HttpException && error.getStatus() < 500, - skipIfClientUndefined: false, - }; + return (context, attributes) => buildAttributes(context, getAttributes(context, attributes)); } } diff --git a/packages/nestjs/src/backtrace.module.ts b/packages/nestjs/src/backtrace.module.ts index c139e4c1..749a79e9 100644 --- a/packages/nestjs/src/backtrace.module.ts +++ b/packages/nestjs/src/backtrace.module.ts @@ -1,8 +1,22 @@ import { BacktraceClient } from '@backtrace/node'; import { ConfigurableModuleBuilder, Global, Module } from '@nestjs/common'; +import { BacktraceExceptionHandler, BacktraceExceptionHandlerOptions } from './backtrace.handler.js'; +import { BacktraceInterceptor } from './backtrace.interceptor.js'; -const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = new ConfigurableModuleBuilder< - BacktraceClient | undefined +export interface BacktraceModuleOptions { + /** + * Optional client instance to be used. If this is not provided, the global instance will be used. + */ + readonly client?: BacktraceClient; + + /** + * Backtrace exception handler options. Will be injected into the interceptor and filter, if not specified there. + */ + readonly options?: BacktraceExceptionHandlerOptions; +} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder< + BacktraceModuleOptions | BacktraceClient | undefined >().build(); /** @@ -20,7 +34,11 @@ const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = new Conf }, { provide: BacktraceClient, - useFactory: (instance?: typeof OPTIONS_TYPE) => { + useFactory: (instanceOrOptions?: BacktraceModuleOptions | BacktraceClient) => { + const instance = + (instanceOrOptions instanceof BacktraceClient ? instanceOrOptions : instanceOrOptions?.client) ?? + BacktraceClient.instance; + if (!instance) { throw new Error( 'Backtrace instance is not available. Initialize it first, or pass it into the module using register/registerAsync.', @@ -31,7 +49,16 @@ const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = new Conf }, inject: [MODULE_OPTIONS_TOKEN], }, + { + provide: BacktraceExceptionHandler, + useFactory: (instanceOrOptions?: BacktraceModuleOptions | BacktraceClient) => { + const options = instanceOrOptions instanceof BacktraceClient ? undefined : instanceOrOptions?.options; + return new BacktraceExceptionHandler(options); + }, + inject: [MODULE_OPTIONS_TOKEN], + }, + BacktraceInterceptor, ], - exports: [BacktraceClient], + exports: [BacktraceClient, BacktraceExceptionHandler, BacktraceInterceptor], }) export class BacktraceModule extends ConfigurableModuleClass {} diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts index aa5aeb99..46052fac 100644 --- a/packages/nestjs/src/index.ts +++ b/packages/nestjs/src/index.ts @@ -1,3 +1,4 @@ export * from '@backtrace/node'; +export * from './backtrace.handler.js'; export * from './backtrace.interceptor.js'; export * from './backtrace.module.js'; diff --git a/packages/nestjs/tests/backtrace.handler.spec.ts b/packages/nestjs/tests/backtrace.handler.spec.ts new file mode 100644 index 00000000..22d0a927 --- /dev/null +++ b/packages/nestjs/tests/backtrace.handler.spec.ts @@ -0,0 +1,521 @@ +import { BacktraceClient } from '@backtrace/node'; +import { + ArgumentsHost, + BadRequestException, + Controller, + Get, + HttpException, + InternalServerErrorException, + NotFoundException, + Type, +} from '@nestjs/common'; +import { BaseExceptionFilter, HttpAdapterHost } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { BacktraceExceptionHandler, BacktraceExceptionHandlerOptions } from '../src/backtrace.handler.js'; + +describe('BacktraceInterceptor', () => { + function createMockClient() { + const send = jest.fn(); + const client = { send } as unknown as BacktraceClient; + return { client, send }; + } + + function createHandler(options?: BacktraceExceptionHandlerOptions) { + const { client, send } = createMockClient(); + const interceptor = new BacktraceExceptionHandler(options, client); + + return { client, send, interceptor }; + } + + async function createAppWithHandler(handler: BacktraceExceptionHandler, controller: Type) { + const module = await Test.createTestingModule({ + controllers: [controller], + }).compile(); + + const app = module.createNestApplication({ + logger: false, + }); + + class Filter extends BaseExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void { + handler.handleException(exception, host); + super.catch(exception, host); + } + } + + const { httpAdapter } = app.get(HttpAdapterHost); + app.useGlobalFilters(new Filter(httpAdapter)); + + return { module, app }; + } + + it('should send report to Backtrace', async () => { + const error = new Error('foo'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({}); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + + expect(send).toBeCalledWith(error, expect.anything()); + }); + + it('should not change the error', async () => { + const error = new Error('foo'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { interceptor } = createHandler({}); + const { app } = await createAppWithHandler(interceptor, TestController); + + const filterPromise = new Promise((resolve, reject) => { + app.useGlobalFilters({ + catch(exception, host) { + try { + expect(exception).toBe(error); + resolve(); + } catch (err) { + reject(err); + } finally { + host.switchToHttp().getResponse().sendStatus(500); + } + }, + }); + }); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + await filterPromise; + }); + + it('should not report to Backtrace when error is not thrown', async () => { + @Controller() + class TestController { + @Get('ok') + public ok() { + return 'ok'; + } + } + + const { send, interceptor } = createHandler({}); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/ok').expect(200); + + expect(send).not.toBeCalled(); + }); + + describe('include', () => { + it('should not send when error type is not on include list', async () => { + const error = new BadRequestException('abc'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({ + includeExceptionTypes: [NotFoundException], + }); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).not.toBeCalled(); + }); + + it('should send when error type is on include list as a type', async () => { + const error = new BadRequestException('abc'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({ + includeExceptionTypes: [BadRequestException], + }); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).toBeCalled(); + }); + + it('should send when error type is on include list as a subtype', async () => { + const error = new BadRequestException('abc'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({ + includeExceptionTypes: [Error], + }); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).toBeCalled(); + }); + + it('should send when include resolves to true', async () => { + const error = new BadRequestException('abc'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({ + includeExceptionTypes: () => true, + }); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).toBeCalled(); + }); + + it('should not send when include resolves to false', async () => { + const error = new BadRequestException('abc'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({ + includeExceptionTypes: () => false, + }); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).not.toBeCalled(); + }); + }); + + describe('exclude', () => { + it('should not send when error type is on exclude list', async () => { + const error = new BadRequestException('abc'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({ + excludeExceptionTypes: [BadRequestException], + }); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).not.toBeCalled(); + }); + + it('should send when error type is not on exclude list as a type', async () => { + const error = new BadRequestException('abc'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({ + excludeExceptionTypes: [NotFoundException], + }); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).toBeCalled(); + }); + + it('should not send when error type is on exclude list as a subtype', async () => { + const error = new BadRequestException('abc'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({ + excludeExceptionTypes: [Error], + }); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).not.toBeCalled(); + }); + + it('should not send when exclude resolves to true', async () => { + const error = new BadRequestException('abc'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({ + excludeExceptionTypes: () => true, + }); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).not.toBeCalled(); + }); + + it('should send when exclude resolves to false', async () => { + const error = new BadRequestException('abc'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const { send, interceptor } = createHandler({ + excludeExceptionTypes: () => false, + }); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).toBeCalled(); + }); + }); + + describe('attributes', () => { + it('should by default add request attributes for class and contextType', async () => { + @Controller() + class TestController { + @Get('error') + public error() { + throw new Error('foo'); + } + } + + const { send, interceptor } = createHandler(); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + + const actual = send.mock.calls[0][1]; + expect(actual).toMatchObject({ + 'request.contextType': 'http', + }); + }); + + it('should use attributes from buildAttributes if available', async () => { + @Controller() + class TestController { + @Get('error') + public error() { + throw new Error('foo'); + } + } + + const attributes = { + foo: 'bar', + xyz: 'abc', + }; + + const { send, interceptor } = createHandler({ + buildAttributes: () => attributes, + }); + + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + + const actual = send.mock.calls[0][1]; + expect(actual).toEqual(attributes); + }); + + it('should pass default attributes to buildAttributes', async () => { + @Controller() + class TestController { + @Get('error') + public error() { + throw new Error('foo'); + } + } + + const buildAttributes = jest.fn().mockReturnValue({ + foo: 'bar', + xyz: 'abc', + }); + + const { interceptor } = createHandler({ + buildAttributes, + }); + + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + + expect(buildAttributes).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + 'request.contextType': 'http', + }), + ); + }); + }); + + describe('include and exclude default behavior', () => { + it('should by default send Error exceptions', async () => { + @Controller() + class TestController { + @Get('error') + public error() { + throw new Error('foo'); + } + } + + const { send, interceptor } = createHandler(); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + + expect(send).toBeCalled(); + }); + + it('should by default send InternalServerErrorException exceptions', async () => { + @Controller() + class TestController { + @Get('error') + public error() { + throw new InternalServerErrorException('foo'); + } + } + + const { send, interceptor } = createHandler(); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + + expect(send).toBeCalled(); + }); + + it('should by default send HttpException exceptions with 500 status', async () => { + @Controller() + class TestController { + @Get('error') + public error() { + throw new HttpException('foo', 500); + } + } + + const { send, interceptor } = createHandler(); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + + expect(send).toBeCalled(); + }); + + it('should by default not send HttpException exceptions with 400 status', async () => { + @Controller() + class TestController { + @Get('error') + public error() { + throw new HttpException('foo', 400); + } + } + + const { send, interceptor } = createHandler(); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).not.toBeCalled(); + }); + + it('should by default not send BadRequestException exceptions', async () => { + @Controller() + class TestController { + @Get('error') + public error() { + throw new BadRequestException('foo'); + } + } + + const { send, interceptor } = createHandler(); + const { app } = await createAppWithHandler(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(400); + + expect(send).not.toBeCalled(); + }); + }); +}); diff --git a/packages/nestjs/tests/e2e.spec.ts b/packages/nestjs/tests/backtrace.interceptor.e2e.spec.ts similarity index 60% rename from packages/nestjs/tests/e2e.spec.ts rename to packages/nestjs/tests/backtrace.interceptor.e2e.spec.ts index a3a1f7ac..958a8caa 100644 --- a/packages/nestjs/tests/e2e.spec.ts +++ b/packages/nestjs/tests/backtrace.interceptor.e2e.spec.ts @@ -3,14 +3,97 @@ import { Controller, Get, UseInterceptors } from '@nestjs/common'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { Test } from '@nestjs/testing'; import request from 'supertest'; +import { BacktraceExceptionHandlerOptions } from '../src/backtrace.handler.js'; import { BacktraceClient, BacktraceInterceptor, BacktraceModule } from '../src/index.js'; -describe('e2e', () => { +describe('BacktraceInterceptor e2e', () => { beforeEach(() => { BacktraceClient.instance?.dispose(); }); - it('should send an error when interceptor is added to controller', async () => { + it('should send an error when interceptor is added to controller via type', async () => { + @Controller() + @UseInterceptors(BacktraceInterceptor) + class TestController { + @Get('error') + public error() { + throw new Error('foo'); + } + } + + const postError = jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})); + + BacktraceClient.initialize( + { + url: 'https://test', + }, + (builder) => + builder.useRequestHandler({ + postError, + post: jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})), + }), + ); + + const module = await Test.createTestingModule({ + controllers: [TestController], + imports: [BacktraceModule], + }).compile(); + + const app = module.createNestApplication({ + logger: false, + }); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + + expect(postError).toBeCalled(); + }); + + it('should use options from module when interceptor is added to controller via type', async () => { + @Controller() + @UseInterceptors(BacktraceInterceptor) + class TestController { + @Get('error') + public error() { + throw new Error('foo'); + } + } + + const postError = jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})); + + BacktraceClient.initialize( + { + url: 'https://test', + }, + (builder) => + builder.useRequestHandler({ + postError, + post: jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})), + }), + ); + + const buildAttributes = jest.fn().mockReturnValue({}); + + const options: BacktraceExceptionHandlerOptions = { + buildAttributes, + }; + + const module = await Test.createTestingModule({ + controllers: [TestController], + imports: [BacktraceModule.register({ options })], + }).compile(); + + const app = module.createNestApplication({ + logger: false, + }); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + + expect(buildAttributes).toBeCalled(); + }); + + it('should send an error when interceptor is added to controller via value', async () => { @Controller() @UseInterceptors(new BacktraceInterceptor()) class TestController { diff --git a/packages/nestjs/tests/backtrace.interceptor.spec.ts b/packages/nestjs/tests/backtrace.interceptor.spec.ts index d31fedab..416db544 100644 --- a/packages/nestjs/tests/backtrace.interceptor.spec.ts +++ b/packages/nestjs/tests/backtrace.interceptor.spec.ts @@ -1,15 +1,8 @@ import { BacktraceClient } from '@backtrace/node'; -import { - BadRequestException, - Controller, - Get, - HttpException, - InternalServerErrorException, - NotFoundException, - Type, -} from '@nestjs/common'; +import { Controller, Get, Type } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; +import { BacktraceExceptionHandler } from '../src/backtrace.handler.js'; import { BacktraceInterceptor, BacktraceInterceptorOptions } from '../src/backtrace.interceptor.js'; describe('BacktraceInterceptor', () => { @@ -60,6 +53,28 @@ describe('BacktraceInterceptor', () => { expect(send).toBeCalledWith(error, expect.anything()); }); + it('should call handler.handleException', async () => { + const error = new Error('foo'); + + @Controller() + class TestController { + @Get('error') + public error() { + throw error; + } + } + + const handleSpy = jest.spyOn(BacktraceExceptionHandler.prototype, 'handleException'); + + const { interceptor } = createInterceptor({}); + const { app } = await createAppWithInterceptor(interceptor, TestController); + + await app.init(); + await request(app.getHttpServer()).get('/error').expect(500); + + expect(handleSpy).toBeCalledWith(error, expect.anything()); + }); + it('should not change the error', async () => { const error = new Error('foo'); @@ -112,232 +127,8 @@ describe('BacktraceInterceptor', () => { expect(send).not.toBeCalled(); }); - describe('include', () => { - it('should not send when error type is not on include list', async () => { - const error = new BadRequestException('abc'); - - @Controller() - class TestController { - @Get('error') - public error() { - throw error; - } - } - - const { send, interceptor } = createInterceptor({ - includeExceptionTypes: [NotFoundException], - }); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).not.toBeCalled(); - }); - - it('should send when error type is on include list as a type', async () => { - const error = new BadRequestException('abc'); - - @Controller() - class TestController { - @Get('error') - public error() { - throw error; - } - } - - const { send, interceptor } = createInterceptor({ - includeExceptionTypes: [BadRequestException], - }); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).toBeCalled(); - }); - - it('should send when error type is on include list as a subtype', async () => { - const error = new BadRequestException('abc'); - - @Controller() - class TestController { - @Get('error') - public error() { - throw error; - } - } - - const { send, interceptor } = createInterceptor({ - includeExceptionTypes: [Error], - }); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).toBeCalled(); - }); - - it('should send when include resolves to true', async () => { - const error = new BadRequestException('abc'); - - @Controller() - class TestController { - @Get('error') - public error() { - throw error; - } - } - - const { send, interceptor } = createInterceptor({ - includeExceptionTypes: () => true, - }); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).toBeCalled(); - }); - - it('should not send when include resolves to false', async () => { - const error = new BadRequestException('abc'); - - @Controller() - class TestController { - @Get('error') - public error() { - throw error; - } - } - - const { send, interceptor } = createInterceptor({ - includeExceptionTypes: () => false, - }); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).not.toBeCalled(); - }); - }); - - describe('exclude', () => { - it('should not send when error type is on exclude list', async () => { - const error = new BadRequestException('abc'); - - @Controller() - class TestController { - @Get('error') - public error() { - throw error; - } - } - - const { send, interceptor } = createInterceptor({ - excludeExceptionTypes: [BadRequestException], - }); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).not.toBeCalled(); - }); - - it('should send when error type is not on exclude list as a type', async () => { - const error = new BadRequestException('abc'); - - @Controller() - class TestController { - @Get('error') - public error() { - throw error; - } - } - - const { send, interceptor } = createInterceptor({ - excludeExceptionTypes: [NotFoundException], - }); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).toBeCalled(); - }); - - it('should not send when error type is on exclude list as a subtype', async () => { - const error = new BadRequestException('abc'); - - @Controller() - class TestController { - @Get('error') - public error() { - throw error; - } - } - - const { send, interceptor } = createInterceptor({ - excludeExceptionTypes: [Error], - }); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).not.toBeCalled(); - }); - - it('should not send when exclude resolves to true', async () => { - const error = new BadRequestException('abc'); - - @Controller() - class TestController { - @Get('error') - public error() { - throw error; - } - } - - const { send, interceptor } = createInterceptor({ - excludeExceptionTypes: () => true, - }); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).not.toBeCalled(); - }); - - it('should send when exclude resolves to false', async () => { - const error = new BadRequestException('abc'); - - @Controller() - class TestController { - @Get('error') - public error() { - throw error; - } - } - - const { send, interceptor } = createInterceptor({ - excludeExceptionTypes: () => false, - }); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).toBeCalled(); - }); - }); - describe('attributes', () => { - it('should by default add request attributes for class and contextType', async () => { + it('should by default add request attributes for controller type', async () => { @Controller() class TestController { @Get('error') @@ -355,7 +146,6 @@ describe('BacktraceInterceptor', () => { const actual = send.mock.calls[0][1]; expect(actual).toMatchObject({ 'request.controller': TestController.name, - 'request.contextType': 'http', }); }); @@ -418,96 +208,4 @@ describe('BacktraceInterceptor', () => { ); }); }); - - describe('include and exclude default behavior', () => { - it('should by default send Error exceptions', async () => { - @Controller() - class TestController { - @Get('error') - public error() { - throw new Error('foo'); - } - } - - const { send, interceptor } = createInterceptor(); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(500); - - expect(send).toBeCalled(); - }); - - it('should by default send InternalServerErrorException exceptions', async () => { - @Controller() - class TestController { - @Get('error') - public error() { - throw new InternalServerErrorException('foo'); - } - } - - const { send, interceptor } = createInterceptor(); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(500); - - expect(send).toBeCalled(); - }); - - it('should by default send HttpException exceptions with 500 status', async () => { - @Controller() - class TestController { - @Get('error') - public error() { - throw new HttpException('foo', 500); - } - } - - const { send, interceptor } = createInterceptor(); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(500); - - expect(send).toBeCalled(); - }); - - it('should by default not send HttpException exceptions with 400 status', async () => { - @Controller() - class TestController { - @Get('error') - public error() { - throw new HttpException('foo', 400); - } - } - - const { send, interceptor } = createInterceptor(); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).not.toBeCalled(); - }); - - it('should by default not send BadRequestException exceptions', async () => { - @Controller() - class TestController { - @Get('error') - public error() { - throw new BadRequestException('foo'); - } - } - - const { send, interceptor } = createInterceptor(); - const { app } = await createAppWithInterceptor(interceptor, TestController); - - await app.init(); - await request(app.getHttpServer()).get('/error').expect(400); - - expect(send).not.toBeCalled(); - }); - }); }); From c2da09c9124c5d70b967d24be7bf12309a5e433c Mon Sep 17 00:00:00 2001 From: Sebastian Alex Date: Wed, 2 Oct 2024 17:22:07 +0200 Subject: [PATCH 2/5] nestjs: add exception filter --- packages/nestjs/src/backtrace.filter.ts | 57 +++++ packages/nestjs/src/backtrace.module.ts | 4 +- packages/nestjs/src/index.ts | 1 + .../nestjs/tests/backtrace.filter.e2e.spec.ts | 213 ++++++++++++++++++ 4 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 packages/nestjs/src/backtrace.filter.ts create mode 100644 packages/nestjs/tests/backtrace.filter.e2e.spec.ts diff --git a/packages/nestjs/src/backtrace.filter.ts b/packages/nestjs/src/backtrace.filter.ts new file mode 100644 index 00000000..f85ac1fd --- /dev/null +++ b/packages/nestjs/src/backtrace.filter.ts @@ -0,0 +1,57 @@ +import { BacktraceClient } from '@backtrace/node'; +import { ArgumentsHost, HttpServer, Inject, Injectable, Optional } from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; +import { BacktraceExceptionHandler, BacktraceExceptionHandlerOptions } from './backtrace.handler.js'; + +export type BacktraceExceptionFilterOptions = BacktraceExceptionHandlerOptions; + +@Injectable() +export class BacktraceExceptionFilter extends BaseExceptionFilter { + private readonly _handler: BacktraceExceptionHandler; + + /** + * Creates a filter with the global client instance. + * + * The instance will be resolved while catching the exception. + * If the instance is not available, an error will be thrown. + */ + constructor(options?: BacktraceExceptionFilterOptions, applicationRef?: HttpServer); + /** + * Creates a filter with the provided client instance. + */ + constructor( + options: BacktraceExceptionFilterOptions | undefined, + client: BacktraceClient, + applicationRef?: HttpServer, + ); + constructor(handler: BacktraceExceptionHandler, applicationRef?: HttpServer); + constructor( + @Inject(BacktraceExceptionHandler) + handlerOrOptions: BacktraceExceptionFilterOptions | BacktraceExceptionHandler | undefined, + @Inject(BacktraceClient) @Optional() clientOrApplicationRef?: BacktraceClient | HttpServer, + @Optional() maybeApplicationRef?: HttpServer, + ) { + const applicationRef = + (maybeApplicationRef ?? clientOrApplicationRef instanceof BacktraceClient) + ? maybeApplicationRef + : clientOrApplicationRef; + + super(applicationRef); + if (handlerOrOptions instanceof BacktraceExceptionHandler) { + this._handler = handlerOrOptions; + return; + } + + const options = handlerOrOptions; + if (clientOrApplicationRef instanceof BacktraceClient) { + this._handler = new BacktraceExceptionHandler(options, clientOrApplicationRef); + } else { + this._handler = new BacktraceExceptionHandler(options); + } + } + + public catch(exception: unknown, host: ArgumentsHost): void { + this._handler.handleException(exception, host); + super.catch(exception, host); + } +} diff --git a/packages/nestjs/src/backtrace.module.ts b/packages/nestjs/src/backtrace.module.ts index 749a79e9..7f4e315b 100644 --- a/packages/nestjs/src/backtrace.module.ts +++ b/packages/nestjs/src/backtrace.module.ts @@ -1,5 +1,6 @@ import { BacktraceClient } from '@backtrace/node'; import { ConfigurableModuleBuilder, Global, Module } from '@nestjs/common'; +import { BacktraceExceptionFilter } from './backtrace.filter.js'; import { BacktraceExceptionHandler, BacktraceExceptionHandlerOptions } from './backtrace.handler.js'; import { BacktraceInterceptor } from './backtrace.interceptor.js'; @@ -58,7 +59,8 @@ const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModule inject: [MODULE_OPTIONS_TOKEN], }, BacktraceInterceptor, + BacktraceExceptionFilter, ], - exports: [BacktraceClient, BacktraceExceptionHandler, BacktraceInterceptor], + exports: [BacktraceClient, BacktraceExceptionFilter, BacktraceExceptionHandler, BacktraceInterceptor], }) export class BacktraceModule extends ConfigurableModuleClass {} diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts index 46052fac..db1b2378 100644 --- a/packages/nestjs/src/index.ts +++ b/packages/nestjs/src/index.ts @@ -1,4 +1,5 @@ export * from '@backtrace/node'; +export * from './backtrace.filter.js'; export * from './backtrace.handler.js'; export * from './backtrace.interceptor.js'; export * from './backtrace.module.js'; diff --git a/packages/nestjs/tests/backtrace.filter.e2e.spec.ts b/packages/nestjs/tests/backtrace.filter.e2e.spec.ts new file mode 100644 index 00000000..689042f7 --- /dev/null +++ b/packages/nestjs/tests/backtrace.filter.e2e.spec.ts @@ -0,0 +1,213 @@ +import { BacktraceReportSubmissionResult } from '@backtrace/sdk-core'; +import { CanActivate, Controller, Get, UseFilters, UseGuards } from '@nestjs/common'; +import { APP_FILTER, HttpAdapterHost } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { BacktraceExceptionFilter } from '../src/backtrace.filter.js'; +import { BacktraceExceptionHandlerOptions } from '../src/backtrace.handler.js'; +import { BacktraceClient, BacktraceModule } from '../src/index.js'; + +describe('BacktraceExceptionFilter e2e', () => { + beforeEach(() => { + BacktraceClient.instance?.dispose(); + }); + + it('should send an error thrown from a guard when filter is added to controller via type', async () => { + class ThrowingGuard implements CanActivate { + canActivate(): never { + throw new Error('foo'); + } + } + + @Controller() + class TestController { + @Get('error') + @UseGuards(ThrowingGuard) + @UseFilters(BacktraceExceptionFilter) + public error() { + return 'should not reach here'; + } + } + + const postError = jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})); + + BacktraceClient.initialize( + { + url: 'https://test', + }, + (builder) => + builder.useRequestHandler({ + postError, + post: jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})), + }), + ); + + const module = await Test.createTestingModule({ + controllers: [TestController], + imports: [BacktraceModule], + }).compile(); + + const app = module.createNestApplication({ + logger: false, + }); + await app.init(); + + await request(app.getHttpServer()).get('/error').expect(500); + + expect(postError).toBeCalled(); + }); + + it('should use options from module when filter is added to controller via type', async () => { + class ThrowingGuard implements CanActivate { + canActivate(): never { + throw new Error('foo'); + } + } + + @Controller() + class TestController { + @Get('error') + @UseGuards(ThrowingGuard) + @UseFilters(BacktraceExceptionFilter) + public error() { + return 'should not reach here'; + } + } + + const postError = jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})); + + BacktraceClient.initialize( + { + url: 'https://test', + }, + (builder) => + builder.useRequestHandler({ + postError, + post: jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})), + }), + ); + + const buildAttributes = jest.fn().mockReturnValue({}); + + const options: BacktraceExceptionHandlerOptions = { + buildAttributes, + }; + + const module = await Test.createTestingModule({ + controllers: [TestController], + imports: [ + BacktraceModule.register({ + options, + }), + ], + }).compile(); + + const app = module.createNestApplication({ + logger: false, + }); + await app.init(); + + await request(app.getHttpServer()).get('/error').expect(500); + + expect(buildAttributes).toBeCalled(); + }); + + it('should send an error thrown from a guard when filter is added globally via useGlobalFilters', async () => { + class ThrowingGuard implements CanActivate { + canActivate(): never { + throw new Error('foo'); + } + } + + @Controller() + class TestController { + @Get('error') + @UseGuards(ThrowingGuard) + public error() { + return 'should not reach here'; + } + } + + const postError = jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})); + + BacktraceClient.initialize( + { + url: 'https://test', + }, + (builder) => + builder.useRequestHandler({ + postError, + post: jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})), + }), + ); + + const module = await Test.createTestingModule({ + controllers: [TestController], + imports: [BacktraceModule], + }).compile(); + + const app = module.createNestApplication({ + logger: false, + }); + + const { httpAdapter } = app.get(HttpAdapterHost); + app.useGlobalFilters(new BacktraceExceptionFilter({}, httpAdapter)); + + await app.init(); + + await request(app.getHttpServer()).get('/error').expect(500); + + expect(postError).toBeCalled(); + }); + + it('should send an error thrown from a guard when filter is added globally via APP_FILTER', async () => { + class ThrowingGuard implements CanActivate { + canActivate(): never { + throw new Error('foo'); + } + } + + @Controller() + class TestController { + @Get('error') + @UseGuards(ThrowingGuard) + public error() { + return 'should not reach here'; + } + } + + const postError = jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})); + + BacktraceClient.initialize( + { + url: 'https://test', + }, + (builder) => + builder.useRequestHandler({ + postError, + post: jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.Ok({})), + }), + ); + + const module = await Test.createTestingModule({ + controllers: [TestController], + providers: [ + { + provide: APP_FILTER, + useExisting: BacktraceExceptionFilter, + }, + ], + imports: [BacktraceModule], + }).compile(); + + const app = module.createNestApplication({ + logger: false, + }); + + await app.init(); + + await request(app.getHttpServer()).get('/error').expect(500); + + expect(postError).toBeCalled(); + }); +}); From 2a8e812966712bbc2a8dc5e968049713ac32544f Mon Sep 17 00:00:00 2001 From: Sebastian Alex Date: Wed, 2 Oct 2024 17:42:11 +0200 Subject: [PATCH 3/5] nestjs: update README --- packages/nestjs/README.md | 132 ++++++++++++++++++++++++++++++++++---- 1 file changed, 118 insertions(+), 14 deletions(-) diff --git a/packages/nestjs/README.md b/packages/nestjs/README.md index e25e3bd7..ce74cba5 100644 --- a/packages/nestjs/README.md +++ b/packages/nestjs/README.md @@ -13,6 +13,8 @@ after which you can explore the rich set of Backtrace features. - [Integrate the SDK](#integrate-the-sdk) - [Upload source maps](#upload-source-maps) - [Add a Backtrace error interceptor](#add-a-backtrace-error-interceptor) + - [Add a Backtrace exception filter](#add-a-backtrace-exception-filter) + - [Use the Backtrace exception handler in your code](#use-the-backtrace-exception-handler-in-your-code) 1. [Error Reporting Features](#error-reporting-features) - [Attributes](#attributes) - [File Attachments](#file-attachments) @@ -75,11 +77,38 @@ class AppController { @Post() public endpoint() { // Manually send an error - this._cclient.send(new Error('Something broke!')); + this._client.send(new Error('Something broke!')); } } ``` +#### Configuring the module + +By default, the exception handler will exclude: + +- all `HttpException` errors that have `status < 500`. + +To include or exclude specific error types, pass options to `BacktraceModule`: + +```ts +@Module({ + imports: [ + BacktraceModule.register({ + options: { + includeExceptionTypes: [MyException], + excludeExceptionTypes: (error) => error instanceof HttpException && error.getStatus() < 500, + }, + }), + ], + controllers: [AppController], +}) +class AppModule {} +``` + +As shown in the example above, `includeExceptionTypes` and `excludeExceptionTypes` accept either an array of error +types, or a function that can return a `boolean`. The array types will match using `instanceof`. The function will have +the thrown error passed as the first parameter. + ### Upload source maps Client-side error reports are based on minified code. Upload source maps and source code to resolve minified code to @@ -107,7 +136,7 @@ import { BacktraceModule, BacktraceInterceptor } from '@backtrace/nestjs'; providers: [ { provide: APP_INTERCEPTOR, - useValue: new BacktraceInterceptor(), + useExisting: BacktraceInterceptor, }, ], controllers: [CatsController], @@ -122,10 +151,17 @@ const app = await NestFactory.create(AppModule); app.useGlobalInterceptors(new BacktraceInterceptor()); ``` -To use it on a controller, use `UseInterceptors` decorator: +To use it on a specific controller, use `UseInterceptors` decorator: + +```ts +@UseInterceptors(BacktraceInterceptor) +export class CatsController {} +``` + +or ```ts -@UseInterceptors(new BacktraceInterceptor()) +@UseInterceptors(new BacktraceInterceptor({ ... })) export class CatsController {} ``` @@ -133,26 +169,94 @@ For more information, consult [NestJS documentation](https://docs.nestjs.com/int #### Configuring the interceptor -By default, the interceptor will include: +By default, the interceptor will use [options from `BacktraceModule`](#configuring-the-module). You can pass the options also directly to the interceptor: -- all errors that are an instance of `Error`, +```ts +new BacktraceInterceptor({ + includeExceptionTypes: [MyException], + excludeExceptionTypes: (error) => error instanceof HttpException && error.getStatus() < 500, +}); +``` -and exclude: +### Add a Backtrace exception filter -- all `HttpException` errors that have `status < 500`. +The interceptor does not capture all exceptions. For example, exceptions thrown by guards will not be captured by the interceptor. You can use the `BacktraceExceptionFilter` exception filter class. +The filter will capture everything that the interceptor would capture. -To include or exclude specific error types, pass options to `BacktraceInterceptor`: +To add the filter globally, you can register it as `APP_FILTER`: ```ts -new BacktraceInterceptor({ - includeExceptionTypes: [Error], +import { Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { BacktraceModule, BacktraceExceptionFilter } from '@backtrace/nestjs'; + +@Module({ + imports: [BacktraceModule], + providers: [ + { + provide: APP_INTERCEPTOR, + useExisting: BacktraceExceptionFilter, + }, + ], + controllers: [CatsController], +}) +export class AppModule {} +``` + +Or, use `app.useGlobalFilters`: + +```ts +const app = await NestFactory.create(AppModule); + +// Note that you need to pass httpAdapter to BacktraceExceptionFilter as a second argument +const { httpAdapter } = app.get(HttpAdapterHost); +app.useGlobalInterceptors(new BacktraceExceptionFilter({}, httpAdapter)); +``` + +To use it on a specific controller, use `UseFilters` decorator: + +```ts +@UseFilters(BacktraceExceptionFilter) +export class CatsController {} +``` + +For more information, consult [NestJS documentation](https://docs.nestjs.com/exception-filters#binding-filters). + +#### Configuring the filter + +By default, the filter will use [options from `BacktraceModule`](#configuring-the-module). You can pass the options also directly to the filter: + +```ts +new BacktraceExceptionFilter({ + includeExceptionTypes: [MyException], excludeExceptionTypes: (error) => error instanceof HttpException && error.getStatus() < 500, }); ``` -As shown in the example above, `includeExceptionTypes` and `excludeExceptionTypes` accept either an array of error -types, or a function that can return a `boolean`. The array types will match using `instanceof`. The function will have -the thrown error passed as the first parameter. +### Use the Backtrace exception handler in your code + +If you want to leverage the exception filtering and handling in your custom logic, e.g. exception filters, you can inject `BacktraceExceptionHandler` to your logic: + +```ts +class MyExceptionFilter implements ExceptionFilter { + constructor(private readonly handler: BacktraceExceptionHandler) {} + + catch(exception: unknown, host: ArgumentsHost) { + const wasExceptionSent = this.handler.handleException(exception, host); + } +} +``` + +#### Configuring the handler + +By default, the handler will use [options from `BacktraceModule`](#configuring-the-module). You can pass the options also directly to the handler: + +```ts +new BacktraceExceptionHandler({ + includeExceptionTypes: [MyException], + excludeExceptionTypes: (error) => error instanceof HttpException && error.getStatus() < 500, +}); +``` ## Error Reporting Features From 2464da3511a24b697d3b1ca0105a8f75495cf68b Mon Sep 17 00:00:00 2001 From: Sebastian Alex Date: Fri, 11 Oct 2024 17:58:33 +0200 Subject: [PATCH 4/5] nestjs: rename filterException to matchException --- packages/nestjs/src/backtrace.handler.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/nestjs/src/backtrace.handler.ts b/packages/nestjs/src/backtrace.handler.ts index 68a62bc7..9329935c 100644 --- a/packages/nestjs/src/backtrace.handler.ts +++ b/packages/nestjs/src/backtrace.handler.ts @@ -132,11 +132,11 @@ export class BacktraceExceptionHandler exception instanceof f); } From efc0533134d01fedb361e534f62aab05c385e6d1 Mon Sep 17 00:00:00 2001 From: Sebastian Alex Date: Fri, 11 Oct 2024 18:01:43 +0200 Subject: [PATCH 5/5] nestjs: add exception both included and excluded note --- packages/nestjs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/nestjs/README.md b/packages/nestjs/README.md index ce74cba5..6734b53b 100644 --- a/packages/nestjs/README.md +++ b/packages/nestjs/README.md @@ -109,6 +109,8 @@ As shown in the example above, `includeExceptionTypes` and `excludeExceptionType types, or a function that can return a `boolean`. The array types will match using `instanceof`. The function will have the thrown error passed as the first parameter. +**Note:** include is tested before exclude, so if exception type is both included and excluded, it will be included. + ### Upload source maps Client-side error reports are based on minified code. Upload source maps and source code to resolve minified code to