Skip to content

Commit 49e5085

Browse files
authored
feat(nestjs): Support WebSocket errors in SentryGlobalFilter (#22224)
Fixes #16067 `SentryGlobalFilter` now handles WebSocket exceptions without delegating them to Nest's HTTP `BaseExceptionFilter`. Unexpected gateway errors are captured with the `auto.ws.nestjs.global_filter` mechanism and emit a generic error response. Expected `WsException` responses are preserved without being reported to Sentry. `WsException` is detected by shape so `@nestjs/websockets` does not become a required dependency. Unit and NestJS WebSocket E2E coverage was added for unexpected errors, expected `WsException` values, and HTTP exceptions raised in a WebSocket context. *Root cause*: WebSocket exceptions were delegated to an HTTP exception filter which expects an HTTP adapter and cannot correctly respond through a WebSocket client.
1 parent 83659e1 commit 49e5085

5 files changed

Lines changed: 165 additions & 8 deletions

File tree

dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1-
import { SubscribeMessage, WebSocketGateway, MessageBody } from '@nestjs/websockets';
1+
import { UseFilters } from '@nestjs/common';
2+
import { MessageBody, SubscribeMessage, WebSocketGateway, WsException } from '@nestjs/websockets';
23
import * as Sentry from '@sentry/nestjs';
4+
import { SentryGlobalFilter } from '@sentry/nestjs/setup';
35

6+
@UseFilters(new SentryGlobalFilter())
47
@WebSocketGateway()
58
export class AppGateway {
69
@SubscribeMessage('test-exception')
710
handleTestException() {
811
throw new Error('This is an exception in a WebSocket handler');
912
}
1013

14+
@SubscribeMessage('test-ws-exception')
15+
handleWsException() {
16+
throw new WsException('Expected WebSocket exception');
17+
}
18+
1119
@SubscribeMessage('test-manual-capture')
1220
handleManualCapture() {
1321
try {

dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,39 @@ test('Captures manually reported error in WebSocket gateway handler', async ({ b
2222
socket.disconnect();
2323
});
2424

25+
test('Automatically captures unexpected errors in WebSocket gateway handlers', async ({ baseURL }) => {
26+
const errorPromise = waitForError('nestjs-websockets', event => {
27+
return event.exception?.values?.[0]?.value === 'This is an exception in a WebSocket handler';
28+
});
29+
30+
const socket = io(baseURL!);
31+
await new Promise<void>(resolve => socket.on('connect', resolve));
32+
33+
socket.emit('test-exception', {});
34+
35+
const error = await errorPromise;
36+
37+
expect(error.exception?.values).toHaveLength(1);
38+
expect(error.exception?.values?.[0]?.type).toBe('Error');
39+
expect(error.exception?.values?.[0]?.value).toBe('This is an exception in a WebSocket handler');
40+
expect(error.exception?.values?.[0]?.mechanism).toEqual({
41+
handled: false,
42+
type: 'auto.ws.nestjs.global_filter',
43+
});
44+
45+
socket.disconnect();
46+
});
47+
2548
// There is no good mechanism to verify that an event was NOT sent to Sentry.
26-
// The idea here is that we first send a message that triggers an exception which won't be auto-captured,
49+
// The idea here is that we first send a message that triggers an expected exception which won't be auto-captured,
2750
// and then send a message that triggers a manually captured error which will be sent to Sentry.
2851
// If the manually captured error arrives, we can deduce that the first exception was not sent,
2952
// because Socket.IO guarantees message ordering: https://socket.io/docs/v4/delivery-guarantees
30-
test('Does not automatically capture exceptions in WebSocket gateway handler', async ({ baseURL }) => {
53+
test('Does not automatically capture expected WebSocket exceptions', async ({ baseURL }) => {
3154
let errorEventOccurred = false;
3255

3356
waitForError('nestjs-websockets', event => {
34-
if (!event.type && event.exception?.values?.[0]?.value === 'This is an exception in a WebSocket handler') {
57+
if (!event.type && event.exception?.values?.[0]?.value === 'Expected WebSocket exception') {
3558
errorEventOccurred = true;
3659
}
3760

@@ -45,7 +68,7 @@ test('Does not automatically capture exceptions in WebSocket gateway handler', a
4568
const socket = io(baseURL!);
4669
await new Promise<void>(resolve => socket.on('connect', resolve));
4770

48-
socket.emit('test-exception', {});
71+
socket.emit('test-ws-exception', {});
4972
socket.emit('test-manual-capture', {});
5073
await manualCapturePromise;
5174

packages/nestjs/src/helpers.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,24 @@ export function isExpectedError(exception: unknown): boolean {
2525
return true;
2626
}
2727

28-
// RpcException
29-
if (typeof ex.getError === 'function' && typeof ex.initMessage === 'function') {
28+
if (isWsOrRpcException(exception)) {
3029
return true;
3130
}
3231

3332
return false;
3433
}
34+
35+
/**
36+
* Determines if the exception is a WsException or RpcException, which have the same shape.
37+
* Both have `getError()` and `initMessage()` methods.
38+
*
39+
* We use duck-typing to avoid importing from `@nestjs/websockets` or `@nestjs/microservices`.
40+
*/
41+
export function isWsOrRpcException(exception: unknown): boolean {
42+
if (typeof exception !== 'object' || exception === null) {
43+
return false;
44+
}
45+
46+
const ex = exception as Record<string, unknown>;
47+
return typeof ex.getError === 'function' && typeof ex.initMessage === 'function';
48+
}

packages/nestjs/src/setup.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Catch, Global, HttpException, Injectable, Logger, Module } from '@nestj
1010
import { APP_INTERCEPTOR, BaseExceptionFilter } from '@nestjs/core';
1111
import { captureException, debug, getDefaultIsolationScope, getIsolationScope } from '@sentry/core';
1212
import type { Observable } from 'rxjs';
13-
import { isExpectedError } from './helpers';
13+
import { isExpectedError, isWsOrRpcException } from './helpers';
1414

1515
// Partial extract of FastifyRequest interface
1616
// https://github.com/fastify/fastify/blob/87f9f20687c938828f1138f91682d568d2a31e53/types/request.d.ts#L41
@@ -152,6 +152,33 @@ class SentryGlobalFilter extends BaseExceptionFilter {
152152
return;
153153
}
154154

155+
if (contextType === 'ws') {
156+
if (!isExpectedError(exception)) {
157+
captureException(exception, {
158+
mechanism: {
159+
handled: false,
160+
type: 'auto.ws.nestjs.global_filter',
161+
},
162+
});
163+
}
164+
165+
const client = host.switchToWs().getClient<{ emit?: (event: string, data: unknown) => void }>();
166+
167+
if (isWsOrRpcException(exception)) {
168+
const result = (exception as { getError: () => unknown }).getError();
169+
const response = typeof result === 'object' && result !== null ? result : { status: 'error', message: result };
170+
client.emit?.('exception', response);
171+
return;
172+
}
173+
174+
if (exception instanceof Error) {
175+
this._logger.error(exception.message, exception.stack);
176+
}
177+
178+
client.emit?.('exception', { status: 'error', message: 'Internal server error' });
179+
return;
180+
}
181+
155182
// HTTP exceptions
156183
if (!isExpectedError(exception)) {
157184
captureException(exception, {

packages/nestjs/test/sentry-global-filter.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SentryGlobalFilter } from '../src/setup';
88

99
vi.mock('../src/helpers', () => ({
1010
isExpectedError: vi.fn(),
11+
isWsOrRpcException: vi.fn(),
1112
}));
1213

1314
vi.mock('@sentry/core', () => ({
@@ -27,6 +28,7 @@ describe('SentryGlobalFilter', () => {
2728
let mockLoggerError: any;
2829
let mockLoggerWarn: any;
2930
let isExpectedErrorMock: any;
31+
let isWsOrRpcExceptionMock: any;
3032

3133
beforeEach(() => {
3234
vi.clearAllMocks();
@@ -57,6 +59,7 @@ describe('SentryGlobalFilter', () => {
5759
mockCaptureException = vi.spyOn(SentryCore, 'captureException').mockReturnValue('mock-event-id');
5860

5961
isExpectedErrorMock = vi.mocked(Helpers.isExpectedError).mockImplementation(() => false);
62+
isWsOrRpcExceptionMock = vi.mocked(Helpers.isWsOrRpcException).mockImplementation(() => false);
6063
});
6164

6265
describe('HTTP context', () => {
@@ -237,4 +240,86 @@ describe('SentryGlobalFilter', () => {
237240
expect(mockCaptureException).not.toHaveBeenCalled();
238241
});
239242
});
243+
244+
describe('WebSocket context', () => {
245+
let mockEmit: ReturnType<typeof vi.fn>;
246+
247+
beforeEach(() => {
248+
mockEmit = vi.fn();
249+
vi.mocked(mockArgumentsHost.getType).mockReturnValue('ws');
250+
vi.mocked(mockArgumentsHost.switchToWs).mockReturnValue({
251+
getClient: () => ({ emit: mockEmit }),
252+
getData: vi.fn(),
253+
getPattern: vi.fn(),
254+
});
255+
});
256+
257+
it('captures unexpected errors and emits a generic error response', () => {
258+
const error = new Error('Test WebSocket error');
259+
260+
filter.catch(error, mockArgumentsHost);
261+
262+
expect(mockCaptureException).toHaveBeenCalledWith(error, {
263+
mechanism: {
264+
handled: false,
265+
type: 'auto.ws.nestjs.global_filter',
266+
},
267+
});
268+
expect(mockLoggerError).toHaveBeenCalledWith(error.message, error.stack);
269+
expect(mockEmit).toHaveBeenCalledWith('exception', {
270+
status: 'error',
271+
message: 'Internal server error',
272+
});
273+
});
274+
275+
it('does not capture expected WebSocket exceptions and emits their response', () => {
276+
isExpectedErrorMock.mockReturnValueOnce(true);
277+
isWsOrRpcExceptionMock.mockReturnValueOnce(true);
278+
const exception = {
279+
getError: () => 'Expected WebSocket exception',
280+
initMessage: vi.fn(),
281+
};
282+
283+
filter.catch(exception, mockArgumentsHost);
284+
285+
expect(mockCaptureException).not.toHaveBeenCalled();
286+
expect(mockEmit).toHaveBeenCalledWith('exception', {
287+
status: 'error',
288+
message: 'Expected WebSocket exception',
289+
});
290+
});
291+
292+
it('does not capture HTTP exceptions and emits a generic error response', () => {
293+
isExpectedErrorMock.mockReturnValueOnce(true);
294+
const exception = new HttpException('Bad request', HttpStatus.BAD_REQUEST);
295+
296+
filter.catch(exception, mockArgumentsHost);
297+
298+
expect(mockCaptureException).not.toHaveBeenCalled();
299+
expect(mockLoggerError).toHaveBeenCalledWith(exception.message, exception.stack);
300+
expect(mockEmit).toHaveBeenCalledWith('exception', {
301+
status: 'error',
302+
message: 'Internal server error',
303+
});
304+
});
305+
306+
it('captures unexpected errors when the WebSocket client cannot emit', () => {
307+
vi.mocked(mockArgumentsHost.switchToWs).mockReturnValue({
308+
getClient: () => ({}),
309+
getData: vi.fn(),
310+
getPattern: vi.fn(),
311+
});
312+
const error = new Error('WebSocket adapter without emit');
313+
314+
filter.catch(error, mockArgumentsHost);
315+
316+
expect(mockCaptureException).toHaveBeenCalledWith(error, {
317+
mechanism: {
318+
handled: false,
319+
type: 'auto.ws.nestjs.global_filter',
320+
},
321+
});
322+
expect(mockLoggerError).toHaveBeenCalledWith(error.message, error.stack);
323+
});
324+
});
240325
});

0 commit comments

Comments
 (0)