From a2229b77c6f45c1d17d3ef387fd8135feb2e8d68 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 25 Jun 2026 14:35:05 +0200 Subject: [PATCH] feat(core): Add `isTracingSuppressed` to the async context strategy Expose a public `isTracingSuppressed` helper from `@sentry/core` that delegates to the async context strategy (falling back to the scope's SDK processing metadata), mirroring how `suppressTracing` works. This lets OTel-based SDKs answer suppression from the active OpenTelemetry context rather than only the scope metadata, keeping `isTracingSuppressed` consistent with `suppressTracing`. - core: export `isTracingSuppressed`, add it to the `AsyncContextStrategy` type, and replace the private `_isTracingSuppressed` usages. - opentelemetry: implement `isTracingSuppressed` (reads the OTel context) and register it on the async context strategy. - node-core: `SentryHttpInstrumentation` and `SentryNodeFetchInstrumentation` now use core's `isTracingSuppressed()` instead of importing from `@opentelemetry/core`. - Add unit tests in core and opentelemetry. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/asyncContext/types.ts | 4 ++ packages/core/src/tracing/index.ts | 1 + packages/core/src/tracing/trace.ts | 29 ++++++---- packages/core/test/lib/tracing/trace.test.ts | 56 +++++++++++++++++++ .../http/SentryHttpInstrumentation.ts | 12 ++-- .../http/httpServerSpansIntegration.ts | 5 +- .../SentryNodeFetchInstrumentation.ts | 6 +- .../opentelemetry/src/asyncContextStrategy.ts | 3 +- .../src/utils/suppressTracing.ts | 12 +++- packages/opentelemetry/test/trace.test.ts | 37 ++++++++++++ 10 files changed, 142 insertions(+), 23 deletions(-) diff --git a/packages/core/src/asyncContext/types.ts b/packages/core/src/asyncContext/types.ts index 53cad387c9db..d0ca85edf91a 100644 --- a/packages/core/src/asyncContext/types.ts +++ b/packages/core/src/asyncContext/types.ts @@ -3,6 +3,7 @@ import type { Span } from '../types/span'; import type { getTraceData } from '../utils/traceData'; import type { continueTrace, + isTracingSuppressed, startInactiveSpan, startNewTrace, startSpan, @@ -86,6 +87,9 @@ export interface AsyncContextStrategy { /** Suppress tracing in the given callback, ensuring no spans are generated inside of it. */ suppressTracing?: typeof suppressTracing; + /** If tracing is suppressed in the given scope. */ + isTracingSuppressed?: typeof isTracingSuppressed; + /** Get trace data as serialized string values for propagation via `sentry-trace` and `baggage`. */ getTraceData?: typeof getTraceData; diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index ffd40cc00406..02092bd0674d 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -17,6 +17,7 @@ export { continueTrace, withActiveSpan, suppressTracing, + isTracingSuppressed, startNewTrace, SUPPRESS_TRACING_KEY, } from './trace'; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 7a31217fe51a..a34d829de5a9 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -291,6 +291,17 @@ export function suppressTracing(callback: () => T): T { }); } +/** Check if tracing is suppressed. */ +export function isTracingSuppressed(scope = getCurrentScope()): boolean { + const acs = getAcs(); + + if (acs.isTracingSuppressed) { + return acs.isTracingSuppressed(scope); + } + + return scope.getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY] === true; +} + /** * Starts a new trace for the duration of the provided callback. Spans started within the * callback will be part of the new trace instead of a potentially previously started trace. @@ -372,7 +383,7 @@ function createChildOrRootSpan({ const client = getClient(); if (_shouldIgnoreStreamedSpan(client, spanArguments)) { - if (!_isTracingSuppressed(scope)) { + if (!isTracingSuppressed(scope)) { // if tracing is actively suppressed (Sentry.suppressTracing(...)), // we don't want to record a client outcome for the ignored span client?.recordDroppedEvent('ignored', 'span'); @@ -489,9 +500,9 @@ function _startRootSpan( const finalAttributes = mutableSpanSamplingData.spanAttributes; const currentPropagationContext = scope.getPropagationContext(); - const isTracingSuppressed = _isTracingSuppressed(scope); + const _isTracingSuppressed = isTracingSuppressed(scope); - const [sampled, sampleRate, localSampleRateWasApplied] = isTracingSuppressed + const [sampled, sampleRate, localSampleRateWasApplied] = _isTracingSuppressed ? [false] : sampleSpan( options, @@ -515,7 +526,7 @@ function _startRootSpan( sampled, }); - if (!sampled && client && !isTracingSuppressed) { + if (!sampled && client && !_isTracingSuppressed) { DEBUG_BUILD && debug.log('[Tracing] Discarding root span because its trace was not chosen to be sampled.'); client.recordDroppedEvent('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction'); } @@ -540,8 +551,8 @@ function _startChildSpan( isolationScope: Scope, ): Span { const { spanId, traceId } = parentSpan.spanContext(); - const isTracingSuppressed = _isTracingSuppressed(scope); - const sampled = isTracingSuppressed ? false : spanIsSampled(parentSpan); + const _isTracingSuppressed = isTracingSuppressed(scope); + const sampled = _isTracingSuppressed ? false : spanIsSampled(parentSpan); const childSpan = sampled ? new SentrySpan({ @@ -569,7 +580,7 @@ function _startChildSpan( // record a client outcome for the child. childSpan.dropReason = parentSpan.dropReason; client.recordDroppedEvent(parentSpan.dropReason, 'span'); - } else if (!isTracingSuppressed) { + } else if (!_isTracingSuppressed) { // Otherwise, the child is not sampled due to sampling of the parent span, // hence we record a sample_rate client outcome for the child. childSpan.dropReason = 'sample_rate'; @@ -642,7 +653,3 @@ function _shouldIgnoreStreamedSpan(client: Client | undefined, spanArguments: Se function _isIgnoredSpan(span: Span): span is SentryNonRecordingSpan { return spanIsNonRecordingSpan(span) && span.dropReason === 'ignored'; } - -function _isTracingSuppressed(scope: Scope): boolean { - return scope.getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY] === true; -} diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index f2e605a7e4de..7cf8f2538dc2 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -19,11 +19,13 @@ import { getAsyncContextStrategy } from '../../../src/asyncContext'; import { continueTrace, getDynamicSamplingContextFromSpan, + isTracingSuppressed, registerSpanErrorInstrumentation, SentrySpan, startInactiveSpan, startSpan, startSpanManual, + SUPPRESS_TRACING_KEY, suppressTracing, withActiveSpan, } from '../../../src/tracing'; @@ -2561,6 +2563,60 @@ describe('suppressTracing', () => { }); }); +describe('isTracingSuppressed', () => { + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + setAsyncContextStrategy(undefined); + + const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns false when tracing is not suppressed', () => { + expect(isTracingSuppressed()).toBe(false); + }); + + it('returns true while inside suppressTracing', () => { + const suppressed = suppressTracing(() => isTracingSuppressed()); + expect(suppressed).toBe(true); + }); + + it('returns false again after suppressTracing has finished', () => { + suppressTracing(() => { + expect(isTracingSuppressed()).toBe(true); + }); + + expect(isTracingSuppressed()).toBe(false); + }); + + it('only suppresses tracing within the active scope', () => { + withScope(() => { + const suppressed = suppressTracing(() => isTracingSuppressed()); + expect(suppressed).toBe(true); + }); + + // Outside of the suppressed scope, tracing is no longer suppressed + expect(isTracingSuppressed()).toBe(false); + }); + + it('respects a scope passed in explicitly', () => { + const scope = getCurrentScope().clone(); + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); + + expect(isTracingSuppressed(scope)).toBe(true); + expect(isTracingSuppressed()).toBe(false); + }); +}); + describe('startNewTrace', () => { beforeEach(() => { getCurrentScope().clear(); diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index f143439e0a33..d2a03a0ea681 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,6 +1,5 @@ import { subscribe } from 'node:diagnostics_channel'; import { context, trace } from '@opentelemetry/api'; -import { isTracingSuppressed } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; import type { ClientRequest, IncomingMessage, ServerResponse } from 'node:http'; @@ -11,7 +10,13 @@ import type { HttpModuleExport, Span, } from '@sentry/core'; -import { getHttpClientSubscriptions, patchHttpModuleClient, SDK_VERSION, getRequestOptions } from '@sentry/core'; +import { + getHttpClientSubscriptions, + patchHttpModuleClient, + SDK_VERSION, + getRequestOptions, + isTracingSuppressed, +} from '@sentry/core'; import { INSTRUMENTATION_NAME } from './constants'; import { HTTP_ON_CLIENT_REQUEST } from '@sentry/core'; import { NODE_VERSION } from '../../nodeVersion'; @@ -172,8 +177,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase boolean; }, ): boolean { - if (isTracingSuppressed(context.active())) { + if (isTracingSuppressed()) { return true; } diff --git a/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts b/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts index 513874f6733c..d1ec458578e3 100644 --- a/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts +++ b/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts @@ -1,8 +1,6 @@ -import { context } from '@opentelemetry/api'; -import { isTracingSuppressed } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase } from '@opentelemetry/instrumentation'; -import { LRUMap, SDK_VERSION } from '@sentry/core'; +import { LRUMap, SDK_VERSION, isTracingSuppressed } from '@sentry/core'; import * as diagch from 'diagnostics_channel'; import { NODE_MAJOR, NODE_MINOR } from '../../nodeVersion'; import { @@ -184,7 +182,7 @@ export class SentryNodeFetchInstrumentation extends InstrumentationBase(callback: () => T): T { const ctx = suppressTracingImpl(context.active()); return context.with(ctx, callback); } + +export function isTracingSuppressed(scope?: Scope): boolean { + const ctx = scope ? getContextFromScope(scope) : context.active(); + return ctx ? isTracingSuppressedImpl(ctx) : false; +} diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index dba136be59e3..02b67b220b43 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -10,6 +10,7 @@ import { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan, getRootSpan, + isTracingSuppressed, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, @@ -2107,6 +2108,42 @@ describe('suppressTracing', () => { }); }); +describe('isTracingSuppressed', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('returns false when tracing is not suppressed', () => { + expect(isTracingSuppressed()).toBe(false); + }); + + it('returns true while inside suppressTracing', () => { + const suppressed = suppressTracing(() => isTracingSuppressed()); + expect(suppressed).toBe(true); + }); + + it('returns false again after suppressTracing has finished', () => { + suppressTracing(() => { + expect(isTracingSuppressed()).toBe(true); + }); + + expect(isTracingSuppressed()).toBe(false); + }); + + it('stays suppressed across async boundaries within suppressTracing', async () => { + const suppressed = await suppressTracing(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + return isTracingSuppressed(); + }); + + expect(suppressed).toBe(true); + }); +}); + describe('span.end() timestamp conversion', () => { beforeEach(() => { mockSdkInit({ tracesSampleRate: 1 });