From 611a260189390fe17808f5e7d546f1179ac7ae6b Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 4 Mar 2026 16:29:34 +0100 Subject: [PATCH 01/10] Add websockets e2e for nestjs --- .../nestjs-websockets/nest-cli.json | 8 +++++ .../nestjs-websockets/package.json | 33 +++++++++++++++++++ .../nestjs-websockets/playwright.config.mjs | 7 ++++ .../nestjs-websockets/src/app.controller.ts | 11 +++++++ .../nestjs-websockets/src/app.gateway.ts | 25 ++++++++++++++ .../nestjs-websockets/src/app.module.ts | 18 ++++++++++ .../nestjs-websockets/src/instrument.ts | 11 +++++++ .../nestjs-websockets/src/main.ts | 15 +++++++++ .../nestjs-websockets/start-event-proxy.mjs | 6 ++++ .../nestjs-websockets/tests/errors.test.ts | 23 +++++++++++++ .../tests/transactions.test.ts | 18 ++++++++++ .../nestjs-websockets/tsconfig.json | 22 +++++++++++++ 12 files changed, 197 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/nest-cli.json create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.controller.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.module.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/tsconfig.json 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..3544c9bed7cd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json @@ -0,0 +1,33 @@ +{ + "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:assert": "pnpm test" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "@nestjs/platform-socket.io": "^10.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": "^10.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..c1f085b11291 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.controller.ts @@ -0,0 +1,11 @@ +import { Controller, Get } from '@nestjs/common'; +import { flush } from '@sentry/nestjs'; + +@Controller() +export class AppController { + @Get('/flush') + async flush() { + await flush(); + return '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..5231385ef80b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/app.gateway.ts @@ -0,0 +1,25 @@ +import { SubscribeMessage, WebSocketGateway, MessageBody } from '@nestjs/websockets'; +import * as Sentry from '@sentry/nestjs'; + +@WebSocketGateway({ cors: true }) +export class AppGateway { + @SubscribeMessage('test-message') + handleTestMessage(@MessageBody() data: { message: string }) { + return { event: 'test-response', data: { message: data.message } }; + } + + @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..99d938e1c05a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/tests/errors.test.ts @@ -0,0 +1,23 @@ +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(); +}); 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..bdff08f2ff67 --- /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 for the flush endpoint', async ({ baseURL }) => { + const txPromise = waitForTransaction('nestjs-websockets', tx => { + return tx?.contexts?.trace?.op === 'http.server' && tx?.transaction === 'GET /flush'; + }); + + await fetch(`${baseURL}/flush`); + + 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" + } +} From 930412d376ec1e62e3d2e4c026dfbf258eb34d12 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 4 Mar 2026 16:39:40 +0100 Subject: [PATCH 02/10] . --- .../nestjs-websockets/src/app.controller.ts | 8 +++----- .../nestjs-websockets/src/app.gateway.ts | 7 +------ .../nestjs-websockets/tests/transactions.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 14 deletions(-) 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 index c1f085b11291..e5e867d95312 100644 --- 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 @@ -1,11 +1,9 @@ import { Controller, Get } from '@nestjs/common'; -import { flush } from '@sentry/nestjs'; @Controller() export class AppController { - @Get('/flush') - async flush() { - await flush(); - return 'ok'; + @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 index 5231385ef80b..712d47aba4d2 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,13 +1,8 @@ import { SubscribeMessage, WebSocketGateway, MessageBody } from '@nestjs/websockets'; import * as Sentry from '@sentry/nestjs'; -@WebSocketGateway({ cors: true }) +@WebSocketGateway() export class AppGateway { - @SubscribeMessage('test-message') - handleTestMessage(@MessageBody() data: { message: string }) { - return { event: 'test-response', data: { message: data.message } }; - } - @SubscribeMessage('test-exception') handleTestException() { throw new Error('This is an exception in a WebSocket handler'); 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 index bdff08f2ff67..d701897cfa56 100644 --- 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 @@ -1,12 +1,12 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -test('Sends an HTTP transaction for the flush endpoint', async ({ baseURL }) => { +test('Sends an HTTP transaction', async ({ baseURL }) => { const txPromise = waitForTransaction('nestjs-websockets', tx => { - return tx?.contexts?.trace?.op === 'http.server' && tx?.transaction === 'GET /flush'; + return tx?.contexts?.trace?.op === 'http.server' && tx?.transaction === 'GET /test-transaction'; }); - await fetch(`${baseURL}/flush`); + await fetch(`${baseURL}/test-transaction`); const tx = await txPromise; From 9dcfb1319c9b75c17ff20d7023115aa36d59da68 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 4 Mar 2026 16:57:06 +0100 Subject: [PATCH 03/10] Add test that verifies that exceptions are not auto-captured --- .../nestjs-websockets/tests/errors.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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 99d938e1c05a..e6843799f05d 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 @@ -21,3 +21,35 @@ test('Captures manually reported error in WebSocket gateway handler', async ({ b 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(); +}); From 8df4927494c140be5bfeb655225206365222b3cb Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 4 Mar 2026 17:28:39 +0100 Subject: [PATCH 04/10] v11 --- .../test-applications/nestjs-websockets/package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json b/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json index 3544c9bed7cd..8fd9301c7ce0 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json @@ -10,11 +10,11 @@ "test:assert": "pnpm test" }, "dependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/websockets": "^10.0.0", - "@nestjs/platform-socket.io": "^10.0.0", + "@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" @@ -22,7 +22,7 @@ "devDependencies": { "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", - "@nestjs/cli": "^10.0.0", + "@nestjs/cli": "^11.0.0", "@types/node": "^18.19.1", "socket.io-client": "^4.0.0", "typescript": "~5.0.0" From 7d9e99b764a82009734a321abe7aef362965f2c5 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 4 Mar 2026 17:33:34 +0100 Subject: [PATCH 05/10] add latest variant to canary tests --- .github/workflows/canary.yml | 3 +++ .../e2e-tests/test-applications/nestjs-websockets/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 252bbc831239..0235dc00e00a 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)' steps: - name: Check out current commit diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json b/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json index 8fd9301c7ce0..6356b48b322f 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/package.json @@ -7,6 +7,7 @@ "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": { From d8fccc35b64c8f627e5f55bb361ec0258da2c181 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 5 Mar 2026 12:52:21 +0100 Subject: [PATCH 06/10] Expand websockets nestjs e2e with tracing tests --- .../nestjs-websockets/src/app.gateway.ts | 29 +++ .../nestjs-websockets/src/example.guard.ts | 10 + .../src/example.interceptor.ts | 15 ++ .../tests/transactions.test.ts | 176 ++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/src/example.guard.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-websockets/src/example.interceptor.ts 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..ed156ed286b5 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,5 +1,8 @@ +import { ParseIntPipe, UseGuards, UseInterceptors, UsePipes } from '@nestjs/common'; import { SubscribeMessage, WebSocketGateway, MessageBody } from '@nestjs/websockets'; import * as Sentry from '@sentry/nestjs'; +import { ExampleGuard } from './example.guard'; +import { ExampleInterceptor } from './example.interceptor'; @WebSocketGateway() export class AppGateway { @@ -17,4 +20,30 @@ export class AppGateway { } return { event: 'capture-response', data: { success: true } }; } + + @SubscribeMessage('test-guard-instrumentation') + @UseGuards(ExampleGuard) + handleGuardInstrumentation() { + return { event: 'guard-response', data: { success: true } }; + } + + @SubscribeMessage('test-interceptor-instrumentation') + @UseInterceptors(ExampleInterceptor) + handleInterceptorInstrumentation() { + return { event: 'interceptor-response', data: { success: true } }; + } + + @SubscribeMessage('test-pipe-instrumentation') + @UsePipes(ParseIntPipe) + handlePipeInstrumentation(@MessageBody() value: number) { + return { event: 'pipe-response', data: { value } }; + } + + @SubscribeMessage('test-manual-span') + handleManualSpan() { + const result = Sentry.startSpan({ name: 'test-ws-manual-span' }, () => { + return { success: true }; + }); + return { event: 'manual-span-response', data: result }; + } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/example.guard.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/example.guard.ts new file mode 100644 index 000000000000..e12bbdc4e994 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/example.guard.ts @@ -0,0 +1,10 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class ExampleGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + Sentry.startSpan({ name: 'test-guard-span' }, () => {}); + return true; + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/example.interceptor.ts b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/example.interceptor.ts new file mode 100644 index 000000000000..f775086a8212 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-websockets/src/example.interceptor.ts @@ -0,0 +1,15 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import { tap } from 'rxjs'; + +@Injectable() +export class ExampleInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler) { + Sentry.startSpan({ name: 'test-interceptor-span' }, () => {}); + return next.handle().pipe( + tap(() => { + Sentry.startSpan({ name: 'test-interceptor-span-after-route' }, () => {}); + }), + ); + } +} 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 index d701897cfa56..939418c5146a 100644 --- 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 @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +import { io } from 'socket.io-client'; test('Sends an HTTP transaction', async ({ baseURL }) => { const txPromise = waitForTransaction('nestjs-websockets', tx => { @@ -16,3 +17,178 @@ test('Sends an HTTP transaction', async ({ baseURL }) => { }), ); }); + +test('WebSocket handler with manual Sentry.startSpan() sends a transaction', async ({ baseURL }) => { + const txPromise = waitForTransaction('nestjs-websockets', tx => { + return tx?.transaction === 'test-ws-manual-span'; + }); + + const socket = io(baseURL!); + await new Promise(resolve => socket.on('connect', resolve)); + + socket.emit('test-manual-span', {}); + + const tx = await txPromise; + + expect(tx.transaction).toBe('test-ws-manual-span'); + + socket.disconnect(); +}); + +test('WebSocket handler with guard includes guard span and nested manual span', async ({ baseURL }) => { + const txPromise = waitForTransaction('nestjs-websockets', tx => { + return tx?.transaction === 'ExampleGuard' || tx?.spans?.some(span => span.description === 'ExampleGuard'); + }); + + const socket = io(baseURL!); + await new Promise(resolve => socket.on('connect', resolve)); + socket.emit('test-guard-instrumentation', {}); + + const tx = await txPromise; + + // Find the ExampleGuard span (either the root transaction or a child span) + const guardSpan = + tx.transaction === 'ExampleGuard' + ? { span_id: tx.contexts?.trace?.span_id } + : tx.spans.find(span => span.description === 'ExampleGuard'); + + expect(guardSpan).toBeDefined(); + + // If ExampleGuard is a child span, verify its properties + if (tx.transaction !== 'ExampleGuard') { + expect(tx.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'ExampleGuard', + op: 'middleware.nestjs', + origin: 'auto.middleware.nestjs', + status: 'ok', + }), + ]), + ); + } + + // The manual span started inside the guard should be a child of the guard span + expect(tx.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'test-guard-span', + origin: 'manual', + status: 'ok', + }), + ]), + ); + + const testGuardSpan = tx.spans.find(span => span.description === 'test-guard-span'); + expect(testGuardSpan.parent_span_id).toBe(guardSpan.span_id); + + socket.disconnect(); +}); + +test('WebSocket handler with interceptor includes interceptor span, after-route span, and nested manual spans', async ({ + baseURL, +}) => { + const txPromise = waitForTransaction('nestjs-websockets', tx => { + return ( + tx?.transaction === 'ExampleInterceptor' || tx?.spans?.some(span => span.description === 'ExampleInterceptor') + ); + }); + + const socket = io(baseURL!); + await new Promise(resolve => socket.on('connect', resolve)); + socket.emit('test-interceptor-instrumentation', {}); + + const tx = await txPromise; + + // Find the ExampleInterceptor span (either the root transaction or a child span) + const interceptorSpan = + tx.transaction === 'ExampleInterceptor' + ? { span_id: tx.contexts?.trace?.span_id } + : tx.spans.find(span => span.description === 'ExampleInterceptor'); + + expect(interceptorSpan).toBeDefined(); + + // If ExampleInterceptor is a child span, verify its properties + if (tx.transaction !== 'ExampleInterceptor') { + expect(tx.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'ExampleInterceptor', + op: 'middleware.nestjs', + origin: 'auto.middleware.nestjs', + status: 'ok', + }), + ]), + ); + } + + // The manual span started inside the interceptor (before route) should be a child of the interceptor span + expect(tx.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'test-interceptor-span', + origin: 'manual', + status: 'ok', + }), + ]), + ); + + const testInterceptorSpan = tx.spans.find(span => span.description === 'test-interceptor-span'); + expect(testInterceptorSpan.parent_span_id).toBe(interceptorSpan.span_id); + + // The after-route interceptor span should also be present + expect(tx.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'Interceptors - After Route', + op: 'middleware.nestjs', + origin: 'auto.middleware.nestjs', + status: 'ok', + }), + ]), + ); + + // The manual span started in the after-route tap should be nested under 'Interceptors - After Route' + const afterRouteSpan = tx.spans.find(span => span.description === 'Interceptors - After Route'); + const testAfterRouteSpan = tx.spans.find(span => span.description === 'test-interceptor-span-after-route'); + + expect(testAfterRouteSpan).toBeDefined(); + expect(testAfterRouteSpan.parent_span_id).toBe(afterRouteSpan?.span_id); + + socket.disconnect(); +}); + +test('WebSocket handler with pipe includes pipe span', async ({ baseURL }) => { + const txPromise = waitForTransaction('nestjs-websockets', tx => { + return tx?.transaction === 'ParseIntPipe' || tx?.spans?.some(span => span.description === 'ParseIntPipe'); + }); + + const socket = io(baseURL!); + await new Promise(resolve => socket.on('connect', resolve)); + socket.emit('test-pipe-instrumentation', '123'); + + const tx = await txPromise; + + // ParseIntPipe can be the root transaction or a child span depending on transport + if (tx.transaction === 'ParseIntPipe') { + expect(tx.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'middleware.nestjs', + origin: 'auto.middleware.nestjs', + }), + ); + } else { + expect(tx.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'ParseIntPipe', + op: 'middleware.nestjs', + origin: 'auto.middleware.nestjs', + status: 'ok', + }), + ]), + ); + } + + socket.disconnect(); +}); From e168719d5e582996c7555c2c5db061bdbc8c323a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 6 Mar 2026 12:04:11 +0100 Subject: [PATCH 07/10] update --- .../tests/transactions.test.ts | 172 ++++++------------ 1 file changed, 59 insertions(+), 113 deletions(-) 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 index 939418c5146a..c67e9ea135dc 100644 --- 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 @@ -1,6 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -import { io } from 'socket.io-client'; +import { io, type Socket } from 'socket.io-client'; + +function connectSocket(baseURL: string): Promise { + const socket = io(baseURL); + return new Promise(resolve => socket.on('connect', resolve)).then(() => socket); +} test('Sends an HTTP transaction', async ({ baseURL }) => { const txPromise = waitForTransaction('nestjs-websockets', tx => { @@ -23,172 +28,113 @@ test('WebSocket handler with manual Sentry.startSpan() sends a transaction', asy return tx?.transaction === 'test-ws-manual-span'; }); - const socket = io(baseURL!); - await new Promise(resolve => socket.on('connect', resolve)); - - socket.emit('test-manual-span', {}); - - const tx = await txPromise; + const socket = await connectSocket(baseURL!); + try { + socket.emit('test-manual-span', {}); - expect(tx.transaction).toBe('test-ws-manual-span'); - - socket.disconnect(); + const tx = await txPromise; + expect(tx.transaction).toBe('test-ws-manual-span'); + } finally { + socket.disconnect(); + } }); test('WebSocket handler with guard includes guard span and nested manual span', async ({ baseURL }) => { const txPromise = waitForTransaction('nestjs-websockets', tx => { - return tx?.transaction === 'ExampleGuard' || tx?.spans?.some(span => span.description === 'ExampleGuard'); + return tx?.transaction === 'ExampleGuard'; }); - const socket = io(baseURL!); - await new Promise(resolve => socket.on('connect', resolve)); - socket.emit('test-guard-instrumentation', {}); + const socket = await connectSocket(baseURL!); + try { + socket.emit('test-guard-instrumentation', {}); - const tx = await txPromise; + const tx = await txPromise; - // Find the ExampleGuard span (either the root transaction or a child span) - const guardSpan = - tx.transaction === 'ExampleGuard' - ? { span_id: tx.contexts?.trace?.span_id } - : tx.spans.find(span => span.description === 'ExampleGuard'); + expect(tx.transaction).toBe('ExampleGuard'); - expect(guardSpan).toBeDefined(); - - // If ExampleGuard is a child span, verify its properties - if (tx.transaction !== 'ExampleGuard') { expect(tx.spans).toEqual( expect.arrayContaining([ expect.objectContaining({ - description: 'ExampleGuard', - op: 'middleware.nestjs', - origin: 'auto.middleware.nestjs', + description: 'test-guard-span', + parent_span_id: tx.contexts?.trace?.span_id, + origin: 'manual', status: 'ok', }), ]), ); + } finally { + socket.disconnect(); } - - // The manual span started inside the guard should be a child of the guard span - expect(tx.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'test-guard-span', - origin: 'manual', - status: 'ok', - }), - ]), - ); - - const testGuardSpan = tx.spans.find(span => span.description === 'test-guard-span'); - expect(testGuardSpan.parent_span_id).toBe(guardSpan.span_id); - - socket.disconnect(); }); test('WebSocket handler with interceptor includes interceptor span, after-route span, and nested manual spans', async ({ baseURL, }) => { const txPromise = waitForTransaction('nestjs-websockets', tx => { - return ( - tx?.transaction === 'ExampleInterceptor' || tx?.spans?.some(span => span.description === 'ExampleInterceptor') - ); + return tx?.transaction === 'ExampleInterceptor'; }); - const socket = io(baseURL!); - await new Promise(resolve => socket.on('connect', resolve)); - socket.emit('test-interceptor-instrumentation', {}); + const socket = await connectSocket(baseURL!); + try { + socket.emit('test-interceptor-instrumentation', {}); - const tx = await txPromise; + const tx = await txPromise; - // Find the ExampleInterceptor span (either the root transaction or a child span) - const interceptorSpan = - tx.transaction === 'ExampleInterceptor' - ? { span_id: tx.contexts?.trace?.span_id } - : tx.spans.find(span => span.description === 'ExampleInterceptor'); + expect(tx.transaction).toBe('ExampleInterceptor'); - expect(interceptorSpan).toBeDefined(); + const rootSpanId = tx.contexts?.trace?.span_id; - // If ExampleInterceptor is a child span, verify its properties - if (tx.transaction !== 'ExampleInterceptor') { expect(tx.spans).toEqual( expect.arrayContaining([ expect.objectContaining({ - description: 'ExampleInterceptor', + description: 'test-interceptor-span', + parent_span_id: rootSpanId, + origin: 'manual', + status: 'ok', + }), + expect.objectContaining({ + description: 'Interceptors - After Route', op: 'middleware.nestjs', origin: 'auto.middleware.nestjs', status: 'ok', }), ]), ); - } - - // The manual span started inside the interceptor (before route) should be a child of the interceptor span - expect(tx.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'test-interceptor-span', - origin: 'manual', - status: 'ok', - }), - ]), - ); - const testInterceptorSpan = tx.spans.find(span => span.description === 'test-interceptor-span'); - expect(testInterceptorSpan.parent_span_id).toBe(interceptorSpan.span_id); + const afterRouteSpan = tx.spans.find(span => span.description === 'Interceptors - After Route'); - // The after-route interceptor span should also be present - expect(tx.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'Interceptors - After Route', - op: 'middleware.nestjs', - origin: 'auto.middleware.nestjs', - status: 'ok', - }), - ]), - ); - - // The manual span started in the after-route tap should be nested under 'Interceptors - After Route' - const afterRouteSpan = tx.spans.find(span => span.description === 'Interceptors - After Route'); - const testAfterRouteSpan = tx.spans.find(span => span.description === 'test-interceptor-span-after-route'); - - expect(testAfterRouteSpan).toBeDefined(); - expect(testAfterRouteSpan.parent_span_id).toBe(afterRouteSpan?.span_id); - - socket.disconnect(); + expect(tx.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'test-interceptor-span-after-route', + parent_span_id: afterRouteSpan?.span_id, + }), + ]), + ); + } finally { + socket.disconnect(); + } }); test('WebSocket handler with pipe includes pipe span', async ({ baseURL }) => { const txPromise = waitForTransaction('nestjs-websockets', tx => { - return tx?.transaction === 'ParseIntPipe' || tx?.spans?.some(span => span.description === 'ParseIntPipe'); + return tx?.transaction === 'ParseIntPipe'; }); - const socket = io(baseURL!); - await new Promise(resolve => socket.on('connect', resolve)); - socket.emit('test-pipe-instrumentation', '123'); + const socket = await connectSocket(baseURL!); + try { + socket.emit('test-pipe-instrumentation', '123'); - const tx = await txPromise; + const tx = await txPromise; - // ParseIntPipe can be the root transaction or a child span depending on transport - if (tx.transaction === 'ParseIntPipe') { + expect(tx.transaction).toBe('ParseIntPipe'); expect(tx.contexts?.trace).toEqual( expect.objectContaining({ op: 'middleware.nestjs', origin: 'auto.middleware.nestjs', }), ); - } else { - expect(tx.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'ParseIntPipe', - op: 'middleware.nestjs', - origin: 'auto.middleware.nestjs', - status: 'ok', - }), - ]), - ); + } finally { + socket.disconnect(); } - - socket.disconnect(); }); From a1e06b2dbfb38ce3cddc61f2360ed856c9f68abb Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 6 Mar 2026 12:41:49 +0100 Subject: [PATCH 08/10] flush --- .../nestjs-websockets/src/app.controller.ts | 7 +++++++ .../nestjs-websockets/tests/transactions.test.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) 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 index e5e867d95312..cdc2be63287f 100644 --- 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 @@ -1,4 +1,5 @@ import { Controller, Get } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; @Controller() export class AppController { @@ -6,4 +7,10 @@ export class AppController { testTransaction() { return { message: 'ok' }; } + + @Get('/flush') + async flush() { + await Sentry.flush(2000); + return { message: 'ok' }; + } } 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 index c67e9ea135dc..699b33008f6a 100644 --- 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 @@ -4,7 +4,7 @@ import { io, type Socket } from 'socket.io-client'; function connectSocket(baseURL: string): Promise { const socket = io(baseURL); - return new Promise(resolve => socket.on('connect', resolve)).then(() => socket); + return new Promise(resolve => socket.on('connect', () => resolve(socket))); } test('Sends an HTTP transaction', async ({ baseURL }) => { @@ -31,6 +31,7 @@ test('WebSocket handler with manual Sentry.startSpan() sends a transaction', asy const socket = await connectSocket(baseURL!); try { socket.emit('test-manual-span', {}); + await fetch(`${baseURL}/flush`); const tx = await txPromise; expect(tx.transaction).toBe('test-ws-manual-span'); @@ -47,6 +48,7 @@ test('WebSocket handler with guard includes guard span and nested manual span', const socket = await connectSocket(baseURL!); try { socket.emit('test-guard-instrumentation', {}); + await fetch(`${baseURL}/flush`); const tx = await txPromise; @@ -77,6 +79,7 @@ test('WebSocket handler with interceptor includes interceptor span, after-route const socket = await connectSocket(baseURL!); try { socket.emit('test-interceptor-instrumentation', {}); + await fetch(`${baseURL}/flush`); const tx = await txPromise; @@ -124,6 +127,7 @@ test('WebSocket handler with pipe includes pipe span', async ({ baseURL }) => { const socket = await connectSocket(baseURL!); try { socket.emit('test-pipe-instrumentation', '123'); + await fetch(`${baseURL}/flush`); const tx = await txPromise; From 5f4b7ac0ef8cbb7ccc2fc90b85f6a00fe01d3330 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 6 Mar 2026 12:46:12 +0100 Subject: [PATCH 09/10] assert root span --- .../nestjs-websockets/tests/transactions.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 699b33008f6a..6881c3a271fd 100644 --- 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 @@ -53,6 +53,12 @@ test('WebSocket handler with guard includes guard span and nested manual span', const tx = await txPromise; expect(tx.transaction).toBe('ExampleGuard'); + expect(tx.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'middleware.nestjs', + origin: 'auto.middleware.nestjs', + }), + ); expect(tx.spans).toEqual( expect.arrayContaining([ @@ -84,6 +90,12 @@ test('WebSocket handler with interceptor includes interceptor span, after-route const tx = await txPromise; expect(tx.transaction).toBe('ExampleInterceptor'); + expect(tx.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'middleware.nestjs', + origin: 'auto.middleware.nestjs', + }), + ); const rootSpanId = tx.contexts?.trace?.span_id; From 0a14b22e4595c86117de2d4a38397ab5f5b515fe Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 6 Mar 2026 12:47:41 +0100 Subject: [PATCH 10/10] . --- .../nestjs-websockets/tests/transactions.test.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 index 6881c3a271fd..45220d34b428 100644 --- 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 @@ -35,6 +35,11 @@ test('WebSocket handler with manual Sentry.startSpan() sends a transaction', asy const tx = await txPromise; expect(tx.transaction).toBe('test-ws-manual-span'); + expect(tx.contexts?.trace).toEqual( + expect.objectContaining({ + origin: 'manual', + }), + ); } finally { socket.disconnect(); }