From 5353fe6cc48466e51c04f903d8331c863334ef04 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 6 Oct 2026 17:01:06 +0530 Subject: [PATCH] fix(nestjs): trace WebSocket interceptor contexts --- packages/nestjs/package.json | 3 +- packages/nestjs/src/setup.ts | 23 +++- .../test/sentry-tracing-interceptor.test.ts | 125 ++++++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 packages/nestjs/test/sentry-tracing-interceptor.test.ts diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 126eb82be976..84b7136c003a 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -57,7 +57,8 @@ }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", - "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0" + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "rxjs": "^7.x" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index a8e56a9d8a66..668f479240f1 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -8,8 +8,8 @@ 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 { captureException, debug, getDefaultIsolationScope, getIsolationScope, isObjectLike, withIsolationScope } from '@sentry/core'; +import { Observable } from 'rxjs'; import { isExpectedError, isWsOrRpcException } from './helpers'; // Partial extract of FastifyRequest interface @@ -48,6 +48,25 @@ class SentryTracingInterceptor implements NestInterceptor { * Intercepts HTTP requests to set the transaction name for Sentry tracing. */ public intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() === 'ws') { + const wsHost = context.switchToWs(); + const client = wsHost.getClient<{ nsp?: { name?: string } }>(); + const pattern = wsHost.getPattern?.(); + + if (!pattern) { + return next.handle(); + } + + return new Observable(subscriber => + withIsolationScope(isolationScope => { + const namespace = client?.nsp?.name; + isolationScope.setTransactionName(namespace ? `WS ${namespace} ${pattern}` : `WS ${pattern}`); + + return next.handle().subscribe(subscriber); + }), + ); + } + 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..f15f8a7b65cf --- /dev/null +++ b/packages/nestjs/test/sentry-tracing-interceptor.test.ts @@ -0,0 +1,125 @@ +import type { CallHandler, ExecutionContext } from '@nestjs/common'; +import { getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; +import { of } 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, +})); + +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; + } + }), + 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'); + }); + + 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); + }), + } 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); + }); +});