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
3 changes: 2 additions & 1 deletion packages/nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 21 additions & 2 deletions packages/nestjs/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
faizansaiyed123 marked this conversation as resolved.
import { isExpectedError, isWsOrRpcException } from './helpers';

// Partial extract of FastifyRequest interface
Expand Down Expand Up @@ -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<unknown> {
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();
Expand Down
125 changes: 125 additions & 0 deletions packages/nestjs/test/sentry-tracing-interceptor.test.ts
Original file line number Diff line number Diff line change
@@ -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<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');
});

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