From 0fde8a002f82fc39ee71fd49556f1e80909b8a9e Mon Sep 17 00:00:00 2001 From: "Seongho.Bak" Date: Mon, 13 Jul 2026 21:13:40 +0900 Subject: [PATCH 1/5] test(nestjs): cover WebSocket global filter errors --- .../nestjs-websockets/src/app.gateway.ts | 7 ++- .../nestjs-websockets/tests/errors.test.ts | 28 +++++++++-- .../nestjs/test/sentry-global-filter.test.ts | 48 +++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts index 712d47aba4d2..c4f81cc9c8e3 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts @@ -1,4 +1,4 @@ -import { SubscribeMessage, WebSocketGateway, MessageBody } from '@nestjs/websockets'; +import { MessageBody, SubscribeMessage, WebSocketGateway, WsException } from '@nestjs/websockets'; import * as Sentry from '@sentry/nestjs'; @WebSocketGateway() @@ -8,6 +8,11 @@ export class AppGateway { throw new Error('This is an exception in a WebSocket handler'); } + @SubscribeMessage('test-ws-exception') + handleWsException() { + throw new WsException('Expected WebSocket exception'); + } + @SubscribeMessage('test-manual-capture') handleManualCapture() { try { diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts index e6843799f05d..616db6f87d28 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts @@ -22,16 +22,36 @@ test('Captures manually reported error in WebSocket gateway handler', async ({ b socket.disconnect(); }); +test('Automatically captures unexpected errors in WebSocket gateway handlers', async ({ baseURL }) => { + const errorPromise = waitForError('nestjs-websockets', event => { + return event.exception?.values?.[0]?.value === 'This is an exception in a WebSocket handler'; + }); + + const socket = io(baseURL!); + await new Promise(resolve => socket.on('connect', resolve)); + + socket.emit('test-exception', {}); + + const error = await errorPromise; + + expect(error.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'This is an exception in a WebSocket handler', + }); + + socket.disconnect(); +}); + // There is no good mechanism to verify that an event was NOT sent to Sentry. -// The idea here is that we first send a message that triggers an exception which won't be auto-captured, +// The idea here is that we first send a message that triggers an expected exception which won't be auto-captured, // and then send a message that triggers a manually captured error which will be sent to Sentry. // If the manually captured error arrives, we can deduce that the first exception was not sent, // because Socket.IO guarantees message ordering: https://socket.io/docs/v4/delivery-guarantees -test('Does not automatically capture exceptions in WebSocket gateway handler', async ({ baseURL }) => { +test('Does not automatically capture expected WebSocket exceptions', async ({ baseURL }) => { let errorEventOccurred = false; waitForError('nestjs-websockets', event => { - if (!event.type && event.exception?.values?.[0]?.value === 'This is an exception in a WebSocket handler') { + if (!event.type && event.exception?.values?.[0]?.value === 'Expected WebSocket exception') { errorEventOccurred = true; } @@ -45,7 +65,7 @@ test('Does not automatically capture exceptions in WebSocket gateway handler', a const socket = io(baseURL!); await new Promise(resolve => socket.on('connect', resolve)); - socket.emit('test-exception', {}); + socket.emit('test-ws-exception', {}); socket.emit('test-manual-capture', {}); await manualCapturePromise; diff --git a/packages/nestjs/test/sentry-global-filter.test.ts b/packages/nestjs/test/sentry-global-filter.test.ts index d9b4ff3d1b1f..3be86043ad7e 100644 --- a/packages/nestjs/test/sentry-global-filter.test.ts +++ b/packages/nestjs/test/sentry-global-filter.test.ts @@ -237,4 +237,52 @@ describe('SentryGlobalFilter', () => { expect(mockCaptureException).not.toHaveBeenCalled(); }); }); + + describe('WebSocket context', () => { + let mockEmit: ReturnType; + + beforeEach(() => { + mockEmit = vi.fn(); + vi.mocked(mockArgumentsHost.getType).mockReturnValue('ws'); + vi.mocked(mockArgumentsHost.switchToWs).mockReturnValue({ + getClient: () => ({ emit: mockEmit }), + getData: vi.fn(), + getPattern: vi.fn(), + }); + }); + + it('captures unexpected errors and emits a generic error response', () => { + const error = new Error('Test WebSocket error'); + + filter.catch(error, mockArgumentsHost); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + mechanism: { + handled: false, + type: 'auto.ws.nestjs.global_filter', + }, + }); + expect(mockLoggerError).toHaveBeenCalledWith(error.message, error.stack); + expect(mockEmit).toHaveBeenCalledWith('exception', { + status: 'error', + message: 'Internal server error', + }); + }); + + it('does not capture expected WebSocket exceptions and emits their response', () => { + isExpectedErrorMock.mockReturnValueOnce(true); + const exception = { + getError: () => 'Expected WebSocket exception', + initMessage: vi.fn(), + }; + + filter.catch(exception, mockArgumentsHost); + + expect(mockCaptureException).not.toHaveBeenCalled(); + expect(mockEmit).toHaveBeenCalledWith('exception', { + status: 'error', + message: 'Expected WebSocket exception', + }); + }); + }); }); From ba75c8bded7f19bc1ac5b48ba325a1af411dc85b Mon Sep 17 00:00:00 2001 From: "Seongho.Bak" Date: Mon, 13 Jul 2026 21:20:43 +0900 Subject: [PATCH 2/5] feat(nestjs): support WebSocket errors in global filter --- packages/nestjs/src/helpers.ts | 19 +++++++++-- packages/nestjs/src/setup.ts | 33 ++++++++++++++++++- .../nestjs/test/sentry-global-filter.test.ts | 4 +++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/nestjs/src/helpers.ts b/packages/nestjs/src/helpers.ts index f5e9d853d4db..0f0d006154c7 100644 --- a/packages/nestjs/src/helpers.ts +++ b/packages/nestjs/src/helpers.ts @@ -25,10 +25,25 @@ export function isExpectedError(exception: unknown): boolean { return true; } - // RpcException - if (typeof ex.getError === 'function' && typeof ex.initMessage === 'function') { + // RpcException / WsException (same duck-type shape) + if (isWsException(exception)) { return true; } return false; } + +/** + * Determines if the exception is a WsException (or RpcException, which has the same shape). + * Both have `getError()` and `initMessage()` methods. + * + * We use duck-typing to avoid importing from `@nestjs/websockets` or `@nestjs/microservices`. + */ +export function isWsException(exception: unknown): boolean { + if (typeof exception !== 'object' || exception === null) { + return false; + } + + const ex = exception as Record; + return typeof ex.getError === 'function' && typeof ex.initMessage === 'function'; +} diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index 2d4255df9b3f..1cc32820b857 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -10,7 +10,7 @@ import { Catch, Global, HttpException, Injectable, Logger, Module } from '@nestj import { APP_INTERCEPTOR, BaseExceptionFilter } from '@nestjs/core'; import { captureException, debug, getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; import type { Observable } from 'rxjs'; -import { isExpectedError } from './helpers'; +import { isExpectedError, isWsException } from './helpers'; // Partial extract of FastifyRequest interface // https://github.com/fastify/fastify/blob/87f9f20687c938828f1138f91682d568d2a31e53/types/request.d.ts#L41 @@ -152,6 +152,37 @@ class SentryGlobalFilter extends BaseExceptionFilter { return; } + if (contextType === 'ws') { + if (exception instanceof HttpException) { + throw exception; + } + + if (!isExpectedError(exception)) { + captureException(exception, { + mechanism: { + handled: false, + type: 'auto.ws.nestjs.global_filter', + }, + }); + } + + const client = host.switchToWs().getClient<{ emit: (event: string, data: unknown) => void }>(); + + if (isWsException(exception)) { + const result = (exception as { getError: () => unknown }).getError(); + const response = typeof result === 'object' && result !== null ? result : { status: 'error', message: result }; + client.emit('exception', response); + return; + } + + if (exception instanceof Error) { + this._logger.error(exception.message, exception.stack); + } + + client.emit('exception', { status: 'error', message: 'Internal server error' }); + return; + } + // HTTP exceptions if (!isExpectedError(exception)) { captureException(exception, { diff --git a/packages/nestjs/test/sentry-global-filter.test.ts b/packages/nestjs/test/sentry-global-filter.test.ts index 3be86043ad7e..3061db622bcc 100644 --- a/packages/nestjs/test/sentry-global-filter.test.ts +++ b/packages/nestjs/test/sentry-global-filter.test.ts @@ -8,6 +8,7 @@ import { SentryGlobalFilter } from '../src/setup'; vi.mock('../src/helpers', () => ({ isExpectedError: vi.fn(), + isWsException: vi.fn(), })); vi.mock('@sentry/core', () => ({ @@ -27,6 +28,7 @@ describe('SentryGlobalFilter', () => { let mockLoggerError: any; let mockLoggerWarn: any; let isExpectedErrorMock: any; + let isWsExceptionMock: any; beforeEach(() => { vi.clearAllMocks(); @@ -57,6 +59,7 @@ describe('SentryGlobalFilter', () => { mockCaptureException = vi.spyOn(SentryCore, 'captureException').mockReturnValue('mock-event-id'); isExpectedErrorMock = vi.mocked(Helpers.isExpectedError).mockImplementation(() => false); + isWsExceptionMock = vi.mocked(Helpers.isWsException).mockImplementation(() => false); }); describe('HTTP context', () => { @@ -271,6 +274,7 @@ describe('SentryGlobalFilter', () => { it('does not capture expected WebSocket exceptions and emits their response', () => { isExpectedErrorMock.mockReturnValueOnce(true); + isWsExceptionMock.mockReturnValueOnce(true); const exception = { getError: () => 'Expected WebSocket exception', initMessage: vi.fn(), From 81e246d9a8fa064e2c42564bc18ffefcc3055460 Mon Sep 17 00:00:00 2001 From: "Seongho.Bak" Date: Mon, 13 Jul 2026 21:40:10 +0900 Subject: [PATCH 3/5] fix(nestjs): handle HTTP exceptions in WebSocket filter --- packages/nestjs/src/setup.ts | 4 ---- packages/nestjs/test/sentry-global-filter.test.ts | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index 1cc32820b857..1b712debbba6 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -153,10 +153,6 @@ class SentryGlobalFilter extends BaseExceptionFilter { } if (contextType === 'ws') { - if (exception instanceof HttpException) { - throw exception; - } - if (!isExpectedError(exception)) { captureException(exception, { mechanism: { diff --git a/packages/nestjs/test/sentry-global-filter.test.ts b/packages/nestjs/test/sentry-global-filter.test.ts index 3061db622bcc..8b0fe1efcf16 100644 --- a/packages/nestjs/test/sentry-global-filter.test.ts +++ b/packages/nestjs/test/sentry-global-filter.test.ts @@ -288,5 +288,19 @@ describe('SentryGlobalFilter', () => { message: 'Expected WebSocket exception', }); }); + + it('does not capture HTTP exceptions and emits a generic error response', () => { + isExpectedErrorMock.mockReturnValueOnce(true); + const exception = new HttpException('Bad request', HttpStatus.BAD_REQUEST); + + filter.catch(exception, mockArgumentsHost); + + expect(mockCaptureException).not.toHaveBeenCalled(); + expect(mockLoggerError).toHaveBeenCalledWith(exception.message, exception.stack); + expect(mockEmit).toHaveBeenCalledWith('exception', { + status: 'error', + message: 'Internal server error', + }); + }); }); }); From a5a9f96868c6c09b58a172941012b72c744fd3b3 Mon Sep 17 00:00:00 2001 From: "Seongho.Bak" Date: Sat, 18 Jul 2026 10:09:15 +0900 Subject: [PATCH 4/5] fix(nestjs): stabilize WebSocket filter coverage --- .../nestjs-websockets/src/app.gateway.ts | 3 +++ .../nestjs-websockets/tests/errors.test.ts | 9 ++++++--- packages/nestjs/src/setup.ts | 6 +++--- .../nestjs/test/sentry-global-filter.test.ts | 19 +++++++++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts index c4f81cc9c8e3..08eb7475cea8 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts @@ -1,6 +1,9 @@ +import { UseFilters } from '@nestjs/common'; import { MessageBody, SubscribeMessage, WebSocketGateway, WsException } from '@nestjs/websockets'; import * as Sentry from '@sentry/nestjs'; +import { SentryGlobalFilter } from '@sentry/nestjs/setup'; +@UseFilters(new SentryGlobalFilter()) @WebSocketGateway() export class AppGateway { @SubscribeMessage('test-exception') diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts index 616db6f87d28..3e24cdc83c39 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts @@ -34,9 +34,12 @@ test('Automatically captures unexpected errors in WebSocket gateway handlers', a const error = await errorPromise; - expect(error.exception?.values?.[0]).toMatchObject({ - type: 'Error', - value: 'This is an exception in a WebSocket handler', + expect(error.exception?.values).toHaveLength(1); + expect(error.exception?.values?.[0]?.type).toBe('Error'); + expect(error.exception?.values?.[0]?.value).toBe('This is an exception in a WebSocket handler'); + expect(error.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.ws.nestjs.global_filter', }); socket.disconnect(); diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index 1b712debbba6..6b4ac0ba348e 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -162,12 +162,12 @@ class SentryGlobalFilter extends BaseExceptionFilter { }); } - const client = host.switchToWs().getClient<{ emit: (event: string, data: unknown) => void }>(); + const client = host.switchToWs().getClient<{ emit?: (event: string, data: unknown) => void }>(); if (isWsException(exception)) { const result = (exception as { getError: () => unknown }).getError(); const response = typeof result === 'object' && result !== null ? result : { status: 'error', message: result }; - client.emit('exception', response); + client.emit?.('exception', response); return; } @@ -175,7 +175,7 @@ class SentryGlobalFilter extends BaseExceptionFilter { this._logger.error(exception.message, exception.stack); } - client.emit('exception', { status: 'error', message: 'Internal server error' }); + client.emit?.('exception', { status: 'error', message: 'Internal server error' }); return; } diff --git a/packages/nestjs/test/sentry-global-filter.test.ts b/packages/nestjs/test/sentry-global-filter.test.ts index 8b0fe1efcf16..5d58cb6fa9b5 100644 --- a/packages/nestjs/test/sentry-global-filter.test.ts +++ b/packages/nestjs/test/sentry-global-filter.test.ts @@ -302,5 +302,24 @@ describe('SentryGlobalFilter', () => { message: 'Internal server error', }); }); + + it('captures unexpected errors when the WebSocket client cannot emit', () => { + vi.mocked(mockArgumentsHost.switchToWs).mockReturnValue({ + getClient: () => ({}), + getData: vi.fn(), + getPattern: vi.fn(), + }); + const error = new Error('WebSocket adapter without emit'); + + filter.catch(error, mockArgumentsHost); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + mechanism: { + handled: false, + type: 'auto.ws.nestjs.global_filter', + }, + }); + expect(mockLoggerError).toHaveBeenCalledWith(error.message, error.stack); + }); }); }); From 503238f934991bb03dfcf6db2541fddb53f71b3e Mon Sep 17 00:00:00 2001 From: "Seongho.Bak" Date: Mon, 20 Jul 2026 19:39:51 +0900 Subject: [PATCH 5/5] ref(nestjs): clarify exception predicate name --- packages/nestjs/src/helpers.ts | 7 +++---- packages/nestjs/src/setup.ts | 4 ++-- packages/nestjs/test/sentry-global-filter.test.ts | 8 ++++---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/nestjs/src/helpers.ts b/packages/nestjs/src/helpers.ts index 0f0d006154c7..58d03dda019f 100644 --- a/packages/nestjs/src/helpers.ts +++ b/packages/nestjs/src/helpers.ts @@ -25,8 +25,7 @@ export function isExpectedError(exception: unknown): boolean { return true; } - // RpcException / WsException (same duck-type shape) - if (isWsException(exception)) { + if (isWsOrRpcException(exception)) { return true; } @@ -34,12 +33,12 @@ export function isExpectedError(exception: unknown): boolean { } /** - * Determines if the exception is a WsException (or RpcException, which has the same shape). + * Determines if the exception is a WsException or RpcException, which have the same shape. * Both have `getError()` and `initMessage()` methods. * * We use duck-typing to avoid importing from `@nestjs/websockets` or `@nestjs/microservices`. */ -export function isWsException(exception: unknown): boolean { +export function isWsOrRpcException(exception: unknown): boolean { if (typeof exception !== 'object' || exception === null) { return false; } diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index 6b4ac0ba348e..b646806532b4 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -10,7 +10,7 @@ import { Catch, Global, HttpException, Injectable, Logger, Module } from '@nestj import { APP_INTERCEPTOR, BaseExceptionFilter } from '@nestjs/core'; import { captureException, debug, getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; import type { Observable } from 'rxjs'; -import { isExpectedError, isWsException } from './helpers'; +import { isExpectedError, isWsOrRpcException } from './helpers'; // Partial extract of FastifyRequest interface // https://github.com/fastify/fastify/blob/87f9f20687c938828f1138f91682d568d2a31e53/types/request.d.ts#L41 @@ -164,7 +164,7 @@ class SentryGlobalFilter extends BaseExceptionFilter { const client = host.switchToWs().getClient<{ emit?: (event: string, data: unknown) => void }>(); - if (isWsException(exception)) { + if (isWsOrRpcException(exception)) { const result = (exception as { getError: () => unknown }).getError(); const response = typeof result === 'object' && result !== null ? result : { status: 'error', message: result }; client.emit?.('exception', response); diff --git a/packages/nestjs/test/sentry-global-filter.test.ts b/packages/nestjs/test/sentry-global-filter.test.ts index 5d58cb6fa9b5..d5f772992854 100644 --- a/packages/nestjs/test/sentry-global-filter.test.ts +++ b/packages/nestjs/test/sentry-global-filter.test.ts @@ -8,7 +8,7 @@ import { SentryGlobalFilter } from '../src/setup'; vi.mock('../src/helpers', () => ({ isExpectedError: vi.fn(), - isWsException: vi.fn(), + isWsOrRpcException: vi.fn(), })); vi.mock('@sentry/core', () => ({ @@ -28,7 +28,7 @@ describe('SentryGlobalFilter', () => { let mockLoggerError: any; let mockLoggerWarn: any; let isExpectedErrorMock: any; - let isWsExceptionMock: any; + let isWsOrRpcExceptionMock: any; beforeEach(() => { vi.clearAllMocks(); @@ -59,7 +59,7 @@ describe('SentryGlobalFilter', () => { mockCaptureException = vi.spyOn(SentryCore, 'captureException').mockReturnValue('mock-event-id'); isExpectedErrorMock = vi.mocked(Helpers.isExpectedError).mockImplementation(() => false); - isWsExceptionMock = vi.mocked(Helpers.isWsException).mockImplementation(() => false); + isWsOrRpcExceptionMock = vi.mocked(Helpers.isWsOrRpcException).mockImplementation(() => false); }); describe('HTTP context', () => { @@ -274,7 +274,7 @@ describe('SentryGlobalFilter', () => { it('does not capture expected WebSocket exceptions and emits their response', () => { isExpectedErrorMock.mockReturnValueOnce(true); - isWsExceptionMock.mockReturnValueOnce(true); + isWsOrRpcExceptionMock.mockReturnValueOnce(true); const exception = { getError: () => 'Expected WebSocket exception', initMessage: vi.fn(),