From 17fa89d283847f9d7d67a68e27b542de27d72262 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 11 Jun 2025 10:43:29 +0200 Subject: [PATCH] fix(browser): Ensure `suppressTracing` does not leak when async --- packages/cloudflare/src/async.ts | 9 ++++++ packages/core/src/tracing/trace.ts | 9 +++++- packages/core/test/lib/tracing/trace.test.ts | 34 ++++++++++++++++++++ packages/opentelemetry/test/trace.test.ts | 32 ++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/async.ts b/packages/cloudflare/src/async.ts index cd20a8d083de..66f2d439a3ce 100644 --- a/packages/cloudflare/src/async.ts +++ b/packages/cloudflare/src/async.ts @@ -64,7 +64,16 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { }); } + // In contrast to the browser, we can rely on async context isolation here + function suppressTracing(callback: () => T): T { + return withScope(scope => { + scope.setSDKProcessingMetadata({ __SENTRY_SUPPRESS_TRACING__: true }); + return callback(); + }); + } + setAsyncContextStrategy({ + suppressTracing, withScope, withSetScope, withIsolationScope, diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index a96159692ac3..427e4ebb0bf6 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -253,8 +253,15 @@ export function suppressTracing(callback: () => T): T { } return withScope(scope => { + // Note: We do not wait for the callback to finish before we reset the metadata + // the reason for this is that otherwise, in the browser this can lead to very weird behavior + // as there is only a single top scope, if the callback takes longer to finish, + // other, unrelated spans may also be suppressed, which we do not want + // so instead, we only suppress tracing synchronoysly in the browser scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); - return callback(); + const res = callback(); + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: undefined }); + return res; }); } diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 83f4b150a6d6..6d25afe13d3e 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -1975,6 +1975,40 @@ describe('suppressTracing', () => { expect(spanIsSampled(child)).toBe(false); }); }); + + it('works with parallel processes', async () => { + const span = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + // Note: This is unintuitive, but it is the expected behavior + // because we only suppress tracing synchronously in the browser + const span2Promise = suppressTracing(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + return startInactiveSpan({ name: 'span2' }); + }); + + const span3Promise = suppressTracing(async () => { + const span = startInactiveSpan({ name: 'span3' }); + await new Promise(resolve => setTimeout(resolve, 100)); + return span; + }); + + const span4 = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + const span5 = startInactiveSpan({ name: 'span5' }); + + const span2 = await span2Promise; + const span3 = await span3Promise; + + expect(spanIsSampled(span)).toBe(false); + expect(spanIsSampled(span2)).toBe(true); + expect(spanIsSampled(span3)).toBe(false); + expect(spanIsSampled(span4)).toBe(false); + expect(spanIsSampled(span5)).toBe(true); + }); }); describe('startNewTrace', () => { diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index f9aed823a4a4..d8432172a601 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -1921,6 +1921,38 @@ describe('suppressTracing', () => { expect(spanIsSampled(child)).toBe(false); }); }); + + it('works with parallel processes', async () => { + const span = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + const span2Promise = suppressTracing(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + return startInactiveSpan({ name: 'span2' }); + }); + + const span3Promise = suppressTracing(async () => { + const span = startInactiveSpan({ name: 'span3' }); + await new Promise(resolve => setTimeout(resolve, 100)); + return span; + }); + + const span4 = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + const span5 = startInactiveSpan({ name: 'span5' }); + + const span2 = await span2Promise; + const span3 = await span3Promise; + + expect(spanIsSampled(span)).toBe(false); + expect(spanIsSampled(span2)).toBe(false); + expect(spanIsSampled(span3)).toBe(false); + expect(spanIsSampled(span4)).toBe(false); + expect(spanIsSampled(span5)).toBe(true); + }); }); function getSpanName(span: AbstractSpan): string | undefined {