Skip to content

Commit c1a0de0

Browse files
committed
fix(nestjs): wrap @catch and @Injectable with 2 flags
1 parent cbc18ea commit c1a0de0

2 files changed

Lines changed: 118 additions & 9 deletions

File tree

packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const ORIGIN_MIDDLEWARE = 'auto.middleware.orchestrion.nestjs';
1818
/** The class an `@Injectable` decorator is applied to (`ctx.arguments[0]`). */
1919
export interface InjectableTarget {
2020
name?: string;
21-
sentryPatched?: boolean;
21+
sentryPatchedInjectable?: boolean;
2222
__SENTRY_INTERNAL__?: boolean;
2323
prototype: {
2424
use?: AnyFn;
@@ -31,7 +31,7 @@ export interface InjectableTarget {
3131
/** The class a `@Catch` decorator is applied to (an exception filter). */
3232
export interface CatchTarget {
3333
name?: string;
34-
sentryPatched?: boolean;
34+
sentryPatchedCatch?: boolean;
3535
__SENTRY_INTERNAL__?: boolean;
3636
prototype: { catch?: AnyFn };
3737
}
@@ -49,14 +49,20 @@ interface ObservableLike {
4949
}
5050

5151
/**
52-
* Mark a target class as patched so it's instrumented only once (mirrors the
53-
* vendored `isPatched`). Also give idempotency across repeated subscriptions.
52+
* Mark a target class as patched (for the given pass) so it's instrumented only
53+
* once, and to stay idempotent across repeated subscriptions.
54+
*
55+
* The `@Injectable` and `@Catch` passes use *separate* flags on purpose: they
56+
* wrap disjoint method sets (use/canActivate/transform/intercept vs catch), and
57+
* a class can be decorated with both (an exception filter that also uses DI).
58+
* A single shared flag would let whichever channel fired first latch it and
59+
* block the other pass, dropping that pass's spans regardless of ordering.
5460
*/
55-
function isTargetPatched(target: { sentryPatched?: boolean }): boolean {
56-
if (target.sentryPatched) {
61+
function isTargetPatched(target: object, flag: 'sentryPatchedInjectable' | 'sentryPatchedCatch'): boolean {
62+
if ((target as Record<string, unknown>)[flag]) {
5763
return true;
5864
}
59-
addNonEnumerableProperty(target as object, 'sentryPatched', true);
65+
addNonEnumerableProperty(target, flag, true);
6066
return false;
6167
}
6268

@@ -201,7 +207,7 @@ function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContex
201207
*/
202208
export function patchInjectableTarget(target: InjectableTarget, seenContexts: WeakSet<object>): void {
203209
const proto = target?.prototype;
204-
if (!proto || target.__SENTRY_INTERNAL__ || isTargetPatched(target)) {
210+
if (!proto || target.__SENTRY_INTERNAL__ || isTargetPatched(target, 'sentryPatchedInjectable')) {
205211
return;
206212
}
207213

@@ -264,7 +270,12 @@ export function patchInjectableTarget(target: InjectableTarget, seenContexts: We
264270
*/
265271
export function patchCatchTarget(target: CatchTarget): void {
266272
const proto = target?.prototype;
267-
if (!proto || typeof proto.catch !== 'function' || target.__SENTRY_INTERNAL__ || isTargetPatched(target)) {
273+
if (
274+
!proto ||
275+
typeof proto.catch !== 'function' ||
276+
target.__SENTRY_INTERNAL__ ||
277+
isTargetPatched(target, 'sentryPatchedCatch')
278+
) {
268279
return;
269280
}
270281
proto.catch = new Proxy(proto.catch, {

packages/server-utils/test/orchestrion/nestjs.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,104 @@ describe('nestjsChannelIntegration: @Catch (exception filter)', () => {
519519
new HttpExceptionFilter().catch('boom', undefined);
520520
expect(spanInside).toBeUndefined();
521521
});
522+
523+
// A class can be decorated with both `@Injectable` and `@Catch` (an exception
524+
// filter that uses DI). Which channel fires first depends on decorator
525+
// stacking order (decorators apply inner-first): `@Catch` over `@Injectable`
526+
// fires @Injectable first; `@Injectable` over `@Catch` fires @Catch first.
527+
// Because the two passes use separate patched-flags, both must wrap their own
528+
// methods regardless of which channel fires first.
529+
function fireInjectable(target: object): void {
530+
tracingChannel<{ arguments: unknown[] }>(CHANNELS.NESTJS_INJECTABLE).traceSync(() => undefined, {
531+
arguments: [target],
532+
});
533+
}
534+
535+
it('still wraps `catch` when the @Injectable channel fired first (dual @Injectable @Catch filter)', () => {
536+
installTestAsyncContextStrategy();
537+
initTestClient();
538+
nestjsChannelIntegration().setupOnce!();
539+
540+
let spanInside: ReturnType<typeof getActiveSpan>;
541+
class HttpExceptionFilter {
542+
public catch(exception: unknown, _host: unknown): string {
543+
spanInside = getActiveSpan();
544+
return `handled:${String(exception)}`;
545+
}
546+
}
547+
fireInjectable(HttpExceptionFilter);
548+
applyCatch(HttpExceptionFilter);
549+
550+
const ret = new HttpExceptionFilter().catch('boom', { switchToHttp: () => ({}) });
551+
expect(ret).toBe('handled:boom');
552+
553+
const json = spanToJSON(spanInside!);
554+
expect(json.description).toBe('HttpExceptionFilter');
555+
expect(json.op).toBe('middleware.nestjs');
556+
expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter');
557+
});
558+
559+
it('still wraps `catch` when the @Catch channel fired first (dual @Injectable @Catch filter)', () => {
560+
installTestAsyncContextStrategy();
561+
initTestClient();
562+
nestjsChannelIntegration().setupOnce!();
563+
564+
let spanInside: ReturnType<typeof getActiveSpan>;
565+
class HttpExceptionFilter {
566+
public catch(exception: unknown, _host: unknown): string {
567+
spanInside = getActiveSpan();
568+
return `handled:${String(exception)}`;
569+
}
570+
}
571+
applyCatch(HttpExceptionFilter);
572+
fireInjectable(HttpExceptionFilter);
573+
574+
const ret = new HttpExceptionFilter().catch('boom', { switchToHttp: () => ({}) });
575+
expect(ret).toBe('handled:boom');
576+
577+
const json = spanToJSON(spanInside!);
578+
expect(json.description).toBe('HttpExceptionFilter');
579+
expect(json.op).toBe('middleware.nestjs');
580+
expect(json.origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter');
581+
});
582+
583+
// A (contrived) class that is BOTH a guard (`canActivate`) and an exception
584+
// filter (`catch`) proves the two passes are independent: neither ordering may
585+
// let one pass's patched-flag block the other. Both spans must appear either way.
586+
for (const order of ['injectable-first', 'catch-first'] as const) {
587+
it(`wraps BOTH canActivate and catch when the ${order} channel fired first`, () => {
588+
installTestAsyncContextStrategy();
589+
initTestClient();
590+
nestjsChannelIntegration().setupOnce!();
591+
592+
let guardSpan: ReturnType<typeof getActiveSpan>;
593+
let filterSpan: ReturnType<typeof getActiveSpan>;
594+
class GuardAndFilter {
595+
public canActivate(_ctx: unknown): boolean {
596+
guardSpan = getActiveSpan();
597+
return true;
598+
}
599+
public catch(exception: unknown, _host: unknown): string {
600+
filterSpan = getActiveSpan();
601+
return `handled:${String(exception)}`;
602+
}
603+
}
604+
605+
if (order === 'injectable-first') {
606+
fireInjectable(GuardAndFilter);
607+
applyCatch(GuardAndFilter);
608+
} else {
609+
applyCatch(GuardAndFilter);
610+
fireInjectable(GuardAndFilter);
611+
}
612+
613+
expect(new GuardAndFilter().canActivate({ ctx: true })).toBe(true);
614+
expect(new GuardAndFilter().catch('boom', { switchToHttp: () => ({}) })).toBe('handled:boom');
615+
616+
expect(spanToJSON(guardSpan!).origin).toBe('auto.middleware.orchestrion.nestjs.guard');
617+
expect(spanToJSON(filterSpan!).origin).toBe('auto.middleware.orchestrion.nestjs.exception_filter');
618+
});
619+
}
522620
});
523621

524622
describe('nestjsChannelIntegration: schedule / event / bullmq', () => {

0 commit comments

Comments
 (0)