Skip to content

Commit f030443

Browse files
committed
fix(nestjs): handle sync interceptor that short-circuits span end
1 parent 992c740 commit f030443

2 files changed

Lines changed: 39 additions & 0 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ function patchInterceptor(target: InjectableTarget, intercept: AnyFn, seenContex
191191
// Sync interceptor: `next.handle()` (if it was going to be called) has
192192
// already run synchronously, so `afterSpan` is settled.
193193
if (!afterSpan) {
194+
// `next.handle()` was never called (e.g. the interceptor
195+
// short-circuited for a cache/validation hit), so its `handle` proxy
196+
// never ended the before-span; close it here.
197+
beforeSpan.end();
194198
return returned;
195199
}
196200

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,41 @@ describe('nestjsChannelIntegration: @Injectable (middleware/guard/pipe/intercept
514514
expect(teardowns).toHaveLength(0);
515515
});
516516

517+
it('sync interceptor that short-circuits without next.handle(): ends the before-span', () => {
518+
installTestAsyncContextStrategy();
519+
initTestClient();
520+
nestjsChannelIntegration().setupOnce!();
521+
522+
const teardowns: Array<() => void> = [];
523+
const observable = {
524+
subscribe(): { add: (fn: () => void) => void } {
525+
return { add: (fn: () => void) => void teardowns.push(fn) };
526+
},
527+
};
528+
529+
let beforeSpan: ReturnType<typeof getActiveSpan>;
530+
class CachingInterceptor {
531+
// Synchronously returns an Observable without calling `next.handle()`
532+
// (a cache/validation short-circuit).
533+
public intercept(_context: unknown, _next: { handle: () => unknown }): unknown {
534+
beforeSpan = getActiveSpan();
535+
return observable;
536+
}
537+
}
538+
applyInjectable(CachingInterceptor);
539+
540+
const next = { handle: vi.fn() };
541+
const returned = new CachingInterceptor().intercept({}, next) as typeof observable;
542+
543+
expect(returned).toBe(observable);
544+
expect(next.handle).not.toHaveBeenCalled();
545+
// before-span is closed even though `next.handle()` (which normally ends it) never ran
546+
expect(spanToJSON(beforeSpan!).timestamp).toBeDefined();
547+
// no after-span, so the observable is left un-instrumented
548+
returned.subscribe();
549+
expect(teardowns).toHaveLength(0);
550+
});
551+
517552
it('skips targets flagged __SENTRY_INTERNAL__', () => {
518553
installTestAsyncContextStrategy();
519554
initTestClient();

0 commit comments

Comments
 (0)