Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand All @@ -13,6 +13,7 @@ import { AppGateway } from './app.gateway';
useClass: SentryGlobalFilter,
},
AppGateway,
EventsGateway,
],
})
export class AppModule {}
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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<void>((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<void>(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();
}
});
Comment thread
cursor[bot] marked this conversation as resolved.
67 changes: 64 additions & 3 deletions packages/nestjs/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<unknown> {
public intercept(context: ExecutionContext, next: CallHandler): ReturnType<CallHandler['handle']> {
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<unknown>, 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();
Expand Down
156 changes: 156 additions & 0 deletions packages/nestjs/test/sentry-tracing-interceptor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { CallHandler, ExecutionContext } from '@nestjs/common';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: this PR only relies on unit tests to verify the introduced behavior. let's please move as much as possible of this to our nestjs-websockets e2e test app so we can fully verify this works and keeps working as expected.

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<CallHandler['handle']>;
};

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);
});
});