From 603d0dcdb1cb4104e95d889e6a7ac2856cbbee75 Mon Sep 17 00:00:00 2001 From: "Seongho.Bak" Date: Mon, 13 Jul 2026 21:13:40 +0900 Subject: [PATCH 1/4] 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 113a52c36abc0ab9660cf2e580ba098145e73b0a Mon Sep 17 00:00:00 2001 From: "Seongho.Bak" Date: Mon, 13 Jul 2026 21:20:43 +0900 Subject: [PATCH 2/4] 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 614b4bc8f2ce7f93889be989c45212a6b1a0954b Mon Sep 17 00:00:00 2001 From: "Seongho.Bak" Date: Mon, 13 Jul 2026 21:40:10 +0900 Subject: [PATCH 3/4] 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 31bc0791a166651aa103f02759622a549c2c9a46 Mon Sep 17 00:00:00 2001 From: "Seongho.Bak" Date: Sat, 18 Jul 2026 10:09:15 +0900 Subject: [PATCH 4/4] 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); + }); }); });