diff --git a/packages/browser-utils/test/browser/metrics/index.test.ts b/packages/browser-utils/test/browser/metrics/index.test.ts index b0015ca0a514..8c4476d4b6b2 100644 --- a/packages/browser-utils/test/browser/metrics/index.test.ts +++ b/packages/browser-utils/test/browser/metrics/index.test.ts @@ -32,7 +32,7 @@ const originalLocation = WINDOW.location; const resourceEntryName = 'https://example.com/assets/to/css'; describe('_addMeasureSpans', () => { - const span = new SentrySpan({ op: 'pageload', name: '/' }); + const span = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); beforeEach(() => { getCurrentScope().clear(); @@ -86,7 +86,7 @@ describe('_addMeasureSpans', () => { }); describe('_addResourceSpans', () => { - const span = new SentrySpan({ op: 'pageload', name: '/' }); + const span = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); beforeAll(() => { setGlobalLocation(mockWindowLocation); diff --git a/packages/browser-utils/test/browser/metrics/utils.test.ts b/packages/browser-utils/test/browser/metrics/utils.test.ts index ad9d0dc801f8..1ec79514af67 100644 --- a/packages/browser-utils/test/browser/metrics/utils.test.ts +++ b/packages/browser-utils/test/browser/metrics/utils.test.ts @@ -26,7 +26,7 @@ describe('startAndEndSpan()', () => { }); it('creates a span with given properties', () => { - const parentSpan = new SentrySpan({ name: 'test' }); + const parentSpan = new SentrySpan({ name: 'test', sampled: true }); const span = startAndEndSpan(parentSpan, 100, 200, { name: 'evaluation', op: 'script', @@ -40,7 +40,7 @@ describe('startAndEndSpan()', () => { }); it('adjusts the start timestamp if child span starts before transaction', () => { - const parentSpan = new SentrySpan({ name: 'test', startTimestamp: 123 }); + const parentSpan = new SentrySpan({ name: 'test', startTimestamp: 123, sampled: true }); const span = startAndEndSpan(parentSpan, 100, 200, { name: 'script.js', op: 'resource', @@ -52,7 +52,7 @@ describe('startAndEndSpan()', () => { }); it('does not adjust start timestamp if child span starts after transaction', () => { - const parentSpan = new SentrySpan({ name: 'test', startTimestamp: 123 }); + const parentSpan = new SentrySpan({ name: 'test', startTimestamp: 123, sampled: true }); const span = startAndEndSpan(parentSpan, 150, 200, { name: 'script.js', op: 'resource', diff --git a/packages/bun/package.json b/packages/bun/package.json index d5454c17c88c..9bad7751c615 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -44,6 +44,7 @@ "dependencies": { "@sentry/core": "8.0.0-alpha.9", "@sentry/node": "8.0.0-alpha.9", + "@sentry/opentelemetry": "8.0.0-alpha.9", "@sentry/types": "8.0.0-alpha.9", "@sentry/utils": "8.0.0-alpha.9" }, diff --git a/packages/bun/src/transports/index.ts b/packages/bun/src/transports/index.ts index ad9832795fc4..b4968f2001c0 100644 --- a/packages/bun/src/transports/index.ts +++ b/packages/bun/src/transports/index.ts @@ -1,4 +1,4 @@ -import { createTransport } from '@sentry/core'; +import { createTransport, suppressTracing } from '@sentry/core'; import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; import { rejectedSyncPromise } from '@sentry/utils'; @@ -19,14 +19,16 @@ export function makeFetchTransport(options: BunTransportOptions): Transport { }; try { - return fetch(options.url, requestOptions).then(response => { - return { - statusCode: response.status, - headers: { - 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), - 'retry-after': response.headers.get('Retry-After'), - }, - }; + return suppressTracing(() => { + return fetch(options.url, requestOptions).then(response => { + return { + statusCode: response.status, + headers: { + 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), + 'retry-after': response.headers.get('Retry-After'), + }, + }; + }); }); } catch (e) { return rejectedSyncPromise(e); diff --git a/packages/core/src/asyncContext.ts b/packages/core/src/asyncContext.ts index 854b03ea9600..4627d4c2453d 100644 --- a/packages/core/src/asyncContext.ts +++ b/packages/core/src/asyncContext.ts @@ -1,7 +1,7 @@ import type { Hub, Integration } from '@sentry/types'; import type { Scope } from '@sentry/types'; import { GLOBAL_OBJ } from '@sentry/utils'; -import type { startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from './tracing/trace'; +import type { startInactiveSpan, startSpan, startSpanManual, suppressTracing, withActiveSpan } from './tracing/trace'; import type { getActiveSpan } from './utils/spanUtils'; /** @@ -62,6 +62,9 @@ export interface AsyncContextStrategy { /** Make a span the active span in the context of the callback. */ withActiveSpan?: typeof withActiveSpan; + + /** Suppress tracing in the given callback, ensuring no spans are generated inside of it. */ + suppressTracing?: typeof suppressTracing; } /** diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 7cc86ae76531..a01fca60316a 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -14,6 +14,7 @@ export { startSpanManual, continueTrace, withActiveSpan, + suppressTracing, } from './trace'; export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; export { setMeasurement, timedEventsToMeasurements } from './measurement'; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index d21a567cecc5..c9e82163fef1 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -3,6 +3,7 @@ import type { ClientOptions, Scope, SentrySpanArguments, Span, SpanTimeInput, St import { propagationContextFromHeaders } from '@sentry/utils'; import type { AsyncContextStrategy } from '../asyncContext'; import { getMainCarrier } from '../asyncContext'; + import { getClient, getCurrentScope, getIsolationScope, withScope } from '../currentScopes'; import { getAsyncContextStrategy } from '../hub'; @@ -25,6 +26,8 @@ import { SentrySpan } from './sentrySpan'; import { SPAN_STATUS_ERROR } from './spanstatus'; import { setCapturedScopesOnSpan } from './utils'; +const SUPPRESS_TRACING_KEY = '__SENTRY_SUPPRESS_TRACING__'; + /** * Wraps a function with a transaction/span and finishes the span after the function is done. * The created span is the active span and will be used as parent by other spans created inside the function @@ -204,6 +207,20 @@ export function withActiveSpan(span: Span | null, callback: (scope: Scope) => }); } +/** Suppress tracing in the given callback, ensuring no spans are generated inside of it. */ +export function suppressTracing(callback: () => T): T { + const acs = getAcs(); + + if (acs.suppressTracing) { + return acs.suppressTracing(callback); + } + + return withScope(scope => { + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); + return callback(); + }); +} + function createChildSpanOrTransaction({ parentSpan, spanContext, @@ -223,7 +240,7 @@ function createChildSpanOrTransaction({ let span: Span; if (parentSpan && !forceTransaction) { - span = _startChildSpan(parentSpan, spanContext); + span = _startChildSpan(parentSpan, scope, spanContext); addChildSpanToSpan(parentSpan, span); } else if (parentSpan) { // If we forced a transaction but have a parent span, make sure to continue from the parent span, not the scope @@ -237,6 +254,7 @@ function createChildSpanOrTransaction({ parentSpanId, ...spanContext, }, + scope, parentSampled, ); @@ -258,6 +276,7 @@ function createChildSpanOrTransaction({ parentSpanId, ...spanContext, }, + scope, parentSampled, ); @@ -296,22 +315,24 @@ function getAcs(): AsyncContextStrategy { return getAsyncContextStrategy(carrier); } -function _startRootSpan(spanArguments: SentrySpanArguments, parentSampled?: boolean): SentrySpan { +function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parentSampled?: boolean): SentrySpan { const client = getClient(); const options: Partial = (client && client.getOptions()) || {}; const { name = '', attributes } = spanArguments; - const [sampled, sampleRate] = sampleSpan(options, { - name, - parentSampled, - attributes, - transactionContext: { - name, - parentSampled, - }, - }); - - const transaction = new SentrySpan({ + const [sampled, sampleRate] = scope.getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY] + ? [false] + : sampleSpan(options, { + name, + parentSampled, + attributes, + transactionContext: { + name, + parentSampled, + }, + }); + + const rootSpan = new SentrySpan({ ...spanArguments, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', @@ -320,30 +341,32 @@ function _startRootSpan(spanArguments: SentrySpanArguments, parentSampled?: bool sampled, }); if (sampleRate !== undefined) { - transaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, sampleRate); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, sampleRate); } if (client) { - client.emit('spanStart', transaction); + client.emit('spanStart', rootSpan); } - return transaction; + return rootSpan; } /** * Creates a new `Span` while setting the current `Span.id` as `parentSpanId`. * This inherits the sampling decision from the parent span. */ -function _startChildSpan(parentSpan: Span, spanArguments: SentrySpanArguments): SentrySpan { +function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySpanArguments): Span { const { spanId, traceId } = parentSpan.spanContext(); - const sampled = spanIsSampled(parentSpan); + const sampled = scope.getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY] ? false : spanIsSampled(parentSpan); - const childSpan = new SentrySpan({ - ...spanArguments, - parentSpanId: spanId, - traceId, - sampled, - }); + const childSpan = sampled + ? new SentrySpan({ + ...spanArguments, + parentSpanId: spanId, + traceId, + sampled, + }) + : new SentryNonRecordingSpan(); addChildSpanToSpan(parentSpan, childSpan); diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 02144174ef1f..7c3962021b2f 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -20,11 +20,12 @@ import { startInactiveSpan, startSpan, startSpanManual, + suppressTracing, withActiveSpan, } from '../../../src/tracing'; import { SentryNonRecordingSpan } from '../../../src/tracing/sentryNonRecordingSpan'; import { _setSpanForScope } from '../../../src/utils/spanOnScope'; -import { getActiveSpan, getRootSpan, getSpanDescendants } from '../../../src/utils/spanUtils'; +import { getActiveSpan, getRootSpan, getSpanDescendants, spanIsSampled } from '../../../src/utils/spanUtils'; import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; beforeAll(() => { @@ -259,7 +260,7 @@ describe('startSpan', () => { const initialScope = getCurrentScope(); const manualScope = initialScope.clone(); - const parentSpan = new SentrySpan({ spanId: 'parent-span-id' }); + const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true }); _setSpanForScope(manualScope, parentSpan); startSpan({ name: 'GET users/[id]', scope: manualScope }, span => { @@ -490,7 +491,7 @@ describe('startSpan', () => { }); it('uses implementation from ACS, if it exists', () => { - const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticSpan = new SentrySpan({ spanId: 'aha', sampled: true }); const carrier = getMainCarrier(); @@ -575,7 +576,7 @@ describe('startSpanManual', () => { const initialScope = getCurrentScope(); const manualScope = initialScope.clone(); - const parentSpan = new SentrySpan({ spanId: 'parent-span-id' }); + const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true }); _setSpanForScope(manualScope, parentSpan); startSpanManual({ name: 'GET users/[id]', scope: manualScope }, (span, finish) => { @@ -761,7 +762,7 @@ describe('startSpanManual', () => { }); it('uses implementation from ACS, if it exists', () => { - const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticSpan = new SentrySpan({ spanId: 'aha', sampled: true }); const carrier = getMainCarrier(); @@ -840,7 +841,7 @@ describe('startInactiveSpan', () => { const initialScope = getCurrentScope(); const manualScope = initialScope.clone(); - const parentSpan = new SentrySpan({ spanId: 'parent-span-id' }); + const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true }); _setSpanForScope(manualScope, parentSpan); const span = startInactiveSpan({ name: 'GET users/[id]', scope: manualScope }); @@ -994,7 +995,7 @@ describe('startInactiveSpan', () => { }); }); - it('includes the scope at the time the span was started when finished xxx', async () => { + it('includes the scope at the time the span was started when finished', async () => { const beforeSendTransaction = jest.fn(event => event); const client = new TestClient( @@ -1048,7 +1049,7 @@ describe('startInactiveSpan', () => { }); it('uses implementation from ACS, if it exists', () => { - const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticSpan = new SentrySpan({ spanId: 'aha', sampled: true }); const carrier = getMainCarrier(); @@ -1206,7 +1207,7 @@ describe('getActiveSpan', () => { }); it('works with an active span on the scope', () => { - const activeSpan = new SentrySpan({ spanId: 'aha' }); + const activeSpan = new SentrySpan({ spanId: 'aha', sampled: true }); withActiveSpan(activeSpan, () => { const span = getActiveSpan(); @@ -1215,7 +1216,7 @@ describe('getActiveSpan', () => { }); it('uses implementation from ACS, if it exists', () => { - const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticSpan = new SentrySpan({ spanId: 'aha', sampled: true }); const carrier = getMainCarrier(); @@ -1285,7 +1286,7 @@ describe('withActiveSpan()', () => { }); it('uses implementation from ACS, if it exists', () => { - const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticSpan = new SentrySpan({ spanId: 'aha', sampled: true }); const staticScope = new Scope(); const carrier = getMainCarrier(); @@ -1359,3 +1360,66 @@ describe('span hooks', () => { expect(endedSpans).toEqual(['span5', 'span3', 'span2', 'span1']); }); }); + +describe('suppressTracing', () => { + beforeEach(() => { + addTracingExtensions(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + setAsyncContextStrategy(undefined); + + const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('works for a root span', () => { + const span = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + expect(span.isRecording()).toBe(false); + expect(spanIsSampled(span)).toBe(false); + }); + + it('works for a child span', () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(spanIsSampled(span)).toBe(true); + + const child1 = startInactiveSpan({ name: 'inner1' }); + + expect(child1.isRecording()).toBe(true); + expect(spanIsSampled(child1)).toBe(true); + + const child2 = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + expect(child2.isRecording()).toBe(false); + expect(spanIsSampled(child2)).toBe(false); + }); + }); + + it('works for a child span with forceTransaction=true', () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(spanIsSampled(span)).toBe(true); + + const child = suppressTracing(() => { + return startInactiveSpan({ name: 'span', forceTransaction: true }); + }); + + expect(child.isRecording()).toBe(false); + expect(spanIsSampled(child)).toBe(false); + }); + }); +}); diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index 525322e3784c..0af455e8b2f7 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -1,3 +1,4 @@ +import { suppressTracing } from '@sentry/core'; import type { Event, EventHint } from '@sentry/types'; import { GLOBAL_OBJ } from '@sentry/utils'; import type { StackFrame } from 'stacktrace-parser'; @@ -40,17 +41,19 @@ async function resolveStackFrame( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 3000); - const res = await fetch( - `${ - // eslint-disable-next-line no-restricted-globals - typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port - }${basePath}/__nextjs_original-stack-frame?${params.toString()}`, - { - signal: controller.signal, - }, - ).finally(() => { - clearTimeout(timer); - }); + const res = await suppressTracing(() => + fetch( + `${ + // eslint-disable-next-line no-restricted-globals + typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port + }${basePath}/__nextjs_original-stack-frame?${params.toString()}`, + { + signal: controller.signal, + }, + ).finally(() => { + clearTimeout(timer); + }), + ); if (!res.ok || res.status === 204) { return null; diff --git a/packages/node/src/transports/http.ts b/packages/node/src/transports/http.ts index 4cbe7ece1f60..d4d7435bdc6d 100644 --- a/packages/node/src/transports/http.ts +++ b/packages/node/src/transports/http.ts @@ -2,9 +2,7 @@ import * as http from 'node:http'; import * as https from 'node:https'; import { Readable } from 'stream'; import { createGzip } from 'zlib'; -import { context } from '@opentelemetry/api'; -import { suppressTracing } from '@opentelemetry/core'; -import { createTransport } from '@sentry/core'; +import { createTransport, suppressTracing } from '@sentry/core'; import type { BaseTransportOptions, Transport, @@ -82,7 +80,7 @@ export function makeNodeTransport(options: NodeTransportOptions): Transport { : new nativeHttpModule.Agent({ keepAlive, maxSockets: 30, timeout: 2000 }); // This ensures we do not generate any spans in OpenTelemetry for the transport - return context.with(suppressTracing(context.active()), () => { + return suppressTracing(() => { const requestExecutor = createRequestExecutor(options, options.httpModule ?? nativeHttpModule, agent); return createTransport(options, requestExecutor); }); diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 75169ab933ed..b7f66870d07f 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -13,6 +13,7 @@ import { startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from '. import type { CurrentScopes } from './types'; import { getScopesFromContext } from './utils/contextData'; import { getActiveSpan } from './utils/getActiveSpan'; +import { suppressTracing } from './utils/suppressTracing'; /** * Sets the async context strategy to use follow the OTEL context under the hood. @@ -122,5 +123,6 @@ export function setOpenTelemetryContextAsyncContextStrategy(): void { // The types here don't fully align, because our own `Span` type is narrower // than the OTEL one - but this is OK for here, as we now we'll only have OTEL spans passed around withActiveSpan: withActiveSpan as typeof defaultWithActiveSpan, + suppressTracing: suppressTracing, }); } diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 43f183be2139..3d727c707897 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -23,6 +23,8 @@ export { isSentryRequestSpan } from './utils/isSentryRequest'; export { getActiveSpan } from './utils/getActiveSpan'; export { startSpan, startSpanManual, startInactiveSpan, withActiveSpan, continueTrace } from './trace'; +export { suppressTracing } from './utils/suppressTracing'; + // eslint-disable-next-line deprecation/deprecation export { setupGlobalHub } from './custom/hub'; // eslint-disable-next-line deprecation/deprecation diff --git a/packages/opentelemetry/src/utils/suppressTracing.ts b/packages/opentelemetry/src/utils/suppressTracing.ts new file mode 100644 index 000000000000..38813fd3239c --- /dev/null +++ b/packages/opentelemetry/src/utils/suppressTracing.ts @@ -0,0 +1,8 @@ +import { context } from '@opentelemetry/api'; +import { suppressTracing as suppressTracingImpl } from '@opentelemetry/core'; + +/** Suppress tracing in the given callback, ensuring no spans are generated inside of it. */ +export function suppressTracing(callback: () => T): T { + const ctx = suppressTracingImpl(context.active()); + return context.with(ctx, callback); +} diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index a871fe1fbeba..4ef8a8d90f6d 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -15,6 +15,7 @@ import { getRootSpan, spanIsSampled, spanToJSON, + suppressTracing, withScope, } from '@sentry/core'; import type { Event, Scope } from '@sentry/types'; @@ -1575,6 +1576,58 @@ describe('continueTrace', () => { }); }); +describe('suppressTracing', () => { + beforeEach(() => { + mockSdkInit({ enableTracing: true }); + }); + + afterEach(() => { + cleanupOtel(); + }); + + it('works for a root span', () => { + const span = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + expect(span.isRecording()).toBe(false); + expect(spanIsSampled(span)).toBe(false); + }); + + it('works for a child span', () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(spanIsSampled(span)).toBe(true); + + const child1 = startInactiveSpan({ name: 'inner1' }); + + expect(child1.isRecording()).toBe(true); + expect(spanIsSampled(child1)).toBe(true); + + const child2 = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + expect(child2.isRecording()).toBe(false); + expect(spanIsSampled(child2)).toBe(false); + }); + }); + + it('works for a child span with forceTransaction=true', () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(spanIsSampled(span)).toBe(true); + + const child = suppressTracing(() => { + return startInactiveSpan({ name: 'span', forceTransaction: true }); + }); + + expect(child.isRecording()).toBe(false); + expect(spanIsSampled(child)).toBe(false); + }); + }); +}); + function getSpanName(span: AbstractSpan): string | undefined { return spanHasName(span) ? span.name : undefined; }