diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index d17505ac94ee..233bc748b112 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -114,6 +114,9 @@ jobs: - test-application: 'nestjs-11' build-command: 'test:build-latest' label: 'nestjs-11 (latest)' + - test-application: 'nestjs-websockets' + build-command: 'test:build-latest' + label: 'nestjs-websockets (latest)' - test-application: 'nestjs-microservices' build-command: 'test:build-latest' label: 'nestjs-microservices (latest)' diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/nest-cli.json b/dev-packages/e2e-tests/test-applications/nestjs-websockets/nest-cli.json new file mode 100644 index 000000000000..f9aa683b1ad5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json b/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json new file mode 100644 index 000000000000..6356b48b322f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json @@ -0,0 +1,34 @@ +{ + "name": "nestjs-websockets", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:build-latest": "pnpm install && pnpm add @nestjs/common@latest @nestjs/core@latest @nestjs/platform-express@latest @nestjs/websockets@latest @nestjs/platform-socket.io@latest && pnpm add -D @nestjs/cli@latest && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", + "@sentry/nestjs": "latest || *", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@nestjs/cli": "^11.0.0", + "@types/node": "^18.19.1", + "socket.io-client": "^4.0.0", + "typescript": "~5.0.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nestjs-websockets/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.controller.ts new file mode 100644 index 000000000000..e5e867d95312 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class AppController { + @Get('/test-transaction') + testTransaction() { + return { message: 'ok' }; + } +} 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 new file mode 100644 index 000000000000..712d47aba4d2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts @@ -0,0 +1,20 @@ +import { SubscribeMessage, WebSocketGateway, MessageBody } from '@nestjs/websockets'; +import * as Sentry from '@sentry/nestjs'; + +@WebSocketGateway() +export class AppGateway { + @SubscribeMessage('test-exception') + handleTestException() { + throw new Error('This is an exception in a WebSocket handler'); + } + + @SubscribeMessage('test-manual-capture') + handleManualCapture() { + try { + throw new Error('Manually captured WebSocket error'); + } catch (e) { + Sentry.captureException(e); + } + return { event: 'capture-response', data: { success: true } }; + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.module.ts new file mode 100644 index 000000000000..96386d3cf29f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup'; +import { AppController } from './app.controller'; +import { AppGateway } from './app.gateway'; + +@Module({ + imports: [SentryModule.forRoot()], + controllers: [AppController], + providers: [ + { + provide: APP_FILTER, + useClass: SentryGlobalFilter, + }, + AppGateway, + ], +}) +export class AppModule {} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/instrument.ts new file mode 100644 index 000000000000..e0a1cead1153 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/instrument.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nestjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1, + transportOptions: { + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/main.ts new file mode 100644 index 000000000000..71ce685f4d61 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/main.ts @@ -0,0 +1,15 @@ +// Import this first +import './instrument'; + +// Import other modules +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +const PORT = 3030; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(PORT); +} + +bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nestjs-websockets/start-event-proxy.mjs new file mode 100644 index 000000000000..1fe76699833c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nestjs-websockets', +}); 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 new file mode 100644 index 000000000000..e6843799f05d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { io } from 'socket.io-client'; + +test('Captures manually reported error in WebSocket gateway handler', async ({ baseURL }) => { + const errorPromise = waitForError('nestjs-websockets', event => { + return event.exception?.values?.[0]?.value === 'Manually captured WebSocket error'; + }); + + const socket = io(baseURL!); + await new Promise(resolve => socket.on('connect', resolve)); + + socket.emit('test-manual-capture', {}); + + const error = await errorPromise; + + expect(error.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'Manually captured WebSocket error', + }); + + 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, +// 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 }) => { + let errorEventOccurred = false; + + waitForError('nestjs-websockets', event => { + if (!event.type && event.exception?.values?.[0]?.value === 'This is an exception in a WebSocket handler') { + errorEventOccurred = true; + } + + return false; + }); + + const manualCapturePromise = waitForError('nestjs-websockets', event => { + return event.exception?.values?.[0]?.value === 'Manually captured WebSocket error'; + }); + + const socket = io(baseURL!); + await new Promise(resolve => socket.on('connect', resolve)); + + socket.emit('test-exception', {}); + socket.emit('test-manual-capture', {}); + await manualCapturePromise; + + expect(errorEventOccurred).toBe(false); + + socket.disconnect(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/transactions.test.ts new file mode 100644 index 000000000000..d701897cfa56 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/transactions.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an HTTP transaction', async ({ baseURL }) => { + const txPromise = waitForTransaction('nestjs-websockets', tx => { + return tx?.contexts?.trace?.op === 'http.server' && tx?.transaction === 'GET /test-transaction'; + }); + + await fetch(`${baseURL}/test-transaction`); + + const tx = await txPromise; + + expect(tx.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'http.server', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/tsconfig.json b/dev-packages/e2e-tests/test-applications/nestjs-websockets/tsconfig.json new file mode 100644 index 000000000000..cf79f029c781 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "moduleResolution": "Node16" + } +}