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 08eb7475cea8..8fbfb978d3b0 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 @@ -26,3 +26,11 @@ export class AppGateway { return { event: 'capture-response', data: { success: true } }; } } + +@WebSocketGateway({ namespace: '/events' }) +export class EventsGateway { + @SubscribeMessage('test-transaction') + handleTestTransaction() { + return { event: 'test-transaction-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 index 96386d3cf29f..2246a3040970 100644 --- 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 @@ -2,7 +2,7 @@ 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'; +import { AppGateway, EventsGateway } from './app.gateway'; @Module({ imports: [SentryModule.forRoot()], @@ -13,6 +13,7 @@ import { AppGateway } from './app.gateway'; useClass: SentryGlobalFilter, }, AppGateway, + EventsGateway, ], }) export class AppModule {} 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 6cebef2405d2..ffb781988ad6 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,7 @@ import { expect, test } from '@playwright/test'; +import { WEBSOCKET } from '@sentry/conventions/op'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { io } from 'socket.io-client'; test('Sends an HTTP segment span', async ({ baseURL }) => { const spanPromise = waitForStreamedSpan('nestjs-websockets', span => { @@ -13,3 +15,31 @@ test('Sends an HTTP segment span', async ({ baseURL }) => { expect(getSpanOp(span)).toBe('http.server'); expect(span.status).toBe('ok'); }); + +test('Names WebSocket segment spans with the Socket.IO namespace and message pattern', async ({ baseURL }) => { + const socket = io(`${baseURL}/events`); + + try { + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('connect_error', reject); + }); + + const spanPromise = waitForStreamedSpan('nestjs-websockets', span => { + return span.is_segment && span.name === 'WS /events test-transaction'; + }); + const responsePromise = new Promise(resolve => { + socket.once('test-transaction-response', () => resolve()); + }); + + socket.emit('test-transaction', {}); + + const [span] = await Promise.all([spanPromise, responsePromise]); + + expect(span.is_segment).toBe(true); + expect(getSpanOp(span)).toBe(WEBSOCKET); + expect(span.name).toBe('WS /events test-transaction'); + } finally { + socket.disconnect(); + } +}); diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index a8e56a9d8a66..60c0eed0f7ed 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -8,9 +8,20 @@ import type { } from '@nestjs/common'; import { Catch, Global, HttpException, Injectable, Logger, Module } from '@nestjs/common'; import { APP_INTERCEPTOR, BaseExceptionFilter } from '@nestjs/core'; -import { captureException, debug, getDefaultIsolationScope, getIsolationScope, isObjectLike } from '@sentry/core'; -import type { Observable } from 'rxjs'; +import { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { WEBSOCKET } from '@sentry/conventions/op'; +import { + captureException, + debug, + getDefaultIsolationScope, + getIsolationScope, + isObjectLike, + startSpanManual, + withIsolationScope, +} from '@sentry/core'; import { isExpectedError, isWsOrRpcException } from './helpers'; +import { instrumentObservable } from './integrations/helpers'; +import type { Observable as IntegrationObservable } from './integrations/types'; // Partial extract of FastifyRequest interface // https://github.com/fastify/fastify/blob/87f9f20687c938828f1138f91682d568d2a31e53/types/request.d.ts#L41 @@ -47,7 +58,57 @@ class SentryTracingInterceptor implements NestInterceptor { /** * Intercepts HTTP requests to set the transaction name for Sentry tracing. */ - public intercept(context: ExecutionContext, next: CallHandler): Observable { + public intercept(context: ExecutionContext, next: CallHandler): ReturnType { + if (context.getType() === 'ws') { + const wsHost = context.switchToWs(); + const client = wsHost.getClient<{ nsp?: { name?: string } }>(); + const pattern = wsHost.getPattern?.(); + + if (!pattern) { + return next.handle(); + } + + const namespace = client?.nsp?.name; + const transactionName = namespace ? `WS ${namespace} ${pattern}` : `WS ${pattern}`; + + return withIsolationScope(isolationScope => { + isolationScope.setTransactionName(transactionName); + + return startSpanManual( + { + name: transactionName, + attributes: { + [SENTRY_OP]: WEBSOCKET, + [SENTRY_ORIGIN]: 'auto.middleware.nestjs.interceptor', + }, + }, + span => { + try { + const observable = next.handle(); + + // Keep the transaction name on the isolation scope active while the handler runs. + observable.subscribe = new Proxy(observable.subscribe, { + apply: (originalSubscribe, thisArgSubscribe, argsSubscribe) => { + return withIsolationScope(subscriptionIsolationScope => { + subscriptionIsolationScope.setTransactionName(transactionName); + return originalSubscribe.apply(thisArgSubscribe, argsSubscribe); + }); + }, + }); + + // Nest returns an RxJS Observable, while the helper intentionally uses a minimal type to avoid importing RxJS here. + instrumentObservable(observable as unknown as IntegrationObservable, span); + + return observable; + } catch (error) { + span.end(); + throw error; + } + }, + ); + }); + } + if (getIsolationScope() === getDefaultIsolationScope()) { debug.warn('Isolation scope is still the default isolation scope, skipping setting transactionName.'); return next.handle(); diff --git a/packages/nestjs/test/sentry-tracing-interceptor.test.ts b/packages/nestjs/test/sentry-tracing-interceptor.test.ts new file mode 100644 index 000000000000..58151e86e559 --- /dev/null +++ b/packages/nestjs/test/sentry-tracing-interceptor.test.ts @@ -0,0 +1,156 @@ +import type { CallHandler, ExecutionContext } from '@nestjs/common'; +import { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { WEBSOCKET } from '@sentry/conventions/op'; +import { getDefaultIsolationScope, getIsolationScope, startSpanManual } from '@sentry/core'; +import { of, tap } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SentryModule } from '../src/setup'; + +const mocks = vi.hoisted(() => ({ + isolationScope: { + setTransactionName: vi.fn(), + }, + defaultIsolationScope: {}, + debugWarn: vi.fn(), + inIsolationScope: false, + span: { + end: vi.fn(), + }, +})); + +vi.mock('@sentry/core', () => ({ + getDefaultIsolationScope: vi.fn(() => mocks.defaultIsolationScope), + getIsolationScope: vi.fn(() => mocks.isolationScope), + withIsolationScope: vi.fn((callback: (scope: unknown) => unknown) => { + mocks.inIsolationScope = true; + try { + return callback(mocks.isolationScope); + } finally { + mocks.inIsolationScope = false; + } + }), + withActiveSpan: vi.fn((_span: unknown, callback: () => unknown) => callback()), + startSpanManual: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => callback(mocks.span)), + debug: { + warn: mocks.debugWarn, + }, + captureException: vi.fn(), +})); + +type Interceptor = { + intercept: (context: ExecutionContext, next: CallHandler) => ReturnType; +}; + +function createInterceptor(): Interceptor { + const provider = SentryModule.forRoot().providers?.[0] as { + useClass: new () => Interceptor; + }; + + return new provider.useClass(); +} + +function createContext(client: unknown, pattern: unknown): ExecutionContext { + return { + getType: vi.fn().mockReturnValue('ws'), + switchToWs: vi.fn().mockReturnValue({ + getClient: vi.fn().mockReturnValue(client), + getPattern: vi.fn().mockReturnValue(pattern), + }), + } as unknown as ExecutionContext; +} + +describe('SentryTracingInterceptor', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getIsolationScope).mockReturnValue(mocks.isolationScope as never); + vi.mocked(getDefaultIsolationScope).mockReturnValue(mocks.defaultIsolationScope as never); + }); + + it('sets a transaction name for Socket.IO WebSocket contexts', () => { + const interceptor = createInterceptor(); + const context = createContext({ nsp: { name: '/events' } }, 'message'); + const next = { handle: vi.fn(() => of(undefined)) } as unknown as CallHandler; + + interceptor.intercept(context, next).subscribe(); + + expect(mocks.isolationScope.setTransactionName).toHaveBeenCalledWith('WS /events message'); + expect(startSpanManual).toHaveBeenCalledWith( + { + name: 'WS /events message', + attributes: { + [SENTRY_OP]: WEBSOCKET, + [SENTRY_ORIGIN]: 'auto.middleware.nestjs.interceptor', + }, + }, + expect.any(Function), + ); + expect(mocks.span.end).toHaveBeenCalled(); + }); + + it('ends the WebSocket span if the handler throws', () => { + const interceptor = createInterceptor(); + const context = createContext({ nsp: { name: '/events' } }, 'message'); + const next = { handle: vi.fn(() => { throw new Error('handler failed'); }) } as unknown as CallHandler; + + expect(() => interceptor.intercept(context, next)).toThrow('handler failed'); + expect(mocks.span.end).toHaveBeenCalled(); + }); + + it('sets a transaction name without a namespace for other WebSocket adapters', () => { + const interceptor = createInterceptor(); + const context = createContext({}, 'message'); + const next = { handle: vi.fn(() => of(undefined)) } as unknown as CallHandler; + + interceptor.intercept(context, next).subscribe(); + + expect(mocks.isolationScope.setTransactionName).toHaveBeenCalledWith('WS message'); + }); + + it('passes through WebSocket contexts without a pattern', () => { + const interceptor = createInterceptor(); + const context = createContext({ nsp: { name: '/events' } }, undefined); + const next = { handle: vi.fn(() => of(undefined)) } as unknown as CallHandler; + + interceptor.intercept(context, next).subscribe(); + + expect(mocks.isolationScope.setTransactionName).not.toHaveBeenCalled(); + expect(next.handle).toHaveBeenCalledTimes(1); + }); + + it('supports WebSocket hosts without getPattern', () => { + const interceptor = createInterceptor(); + const context = { + getType: vi.fn().mockReturnValue('ws'), + switchToWs: vi.fn().mockReturnValue({ + getClient: vi.fn().mockReturnValue({ nsp: { name: '/events' } }), + }), + } as unknown as ExecutionContext; + const next = { handle: vi.fn(() => of(undefined)) } as unknown as CallHandler; + + interceptor.intercept(context, next).subscribe(); + + expect(mocks.isolationScope.setTransactionName).not.toHaveBeenCalled(); + expect(next.handle).toHaveBeenCalledTimes(1); + }); + + it('names WebSocket transactions when the current isolation scope is the default scope', () => { + const interceptor = createInterceptor(); + const context = createContext({ nsp: { name: '/events' } }, 'message'); + const next = { + handle: vi.fn(() => { + expect(mocks.inIsolationScope).toBe(true); + return of(undefined).pipe( + tap(() => { + expect(mocks.inIsolationScope).toBe(true); + }), + ); + }), + } as unknown as CallHandler; + vi.mocked(getIsolationScope).mockReturnValue(mocks.defaultIsolationScope as never); + + interceptor.intercept(context, next).subscribe(); + + expect(mocks.isolationScope.setTransactionName).toHaveBeenCalledWith('WS /events message'); + expect(next.handle).toHaveBeenCalledTimes(1); + }); +});