Skip to content

Commit

Permalink
feat(nestjs): Instrument event handlers (#14307)
Browse files Browse the repository at this point in the history
  • Loading branch information
chargome authored and onurtemizkan committed Nov 22, 2024
1 parent 6b2c304 commit 75b3d41
Show file tree
Hide file tree
Showing 14 changed files with 380 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/event-emitter": "^2.0.0",
"@sentry/nestjs": "latest || *",
"@sentry/types": "latest || *",
"reflect-metadata": "^0.2.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import { EventsService } from './events.service';

@Controller('events')
export class EventsController {
constructor(private readonly eventsService: EventsService) {}

@Get('emit')
async emitEvents() {
await this.eventsService.emitEvents();

return { message: 'Events emitted' };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
import { TestEventListener } from './listeners/test-event.listener';

@Module({
imports: [SentryModule.forRoot(), EventEmitterModule.forRoot()],
controllers: [EventsController],
providers: [
{
provide: APP_FILTER,
useClass: SentryGlobalFilter,
},
EventsService,
TestEventListener,
],
})
export class EventsModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';

@Injectable()
export class EventsService {
constructor(private readonly eventEmitter: EventEmitter2) {}

async emitEvents() {
await this.eventEmitter.emit('myEvent.pass', { data: 'test' });
await this.eventEmitter.emit('myEvent.throw');

return { message: 'Events emitted' };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';

@Injectable()
export class TestEventListener {
@OnEvent('myEvent.pass')
async handlePassEvent(payload: any): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 100));
}

@OnEvent('myEvent.throw')
async handleThrowEvent(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 100));
throw new Error('Test error from event handler');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@ import './instrument';

// Import other modules
import { NestFactory } from '@nestjs/core';
import { EventsModule } from './events.module';
import { TraceInitiatorModule } from './trace-initiator.module';
import { TraceReceiverModule } from './trace-receiver.module';

const TRACE_INITIATOR_PORT = 3030;
const TRACE_RECEIVER_PORT = 3040;
const EVENTS_PORT = 3050;

async function bootstrap() {
const trace_initiator_app = await NestFactory.create(TraceInitiatorModule);
await trace_initiator_app.listen(TRACE_INITIATOR_PORT);

const trace_receiver_app = await NestFactory.create(TraceReceiverModule);
await trace_receiver_app.listen(TRACE_RECEIVER_PORT);

const events_app = await NestFactory.create(EventsModule);
await events_app.listen(EVENTS_PORT);
}

bootstrap();
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';

test('Event emitter', async () => {
const eventErrorPromise = waitForError('nestjs-distributed-tracing', errorEvent => {
return errorEvent.exception.values[0].value === 'Test error from event handler';
});
const successEventTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => {
return transactionEvent.transaction === 'event myEvent.pass';
});

const eventsUrl = `http://localhost:3050/events/emit`;
await fetch(eventsUrl);

const eventError = await eventErrorPromise;
const successEventTransaction = await successEventTransactionPromise;

expect(eventError.exception).toEqual({
values: [
{
type: 'Error',
value: 'Test error from event handler',
stacktrace: expect.any(Object),
mechanism: expect.any(Object),
},
],
});

expect(successEventTransaction.contexts.trace).toEqual({
parent_span_id: expect.any(String),
span_id: expect.any(String),
trace_id: expect.any(String),
data: {
'sentry.source': 'custom',
'sentry.sample_rate': 1,
'sentry.op': 'event.nestjs',
'sentry.origin': 'auto.event.nestjs',
},
origin: 'auto.event.nestjs',
op: 'event.nestjs',
status: 'ok',
});
});
4 changes: 2 additions & 2 deletions packages/nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
"@sentry/utils": "8.38.0"
},
"devDependencies": {
"@nestjs/common": "10.4.7",
"@nestjs/core": "10.4.7"
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
Expand Down
18 changes: 18 additions & 0 deletions packages/node/src/integrations/tracing/nest/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ export function getMiddlewareSpanOptions(target: InjectableTarget | CatchTarget,
};
}

/**
* Returns span options for nest event spans.
*/
export function getEventSpanOptions(event: string): {
name: string;
attributes: Record<string, string>;
forceTransaction: boolean;
} {
return {
name: `event ${event}`,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs',
},
forceTransaction: true,
};
}

/**
* Adds instrumentation to a js observable and attaches the span to an active parent span.
*/
Expand Down
6 changes: 6 additions & 0 deletions packages/node/src/integrations/tracing/nest/nest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import type { IntegrationFn, Span } from '@sentry/types';
import { logger } from '@sentry/utils';
import { generateInstrumentOnce } from '../../../otel/instrument';
import { SentryNestEventInstrumentation } from './sentry-nest-event-instrumentation';
import { SentryNestInstrumentation } from './sentry-nest-instrumentation';
import type { MinimalNestJsApp, NestJsErrorFilter } from './types';

Expand All @@ -25,10 +26,15 @@ const instrumentNestCommon = generateInstrumentOnce('Nest-Common', () => {
return new SentryNestInstrumentation();
});

const instrumentNestEvent = generateInstrumentOnce('Nest-Event', () => {
return new SentryNestEventInstrumentation();
});

export const instrumentNest = Object.assign(
(): void => {
instrumentNestCore();
instrumentNestCommon();
instrumentNestEvent();
},
{ id: INTEGRATION_NAME },
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { isWrapped } from '@opentelemetry/core';
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
import {
InstrumentationBase,
InstrumentationNodeModuleDefinition,
InstrumentationNodeModuleFile,
} from '@opentelemetry/instrumentation';
import { captureException, startSpan } from '@sentry/core';
import { SDK_VERSION } from '@sentry/utils';
import { getEventSpanOptions } from './helpers';
import type { OnEventTarget } from './types';

const supportedVersions = ['>=2.0.0'];

/**
* Custom instrumentation for nestjs event-emitter
*
* This hooks into the `OnEvent` decorator, which is applied on event handlers.
*/
export class SentryNestEventInstrumentation extends InstrumentationBase {
public static readonly COMPONENT = '@nestjs/event-emitter';
public static readonly COMMON_ATTRIBUTES = {
component: SentryNestEventInstrumentation.COMPONENT,
};

public constructor(config: InstrumentationConfig = {}) {
super('sentry-nestjs-event', SDK_VERSION, config);
}

/**
* Initializes the instrumentation by defining the modules to be patched.
*/
public init(): InstrumentationNodeModuleDefinition {
const moduleDef = new InstrumentationNodeModuleDefinition(
SentryNestEventInstrumentation.COMPONENT,
supportedVersions,
);

moduleDef.files.push(this._getOnEventFileInstrumentation(supportedVersions));
return moduleDef;
}

/**
* Wraps the @OnEvent decorator.
*/
private _getOnEventFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile {
return new InstrumentationNodeModuleFile(
'@nestjs/event-emitter/dist/decorators/on-event.decorator.js',
versions,
(moduleExports: { OnEvent: OnEventTarget }) => {
if (isWrapped(moduleExports.OnEvent)) {
this._unwrap(moduleExports, 'OnEvent');
}
this._wrap(moduleExports, 'OnEvent', this._createWrapOnEvent());
return moduleExports;
},
(moduleExports: { OnEvent: OnEventTarget }) => {
this._unwrap(moduleExports, 'OnEvent');
},
);
}

/**
* Creates a wrapper function for the @OnEvent decorator.
*/
private _createWrapOnEvent() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function wrapOnEvent(original: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function wrappedOnEvent(event: any, options?: any) {
const eventName = Array.isArray(event)
? event.join(',')
: typeof event === 'string' || typeof event === 'symbol'
? event.toString()
: '<unknown_event>';

// Get the original decorator result
const decoratorResult = original(event, options);

// Return a new decorator function that wraps the handler
return function (target: OnEventTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
if (!descriptor.value || typeof descriptor.value !== 'function' || target.__SENTRY_INTERNAL__) {
return decoratorResult(target, propertyKey, descriptor);
}

// Get the original handler
const originalHandler = descriptor.value;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const handlerName = originalHandler.name || propertyKey;

// Instrument the handler
// eslint-disable-next-line @typescript-eslint/no-explicit-any
descriptor.value = async function (...args: any[]) {
return startSpan(getEventSpanOptions(eventName), async () => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const result = await originalHandler.apply(this, args);
return result;
} catch (error) {
// exceptions from event handlers are not caught by global error filter
captureException(error);
throw error;
}
});
};

// Preserve the original function name
Object.defineProperty(descriptor.value, 'name', {
value: handlerName,
configurable: true,
});

// Apply the original decorator
return decoratorResult(target, propertyKey, descriptor);
};
};
};
}
}
9 changes: 9 additions & 0 deletions packages/node/src/integrations/tracing/nest/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ export interface CatchTarget {
};
}

/**
* Represents a target method in NestJS annotated with @OnEvent.
*/
export interface OnEventTarget {
name: string;
sentryPatched?: boolean;
__SENTRY_INTERNAL__?: boolean;
}

/**
* Represents an express NextFunction.
*/
Expand Down
Loading

0 comments on commit 75b3d41

Please sign in to comment.