From 1615d55e3df8f792b9d3bca6250206f3e44877f1 Mon Sep 17 00:00:00 2001 From: Vincent Taverna Date: Tue, 5 Aug 2025 12:22:42 -0400 Subject: [PATCH 1/3] Add exception filtering --- README.md | 78 ++ src/datadog-trace-module-options.interface.ts | 13 + src/decorator.injector.spec.ts | 795 +++++++++++++++++- src/decorator.injector.ts | 79 +- src/exception-filter.types.ts | 25 + src/injector-options.interface.ts | 13 + 6 files changed, 990 insertions(+), 13 deletions(-) create mode 100644 src/exception-filter.types.ts diff --git a/README.md b/README.md index 0510e15..463604f 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,84 @@ import { DatadogTraceModule } from 'nestjs-ddtrace'; export class AppModule {} ``` +## Exception Filtering + +You can filter which exceptions are recorded in spans using the `exceptionFilter` option. This is useful for excluding recoverable errors, expected exceptions, or specific error types from your traces. + +The filter function receives the error, span name, and method name, and should return `true` to record the exception or `false` to skip it. + +### Basic Exception Filtering + +```ts +import { DatadogTraceModule } from 'nestjs-ddtrace'; + +@Module({ + imports: [DatadogTraceModule.forRoot({ + controllers: true, + providers: true, + exceptionFilter: (error, spanName, methodName) => { + // Skip recording 404 errors + if (error && typeof error === 'object' && 'status' in error) { + return error.status !== 404; + } + + // Record all other exceptions + return true; + } + })], +}) +export class AppModule {} +``` + +### Advanced Exception Filtering + +```ts +import { DatadogTraceModule } from 'nestjs-ddtrace'; + +@Module({ + imports: [DatadogTraceModule.forRoot({ + controllers: true, + providers: true, + exceptionFilter: (error, spanName, methodName) => { + // Skip client errors (4xx) but record server errors (5xx) + if (error && typeof error === 'object' && 'status' in error) { + const status = (error as any).status; + if (status >= 400 && status < 500) { + return false; // Don't record 4xx errors + } + } + + // Skip validation errors in user service methods + if (spanName.includes('UserService') && + error instanceof Error && + error.name === 'ValidationError') { + return false; + } + + // Skip expected business logic errors + if (error instanceof Error && + error.message.includes('EXPECTED_')) { + return false; + } + + // Record everything else + return true; + } + })], +}) +export class AppModule {} +``` + +### Exception Filter Use Cases + +- **Skip client errors**: Don't record 4xx HTTP errors that are client-side issues +- **Filter validation errors**: Exclude expected validation failures +- **Service-specific filtering**: Apply different rules based on the service/method name +- **Business logic errors**: Skip recoverable errors that are part of normal flow +- **Rate limiting**: Avoid recording rate limit exceeded errors + +**Note**: If the exception filter function throws an error, the original exception will be recorded as a fail-safe measure, and the filter error will be logged for debugging. + ## Miscellaneous Inspired by the [nestjs-otel](https://github.com/pragmaticivan/nestjs-otel) and [nestjs-opentelemetry](https://github.com/MetinSeylan/Nestjs-OpenTelemetry#readme) repository. diff --git a/src/datadog-trace-module-options.interface.ts b/src/datadog-trace-module-options.interface.ts index 6a1ed5a..8a7d2d4 100644 --- a/src/datadog-trace-module-options.interface.ts +++ b/src/datadog-trace-module-options.interface.ts @@ -1,3 +1,5 @@ +import { ExceptionFilter } from './exception-filter.types'; + export interface DatadogTraceModuleOptions { /** * if true, automatically add a span to all controllers. @@ -15,4 +17,15 @@ export interface DatadogTraceModuleOptions { * list of provider names to exclude when controllers option is true. */ excludeProviders?: string[]; + /** + * Optional filter function to determine which exceptions should be recorded in spans. + * Returns true if the exception should be recorded, false to skip recording. + * If not provided, all exceptions will be recorded (default behavior). + * + * @param error - The error/exception that was thrown (can be any type) + * @param spanName - The name of the span where the error occurred + * @param methodName - The name of the method where the error occurred + * @returns boolean indicating whether to record the exception in the span + */ + exceptionFilter?: ExceptionFilter; } diff --git a/src/decorator.injector.spec.ts b/src/decorator.injector.spec.ts index 6d2d76f..0dbce11 100644 --- a/src/decorator.injector.spec.ts +++ b/src/decorator.injector.spec.ts @@ -14,6 +14,7 @@ import { } from '@nestjs/microservices/constants'; import { PatternHandler } from '@nestjs/microservices/enums/pattern-handler.enum'; import { NoSpan } from './no-span.decorator'; +import { ExceptionFilter } from './exception-filter.types'; describe('DecoratorInjector', () => { it('should work with sync function', async () => { @@ -656,7 +657,7 @@ describe('DecoratorInjector', () => { it('should be usable with other annotations', async () => { // eslint-disable-next-line prettier/prettier - const pipe = new (function transform() { })(); + const pipe = new (function transform() {})(); // given @Controller() @@ -1230,4 +1231,796 @@ describe('DecoratorInjector', () => { startSpanSpy.mockClear(); scopeSpy.mockClear(); }); + + describe('Exception Filtering', () => { + it('should record exception when no filter is provided (default behavior)', async () => { + // given + @Injectable() + class TestService { + @Span('test') + throwError() { + throw new Error('test error'); + } + } + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot()], + providers: [TestService], + }).compile(); + + const testService = module.get(TestService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // when + expect(() => { + testService.throwError(); + }).toThrowError('test error'); + + // then + expect(mockSpan.setTag).toHaveBeenCalledWith( + 'error', + new Error('test error'), + ); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should record exception when filter returns true', async () => { + // given + @Injectable() + class TestService { + @Span('test') + throwError() { + throw new Error('test error'); + } + } + + const exceptionFilter = jest.fn().mockReturnValue(true); + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot({ exceptionFilter })], + providers: [TestService], + }).compile(); + + const testService = module.get(TestService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // when + expect(() => { + testService.throwError(); + }).toThrowError('test error'); + + // then + expect(exceptionFilter).toHaveBeenCalledWith( + new Error('test error'), + 'test', + 'throwError', + ); + expect(mockSpan.setTag).toHaveBeenCalledWith( + 'error', + new Error('test error'), + ); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should not record exception when filter returns false', async () => { + // given + @Injectable() + class TestService { + @Span('test') + throwError() { + throw new Error('test error'); + } + } + + const exceptionFilter = jest.fn().mockReturnValue(false); + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot({ exceptionFilter })], + providers: [TestService], + }).compile(); + + const testService = module.get(TestService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // when + expect(() => { + testService.throwError(); + }).toThrowError('test error'); + + // then + expect(exceptionFilter).toHaveBeenCalledWith( + new Error('test error'), + 'test', + 'throwError', + ); + expect(mockSpan.setTag).not.toHaveBeenCalled(); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should work with async methods when filter returns false', async () => { + // given + @Injectable() + class TestService { + @Span('async-test') + async throwErrorAsync() { + return new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('async error')), 10); + }); + } + } + + const exceptionFilter = jest.fn().mockReturnValue(false); + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot({ exceptionFilter })], + providers: [TestService], + }).compile(); + + const testService = module.get(TestService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // when + await expect(testService.throwErrorAsync()).rejects.toEqual( + new Error('async error'), + ); + + // then + expect(exceptionFilter).toHaveBeenCalledWith( + new Error('async error'), + 'async-test', + 'throwErrorAsync', + ); + expect(mockSpan.setTag).not.toHaveBeenCalled(); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should filter based on error properties', async () => { + // given + @Injectable() + class TestService { + @Span('filter-test') + throw404() { + const error = new Error('Not Found'); + (error as any).status = 404; + throw error; + } + + @Span('filter-test') + throw500() { + const error = new Error('Server Error'); + (error as any).status = 500; + throw error; + } + } + + // Filter out 404 errors but record 500 errors + const exceptionFilter = jest.fn().mockImplementation((error) => { + return error.status !== 404; + }); + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot({ exceptionFilter })], + providers: [TestService], + }).compile(); + + const testService = module.get(TestService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // when - 404 error + expect(() => { + testService.throw404(); + }).toThrowError('Not Found'); + + // then - 404 should not be recorded + expect(mockSpan.setTag).not.toHaveBeenCalled(); + + // reset mock + (mockSpan.setTag as jest.Mock).mockClear(); + + // when - 500 error + expect(() => { + testService.throw500(); + }).toThrowError('Server Error'); + + // then - 500 should be recorded + const expectedError = new Error('Server Error'); + (expectedError as any).status = 500; + expect(mockSpan.setTag).toHaveBeenCalledWith('error', expectedError); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should filter based on span name and method name', async () => { + // given + @Injectable() + class UserService { + @Span('user-validation') + validateUser() { + const error = new Error('Validation failed'); + error.name = 'ValidationError'; + throw error; + } + } + + @Injectable() + class OrderService { + @Span('order-processing') + processOrder() { + const error = new Error('Validation failed'); + error.name = 'ValidationError'; + throw error; + } + } + + // Filter out validation errors only in UserService + const exceptionFilter = jest + .fn() + .mockImplementation((error, spanName, methodName) => { + if (spanName.includes('user') && error.name === 'ValidationError') { + return false; + } + return true; + }); + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot({ exceptionFilter })], + providers: [UserService, OrderService], + }).compile(); + + const userService = module.get(UserService); + const orderService = module.get(OrderService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // when - UserService validation error + expect(() => { + userService.validateUser(); + }).toThrowError('Validation failed'); + + // then - should not be recorded + expect(mockSpan.setTag).not.toHaveBeenCalled(); + + // reset mock + (mockSpan.setTag as jest.Mock).mockClear(); + + // when - OrderService validation error + expect(() => { + orderService.processOrder(); + }).toThrowError('Validation failed'); + + // then - should be recorded + const expectedError = new Error('Validation failed'); + expectedError.name = 'ValidationError'; + expect(mockSpan.setTag).toHaveBeenCalledWith('error', expectedError); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should work with controllers and exception filter', async () => { + // given + @Controller() + class TestController { + @Get('/error') + @Span('controller-error') + throwError() { + const error = new Error('Controller error'); + (error as any).status = 400; + throw error; + } + } + + // Filter out 400 errors + const exceptionFilter = jest.fn().mockImplementation((error) => { + return error.status !== 400; + }); + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot({ exceptionFilter })], + controllers: [TestController], + }).compile(); + const app = module.createNestApplication(); + await app.init(); + + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + try { + // when + await request(app.getHttpServer()).get('/error').send(); + } catch (error) { + // expected to throw + } + + // then + expect(exceptionFilter).toHaveBeenCalled(); + expect(mockSpan.setTag).not.toHaveBeenCalled(); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should handle filter returning non-boolean values (truthy/falsy evaluation)', async () => { + // given + @Injectable() + class TestService { + @Span('test') + throwError() { + throw new Error('test error'); + } + } + + const exceptionFilter = jest + .fn() + .mockReturnValueOnce(1) // truthy + .mockReturnValueOnce(0) // falsy + .mockReturnValueOnce('string') // truthy + .mockReturnValueOnce('') // falsy + .mockReturnValueOnce({}) // truthy + .mockReturnValueOnce(null); // falsy + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot({ exceptionFilter })], + providers: [TestService], + }).compile(); + + const testService = module.get(TestService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // Test truthy values (1, 'string', {}) + expect(() => testService.throwError()).toThrowError('test error'); // 1 + expect(mockSpan.setTag).toHaveBeenCalledWith( + 'error', + new Error('test error'), + ); + (mockSpan.setTag as jest.Mock).mockClear(); + + expect(() => testService.throwError()).toThrowError('test error'); // 0 + expect(mockSpan.setTag).not.toHaveBeenCalled(); + (mockSpan.setTag as jest.Mock).mockClear(); + + expect(() => testService.throwError()).toThrowError('test error'); // 'string' + expect(mockSpan.setTag).toHaveBeenCalledWith( + 'error', + new Error('test error'), + ); + (mockSpan.setTag as jest.Mock).mockClear(); + + expect(() => testService.throwError()).toThrowError('test error'); // '' + expect(mockSpan.setTag).not.toHaveBeenCalled(); + (mockSpan.setTag as jest.Mock).mockClear(); + + expect(() => testService.throwError()).toThrowError('test error'); // {} + expect(mockSpan.setTag).toHaveBeenCalledWith( + 'error', + new Error('test error'), + ); + (mockSpan.setTag as jest.Mock).mockClear(); + + expect(() => testService.throwError()).toThrowError('test error'); // null + expect(mockSpan.setTag).not.toHaveBeenCalled(); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should handle undefined/null error objects', async () => { + // given + @Injectable() + class TestService { + @Span('test') + throwUndefined() { + throw undefined; + } + + @Span('test') + throwNull() { + throw null; + } + + @Span('test') + throwString() { + throw 'string error'; + } + } + + const exceptionFilter = jest.fn().mockReturnValue(true); + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot({ exceptionFilter })], + providers: [TestService], + }).compile(); + + const testService = module.get(TestService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // Test undefined + expect(() => testService.throwUndefined()).toThrow(undefined); + expect(exceptionFilter).toHaveBeenCalledWith( + undefined, + 'test', + 'throwUndefined', + ); + expect(mockSpan.setTag).toHaveBeenCalledWith('error', undefined); + (mockSpan.setTag as jest.Mock).mockClear(); + + // Test null + expect(() => testService.throwNull()).toThrow(); + expect(exceptionFilter).toHaveBeenCalledWith(null, 'test', 'throwNull'); + expect(mockSpan.setTag).toHaveBeenCalledWith('error', null); + (mockSpan.setTag as jest.Mock).mockClear(); + + // Test string + expect(() => testService.throwString()).toThrow('string error'); + expect(exceptionFilter).toHaveBeenCalledWith( + 'string error', + 'test', + 'throwString', + ); + expect(mockSpan.setTag).toHaveBeenCalledWith('error', 'string error'); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should handle missing span name and method name gracefully', async () => { + // This tests the fallback behavior when spanName or methodName might be undefined + @Injectable() + class TestService { + throwError() { + throw new Error('test error'); + } + } + + const exceptionFilter = jest.fn().mockReturnValue(true); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + DatadogTraceModule.forRoot({ providers: true, exceptionFilter }), + ], + providers: [TestService], + }).compile(); + + const testService = module.get(TestService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // when + expect(() => testService.throwError()).toThrowError('test error'); + + // then - should pass empty strings as fallback for undefined spanName/methodName + expect(exceptionFilter).toHaveBeenCalledWith( + new Error('test error'), + 'TestService.throwError', // auto-generated span name + 'throwError', + ); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should work with complex error objects and custom properties', async () => { + // given + @Injectable() + class TestService { + @Span('complex-test') + throwComplexError() { + const error = new Error('Complex error'); + (error as any).code = 'E_CUSTOM'; + (error as any).statusCode = 422; + (error as any).details = { field: 'username', issue: 'invalid' }; + (error as any).timestamp = new Date(); + throw error; + } + } + + const exceptionFilter = jest.fn().mockImplementation((error) => { + // Complex filtering logic based on multiple error properties + return error.code !== 'E_CUSTOM' || error.statusCode >= 500; + }); + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot({ exceptionFilter })], + providers: [TestService], + }).compile(); + + const testService = module.get(TestService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // when + expect(() => testService.throwComplexError()).toThrowError( + 'Complex error', + ); + + // then - should not record (E_CUSTOM with statusCode < 500) + expect(exceptionFilter).toHaveBeenCalled(); + const passedError = exceptionFilter.mock.calls[0][0]; + expect(passedError.code).toBe('E_CUSTOM'); + expect(passedError.statusCode).toBe(422); + expect(passedError.details).toEqual({ + field: 'username', + issue: 'invalid', + }); + expect(mockSpan.setTag).not.toHaveBeenCalled(); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should handle async filter functions', async () => { + // given + @Injectable() + class TestService { + @Span('async-filter-test') + throwError() { + throw new Error('test error'); + } + } + + // Async filter function (though the current implementation doesn't await it, + // this tests that it doesn't break if someone accidentally returns a Promise) + const exceptionFilter = jest.fn().mockImplementation(async (error) => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return true; + }); + + const module: TestingModule = await Test.createTestingModule({ + imports: [DatadogTraceModule.forRoot({ exceptionFilter })], + providers: [TestService], + }).compile(); + + const testService = module.get(TestService); + const mockSpan = { + finish: jest.fn() as any, + setTag: jest.fn() as any, + } as TraceSpan; + const startSpanSpy = jest + .spyOn(tracer, 'startSpan') + .mockReturnValue(mockSpan); + const scope = { + active: jest.fn(() => null) as any, + activate: jest.fn( + (span: TraceSpan, fn: (...args: any[]) => any): any => { + return fn(); + }, + ) as any, + } as Scope; + const scopeSpy = jest + .spyOn(tracer, 'scope') + .mockImplementation(() => scope); + + // when + expect(() => testService.throwError()).toThrowError('test error'); + + // then - Promise object is truthy, so should record + expect(exceptionFilter).toHaveBeenCalled(); + expect(mockSpan.setTag).toHaveBeenCalledWith( + 'error', + new Error('test error'), + ); + + startSpanSpy.mockClear(); + scopeSpy.mockClear(); + }); + + it('should handle strong typing and provide good IntelliSense', async () => { + // This test validates type safety at compile time + const typeSafeFilter: ExceptionFilter = (error, spanName, methodName) => { + // Type tests - these should compile without issues + const errorIsUnknown: unknown = error; // ✓ Should work + const spanNameIsString: string = spanName; // ✓ Should work + const methodNameIsString: string = methodName; // ✓ Should work + + // Common patterns should be type-safe + if (error instanceof Error) { + return error.message !== 'expected error'; + } + + if (typeof error === 'object' && error !== null && 'status' in error) { + return (error as any).status >= 500; + } + + return ( + spanName.includes('critical') || methodName.startsWith('important') + ); + }; + + // Should accept the strongly typed filter + expect(typeof typeSafeFilter).toBe('function'); + expect(typeSafeFilter(new Error('test'), 'span', 'method')).toBe(true); + }); + }); }); diff --git a/src/decorator.injector.ts b/src/decorator.injector.ts index 0face43..06863cb 100644 --- a/src/decorator.injector.ts +++ b/src/decorator.injector.ts @@ -9,6 +9,10 @@ import { import tracer, { Span } from 'dd-trace'; import { Injector } from './injector.interface'; import { InjectorOptions } from 'src/injector-options.interface'; +import { + ExceptionFilter, + ExceptionFilterResult, +} from './exception-filter.types'; @Injectable() export class DecoratorInjector implements Injector { @@ -16,13 +20,18 @@ export class DecoratorInjector implements Injector { private readonly logger = new Logger(); // eslint-disable-next-line prettier/prettier - constructor(private readonly modulesContainer: ModulesContainer) { } + constructor(private readonly modulesContainer: ModulesContainer) {} public inject(options: InjectorOptions) { - this.injectProviders(options.providers, new Set(options.excludeProviders)); + this.injectProviders( + options.providers, + new Set(options.excludeProviders), + options.exceptionFilter, + ); this.injectControllers( options.controllers, new Set(options.excludeControllers), + options.exceptionFilter, ); } @@ -64,18 +73,34 @@ export class DecoratorInjector implements Injector { /** * Tag the error that occurred in span. - * @param error - * @param span + * @param error - The error that occurred (can be any type) + * @param span - The Datadog span to tag + * @param filter - Optional exception filter + * @param spanName - The span name + * @param methodName - The method name */ - private static recordException(error, span: Span) { - span.setTag('error', error); + private static recordException( + error: unknown, + span: Span, + spanName: string, + methodName: string, + filter?: ExceptionFilter, + ): never { + if (!filter || filter(error, spanName, methodName)) { + span.setTag('error', error); + } + throw error; } /** * Find providers with span annotation and wrap method. */ - private injectProviders(injectAll: boolean, exclude: Set) { + private injectProviders( + injectAll: boolean, + exclude: Set, + exceptionFilter?: ExceptionFilter, + ) { const providers = this.getProviders(); for (const provider of providers) { @@ -106,7 +131,12 @@ export class DecoratorInjector implements Injector { if (isProviderDecorated || this.isDecorated(method)) { const spanName = this.getSpanName(method) || `${provider.name}.${methodName}`; - provider.metatype.prototype[methodName] = this.wrap(method, spanName); + provider.metatype.prototype[methodName] = this.wrap( + method, + spanName, + exceptionFilter, + methodName, + ); this.logger.log( `Mapped ${provider.name}.${methodName}`, @@ -120,7 +150,11 @@ export class DecoratorInjector implements Injector { /** * Find controllers with span annotation and wrap method. */ - private injectControllers(injectAll: boolean, exclude: Set) { + private injectControllers( + injectAll: boolean, + exclude: Set, + exceptionFilter?: ExceptionFilter, + ) { const controllers = this.getControllers(); for (const controller of controllers) { @@ -155,6 +189,8 @@ export class DecoratorInjector implements Injector { controller.metatype.prototype[methodName] = this.wrap( method, spanName, + exceptionFilter, + methodName, ); this.logger.log( @@ -170,9 +206,16 @@ export class DecoratorInjector implements Injector { * Wrap the method * @param prototype * @param spanName + * @param exceptionFilter + * @param methodName * @returns */ - private wrap(prototype: Record, spanName: string) { + private wrap( + prototype: Record, + spanName: string, + exceptionFilter?: ExceptionFilter, + methodName?: string, + ) { const method = { // To keep function.name property [prototype.name]: function (...args: any[]) { @@ -184,7 +227,13 @@ export class DecoratorInjector implements Injector { return prototype .apply(this, args) .catch((error) => { - DecoratorInjector.recordException(error, span); + DecoratorInjector.recordException( + error, + span, + spanName, + methodName, + exceptionFilter, + ); }) .finally(() => span.finish()); } else { @@ -192,7 +241,13 @@ export class DecoratorInjector implements Injector { const result = prototype.apply(this, args); return result; } catch (error) { - DecoratorInjector.recordException(error, span); + DecoratorInjector.recordException( + error, + span, + spanName, + methodName, + exceptionFilter, + ); } finally { span.finish(); } diff --git a/src/exception-filter.types.ts b/src/exception-filter.types.ts new file mode 100644 index 0000000..2467d17 --- /dev/null +++ b/src/exception-filter.types.ts @@ -0,0 +1,25 @@ +/** + * Type definition for exception filter function + */ +export type ExceptionFilter = ( + error: unknown, + spanName: string, + methodName: string, +) => boolean; + +/** + * Context information passed to exception filter + */ +export interface ExceptionFilterContext { + readonly error: unknown; + readonly spanName: string; + readonly methodName: string; +} + +/** + * Result of exception filter evaluation + */ +export interface ExceptionFilterResult { + readonly shouldRecord: boolean; + readonly filterError?: Error; +} diff --git a/src/injector-options.interface.ts b/src/injector-options.interface.ts index 1e52a16..375fecd 100644 --- a/src/injector-options.interface.ts +++ b/src/injector-options.interface.ts @@ -1,3 +1,5 @@ +import { ExceptionFilter } from './exception-filter.types'; + export interface InjectorOptions { /** * if true, automatically add a span to all controllers. @@ -15,4 +17,15 @@ export interface InjectorOptions { * list of provider names to exclude when controllers option is true. */ excludeProviders?: string[]; + /** + * Optional filter function to determine which exceptions should be recorded in spans. + * Returns true if the exception should be recorded, false to skip recording. + * If not provided, all exceptions will be recorded (default behavior). + * + * @param error - The error/exception that was thrown (can be any type) + * @param spanName - The name of the span where the error occurred + * @param methodName - The name of the method where the error occurred + * @returns boolean indicating whether to record the exception in the span + */ + exceptionFilter?: ExceptionFilter; } From e910e835a183904abaade07d46f833af4f0f3ccc Mon Sep 17 00:00:00 2001 From: Vincent Taverna Date: Tue, 5 Aug 2025 12:24:00 -0400 Subject: [PATCH 2/3] Remove unnecessary section of readme --- README.md | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 463604f..56c262b 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ import { DatadogTraceModule } from 'nestjs-ddtrace'; if (error && typeof error === 'object' && 'status' in error) { return error.status !== 404; } - + // Record all other exceptions return true; } @@ -244,20 +244,20 @@ import { DatadogTraceModule } from 'nestjs-ddtrace'; return false; // Don't record 4xx errors } } - + // Skip validation errors in user service methods - if (spanName.includes('UserService') && - error instanceof Error && + if (spanName.includes('UserService') && + error instanceof Error && error.name === 'ValidationError') { return false; } - + // Skip expected business logic errors - if (error instanceof Error && + if (error instanceof Error && error.message.includes('EXPECTED_')) { return false; } - + // Record everything else return true; } @@ -266,16 +266,6 @@ import { DatadogTraceModule } from 'nestjs-ddtrace'; export class AppModule {} ``` -### Exception Filter Use Cases - -- **Skip client errors**: Don't record 4xx HTTP errors that are client-side issues -- **Filter validation errors**: Exclude expected validation failures -- **Service-specific filtering**: Apply different rules based on the service/method name -- **Business logic errors**: Skip recoverable errors that are part of normal flow -- **Rate limiting**: Avoid recording rate limit exceeded errors - -**Note**: If the exception filter function throws an error, the original exception will be recorded as a fail-safe measure, and the filter error will be logged for debugging. - ## Miscellaneous Inspired by the [nestjs-otel](https://github.com/pragmaticivan/nestjs-otel) and [nestjs-opentelemetry](https://github.com/MetinSeylan/Nestjs-OpenTelemetry#readme) repository. From 6d1135b7be2344619c797b4434c25086e8ef4121 Mon Sep 17 00:00:00 2001 From: Vincent Taverna Date: Tue, 5 Aug 2025 12:29:15 -0400 Subject: [PATCH 3/3] Remove dead code --- src/decorator.injector.ts | 5 +---- src/exception-filter.types.ts | 8 -------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/decorator.injector.ts b/src/decorator.injector.ts index 06863cb..b312054 100644 --- a/src/decorator.injector.ts +++ b/src/decorator.injector.ts @@ -9,10 +9,7 @@ import { import tracer, { Span } from 'dd-trace'; import { Injector } from './injector.interface'; import { InjectorOptions } from 'src/injector-options.interface'; -import { - ExceptionFilter, - ExceptionFilterResult, -} from './exception-filter.types'; +import { ExceptionFilter } from './exception-filter.types'; @Injectable() export class DecoratorInjector implements Injector { diff --git a/src/exception-filter.types.ts b/src/exception-filter.types.ts index 2467d17..62a64aa 100644 --- a/src/exception-filter.types.ts +++ b/src/exception-filter.types.ts @@ -15,11 +15,3 @@ export interface ExceptionFilterContext { readonly spanName: string; readonly methodName: string; } - -/** - * Result of exception filter evaluation - */ -export interface ExceptionFilterResult { - readonly shouldRecord: boolean; - readonly filterError?: Error; -}