Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nestjs): Instrument event handlers #14307

Merged
merged 8 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from 7 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 @@ -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',
});
});
5 changes: 3 additions & 2 deletions packages/nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@
"@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",
"@nestjs/event-emitter": "^2.0.0"
chargome marked this conversation as resolved.
Show resolved Hide resolved
},
"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
Loading