From 5353fe6cc48466e51c04f903d8331c863334ef04 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 6 Oct 2026 17:01:06 +0530 Subject: [PATCH 01/15] 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); + }); +}); From e61512ece65661e1356117ea75fa593e20f8f493 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:09:40 +0530 Subject: [PATCH 02/15] fix(nestjs): instrument existing WebSocket observable --- packages/nestjs/src/setup.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index 668f479240f1..b1dd5686b32a 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -9,7 +9,6 @@ import type { import { Catch, Global, HttpException, Injectable, Logger, Module } from '@nestjs/common'; import { APP_INTERCEPTOR, BaseExceptionFilter } from '@nestjs/core'; import { captureException, debug, getDefaultIsolationScope, getIsolationScope, isObjectLike, withIsolationScope } from '@sentry/core'; -import { Observable } from 'rxjs'; import { isExpectedError, isWsOrRpcException } from './helpers'; // Partial extract of FastifyRequest interface @@ -47,7 +46,7 @@ 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 } }>(); @@ -57,14 +56,19 @@ class SentryTracingInterceptor implements NestInterceptor { return next.handle(); } - return new Observable(subscriber => - withIsolationScope(isolationScope => { - const namespace = client?.nsp?.name; - isolationScope.setTransactionName(namespace ? `WS ${namespace} ${pattern}` : `WS ${pattern}`); + const observable = next.handle(); + observable.subscribe = new Proxy(observable.subscribe, { + apply: (originalSubscribe, thisArgSubscribe, argsSubscribe) => { + return withIsolationScope(isolationScope => { + const namespace = client?.nsp?.name; + isolationScope.setTransactionName(namespace ? `WS ${namespace} ${pattern}` : `WS ${pattern}`); - return next.handle().subscribe(subscriber); - }), - ); + return originalSubscribe.apply(thisArgSubscribe, argsSubscribe); + }); + }, + }); + + return observable; } if (getIsolationScope() === getDefaultIsolationScope()) { From daea785bb01b6a0c5b216e1ebf832421d6b430aa Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:09:43 +0530 Subject: [PATCH 03/15] fix(nestjs): remove unnecessary rxjs peer dependency --- packages/nestjs/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 84b7136c003a..126eb82be976 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -57,8 +57,7 @@ }, "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", - "rxjs": "^7.x" + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0" }, "scripts": { "build": "run-p build:transpile build:types", From b784167020806b88af02eed96e817989a5a4f29e Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:09:46 +0530 Subject: [PATCH 04/15] test(nestjs): assert scope during WebSocket subscription --- .../nestjs/test/sentry-tracing-interceptor.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/nestjs/test/sentry-tracing-interceptor.test.ts b/packages/nestjs/test/sentry-tracing-interceptor.test.ts index f15f8a7b65cf..508043337667 100644 --- a/packages/nestjs/test/sentry-tracing-interceptor.test.ts +++ b/packages/nestjs/test/sentry-tracing-interceptor.test.ts @@ -1,6 +1,6 @@ import type { CallHandler, ExecutionContext } from '@nestjs/common'; import { getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; -import { of } from 'rxjs'; +import { of, tap } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SentryModule } from '../src/setup'; @@ -110,10 +110,13 @@ describe('SentryTracingInterceptor', () => { const interceptor = createInterceptor(); const context = createContext({ nsp: { name: '/events' } }, 'message'); const next = { - handle: vi.fn(() => { - expect(mocks.inIsolationScope).toBe(true); - return of(undefined); - }), + handle: vi.fn(() => + of(undefined).pipe( + tap(() => { + expect(mocks.inIsolationScope).toBe(true); + }), + ), + ), } as unknown as CallHandler; vi.mocked(getIsolationScope).mockReturnValue(mocks.defaultIsolationScope as never); From c8d133c8cd69ebdea0cf6aab46db12ad89c50ae7 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:15:21 +0530 Subject: [PATCH 05/15] test(nestjs): add namespaced WebSocket tracing endpoint --- .../nestjs-websockets/src/app.gateway.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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 } }; + } +} From 40e8cc41da890871f3338f9534beb1d000fb7a14 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:15:28 +0530 Subject: [PATCH 06/15] test(nestjs): register namespaced WebSocket gateway --- .../test-applications/nestjs-websockets/src/app.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 {} From 2a76c7625ee637e47611eaac929914795e2b011c Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:15:41 +0530 Subject: [PATCH 07/15] test(nestjs): assert WebSocket transaction naming in e2e --- .../tests/transactions.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 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 6cebef2405d2..9f3635df65fc 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 { 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 +14,30 @@ 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(span.name).toBe('WS /events test-transaction'); + } finally { + socket.disconnect(); + } +}); From 883878007bdc101fc1d6fefa135a180dbfd95948 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:28:30 +0530 Subject: [PATCH 08/15] fix(nestjs): create spans for WebSocket handlers --- packages/nestjs/src/setup.ts | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index b1dd5686b32a..34c871846195 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -8,7 +8,9 @@ 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, withIsolationScope } from '@sentry/core'; +import { captureException, debug, getDefaultIsolationScope, getIsolationScope, isObjectLike, startSpanManual, withIsolationScope } from '@sentry/core'; +import { instrumentObservable } from './integrations/helpers'; +import type { Observable as IntegrationObservable } from './integrations/types'; import { isExpectedError, isWsOrRpcException } from './helpers'; // Partial extract of FastifyRequest interface @@ -56,19 +58,31 @@ class SentryTracingInterceptor implements NestInterceptor { return next.handle(); } - const observable = next.handle(); - observable.subscribe = new Proxy(observable.subscribe, { - apply: (originalSubscribe, thisArgSubscribe, argsSubscribe) => { - return withIsolationScope(isolationScope => { - const namespace = client?.nsp?.name; - isolationScope.setTransactionName(namespace ? `WS ${namespace} ${pattern}` : `WS ${pattern}`); + const namespace = client?.nsp?.name; + const transactionName = namespace ? `WS ${namespace} ${pattern}` : `WS ${pattern}`; - return originalSubscribe.apply(thisArgSubscribe, argsSubscribe); + return withIsolationScope(isolationScope => { + isolationScope.setTransactionName(transactionName); + + return startSpanManual({ op: 'ws', name: transactionName }, span => { + 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); + }); + }, }); - }, - }); - return observable; + // Instrument the Observable returned by Nest rather than creating a new one. + instrumentObservable(observable as unknown as IntegrationObservable, span); + + return observable; + }); + }); } if (getIsolationScope() === getDefaultIsolationScope()) { From 37084794d85badb049e8054bacbd1b20c4263e76 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:28:44 +0530 Subject: [PATCH 09/15] test(nestjs): assert WebSocket spans are created and ended --- .../nestjs/test/sentry-tracing-interceptor.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/nestjs/test/sentry-tracing-interceptor.test.ts b/packages/nestjs/test/sentry-tracing-interceptor.test.ts index 508043337667..9e15316716d7 100644 --- a/packages/nestjs/test/sentry-tracing-interceptor.test.ts +++ b/packages/nestjs/test/sentry-tracing-interceptor.test.ts @@ -1,5 +1,5 @@ import type { CallHandler, ExecutionContext } from '@nestjs/common'; -import { getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; +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'; @@ -11,6 +11,9 @@ const mocks = vi.hoisted(() => ({ defaultIsolationScope: {}, debugWarn: vi.fn(), inIsolationScope: false, + span: { + end: vi.fn(), + }, })); vi.mock('@sentry/core', () => ({ @@ -24,6 +27,8 @@ vi.mock('@sentry/core', () => ({ 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, }, @@ -67,6 +72,11 @@ describe('SentryTracingInterceptor', () => { interceptor.intercept(context, next).subscribe(); expect(mocks.isolationScope.setTransactionName).toHaveBeenCalledWith('WS /events message'); + expect(startSpanManual).toHaveBeenCalledWith( + { op: 'ws', name: 'WS /events message' }, + expect.any(Function), + ); + expect(mocks.span.end).toHaveBeenCalled(); }); it('sets a transaction name without a namespace for other WebSocket adapters', () => { From 8b8306573900cfc9f2dafa7150120c895bdafd16 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:29:01 +0530 Subject: [PATCH 10/15] test(nestjs): assert WebSocket span operation in e2e --- .../nestjs-websockets/tests/transactions.test.ts | 1 + 1 file changed, 1 insertion(+) 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 9f3635df65fc..f5975217c6cf 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 @@ -36,6 +36,7 @@ test('Names WebSocket segment spans with the Socket.IO namespace and message pat const [span] = await Promise.all([spanPromise, responsePromise]); expect(span.is_segment).toBe(true); + expect(getSpanOp(span)).toBe('ws'); expect(span.name).toBe('WS /events test-transaction'); } finally { socket.disconnect(); From 61af19834de92f11b8d6af188c1d5bc32007ecf4 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:29:27 +0530 Subject: [PATCH 11/15] style(nestjs): format WebSocket tracing imports --- packages/nestjs/src/setup.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index 34c871846195..6dfe2afd8d9c 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -8,10 +8,18 @@ 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, startSpanManual, withIsolationScope } from '@sentry/core'; +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'; -import { isExpectedError, isWsOrRpcException } from './helpers'; // Partial extract of FastifyRequest interface // https://github.com/fastify/fastify/blob/87f9f20687c938828f1138f91682d568d2a31e53/types/request.d.ts#L41 From 55a57698e8f5c3263ae4f6e8e751c114a267b751 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:29:30 +0530 Subject: [PATCH 12/15] test(nestjs): assert isolation scope during handler execution --- packages/nestjs/test/sentry-tracing-interceptor.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/nestjs/test/sentry-tracing-interceptor.test.ts b/packages/nestjs/test/sentry-tracing-interceptor.test.ts index 9e15316716d7..c5f9940fae75 100644 --- a/packages/nestjs/test/sentry-tracing-interceptor.test.ts +++ b/packages/nestjs/test/sentry-tracing-interceptor.test.ts @@ -120,13 +120,14 @@ describe('SentryTracingInterceptor', () => { const interceptor = createInterceptor(); const context = createContext({ nsp: { name: '/events' } }, 'message'); const next = { - handle: vi.fn(() => - of(undefined).pipe( + 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); From d62c6319ff9a9fe891bfaa4efe3c6d02713241fa Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:37:13 +0530 Subject: [PATCH 13/15] fix(nestjs): use conventional websocket span attributes --- packages/nestjs/src/setup.ts | 46 ++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index 6dfe2afd8d9c..60c0eed0f7ed 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -8,6 +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 { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { WEBSOCKET } from '@sentry/conventions/op'; import { captureException, debug, @@ -72,24 +74,38 @@ class SentryTracingInterceptor implements NestInterceptor { return withIsolationScope(isolationScope => { isolationScope.setTransactionName(transactionName); - return startSpanManual({ op: 'ws', name: transactionName }, span => { - 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); - }); + 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); + }); + }, + }); - // Instrument the Observable returned by Nest rather than creating a new one. - instrumentObservable(observable as unknown as IntegrationObservable, span); + // 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; - }); + return observable; + } catch (error) { + span.end(); + throw error; + } + }, + ); }); } From b7f16f58b6d2021f53ef4e57ca12ebafdee0d0e5 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:37:16 +0530 Subject: [PATCH 14/15] test(nestjs): cover websocket span cleanup --- .../test/sentry-tracing-interceptor.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/nestjs/test/sentry-tracing-interceptor.test.ts b/packages/nestjs/test/sentry-tracing-interceptor.test.ts index c5f9940fae75..58151e86e559 100644 --- a/packages/nestjs/test/sentry-tracing-interceptor.test.ts +++ b/packages/nestjs/test/sentry-tracing-interceptor.test.ts @@ -1,4 +1,6 @@ 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'; @@ -73,12 +75,27 @@ describe('SentryTracingInterceptor', () => { expect(mocks.isolationScope.setTransactionName).toHaveBeenCalledWith('WS /events message'); expect(startSpanManual).toHaveBeenCalledWith( - { op: 'ws', name: 'WS /events message' }, + { + 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'); From faf9eaab4bc651ac5726949d4dcb6c3c1f56f4ea Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Tue, 27 Oct 2026 22:37:19 +0530 Subject: [PATCH 15/15] test(nestjs): assert conventional websocket span op --- .../nestjs-websockets/tests/transactions.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 f5975217c6cf..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,4 +1,5 @@ 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'; @@ -36,7 +37,7 @@ test('Names WebSocket segment spans with the Socket.IO namespace and message pat const [span] = await Promise.all([spanPromise, responsePromise]); expect(span.is_segment).toBe(true); - expect(getSpanOp(span)).toBe('ws'); + expect(getSpanOp(span)).toBe(WEBSOCKET); expect(span.name).toBe('WS /events test-transaction'); } finally { socket.disconnect();