From bfcc10e4f243e6b33300364605abf82084957778 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 09:16:25 +0100 Subject: [PATCH 1/3] failing test --- .../src/events.controller.ts | 5 +++++ .../src/events.service.ts | 10 ++++++++- .../src/listeners/test-event.listener.ts | 8 +++++++ .../tests/events.test.ts | 22 +++++++++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts index 5c4c92ac5f7d..581ee0b49b09 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts @@ -18,4 +18,9 @@ export class EventsController { return { message: 'Events emitted' }; } + + @Get('test-isolation') + testIsolation() { + return { message: 'ok' }; + } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts index ad119106ef08..9ff85ae949d1 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts @@ -3,7 +3,15 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class EventsService { - constructor(private readonly eventEmitter: EventEmitter2) {} + constructor(private readonly eventEmitter: EventEmitter2) { + // Emit event periodically outside of HTTP context to test isolation scope behavior. + // setInterval runs in the default async context (no HTTP request), so without proper + // isolation scope forking, the breadcrumb set by the handler leaks into the default + // isolation scope and gets cloned into subsequent HTTP requests. + setInterval(() => { + this.eventEmitter.emit('test-isolation.breadcrumb'); + }, 2000); + } async emitEvents() { await this.eventEmitter.emit('myEvent.pass', { data: 'test' }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts index 26d934ba384c..ddbe3dd13261 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts @@ -15,6 +15,14 @@ export class TestEventListener { throw new Error('Test error from event handler'); } + @OnEvent('test-isolation.breadcrumb') + handleIsolationBreadcrumbEvent(): void { + Sentry.addBreadcrumb({ + message: 'leaked-breadcrumb-from-event-handler', + level: 'info', + }); + } + @OnEvent('multiple.first') @OnEvent('multiple.second') async handleMultipleEvents(payload: any): Promise { diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts index 60c1ad6590af..92216d1cabfd 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts @@ -44,6 +44,28 @@ test('Event emitter', async () => { }); }); +test('Event handler breadcrumbs do not leak into subsequent HTTP requests', async () => { + // The app emits 'test-isolation.breadcrumb' every 2s via setInterval (outside HTTP context). + // The handler adds a breadcrumb. Without isolation scope forking, this breadcrumb leaks + // into the default isolation scope and gets cloned into subsequent HTTP requests. + + // Wait for at least one setInterval tick to fire and add the breadcrumb + await new Promise(resolve => setTimeout(resolve, 3000)); + + const transactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { + return transactionEvent.transaction === 'GET /events/test-isolation'; + }); + + await fetch('http://localhost:3050/events/test-isolation'); + + const transaction = await transactionPromise; + + const leakedBreadcrumb = (transaction.breadcrumbs || []).find( + (b: any) => b.message === 'leaked-breadcrumb-from-event-handler', + ); + expect(leakedBreadcrumb).toBeUndefined(); +}); + test('Multiple OnEvent decorators', async () => { const firstTxPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { return transactionEvent.transaction === 'event multiple.first|multiple.second'; From f3c3386be9b2b714ecf36c5c0176cb6352ad633f Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 09:34:04 +0100 Subject: [PATCH 2/3] fix --- .../tests/events.test.ts | 5 +-- .../sentry-nest-event-instrumentation.ts | 34 ++++++++++--------- .../nestjs/test/integrations/nest.test.ts | 6 ++++ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts index 92216d1cabfd..6ffd50116a76 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts @@ -86,6 +86,7 @@ test('Multiple OnEvent decorators', async () => { expect(firstTx).toBeDefined(); expect(secondTx).toBeDefined(); - // assert that the correct payloads were added - expect(rootTx.tags).toMatchObject({ 'test-first': true, 'test-second': true }); + // With isolation scope forking, tags set in event handlers should NOT leak onto the root HTTP transaction + expect(rootTx.tags?.['test-first']).toBeUndefined(); + expect(rootTx.tags?.['test-second']).toBeUndefined(); }); diff --git a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts index 92c90c3719de..d4ef20dcae01 100644 --- a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts @@ -5,7 +5,7 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { captureException, SDK_VERSION, startSpan } from '@sentry/core'; +import { captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core'; import { getEventSpanOptions } from './helpers'; import type { OnEventTarget } from './types'; @@ -110,21 +110,23 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { } } - 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; - } + 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; + } + }); }); }; diff --git a/packages/nestjs/test/integrations/nest.test.ts b/packages/nestjs/test/integrations/nest.test.ts index 2d1d73b4657a..bebe32b915aa 100644 --- a/packages/nestjs/test/integrations/nest.test.ts +++ b/packages/nestjs/test/integrations/nest.test.ts @@ -38,6 +38,7 @@ describe('Nest', () => { } as OnEventTarget; vi.spyOn(core, 'startSpan'); vi.spyOn(core, 'captureException'); + vi.spyOn(core, 'withIsolationScope'); }); afterEach(() => { @@ -75,6 +76,7 @@ describe('Nest', () => { await descriptor.value(); + expect(core.withIsolationScope).toHaveBeenCalled(); expect(core.startSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'event test.event', @@ -90,6 +92,7 @@ describe('Nest', () => { await descriptor.value(); + expect(core.withIsolationScope).toHaveBeenCalled(); expect(core.startSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'event Symbol(test.event)', @@ -105,6 +108,7 @@ describe('Nest', () => { await descriptor.value(); + expect(core.withIsolationScope).toHaveBeenCalled(); expect(core.startSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'event test.event1,test.event2', @@ -120,6 +124,7 @@ describe('Nest', () => { await descriptor.value(); + expect(core.withIsolationScope).toHaveBeenCalled(); expect(core.startSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'event Symbol(test.event1),Symbol(test.event2)', @@ -135,6 +140,7 @@ describe('Nest', () => { await descriptor.value(); + expect(core.withIsolationScope).toHaveBeenCalled(); expect(core.startSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'event Symbol(test.event1),test.event2,Symbol(test.event3)', From f6a1eae23ae4ab6b41428091b2b94f6425ebf7a0 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 09:41:52 +0100 Subject: [PATCH 3/3] update test --- .../nestjs-distributed-tracing/tests/events.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts index 6ffd50116a76..24e93b6cbd86 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts @@ -86,7 +86,10 @@ test('Multiple OnEvent decorators', async () => { expect(firstTx).toBeDefined(); expect(secondTx).toBeDefined(); - // With isolation scope forking, tags set in event handlers should NOT leak onto the root HTTP transaction + + // Tags should be on the event handler transactions, not the root HTTP transaction + expect(firstTx.tags?.['test-first'] || firstTx.tags?.['test-second']).toBe(true); + expect(secondTx.tags?.['test-first'] || secondTx.tags?.['test-second']).toBe(true); expect(rootTx.tags?.['test-first']).toBeUndefined(); expect(rootTx.tags?.['test-second']).toBeUndefined(); });