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
71 changes: 52 additions & 19 deletions packages/nestjs/src/integrations/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,73 @@ import {
} from '@sentry/core';
import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types';

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

/**
* Helper checking if a concrete target class is already patched.
*
* 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.
* This check might not be necessary, but better to play it safe.
* Marks a function as already wrapped so repeated subscriptions/decoration
* don't double-wrap it.
*/
export function isPatched(target: InjectableTarget | CatchTarget): boolean {
if (target.sentryPatched) {
const SENTRY_WRAPPED = Symbol.for('sentry.nestjs.wrapped');

/** Whether `fn` has already been wrapped by this integration. */
export function isWrapped(fn: AnyFn): boolean {
return !!(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED];
}

/** Mark `fn` as wrapped (see {@link isWrapped}). */
export function markWrapped(fn: AnyFn): void {
(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED] = true;
}

/**
* Mark a target class as patched (for the given pass) so it's instrumented
* only once, and to stay idempotent across repeated subscriptions/decoration.
*/
export function isTargetPatched(target: object, flag: 'sentryPatchedInjectable' | 'sentryPatchedCatch'): boolean {
if ((target as Record<string, unknown>)[flag]) {
return true;
}

addNonEnumerableProperty(target, sentryPatched, true);
addNonEnumerableProperty(target, flag, true);
return false;
}

/** Origin for middleware/guard/pipe/interceptor/exception_filter spans. */
function middlewareOrigin(componentType?: string): string {
return componentType ? `auto.middleware.nestjs.${componentType}` : 'auto.middleware.nestjs';
}

/**
* Origin for the app-creation / request-context / request-handler HTTP spans.
*/
export function httpOrigin(): string {
Comment thread
isaacs marked this conversation as resolved.
return 'auto.http.otel.nestjs';
}

/** Origin for `@OnEvent` spans. */
function eventOrigin(): string {
return 'auto.event.nestjs';
}

/** Origin for BullMQ `@Processor` `process` spans. */
function bullmqOrigin(): string {
return 'auto.queue.nestjs.bullmq';
}

/**
* Returns span options for nest middleware spans.
* name = provided name or class name.
*/
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function getMiddlewareSpanOptions(
target: InjectableTarget | CatchTarget,
target: InjectableTarget | CatchTarget | { name?: string },
name: string | undefined = undefined,
componentType: string | undefined = undefined,
) {
const span_name = name ?? target.name; // fallback to class name if no name is provided
const origin = componentType ? `auto.middleware.nestjs.${componentType}` : 'auto.middleware.nestjs';

): { name: string; attributes: Record<string, string> } {
return {
name: span_name,
name: name ?? target.name ?? 'unknown',
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: middlewareOrigin(componentType),
},
};
}
Expand All @@ -57,7 +90,7 @@ export function getEventSpanOptions(event: string): {
name: `event ${event}`,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: eventOrigin(),
},
forceTransaction: true,
};
Expand All @@ -75,7 +108,7 @@ export function getBullMQProcessSpanOptions(queueName: string): {
name: `${queueName} process`,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.nestjs.bullmq',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: bullmqOrigin(),
'messaging.system': 'bullmq',
'messaging.destination.name': queueName,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import {
InstrumentationNodeModuleFile,
isWrapped,
} from '@opentelemetry/instrumentation';
import { captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core';
import { getBullMQProcessSpanOptions } from './helpers';
import { SDK_VERSION } from '@sentry/core';
import type { ProcessorDecoratorTarget } from './types';
import { extractQueueName, patchProcessorTarget } from './wrap-handlers';

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

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

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

// Return a new class decorator that wraps the process method
return function (target: ProcessorDecoratorTarget) {
const originalProcess = target.prototype.process;

if (
originalProcess &&
typeof originalProcess === 'function' &&
!target.__SENTRY_INTERNAL__ &&
!originalProcess.__SENTRY_INSTRUMENTED__
) {
target.prototype.process = new Proxy(originalProcess, {
apply: (originalProcessFn, thisArg, args) => {
return withIsolationScope(() => {
return startSpan(getBullMQProcessSpanOptions(queueName), async () => {
try {
return await originalProcessFn.apply(thisArg, args);
} catch (error) {
captureException(error, {
mechanism: {
handled: false,
type: 'auto.queue.nestjs.bullmq',
},
});
throw error;
}
});
});
},
});

target.prototype.process.__SENTRY_INSTRUMENTED__ = true;
}

// Apply the original class decorator
patchProcessorTarget(target, queueName);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return classDecorator(target);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import {
InstrumentationNodeModuleFile,
isWrapped,
} from '@opentelemetry/instrumentation';
import { isObjectLike, captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core';
import { getEventSpanOptions } from './helpers';
import { SDK_VERSION } from '@sentry/core';
import type { AnyFn } from './helpers';
import type { OnEventTarget } from './types';
import { patchMethodDescriptor, wrapEventHandler } from './wrap-handlers';

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

// Return a new decorator function that wraps the handler
return (target: OnEventTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
if (
!descriptor.value ||
typeof descriptor.value !== 'function' ||
target.__SENTRY_INTERNAL__ ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
descriptor.value.__SENTRY_INSTRUMENTED__
) {
return decoratorResult(target, propertyKey, descriptor);
}

function eventNameFromEvent(event: unknown): string {
if (typeof event === 'string') {
return event;
} else if (Array.isArray(event)) {
return event.map(eventNameFromEvent).join(',');
} else return String(event);
}

const originalHandler = descriptor.value;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const handlerName = originalHandler.name || propertyKey;
let eventName = eventNameFromEvent(event);

// Instrument the actual handler
descriptor.value = async function (...args: unknown[]) {
// When multiple @OnEvent decorators are used on a single method, we need to get all event names
// from the reflector metadata as there is no information during execution which event triggered it
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect
if (Reflect.getMetadataKeys(descriptor.value).includes('EVENT_LISTENER_METADATA')) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect
const eventData = Reflect.getMetadata('EVENT_LISTENER_METADATA', descriptor.value);
if (Array.isArray(eventData)) {
eventName = eventData
.map((data: unknown) => {
if (isObjectLike(data) && 'event' in data && data.event) {
return eventNameFromEvent(data.event);
}
return '';
})
.reverse() // decorators are evaluated bottom to top
.join('|');
}
}

return withIsolationScope(() => {
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, {
mechanism: {
handled: false,
type: 'auto.event.nestjs',
},
});
throw error;
}
});
});
};

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
descriptor.value.__SENTRY_INSTRUMENTED__ = true;

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

// Apply the original decorator
patchMethodDescriptor(target, propertyKey, descriptor, (handler: AnyFn) => wrapEventHandler(handler, event));
return decoratorResult(target, propertyKey, descriptor);
};
};
Expand Down
Loading
Loading