Skip to content

Commit c21bf07

Browse files
authored
feat(nestjs): Instrument event handlers (#14307)
1 parent a5a214c commit c21bf07

File tree

14 files changed

+380
-4
lines changed

14 files changed

+380
-4
lines changed

dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@nestjs/common": "^10.0.0",
1919
"@nestjs/core": "^10.0.0",
2020
"@nestjs/platform-express": "^10.0.0",
21+
"@nestjs/event-emitter": "^2.0.0",
2122
"@sentry/nestjs": "latest || *",
2223
"@sentry/types": "latest || *",
2324
"reflect-metadata": "^0.2.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Controller, Get } from '@nestjs/common';
2+
import { EventsService } from './events.service';
3+
4+
@Controller('events')
5+
export class EventsController {
6+
constructor(private readonly eventsService: EventsService) {}
7+
8+
@Get('emit')
9+
async emitEvents() {
10+
await this.eventsService.emitEvents();
11+
12+
return { message: 'Events emitted' };
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Module } from '@nestjs/common';
2+
import { APP_FILTER } from '@nestjs/core';
3+
import { EventEmitterModule } from '@nestjs/event-emitter';
4+
import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup';
5+
import { EventsController } from './events.controller';
6+
import { EventsService } from './events.service';
7+
import { TestEventListener } from './listeners/test-event.listener';
8+
9+
@Module({
10+
imports: [SentryModule.forRoot(), EventEmitterModule.forRoot()],
11+
controllers: [EventsController],
12+
providers: [
13+
{
14+
provide: APP_FILTER,
15+
useClass: SentryGlobalFilter,
16+
},
17+
EventsService,
18+
TestEventListener,
19+
],
20+
})
21+
export class EventsModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { EventEmitter2 } from '@nestjs/event-emitter';
3+
4+
@Injectable()
5+
export class EventsService {
6+
constructor(private readonly eventEmitter: EventEmitter2) {}
7+
8+
async emitEvents() {
9+
await this.eventEmitter.emit('myEvent.pass', { data: 'test' });
10+
await this.eventEmitter.emit('myEvent.throw');
11+
12+
return { message: 'Events emitted' };
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { OnEvent } from '@nestjs/event-emitter';
3+
4+
@Injectable()
5+
export class TestEventListener {
6+
@OnEvent('myEvent.pass')
7+
async handlePassEvent(payload: any): Promise<void> {
8+
await new Promise(resolve => setTimeout(resolve, 100));
9+
}
10+
11+
@OnEvent('myEvent.throw')
12+
async handleThrowEvent(): Promise<void> {
13+
await new Promise(resolve => setTimeout(resolve, 100));
14+
throw new Error('Test error from event handler');
15+
}
16+
}

dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/main.ts

+5
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,23 @@ import './instrument';
33

44
// Import other modules
55
import { NestFactory } from '@nestjs/core';
6+
import { EventsModule } from './events.module';
67
import { TraceInitiatorModule } from './trace-initiator.module';
78
import { TraceReceiverModule } from './trace-receiver.module';
89

910
const TRACE_INITIATOR_PORT = 3030;
1011
const TRACE_RECEIVER_PORT = 3040;
12+
const EVENTS_PORT = 3050;
1113

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

1618
const trace_receiver_app = await NestFactory.create(TraceReceiverModule);
1719
await trace_receiver_app.listen(TRACE_RECEIVER_PORT);
20+
21+
const events_app = await NestFactory.create(EventsModule);
22+
await events_app.listen(EVENTS_PORT);
1823
}
1924

2025
bootstrap();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Event emitter', async () => {
5+
const eventErrorPromise = waitForError('nestjs-distributed-tracing', errorEvent => {
6+
return errorEvent.exception.values[0].value === 'Test error from event handler';
7+
});
8+
const successEventTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => {
9+
return transactionEvent.transaction === 'event myEvent.pass';
10+
});
11+
12+
const eventsUrl = `http://localhost:3050/events/emit`;
13+
await fetch(eventsUrl);
14+
15+
const eventError = await eventErrorPromise;
16+
const successEventTransaction = await successEventTransactionPromise;
17+
18+
expect(eventError.exception).toEqual({
19+
values: [
20+
{
21+
type: 'Error',
22+
value: 'Test error from event handler',
23+
stacktrace: expect.any(Object),
24+
mechanism: expect.any(Object),
25+
},
26+
],
27+
});
28+
29+
expect(successEventTransaction.contexts.trace).toEqual({
30+
parent_span_id: expect.any(String),
31+
span_id: expect.any(String),
32+
trace_id: expect.any(String),
33+
data: {
34+
'sentry.source': 'custom',
35+
'sentry.sample_rate': 1,
36+
'sentry.op': 'event.nestjs',
37+
'sentry.origin': 'auto.event.nestjs',
38+
},
39+
origin: 'auto.event.nestjs',
40+
op: 'event.nestjs',
41+
status: 'ok',
42+
});
43+
});

packages/nestjs/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@
5050
"@sentry/utils": "8.38.0"
5151
},
5252
"devDependencies": {
53-
"@nestjs/common": "10.4.7",
54-
"@nestjs/core": "10.4.7"
53+
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
54+
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
5555
},
5656
"peerDependencies": {
5757
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",

packages/node/src/integrations/tracing/nest/helpers.ts

+18
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ export function getMiddlewareSpanOptions(target: InjectableTarget | CatchTarget,
3636
};
3737
}
3838

39+
/**
40+
* Returns span options for nest event spans.
41+
*/
42+
export function getEventSpanOptions(event: string): {
43+
name: string;
44+
attributes: Record<string, string>;
45+
forceTransaction: boolean;
46+
} {
47+
return {
48+
name: `event ${event}`,
49+
attributes: {
50+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs',
51+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs',
52+
},
53+
forceTransaction: true,
54+
};
55+
}
56+
3957
/**
4058
* Adds instrumentation to a js observable and attaches the span to an active parent span.
4159
*/

packages/node/src/integrations/tracing/nest/nest.ts

+6
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import type { IntegrationFn, Span } from '@sentry/types';
1313
import { logger } from '@sentry/utils';
1414
import { generateInstrumentOnce } from '../../../otel/instrument';
15+
import { SentryNestEventInstrumentation } from './sentry-nest-event-instrumentation';
1516
import { SentryNestInstrumentation } from './sentry-nest-instrumentation';
1617
import type { MinimalNestJsApp, NestJsErrorFilter } from './types';
1718

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

29+
const instrumentNestEvent = generateInstrumentOnce('Nest-Event', () => {
30+
return new SentryNestEventInstrumentation();
31+
});
32+
2833
export const instrumentNest = Object.assign(
2934
(): void => {
3035
instrumentNestCore();
3136
instrumentNestCommon();
37+
instrumentNestEvent();
3238
},
3339
{ id: INTEGRATION_NAME },
3440
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { isWrapped } from '@opentelemetry/core';
2+
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
3+
import {
4+
InstrumentationBase,
5+
InstrumentationNodeModuleDefinition,
6+
InstrumentationNodeModuleFile,
7+
} from '@opentelemetry/instrumentation';
8+
import { captureException, startSpan } from '@sentry/core';
9+
import { SDK_VERSION } from '@sentry/utils';
10+
import { getEventSpanOptions } from './helpers';
11+
import type { OnEventTarget } from './types';
12+
13+
const supportedVersions = ['>=2.0.0'];
14+
15+
/**
16+
* Custom instrumentation for nestjs event-emitter
17+
*
18+
* This hooks into the `OnEvent` decorator, which is applied on event handlers.
19+
*/
20+
export class SentryNestEventInstrumentation extends InstrumentationBase {
21+
public static readonly COMPONENT = '@nestjs/event-emitter';
22+
public static readonly COMMON_ATTRIBUTES = {
23+
component: SentryNestEventInstrumentation.COMPONENT,
24+
};
25+
26+
public constructor(config: InstrumentationConfig = {}) {
27+
super('sentry-nestjs-event', SDK_VERSION, config);
28+
}
29+
30+
/**
31+
* Initializes the instrumentation by defining the modules to be patched.
32+
*/
33+
public init(): InstrumentationNodeModuleDefinition {
34+
const moduleDef = new InstrumentationNodeModuleDefinition(
35+
SentryNestEventInstrumentation.COMPONENT,
36+
supportedVersions,
37+
);
38+
39+
moduleDef.files.push(this._getOnEventFileInstrumentation(supportedVersions));
40+
return moduleDef;
41+
}
42+
43+
/**
44+
* Wraps the @OnEvent decorator.
45+
*/
46+
private _getOnEventFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile {
47+
return new InstrumentationNodeModuleFile(
48+
'@nestjs/event-emitter/dist/decorators/on-event.decorator.js',
49+
versions,
50+
(moduleExports: { OnEvent: OnEventTarget }) => {
51+
if (isWrapped(moduleExports.OnEvent)) {
52+
this._unwrap(moduleExports, 'OnEvent');
53+
}
54+
this._wrap(moduleExports, 'OnEvent', this._createWrapOnEvent());
55+
return moduleExports;
56+
},
57+
(moduleExports: { OnEvent: OnEventTarget }) => {
58+
this._unwrap(moduleExports, 'OnEvent');
59+
},
60+
);
61+
}
62+
63+
/**
64+
* Creates a wrapper function for the @OnEvent decorator.
65+
*/
66+
private _createWrapOnEvent() {
67+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
68+
return function wrapOnEvent(original: any) {
69+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
70+
return function wrappedOnEvent(event: any, options?: any) {
71+
const eventName = Array.isArray(event)
72+
? event.join(',')
73+
: typeof event === 'string' || typeof event === 'symbol'
74+
? event.toString()
75+
: '<unknown_event>';
76+
77+
// Get the original decorator result
78+
const decoratorResult = original(event, options);
79+
80+
// Return a new decorator function that wraps the handler
81+
return function (target: OnEventTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
82+
if (!descriptor.value || typeof descriptor.value !== 'function' || target.__SENTRY_INTERNAL__) {
83+
return decoratorResult(target, propertyKey, descriptor);
84+
}
85+
86+
// Get the original handler
87+
const originalHandler = descriptor.value;
88+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
89+
const handlerName = originalHandler.name || propertyKey;
90+
91+
// Instrument the handler
92+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93+
descriptor.value = async function (...args: any[]) {
94+
return startSpan(getEventSpanOptions(eventName), async () => {
95+
try {
96+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
97+
const result = await originalHandler.apply(this, args);
98+
return result;
99+
} catch (error) {
100+
// exceptions from event handlers are not caught by global error filter
101+
captureException(error);
102+
throw error;
103+
}
104+
});
105+
};
106+
107+
// Preserve the original function name
108+
Object.defineProperty(descriptor.value, 'name', {
109+
value: handlerName,
110+
configurable: true,
111+
});
112+
113+
// Apply the original decorator
114+
return decoratorResult(target, propertyKey, descriptor);
115+
};
116+
};
117+
};
118+
}
119+
}

packages/node/src/integrations/tracing/nest/types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ export interface CatchTarget {
7474
};
7575
}
7676

77+
/**
78+
* Represents a target method in NestJS annotated with @OnEvent.
79+
*/
80+
export interface OnEventTarget {
81+
name: string;
82+
sentryPatched?: boolean;
83+
__SENTRY_INTERNAL__?: boolean;
84+
}
85+
7786
/**
7887
* Represents an express NextFunction.
7988
*/

0 commit comments

Comments
 (0)