Skip to content

feat(nestjs): Gracefully handle RPC scenarios in SentryGlobalFilter #16066

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@
"types": "./setup.d.ts",
"default": "./build/cjs/setup.js"
}
},
"./microservices": {
"import": {
"types": "./microservices.d.ts",
"default": "./build/esm/microservices.js"
},
"require": {
"types": "./microservices.d.ts",
"default": "./build/cjs/microservices.js"
}
}
},
"publishConfig": {
Expand All @@ -55,11 +65,18 @@
"devDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/microservices": "^10.0.0",
"reflect-metadata": "^0.2.2"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/microservices": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
},
"peerDependenciesMeta": {
"@nestjs/microservices": {
"optional": true
}
},
"scripts": {
"build": "run-p build:transpile build:types",
Expand Down
53 changes: 53 additions & 0 deletions packages/nestjs/src/microservices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ArgumentsHost, HttpServer } from '@nestjs/common';
import { Catch, Logger } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { captureException } from '@sentry/core';
import { SentryGlobalFilter } from './setup';
import { isExpectedError } from './helpers';

/**
* Global filter to handle exceptions and report them to Sentry in nestjs microservice applications.
* Extends the standard SentryGlobalFilter with RPC exception handling.
*/
class SentryRpcFilter extends SentryGlobalFilter {
private readonly _rpcLogger: Logger;

public constructor(applicationRef?: HttpServer) {
super(applicationRef);
this._rpcLogger = new Logger('RpcExceptionsHandler');
}

/**
* Extend the base filter with RPC-specific handling.
*/
public catch(exception: unknown, host: ArgumentsHost): void {
const contextType = host.getType<string>();

if (contextType === 'rpc') {
// Don't report RpcExceptions as they are expected errors
if (exception instanceof RpcException) {
throw exception;
}

if (!isExpectedError(exception)) {
if (exception instanceof Error) {
this._rpcLogger.error(exception.message, exception.stack);
}
captureException(exception);
}

// Wrap non-RpcExceptions in RpcExceptions to avoid misleading error messages
if (!(exception instanceof RpcException)) {
const errorMessage = exception instanceof Error ? exception.message : 'Internal server error';
throw new RpcException(errorMessage);
}

throw exception;
}

// For all other context types, use the base SentryGlobalFilter filter
return super.catch(exception, host);
}
}
Catch()(SentryRpcFilter);
export { SentryRpcFilter };
12 changes: 11 additions & 1 deletion packages/nestjs/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,12 @@ class SentryGlobalFilter extends BaseExceptionFilter {
* Catches exceptions and reports them to Sentry unless they are expected errors.
*/
public catch(exception: unknown, host: ArgumentsHost): void {
const contextType = host.getType<string>();

// The BaseExceptionFilter does not work well in GraphQL applications.
// By default, Nest GraphQL applications use the ExternalExceptionFilter, which just rethrows the error:
// https://github.com/nestjs/nest/blob/master/packages/core/exceptions/external-exception-filter.ts
if (host.getType<'graphql'>() === 'graphql') {
if (contextType === 'graphql') {
// neither report nor log HttpExceptions
if (exception instanceof HttpException) {
throw exception;
Expand All @@ -103,6 +105,14 @@ class SentryGlobalFilter extends BaseExceptionFilter {
throw exception;
}

// Skip RPC contexts - they should be handled by SentryRpcFilter if the user has included it
if (contextType === 'rpc') {
// Let it propagate to a properly configured RPC filter if present
// Else it will be handled by the default NestJS exception system
throw exception;
}

// HTTP exceptions
if (!isExpectedError(exception)) {
captureException(exception);
}
Expand Down
128 changes: 128 additions & 0 deletions packages/nestjs/test/microservices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import type { ArgumentsHost } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { SentryRpcFilter } from '../src/microservices';
import * as sentryCore from '@sentry/core';

vi.mock('@sentry/core', () => ({
captureException: vi.fn(),
logger: {
warn: vi.fn(),
},
}));

describe('SentryRpcFilter', () => {
let filter: SentryRpcFilter;
let mockHost: ArgumentsHost;

beforeEach(() => {
filter = new SentryRpcFilter();

mockHost = {
getType: vi.fn().mockReturnValue('rpc'),
switchToRpc: vi.fn().mockReturnValue({
getData: vi.fn(),
getContext: vi.fn(),
}),
} as unknown as ArgumentsHost;

vi.clearAllMocks();
});

afterEach(() => {
vi.resetAllMocks();
});

it('should not report RpcException to Sentry', () => {
const rpcException = new RpcException('Expected RPC error');

expect(() => {
filter.catch(rpcException, mockHost);
}).toThrow(RpcException);

expect(sentryCore.captureException).not.toHaveBeenCalled();
});

it('should report regular Error to Sentry and wrap it in RpcException', () => {
const error = new Error('Unexpected error');

expect(() => {
filter.catch(error, mockHost);
}).toThrow(RpcException);

expect(sentryCore.captureException).toHaveBeenCalledWith(error);

try {
filter.catch(error, mockHost);
} catch (e) {
expect(e).toBeInstanceOf(RpcException);
expect(e.message).toContain('Unexpected error');
}
});

it('should wrap string exceptions in RpcException', () => {
const errorMessage = 'String error message';

expect(() => {
filter.catch(errorMessage, mockHost);
}).toThrow(RpcException);

expect(sentryCore.captureException).toHaveBeenCalledWith(errorMessage);
});

it('should handle null/undefined exceptions', () => {
expect(() => {
filter.catch(null, mockHost);
}).toThrow(RpcException);

expect(sentryCore.captureException).toHaveBeenCalledWith(null);

try {
filter.catch(null, mockHost);
} catch (e) {
expect(e).toBeInstanceOf(RpcException);
expect(e.message).toContain('Internal server error');
}
});

it('should preserve the stack trace when possible', () => {
const originalError = new Error('Original error');
originalError.stack = 'Original stack trace';

try {
filter.catch(originalError, mockHost);
} catch (e) {
expect(e).toBeInstanceOf(RpcException);

// Extract the error inside the RpcException
const wrappedError = (e as any).getError();

// If implementation preserves stack, verify it
if (typeof wrappedError === 'object' && wrappedError.stack) {
expect(wrappedError.stack).toContain('Original stack trace');
}
}
});

it('should properly handle non-rpc context by delegating to parent', () => {
// Mock HTTP context
const httpHost = {
getType: vi.fn().mockReturnValue('http'),
switchToHttp: vi.fn().mockReturnValue({
getRequest: vi.fn(),
getResponse: vi.fn(),
}),
} as unknown as ArgumentsHost;

// Mock the parent class behavior
const parentCatchSpy = vi.spyOn(filter, 'catch');
parentCatchSpy.mockImplementation(vi.fn());

const error = new Error('HTTP error');

filter.catch(error, httpHost);

// Verify parent catch was called
expect(parentCatchSpy).toHaveBeenCalled();
});
});
19 changes: 13 additions & 6 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4613,14 +4613,14 @@
dependencies:
sparse-bitfield "^3.0.3"

"@nestjs/common@10.4.6":
version "10.4.6"
resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.6.tgz#952e8fd0ceafeffcc4eaf47effd67fb395844ae0"
integrity sha512-KkezkZvU9poWaNq4L+lNvx+386hpOxPJkfXBBeSMrcqBOx8kVr36TGN2uYkF4Ta4zNu1KbCjmZbc0rhHSg296g==
"@nestjs/common@11.0.16":
version "11.0.16"
resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.0.16.tgz#b6550ac2998e9991f24a99563a93475542885ba7"
integrity sha512-agvuQ8su4aZ+PVxAmY89odG1eR97HEQvxPmTMdDqyvDWzNerl7WQhUEd+j4/UyNWcF1or1UVcrtPj52x+eUSsA==
dependencies:
uid "2.0.2"
iterare "1.2.1"
tslib "2.7.0"
tslib "2.8.1"

"@nestjs/common@^10.0.0":
version "10.4.15"
Expand Down Expand Up @@ -4655,6 +4655,14 @@
path-to-regexp "3.3.0"
tslib "2.8.1"

"@nestjs/microservices@^10.0.0":
version "10.4.16"
resolved "https://registry.yarnpkg.com/@nestjs/microservices/-/microservices-10.4.16.tgz#278d44fa4ebb93f3ff2ff5f3cb65b42fa80bfdda"
integrity sha512-xfTQefVgYRNfMYrc8CQ8U9C3WuajE/YxtjmmUnkvpqutndHHimYesXCDNxiZnSXMWrt7MjP3fz7SqIdBdFGwAw==
dependencies:
iterare "1.2.1"
tslib "2.8.1"

"@nestjs/platform-express@10.4.6":
version "10.4.6"
resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.4.6.tgz#6c39c522fa66036b4256714fea203fbeb49fc4de"
Expand Down Expand Up @@ -27039,7 +27047,6 @@ stylus@0.59.0, stylus@^0.59.0:

sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills:
version "3.36.0"
uid fd682f6129e507c00bb4e6319cc5d6b767e36061
resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061"
dependencies:
"@jridgewell/gen-mapping" "^0.3.2"
Expand Down
Loading