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/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..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 @@ -1,5 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +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(socket))); +} test('Sends an HTTP transaction', async ({ baseURL }) => { const txPromise = waitForTransaction('nestjs-websockets', tx => { @@ -16,3 +22,140 @@ 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 = 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'); + expect(tx.contexts?.trace).toEqual( + expect.objectContaining({ + origin: 'manual', + }), + ); + } 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'; + }); + + const socket = await connectSocket(baseURL!); + try { + socket.emit('test-guard-instrumentation', {}); + await fetch(`${baseURL}/flush`); + + 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([ + expect.objectContaining({ + description: 'test-guard-span', + parent_span_id: tx.contexts?.trace?.span_id, + origin: 'manual', + status: 'ok', + }), + ]), + ); + } finally { + 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'; + }); + + const socket = await connectSocket(baseURL!); + try { + socket.emit('test-interceptor-instrumentation', {}); + await fetch(`${baseURL}/flush`); + + 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; + + expect(tx.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + 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', + }), + ]), + ); + + const afterRouteSpan = tx.spans.find(span => span.description === 'Interceptors - After Route'); + + 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'; + }); + + const socket = await connectSocket(baseURL!); + try { + socket.emit('test-pipe-instrumentation', '123'); + await fetch(`${baseURL}/flush`); + + const tx = await txPromise; + + expect(tx.transaction).toBe('ParseIntPipe'); + expect(tx.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'middleware.nestjs', + origin: 'auto.middleware.nestjs', + }), + ); + } finally { + socket.disconnect(); + } +});