Skip to content

Commit b4e6fdc

Browse files
committed
ref(nestjs): extract shared span helpers
Pull the span-emitting/patching logic out of the OTel `InstrumentationBase` classes into `wrap-components`, `wrap-handlers`, and `wrap-route` so it can be reused by the diagnostics-channel integration. Refactor only. There is still only the OTel implementation, which still emits the same spans as before. However, this will be needed when the next implementation is added.
1 parent 2d3fc43 commit b4e6fdc

11 files changed

Lines changed: 660 additions & 577 deletions

packages/nestjs/src/integrations/helpers.ts

Lines changed: 82 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,103 @@ import {
77
} from '@sentry/core';
88
import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types';
99

10-
const sentryPatched = 'sentryPatched';
10+
/** A function of unknown signature, matching the methods/handlers we wrap. */
11+
export type AnyFn = (this: unknown, ...args: unknown[]) => unknown;
1112

1213
/**
13-
* Helper checking if a concrete target class is already patched.
14-
*
15-
* We already guard duplicate patching with isWrapped. However, isWrapped checks whether a file has been patched, whereas we use this check for concrete target classes.
16-
* This check might not be necessary, but better to play it safe.
14+
* Marks a function as already wrapped so repeated subscriptions/decoration
15+
* don't double-wrap it.
1716
*/
18-
export function isPatched(target: InjectableTarget | CatchTarget): boolean {
19-
if (target.sentryPatched) {
20-
return true;
17+
const SENTRY_WRAPPED = Symbol.for('sentry.nestjs.wrapped');
18+
19+
/** Whether `fn` has already been wrapped by this integration. */
20+
export function isWrapped(fn: AnyFn): boolean {
21+
return !!(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED];
22+
}
23+
24+
/** Mark `fn` as wrapped (see {@link isWrapped}). */
25+
export function markWrapped(fn: AnyFn): void {
26+
(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED] = true;
27+
}
28+
29+
/**
30+
* The subset of `reflect-metadata`'s `Reflect` augmentation that NestJS
31+
* relies on. Methods are optional because `reflect-metadata` may not be
32+
* loaded; guard each before use.
33+
*/
34+
export interface ReflectWithMetadata {
35+
getMetadataKeys?: (target: object) => unknown[];
36+
getMetadata?: (key: unknown, target: object) => unknown;
37+
defineMetadata?: (key: unknown, value: unknown, target: object) => void;
38+
}
39+
40+
/**
41+
* Copy NestJS reflect-metadata from one object onto another so decorators
42+
* (param decorators, guards, `@EventPattern`, ...) that read it keep working
43+
* No-op when `reflect-metadata` isn't loaded.
44+
*/
45+
export function copyReflectMetadata(from: object, to: object): void {
46+
const R = Reflect as unknown as ReflectWithMetadata;
47+
if (
48+
typeof R.getMetadataKeys !== 'function' ||
49+
typeof R.getMetadata !== 'function' ||
50+
typeof R.defineMetadata !== 'function'
51+
) {
52+
return;
2153
}
54+
for (const key of R.getMetadataKeys(from)) {
55+
R.defineMetadata(key, R.getMetadata(key, from), to);
56+
}
57+
}
2258

23-
addNonEnumerableProperty(target, sentryPatched, true);
59+
/**
60+
* Mark a target class as patched (for the given pass) so it's instrumented
61+
* only once, and to stay idempotent across repeated subscriptions/decoration.
62+
*/
63+
export function isTargetPatched(target: object, flag: 'sentryPatchedInjectable' | 'sentryPatchedCatch'): boolean {
64+
if ((target as Record<string, unknown>)[flag]) {
65+
return true;
66+
}
67+
addNonEnumerableProperty(target, flag, true);
2468
return false;
2569
}
2670

71+
/** Origin for middleware/guard/pipe/interceptor/exception_filter spans. */
72+
function middlewareOrigin(componentType?: string): string {
73+
return componentType ? `auto.middleware.nestjs.${componentType}` : 'auto.middleware.nestjs';
74+
}
75+
76+
/**
77+
* Origin for the app-creation / request-context / request-handler HTTP spans.
78+
*/
79+
export function httpOrigin(): string {
80+
return 'auto.http.otel.nestjs';
81+
}
82+
83+
/** Origin for `@OnEvent` spans. */
84+
function eventOrigin(): string {
85+
return 'auto.event.nestjs';
86+
}
87+
88+
/** Origin for BullMQ `@Processor` `process` spans. */
89+
function bullmqOrigin(): string {
90+
return 'auto.queue.nestjs.bullmq';
91+
}
92+
2793
/**
2894
* Returns span options for nest middleware spans.
95+
* name = provided name or class name.
2996
*/
30-
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
3197
export function getMiddlewareSpanOptions(
32-
target: InjectableTarget | CatchTarget,
98+
target: InjectableTarget | CatchTarget | { name?: string },
3399
name: string | undefined = undefined,
34100
componentType: string | undefined = undefined,
35-
) {
36-
const span_name = name ?? target.name; // fallback to class name if no name is provided
37-
const origin = componentType ? `auto.middleware.nestjs.${componentType}` : 'auto.middleware.nestjs';
38-
101+
): { name: string; attributes: Record<string, string> } {
39102
return {
40-
name: span_name,
103+
name: name ?? target.name ?? 'unknown',
41104
attributes: {
42105
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs',
43-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin,
106+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: middlewareOrigin(componentType),
44107
},
45108
};
46109
}
@@ -57,7 +120,7 @@ export function getEventSpanOptions(event: string): {
57120
name: `event ${event}`,
58121
attributes: {
59122
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs',
60-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs',
123+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: eventOrigin(),
61124
},
62125
forceTransaction: true,
63126
};
@@ -75,7 +138,7 @@ export function getBullMQProcessSpanOptions(queueName: string): {
75138
name: `${queueName} process`,
76139
attributes: {
77140
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process',
78-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.nestjs.bullmq',
141+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: bullmqOrigin(),
79142
'messaging.system': 'bullmq',
80143
'messaging.destination.name': queueName,
81144
},

packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts

Lines changed: 6 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,18 @@ import {
55
InstrumentationNodeModuleFile,
66
isWrapped,
77
} from '@opentelemetry/instrumentation';
8-
import { captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core';
9-
import { getBullMQProcessSpanOptions } from './helpers';
8+
import { SDK_VERSION } from '@sentry/core';
109
import type { ProcessorDecoratorTarget } from './types';
10+
import { extractQueueName, patchProcessorTarget } from './wrap-handlers';
1111

1212
const supportedVersions = ['>=10.0.0'];
1313
const COMPONENT = '@nestjs/bullmq';
1414

1515
/**
1616
* Custom instrumentation for nestjs bullmq module.
1717
*
18-
* This hooks into the `@Processor` class decorator, which is applied on queue processor classes.
19-
* It wraps the `process` method on the decorated class to fork the isolation scope for each job
20-
* invocation, create a span, and capture errors.
18+
* This hooks into the `@Processor` class decorator, which is applied on queue
19+
* processor classes.
2120
*/
2221
export class SentryNestBullMQInstrumentation extends InstrumentationBase {
2322
public constructor(config: InstrumentationConfig = {}) {
@@ -62,50 +61,13 @@ export class SentryNestBullMQInstrumentation extends InstrumentationBase {
6261
return function wrapProcessor(original: any) {
6362
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6463
return function wrappedProcessor(...decoratorArgs: any[]) {
65-
// Extract queue name from decorator args
66-
// @Processor('queueName') or @Processor({ name: 'queueName' })
67-
const queueName =
68-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
69-
typeof decoratorArgs[0] === 'string' ? decoratorArgs[0] : decoratorArgs[0]?.name || 'unknown';
64+
const queueName = extractQueueName(decoratorArgs[0]);
7065

71-
// Get the original class decorator
7266
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
7367
const classDecorator = original(...decoratorArgs);
7468

75-
// Return a new class decorator that wraps the process method
7669
return function (target: ProcessorDecoratorTarget) {
77-
const originalProcess = target.prototype.process;
78-
79-
if (
80-
originalProcess &&
81-
typeof originalProcess === 'function' &&
82-
!target.__SENTRY_INTERNAL__ &&
83-
!originalProcess.__SENTRY_INSTRUMENTED__
84-
) {
85-
target.prototype.process = new Proxy(originalProcess, {
86-
apply: (originalProcessFn, thisArg, args) => {
87-
return withIsolationScope(() => {
88-
return startSpan(getBullMQProcessSpanOptions(queueName), async () => {
89-
try {
90-
return await originalProcessFn.apply(thisArg, args);
91-
} catch (error) {
92-
captureException(error, {
93-
mechanism: {
94-
handled: false,
95-
type: 'auto.queue.nestjs.bullmq',
96-
},
97-
});
98-
throw error;
99-
}
100-
});
101-
});
102-
},
103-
});
104-
105-
target.prototype.process.__SENTRY_INSTRUMENTED__ = true;
106-
}
107-
108-
// Apply the original class decorator
70+
patchProcessorTarget(target, queueName);
10971
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
11072
return classDecorator(target);
11173
};

packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts

Lines changed: 4 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import {
55
InstrumentationNodeModuleFile,
66
isWrapped,
77
} from '@opentelemetry/instrumentation';
8-
import { isObjectLike, captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core';
9-
import { getEventSpanOptions } from './helpers';
8+
import { SDK_VERSION } from '@sentry/core';
9+
import type { AnyFn } from './helpers';
1010
import type { OnEventTarget } from './types';
11+
import { patchMethodDescriptor, wrapEventHandler } from './wrap-handlers';
1112

1213
const supportedVersions = ['>=2.0.0'];
1314
const COMPONENT = '@nestjs/event-emitter';
@@ -16,9 +17,6 @@ const COMPONENT = '@nestjs/event-emitter';
1617
* Custom instrumentation for nestjs event-emitter
1718
*
1819
* This hooks into the `OnEvent` decorator, which is applied on event handlers.
19-
* Wrapped handlers run inside a forked isolation scope to ensure event-scoped data
20-
* (breadcrumbs, tags, etc.) does not leak between concurrent event invocations
21-
* or into subsequent HTTP requests.
2220
*/
2321
export class SentryNestEventInstrumentation extends InstrumentationBase {
2422
public constructor(config: InstrumentationConfig = {}) {
@@ -62,87 +60,10 @@ export class SentryNestEventInstrumentation extends InstrumentationBase {
6260
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6361
return function wrapOnEvent(original: any) {
6462
return function wrappedOnEvent(event: unknown, options?: unknown) {
65-
// Get the original decorator result
6663
const decoratorResult = original(event, options);
6764

68-
// Return a new decorator function that wraps the handler
6965
return (target: OnEventTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
70-
if (
71-
!descriptor.value ||
72-
typeof descriptor.value !== 'function' ||
73-
target.__SENTRY_INTERNAL__ ||
74-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
75-
descriptor.value.__SENTRY_INSTRUMENTED__
76-
) {
77-
return decoratorResult(target, propertyKey, descriptor);
78-
}
79-
80-
function eventNameFromEvent(event: unknown): string {
81-
if (typeof event === 'string') {
82-
return event;
83-
} else if (Array.isArray(event)) {
84-
return event.map(eventNameFromEvent).join(',');
85-
} else return String(event);
86-
}
87-
88-
const originalHandler = descriptor.value;
89-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
90-
const handlerName = originalHandler.name || propertyKey;
91-
let eventName = eventNameFromEvent(event);
92-
93-
// Instrument the actual handler
94-
descriptor.value = async function (...args: unknown[]) {
95-
// When multiple @OnEvent decorators are used on a single method, we need to get all event names
96-
// from the reflector metadata as there is no information during execution which event triggered it
97-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
98-
// @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect
99-
if (Reflect.getMetadataKeys(descriptor.value).includes('EVENT_LISTENER_METADATA')) {
100-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
101-
// @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect
102-
const eventData = Reflect.getMetadata('EVENT_LISTENER_METADATA', descriptor.value);
103-
if (Array.isArray(eventData)) {
104-
eventName = eventData
105-
.map((data: unknown) => {
106-
if (isObjectLike(data) && 'event' in data && data.event) {
107-
return eventNameFromEvent(data.event);
108-
}
109-
return '';
110-
})
111-
.reverse() // decorators are evaluated bottom to top
112-
.join('|');
113-
}
114-
}
115-
116-
return withIsolationScope(() => {
117-
return startSpan(getEventSpanOptions(eventName), async () => {
118-
try {
119-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
120-
const result = await originalHandler.apply(this, args);
121-
return result;
122-
} catch (error) {
123-
// exceptions from event handlers are not caught by global error filter
124-
captureException(error, {
125-
mechanism: {
126-
handled: false,
127-
type: 'auto.event.nestjs',
128-
},
129-
});
130-
throw error;
131-
}
132-
});
133-
});
134-
};
135-
136-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
137-
descriptor.value.__SENTRY_INSTRUMENTED__ = true;
138-
139-
// Preserve the original function name
140-
Object.defineProperty(descriptor.value, 'name', {
141-
value: handlerName,
142-
configurable: true,
143-
});
144-
145-
// Apply the original decorator
66+
patchMethodDescriptor(target, propertyKey, descriptor, (handler: AnyFn) => wrapEventHandler(handler, event));
14667
return decoratorResult(target, propertyKey, descriptor);
14768
};
14869
};

0 commit comments

Comments
 (0)