diff --git a/dev-packages/cloudflare-integration-tests/expect.ts b/dev-packages/cloudflare-integration-tests/expect.ts index c3e2bd007436..9931fd30c6a8 100644 --- a/dev-packages/cloudflare-integration-tests/expect.ts +++ b/dev-packages/cloudflare-integration-tests/expect.ts @@ -61,7 +61,17 @@ export function expectedEvent(event: Event, { sdk }: { sdk: 'cloudflare' | 'hono export function eventEnvelope( event: Event, - { includeSampleRand = false, sdk = 'cloudflare' }: { includeSampleRand?: boolean; sdk?: 'cloudflare' | 'hono' } = {}, + { + includeSamplingFields = false, + includeSampleRand = false, + includeTransaction = true, + sdk = 'cloudflare', + }: { + includeSamplingFields?: boolean; + includeSampleRand?: boolean; + includeTransaction?: boolean; + sdk?: 'cloudflare' | 'hono'; + } = {}, ): Envelope { return [ { @@ -72,10 +82,11 @@ export function eventEnvelope( environment: event.environment || 'production', public_key: 'public', trace_id: UUID_MATCHER, - sample_rate: expect.any(String), + ...(includeSamplingFields && { sample_rate: expect.any(String), sampled: expect.any(String) }), ...(includeSampleRand && { sample_rand: expect.stringMatching(/^[01](\.\d+)?$/) }), - sampled: expect.any(String), - transaction: expect.any(String), + // A new (head-of-trace) TwP trace does not stamp a local transaction in its DSC; the DSC is + // resolved from the scope. Continued traces still carry the upstream transaction. + ...(includeTransaction && { transaction: expect.any(String) }), }, }, [[{ type: 'event' }, expectedEvent(event, { sdk })]], diff --git a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts index 347c0d3530d8..1f6480f4cab4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts @@ -5,26 +5,30 @@ import { createRunner } from '../../runner'; it('Basic error in fetch handler', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'error', - exception: { - values: [ - { - type: 'Error', - value: 'This is a test error from the Cloudflare integration tests', - stacktrace: { - frames: expect.any(Array), + eventEnvelope( + { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'This is a test error from the Cloudflare integration tests', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, }, - mechanism: { type: 'auto.http.cloudflare', handled: false }, - }, - ], + ], + }, + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), + }, + // A new (head-of-trace) TwP trace does not stamp a local transaction in its DSC. }, - request: { - headers: expect.any(Object), - method: 'GET', - url: expect.any(String), - }, - }), + { includeTransaction: false }, + ), ) .start(signal); await runner.makeRequest('get', '/', { expectError: true }); diff --git a/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts b/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts index 453cdb8a09f4..ab0b2155349b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts @@ -5,26 +5,30 @@ import { createRunner } from '../../runner'; it('Only sends one error event when withSentry is called twice', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'error', - exception: { - values: [ - { - type: 'Error', - value: 'Test error from double-instrumented worker', - stacktrace: { - frames: expect.any(Array), + eventEnvelope( + { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'Test error from double-instrumented worker', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, }, - mechanism: { type: 'auto.http.cloudflare', handled: false }, - }, - ], + ], + }, + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), + }, }, - request: { - headers: expect.any(Object), - method: 'GET', - url: expect.any(String), - }, - }), + // `/error` resolves to a raw URL span (source `url`), so the TwP DSC omits the span name. + { includeTransaction: false }, + ), ) // The http.server span produces a transaction envelope that is sent in parallel with the // error event. Either can arrive first at the mock server, so ignore it here to keep the diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts index 0cf4f1dec328..e69cb0951c39 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts @@ -28,7 +28,7 @@ it('Hono app captures errors', async ({ signal }) => { url: expect.any(String), }, }, - { includeSampleRand: true }, + { includeSamplingFields: true, includeSampleRand: true }, ), ) // Second envelope: transaction event diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts index c1f17ddb6d19..bbaa75aae4e8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts @@ -39,7 +39,7 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { }, ], }, - { includeSampleRand: true, sdk: 'hono' }, + { includeSamplingFields: true, includeSampleRand: true, sdk: 'hono' }, ), ) diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts index 6773a4cf297e..f36849462a42 100644 --- a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts @@ -5,16 +5,20 @@ import { createRunner } from '../../../runner'; it('Captures JSON request body', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'info', - message: 'POST JSON request', - request: { - headers: expect.any(Object), - method: 'POST', - url: expect.stringContaining('/post-json'), - data: '{"username":"test","action":"login"}', + eventEnvelope( + { + level: 'info', + message: 'POST JSON request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-json'), + data: '{"username":"test","action":"login"}', + }, }, - }), + // Raw URL span (source `url`), so the TwP DSC omits the span name. + { includeTransaction: false }, + ), ) .start(signal); @@ -29,16 +33,20 @@ it('Captures JSON request body', async ({ signal }) => { it('Captures form-urlencoded request body', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'info', - message: 'POST form request', - request: { - headers: expect.any(Object), - method: 'POST', - url: expect.stringContaining('/post-form'), - data: 'username=test&password=secret', + eventEnvelope( + { + level: 'info', + message: 'POST form request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-form'), + data: 'username=test&password=secret', + }, }, - }), + // Raw URL span (source `url`), so the TwP DSC omits the span name. + { includeTransaction: false }, + ), ) .start(signal); @@ -53,16 +61,20 @@ it('Captures form-urlencoded request body', async ({ signal }) => { it('Captures plain text request body', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'info', - message: 'POST text request', - request: { - headers: expect.any(Object), - method: 'POST', - url: expect.stringContaining('/post-text'), - data: 'This is plain text content', + eventEnvelope( + { + level: 'info', + message: 'POST text request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-text'), + data: 'This is plain text content', + }, }, - }), + // Raw URL span (source `url`), so the TwP DSC omits the span name. + { includeTransaction: false }, + ), ) .start(signal); @@ -77,15 +89,19 @@ it('Captures plain text request body', async ({ signal }) => { it('Does not capture body for POST without content', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'info', - message: 'POST no body request', - request: { - headers: expect.any(Object), - method: 'POST', - url: expect.stringContaining('/post-no-body'), + eventEnvelope( + { + level: 'info', + message: 'POST no body request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-no-body'), + }, }, - }), + // Raw URL span (source `url`), so the TwP DSC omits the span name. + { includeTransaction: false }, + ), ) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts new file mode 100644 index 000000000000..2f914b401772 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// Tracing is enabled (not TwP), but the route is a raw, non-parametrized URL so the +// http.server span source is `url`. The span name must therefore be omitted from the +// DSC (raw URLs may contain PII), even though a real transaction is recorded. +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(_request, _env, _ctx) { + throw new Error('Test error from URL-source worker'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts new file mode 100644 index 000000000000..14ea900e179a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts @@ -0,0 +1,55 @@ +import { expect, it } from 'vitest'; +import { eventEnvelope } from '../../../expect'; +import { createRunner } from '../../../runner'; + +it('omits the span name from the DSC for url-source spans when tracing is enabled', async ({ signal }) => { + const runner = createRunner(__dirname) + // Error event: because tracing is enabled, the DSC carries the sampling fields. But the span + // source is `url`, so the span name is omitted from the DSC (raw URLs may contain PII). + .expect( + eventEnvelope( + { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'Test error from URL-source worker', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, + }, + ], + }, + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), + }, + }, + { includeSamplingFields: true, includeSampleRand: true, includeTransaction: false }, + ), + ) + // Transaction event: proves we are NOT in TwP — the span is recorded with a `url` source and + // carries the name on the event itself, even though it is intentionally absent from the DSC. + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /error', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ 'sentry.source': 'url' }), + }), + }), + }), + ); + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts index d92fde438eb8..a20ec16a354f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts @@ -9,8 +9,8 @@ it('Tracing headers', async ({ signal }) => { const [SERVER_URL, closeTestServer] = await createTestServer() .get('/', headers => { expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-0$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); expect(headers['traceparent']).toEqual(expect.stringMatching(/^00-([a-f\d]{32})-([a-f\d]{16})-00$/)); }) .start(); @@ -18,38 +18,42 @@ it('Tracing headers', async ({ signal }) => { const runner = createRunner(__dirname) .withServerUrl(SERVER_URL) .expect( - eventEnvelope({ - level: 'error', - exception: { - values: [ + eventEnvelope( + { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'Test error to capture trace headers', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, + }, + ], + }, + breadcrumbs: [ { - type: 'Error', - value: 'Test error to capture trace headers', - stacktrace: { - frames: expect.any(Array), + category: 'fetch', + data: { + method: 'GET', + status_code: 200, + url: expect.stringContaining('http://localhost:'), }, - mechanism: { type: 'auto.http.cloudflare', handled: false }, + timestamp: expect.any(Number), + type: 'http', }, ], - }, - breadcrumbs: [ - { - category: 'fetch', - data: { - method: 'GET', - status_code: 200, - url: expect.stringContaining('http://localhost:'), - }, - timestamp: expect.any(Number), - type: 'http', + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), }, - ], - request: { - headers: expect.any(Object), - method: 'GET', - url: expect.any(String), + // A new (head-of-trace) TwP trace does not stamp a local transaction in its DSC. }, - }), + { includeTransaction: false }, + ), ) .start(signal); diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 134d4c2d5da3..7aa7f159aa38 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -14,6 +14,7 @@ import { extractOrgIdFromClient } from '../utils/dsn'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { addNonEnumerableProperty } from '../utils/object'; import { getRootSpan, spanIsSampled, spanToJSON } from '../utils/spanUtils'; +import { spanIsNonRecordingSpan } from './sentryNonRecordingSpan'; import { getCapturedScopesOnSpan } from './utils'; /** @@ -105,6 +106,16 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly; - freezeDscOnSpan(span, dsc); + // Capture scopes so consumers (e.g. SentryTraceProvider) can read them and so the DSC can be + // resolved from the scope by `getDynamicSamplingContextFromSpan`. + setCapturedScopesOnSpan(span, scope, getIsolationScope()); return span; } - const scope = getCurrentScope(); const previousActiveSpan = getActiveSpan(); const span = _startIdleSpan(startSpanOptions); @@ -148,7 +146,7 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti // If the span is non-recording, nothing more to do here... // This is the case if tracing is enabled but this specific span was not sampled - if (thisArg instanceof SentryNonRecordingSpan) { + if (spanIsNonRecordingSpan(thisArg)) { return; } diff --git a/packages/core/src/tracing/sentryNonRecordingSpan.ts b/packages/core/src/tracing/sentryNonRecordingSpan.ts index 4c4c1064eedb..bc65e90e185c 100644 --- a/packages/core/src/tracing/sentryNonRecordingSpan.ts +++ b/packages/core/src/tracing/sentryNonRecordingSpan.ts @@ -8,6 +8,7 @@ import type { SpanTimeInput, } from '../types/span'; import type { SpanStatus } from '../types/spanStatus'; +import { addNonEnumerableProperty } from '../utils/object'; import { generateSpanId, generateTraceId } from '../utils/propagationContext'; import { TRACE_FLAG_NONE } from '../utils/spanUtils'; @@ -15,6 +16,11 @@ interface SentryNonRecordingSpanArguments extends SentrySpanArguments { dropReason?: EventDropReason; } +// Brand used to detect non-recording spans via {@link spanIsNonRecordingSpan} without `instanceof`, +// which is brittle when `@sentry/core` is duplicated across packages. We use `Symbol.for` so the key +// is shared across copies of the module, and so user payloads (e.g. JSON) cannot spoof the marker. +const NON_RECORDING_SPAN_FIELD = Symbol.for('sentry.nonRecordingSpan'); + /** * A Sentry Span that is non-recording, meaning it will not be sent to Sentry. */ @@ -33,6 +39,7 @@ export class SentryNonRecordingSpan implements Span { this._traceId = spanContext.traceId || generateTraceId(); this._spanId = spanContext.spanId || generateSpanId(); this.dropReason = spanContext.dropReason; + addNonEnumerableProperty(this, NON_RECORDING_SPAN_FIELD, true); } /** @inheritdoc */ @@ -98,7 +105,14 @@ export class SentryNonRecordingSpan implements Span { * @hidden * @internal */ - public recordException(_exception: unknown, _time?: number | undefined): void { + public recordException(_exception: unknown, _time?: SpanTimeInput | undefined): void { // noop } } + +/** + * Whether the given span is a {@link SentryNonRecordingSpan}. + */ +export function spanIsNonRecordingSpan(span: Span | undefined): span is SentryNonRecordingSpan { + return !!span && (span as { [NON_RECORDING_SPAN_FIELD]?: boolean })[NON_RECORDING_SPAN_FIELD] === true; +} diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 3134c58309b1..63f8c05b6a7c 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -11,7 +11,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../semanticAttributes'; -import type { DynamicSamplingContext } from '../types/envelope'; import type { ClientOptions } from '../types/options'; import type { SentrySpanArguments, Span, SpanTimeInput } from '../types/span'; import type { StartSpanOptions } from '../types/startSpanOptions'; @@ -30,7 +29,7 @@ import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tra import { freezeDscOnSpan, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanStart } from './logSpans'; import { sampleSpan } from './sampling'; -import { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; +import { SentryNonRecordingSpan, spanIsNonRecordingSpan } from './sentryNonRecordingSpan'; import { SentrySpan } from './sentrySpan'; import { SPAN_STATUS_ERROR } from './spanstatus'; import { setCapturedScopesOnSpan } from './utils'; @@ -72,7 +71,7 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = const missingRequiredParent = options.onlyIfParent && !parentSpan; const activeSpan = missingRequiredParent - ? new SentryNonRecordingSpan() + ? startMissingRequiredParentSpan(scope, client) : createChildOrRootSpan({ parentSpan, spanArguments, @@ -80,10 +79,6 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = scope, }); - if (missingRequiredParent) { - client?.recordDroppedEvent('no_parent_span', 'span'); - } - // Ignored root spans still need to be set on scope so that `getActiveSpan()` returns them // and descendants are also non-recording. Ignored child spans don't need this because // the parent span is already on scope. @@ -139,7 +134,7 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S const missingRequiredParent = options.onlyIfParent && !parentSpan; const activeSpan = missingRequiredParent - ? new SentryNonRecordingSpan() + ? startMissingRequiredParentSpan(scope, getClient()) : createChildOrRootSpan({ parentSpan, spanArguments, @@ -147,10 +142,6 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S scope, }); - if (missingRequiredParent) { - getClient()?.recordDroppedEvent('no_parent_span', 'span'); - } - // We don't set ignored child spans onto the scope because there likely is an active, // unignored span on the scope already. if (!_isIgnoredSpan(activeSpan) || !parentSpan) { @@ -209,8 +200,7 @@ export function startInactiveSpan(options: StartSpanOptions): Span { const missingRequiredParent = options.onlyIfParent && !parentSpan; if (missingRequiredParent) { - client?.recordDroppedEvent('no_parent_span', 'span'); - return new SentryNonRecordingSpan(); + return startMissingRequiredParentSpan(scope, client); } return createChildOrRootSpan({ @@ -333,6 +323,19 @@ export function startNewTrace(callback: () => T): T { }); } +/** + * The placeholder returned from `startSpan*` when `onlyIfParent` is set but there is no parent span. + * It carries the current trace id and captured scopes so the trace data it propagates (and any nested + * span that resolves it as its root via `getRootSpan`) reads its DSC from the scope, preserving a + * continued trace's DSC instead of fabricating a fresh client one. Also records the dropped-span outcome. + */ +function startMissingRequiredParentSpan(scope: Scope, client: Client | undefined): SentryNonRecordingSpan { + client?.recordDroppedEvent('no_parent_span', 'span'); + const span = new SentryNonRecordingSpan({ traceId: scope.getPropagationContext().traceId }); + setCapturedScopesOnSpan(span, scope, getIsolationScope()); + return span; +} + function createChildOrRootSpan({ parentSpan, spanArguments, @@ -344,21 +347,26 @@ function createChildOrRootSpan({ forceTransaction?: boolean; scope: Scope; }): Span { + const isolationScope = getIsolationScope(); + if (!hasSpansEnabled()) { - const span = new SentryNonRecordingSpan(); - - // If this is a root span, we ensure to freeze a DSC - // So we can have at least partial data here - if (forceTransaction || !parentSpan) { - const dsc = { - sampled: 'false', - sample_rate: '0', - transaction: spanArguments.name, - ...getDynamicSamplingContextFromSpan(span), - } satisfies Partial; - freezeDscOnSpan(span, dsc); + const scopePropagationContext = { ...isolationScope.getPropagationContext(), ...scope.getPropagationContext() }; + const traceId = parentSpan ? parentSpan.spanContext().traceId : scopePropagationContext.traceId; + + // The placeholder is a thin marker; it carries no sampling decision or DSC. Both are read from + // the scope: the sampling decision in `getTraceData`, the DSC in `getDynamicSamplingContextFromSpan`. + const span = new SentryNonRecordingSpan({ traceId }); + + // Nested placeholders link to their parent so `getRootSpan` resolves to the root placeholder, + // whose captured scope is the source of truth. Root/forced placeholders are their own root. + if (parentSpan && !forceTransaction) { + addChildSpanToSpan(parentSpan, span); } + // Capture scopes so consumers (e.g. SentryTraceProvider) can read them and so the DSC can be + // resolved from the scope by `getDynamicSamplingContextFromSpan`. Consistent with `startIdleSpan`. + setCapturedScopesOnSpan(span, scope, isolationScope); + return span; } @@ -376,8 +384,6 @@ function createChildOrRootSpan({ }); } - const isolationScope = getIsolationScope(); - let span: Span; if (parentSpan && !forceTransaction) { span = _startChildSpan(parentSpan, scope, spanArguments); @@ -539,8 +545,8 @@ function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySp return childSpan; } - if (hasSpanStreamingEnabled(client) && childSpan instanceof SentryNonRecordingSpan) { - if (parentSpan instanceof SentryNonRecordingSpan && parentSpan.dropReason) { + if (hasSpanStreamingEnabled(client) && spanIsNonRecordingSpan(childSpan)) { + if (spanIsNonRecordingSpan(parentSpan) && parentSpan.dropReason) { // We land here if the parent span was a segment span that was ignored (`ignoreSpans`). // In this case, the child was also ignored (see `sampled` above) but we need to // record a client outcome for the child. @@ -617,7 +623,7 @@ function _shouldIgnoreStreamedSpan(client: Client | undefined, spanArguments: Se } function _isIgnoredSpan(span: Span): span is SentryNonRecordingSpan { - return span instanceof SentryNonRecordingSpan && span.dropReason === 'ignored'; + return spanIsNonRecordingSpan(span) && span.dropReason === 'ignored'; } function _isTracingSuppressed(scope: Scope): boolean { diff --git a/packages/core/src/utils/traceData.ts b/packages/core/src/utils/traceData.ts index fe39ab539994..62c98a8fc7f8 100644 --- a/packages/core/src/utils/traceData.ts +++ b/packages/core/src/utils/traceData.ts @@ -5,10 +5,12 @@ import { getClient, getCurrentScope, hasExternalPropagationContext } from '../cu import { isEnabled } from '../exports'; import type { Scope } from '../scope'; import { getDynamicSamplingContextFromScope, getDynamicSamplingContextFromSpan } from '../tracing'; +import { spanIsNonRecordingSpan } from '../tracing/sentryNonRecordingSpan'; import type { Span } from '../types/span'; import type { SerializedTraceData } from '../types/tracing'; import { dynamicSamplingContextToSentryBaggageHeader } from './baggage'; import { debug } from './debug-logger'; +import { hasSpansEnabled } from './hasSpansEnabled'; import { getActiveSpan, spanToTraceHeader, spanToTraceparentHeader } from './spanUtils'; import { generateSentryTraceHeader, generateTraceparentHeader, TRACEPARENT_REGEXP } from './tracing'; @@ -47,13 +49,20 @@ export function getTraceData( const scope = options.scope || getCurrentScope(); const span = options.span || getActiveSpan(); - // When no active span and external propagation context is registered (e.g. OTLP integration), - // return empty to let the OTel propagator handle outgoing request propagation. + // In Tracing-without-Performance (TwP) mode, spans are non-recording placeholders that carry no + // sampling decision of their own (it's deferred). The scope is the source of truth, so we read + // the trace headers from the scope. A non-recording span in *tracing* mode (e.g. an unsampled + // child or an ignored span) is different: it represents an explicit negative decision, which + // `spanToTraceHeader` correctly encodes as `-0`, so we keep reading those from the span. + const isTwpPlaceholder = spanIsNonRecordingSpan(span) && !hasSpansEnabled(client.getOptions()); + + // When there's no recording span and an external propagation context is registered (e.g. OTLP + // integration), return empty to let the external propagator handle outgoing request propagation. if (!span && hasExternalPropagationContext()) { return {}; } - const sentryTrace = span ? spanToTraceHeader(span) : scopeToTraceHeader(scope); + const sentryTrace = span && !isTwpPlaceholder ? spanToTraceHeader(span) : scopeToTraceHeader(scope); const dsc = span ? getDynamicSamplingContextFromSpan(span) : getDynamicSamplingContextFromScope(client, scope); const baggage = dynamicSamplingContextToSentryBaggageHeader(dsc); @@ -69,7 +78,7 @@ export function getTraceData( }; if (options.propagateTraceparent) { - traceData.traceparent = span ? spanToTraceparentHeader(span) : scopeToTraceparentHeader(scope); + traceData.traceparent = span && !isTwpPlaceholder ? spanToTraceparentHeader(span) : scopeToTraceparentHeader(scope); } return traceData; diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index 29aaa63c2bb0..9ef6c834d251 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -1,15 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getActiveSpan, + getCapturedScopesOnSpan, getClient, getCurrentScope, getDynamicSamplingContextFromSpan, getGlobalScope, getIsolationScope, + getTraceData, SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SentryNonRecordingSpan, SentrySpan, setCurrentClient, + spanToBaggageHeader, spanToJSON, startInactiveSpan, startSpan, @@ -59,22 +63,136 @@ describe('startIdleSpan', () => { setCurrentClient(client); client.init(); + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + propagationSpanId: '1234567890abcdef', + sampleRand: 0.42, + }); + const idleSpan = startIdleSpan({ name: 'foo' }); expect(idleSpan).toBeDefined(); expect(idleSpan).toBeInstanceOf(SentryNonRecordingSpan); - // DSC is still correctly set on the span + + // Continues the trace from the scope, with the sampling decision deferred (no `sampled`/`sample_rate`). + expect(idleSpan.spanContext().traceId).toBe('12345678901234567890123456789012'); expect(getDynamicSamplingContextFromSpan(idleSpan)).toEqual({ environment: 'production', public_key: '123', - sample_rate: '0', - sampled: 'false', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: '12345678901234567890123456789012', }); - // not set as active span, though + // The deferred decision surfaces via `getTraceData` (read from the scope): the `sentry-trace` + // header uses the scope's propagation span id, omits the flag, and the baggage asserts no decision. + const data = getTraceData({ span: idleSpan }); + expect(data['sentry-trace']).toBe('12345678901234567890123456789012-1234567890abcdef'); + expect(data.baggage).not.toContain('sentry-sampled'); + expect(data.baggage).not.toContain('sentry-sample_rate'); + + // Scopes are captured on the placeholder so consumers (e.g. SentryTraceProvider) can read them. + expect(getCapturedScopesOnSpan(idleSpan).scope).toBe(getCurrentScope()); + expect(getCapturedScopesOnSpan(idleSpan).isolationScope).toBe(getIsolationScope()); + expect(getActiveSpan()).toBe(undefined); }); + it('preserves a continued trace DSC transaction when tracing is disabled', () => { + const options = getDefaultTestClientOptions({ dsn }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + dsc: { + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + transaction: 'upstream-root', + sampled: 'true', + sample_rate: '0.5', + }, + }); + + const idleSpan = startIdleSpan({ name: 'foo' }); + + // The continued trace's frozen DSC wins over the local idle span name. + expect(getDynamicSamplingContextFromSpan(idleSpan)).toEqual({ + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + transaction: 'upstream-root', + sampled: 'true', + sample_rate: '0.5', + }); + }); + + it('keeps a continued trace sampling decision when tracing is disabled', () => { + const options = getDefaultTestClientOptions({ dsn }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + propagationSpanId: '1234567890abcdef', + sampleRand: 0.42, + sampled: true, + dsc: { sampled: 'true' }, + }); + + const idleSpan = startIdleSpan({ name: 'foo' }); + + // The placeholder carries no decision of its own; the upstream sampling decision and DSC are + // read from the scope. `getTraceData` reflects the positive decision in both headers. + expect(getDynamicSamplingContextFromSpan(idleSpan).sampled).toBe('true'); + const data = getTraceData({ span: idleSpan }); + expect(data['sentry-trace']).toBe('12345678901234567890123456789012-1234567890abcdef-1'); + expect(data.baggage).toContain('sentry-sampled=true'); + }); + + it('freezes a continued trace empty DSC as-is when tracing is disabled', () => { + const options = getDefaultTestClientOptions({ dsn }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // A continued `sentry-trace` without baggage yields an empty frozen DSC marker. + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + dsc: {}, + }); + + const idleSpan = startIdleSpan({ name: 'foo' }); + + // We are not head of trace: don't fabricate client fields or inject the local transaction. + expect(getDynamicSamplingContextFromSpan(idleSpan)).toEqual({}); + }); + + it('does not add a url-source span name to the DSC when tracing is disabled', () => { + const options = getDefaultTestClientOptions({ dsn }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // Mirrors a browser pageload/navigation span, whose name is the URL path. + const idleSpan = startIdleSpan({ + name: '/users/123e4567-e89b-12d3-a456-426614174000', + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }, + }); + + expect(idleSpan).toBeInstanceOf(SentryNonRecordingSpan); + // URLs might contain PII, so the span name must not end up in the DSC. + expect(getDynamicSamplingContextFromSpan(idleSpan)).toEqual({ + environment: 'production', + public_key: '123', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + expect(spanToBaggageHeader(idleSpan)).not.toContain('sentry-transaction'); + }); + it('does not finish idle span if there are still active activities', () => { const idleSpan = startIdleSpan({ name: 'foo' }); expect(idleSpan).toBeDefined(); diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 0d0e652e64aa..138dc3b2d0ee 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + getCapturedScopesOnSpan, getCurrentScope, getGlobalScope, getIsolationScope, @@ -8,6 +9,7 @@ import { Scope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setAsyncContextStrategy, setCurrentClient, spanToJSON, @@ -230,18 +232,73 @@ describe('startSpan', () => { setCurrentClient(client); client.init(); + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + }); + + let scopeInCallback: Scope | undefined; const span = startSpan({ name: 'GET users/[id]' }, span => { + scopeInCallback = getCurrentScope(); return span; }); expect(span).toBeDefined(); expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(span.spanContext().traceId).toBe('12345678901234567890123456789012'); + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + environment: 'production', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // Scopes are captured on the placeholder so consumers (e.g. SentryTraceProvider) can read them. + // `startSpan` forks the scope, so the captured scope is the one active inside the callback. + expect(getCapturedScopesOnSpan(span).scope).toBe(scopeInCallback); + expect(getCapturedScopesOnSpan(span).isolationScope).toBe(getIsolationScope()); + }); + + it('freezes a continued trace empty DSC as-is when tracing is disabled', () => { + const options = getDefaultTestClientOptions({}); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // A continued `sentry-trace` without baggage yields an empty frozen DSC marker. + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + dsc: {}, + }); + + const span = startSpan({ name: 'GET users/[id]' }, span => { + return span; + }); + + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(span.spanContext().traceId).toBe('12345678901234567890123456789012'); + // We are not head of trace: don't fabricate client fields or inject the local transaction. + expect(getDynamicSamplingContextFromSpan(span)).toEqual({}); + }); + + it('does not add a url-source span name to the DSC when tracing is disabled', () => { + const options = getDefaultTestClientOptions({}); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + const span = startSpan( + { + name: '/users/123e4567-e89b-12d3-a456-426614174000', + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }, + }, + span => span, + ); + + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + // URLs might contain PII, so the span name must not end up in the DSC. expect(getDynamicSamplingContextFromSpan(span)).toEqual({ environment: 'production', - sample_rate: '0', - sampled: 'false', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - transaction: 'GET users/[id]', }); }); @@ -874,7 +931,9 @@ describe('startSpanManual', () => { setCurrentClient(client); client.init(); + let scopeInCallback: Scope | undefined; const span = startSpanManual({ name: 'GET users/[id]' }, span => { + scopeInCallback = getCurrentScope(); return span; }); @@ -882,11 +941,13 @@ describe('startSpanManual', () => { expect(span).toBeInstanceOf(SentryNonRecordingSpan); expect(getDynamicSamplingContextFromSpan(span)).toEqual({ environment: 'production', - sample_rate: '0', - sampled: 'false', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - transaction: 'GET users/[id]', }); + + // Scopes are captured on the placeholder so consumers (e.g. SentryTraceProvider) can read them. + // `startSpanManual` forks the scope, so the captured scope is the one active inside the callback. + expect(getCapturedScopesOnSpan(span).scope).toBe(scopeInCallback); + expect(getCapturedScopesOnSpan(span).isolationScope).toBe(getIsolationScope()); }); it('creates & finishes span', async () => { @@ -1394,11 +1455,12 @@ describe('startInactiveSpan', () => { expect(span).toBeInstanceOf(SentryNonRecordingSpan); expect(getDynamicSamplingContextFromSpan(span)).toEqual({ environment: 'production', - sample_rate: '0', - sampled: 'false', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - transaction: 'GET users/[id]', }); + + // Scopes are captured on the placeholder so consumers (e.g. SentryTraceProvider) can read them. + expect(getCapturedScopesOnSpan(span).scope).toBe(getCurrentScope()); + expect(getCapturedScopesOnSpan(span).isolationScope).toBe(getIsolationScope()); }); it('creates & finishes span', async () => { diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index b2101502cb43..de6682a10596 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -11,6 +11,7 @@ import { SentrySpan, setAsyncContextStrategy, setCurrentClient, + startSpan, withActiveSpan, } from '../../../src/'; import { getAsyncContextStrategy } from '../../../src/asyncContext'; @@ -142,6 +143,246 @@ describe('getTraceData', () => { }); }); + it('does not add a sampled flag for an active TwP placeholder span', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + }); + + startSpan({ name: 'twp-root' }, () => { + const data = getTraceData(); + + expect(data).toEqual({ + 'sentry-trace': expect.stringMatching(/^12345678901234567890123456789012-[a-f0-9]{16}$/), + baggage: 'sentry-environment=production,sentry-public_key=123,sentry-trace_id=12345678901234567890123456789012', + }); + expect(data['sentry-trace']?.split('-')).toHaveLength(2); + }); + }); + + it('keeps the scope trace id consistent for an onlyIfParent placeholder without a parent', () => { + // `onlyIfParent` without a parent yields a non-recording placeholder. It must continue the + // scope's trace, so `sentry-trace` and `baggage` agree on the trace id instead of the + // placeholder inventing a random one (which would break distributed tracing). + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + }); + + startSpan({ name: 'child', onlyIfParent: true }, () => { + const data = getTraceData(); + + expect(data['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}$/); + expect(data.baggage).toContain('sentry-trace_id=12345678901234567890123456789012'); + }); + }); + + it('preserves the continued-trace DSC under (and nested within) an onlyIfParent placeholder', () => { + // The placeholder captures the scope, so it (and any nested span that resolves it as its root + // via `getRootSpan`) reads the continued trace's DSC from the scope instead of fabricating a + // fresh client one. + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + sampled: true, + dsc: { + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + sampled: 'true', + sample_rate: '0.5', + transaction: 'continued-root-txn', + }, + }); + + startSpan({ name: 'parent', onlyIfParent: true }, () => { + expect(getTraceData().baggage).toContain('sentry-transaction=continued-root-txn'); + + startSpan({ name: 'nested' }, () => { + const baggage = getTraceData().baggage; + expect(baggage).toContain('sentry-transaction=continued-root-txn'); + expect(baggage).toContain('sentry-sample_rate=0.5'); + expect(baggage).toContain('sentry-sampled=true'); + }); + }); + }); + + it('keeps an explicit negative sampling decision for an active unsampled span', () => { + setupClient({ tracesSampleRate: 0 }); + + startSpan({ name: 'unsampled-root' }, () => { + const data = getTraceData(); + + expect(data['sentry-trace']).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-0$/); + expect(data.baggage).toContain('sentry-sampled=false'); + }); + }); + + it('keeps the negative sampling decision for a non-recording child of an unsampled tracing-mode span', () => { + // With tracing enabled but sampled out, the child of an unsampled span is a non-recording span. + // Unlike a TwP placeholder, it carries an explicit negative decision that must propagate as `-0`, + // not be deferred by reading the (undecided) scope. + setupClient({ tracesSampleRate: 0 }); + + startSpan({ name: 'unsampled-root' }, () => { + startSpan({ name: 'unsampled-child' }, () => { + const data = getTraceData(); + + expect(data['sentry-trace']).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-0$/); + expect(data.baggage).toContain('sentry-sampled=false'); + }); + }); + }); + + it('keeps a continued positive sampling decision for an active TwP placeholder span', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: true, + dsc: { + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + sampled: 'true', + }, + }); + + startSpan({ name: 'twp-root' }, () => { + const data = getTraceData(); + + // The upstream decision must survive in the sentry-trace header so it agrees with the frozen baggage. + expect(data['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-1$/); + expect(data.baggage).toContain('sentry-sampled=true'); + }); + }); + + it('keeps a continued negative sampling decision for an active TwP placeholder span', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: false, + dsc: {}, + }); + + startSpan({ name: 'twp-root' }, () => { + const data = getTraceData(); + + expect(data['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-0$/); + }); + }); + + it('keeps a continued sampling decision on nested TwP placeholder spans', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: true, + dsc: {}, + }); + + startSpan({ name: 'twp-root' }, () => { + startSpan({ name: 'twp-child' }, () => { + const data = getTraceData(); + + expect(data['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-1$/); + }); + }); + }); + + it('keeps a continued trace frozen DSC on nested TwP placeholder spans', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: true, + dsc: { + environment: 'staging', + public_key: 'key', + trace_id: '12345678901234567890123456789012', + transaction: 'upstream-root', + sampled: 'true', + }, + }); + + startSpan({ name: 'twp-root' }, () => { + startSpan({ name: 'twp-child' }, () => { + startSpan({ name: 'twp-grandchild' }, () => { + const data = getTraceData(); + + // Header and baggage must agree at any depth: both reflect the continued trace. + expect(data['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-1$/); + expect(data.baggage).toContain('sentry-transaction=upstream-root'); + expect(data.baggage).toContain('sentry-sampled=true'); + expect(data.baggage).toContain('sentry-environment=staging'); + }); + }); + }); + }); + + it('does not fabricate baggage for a continued empty frozen DSC on nested TwP placeholder spans', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: true, + dsc: {}, + }); + + startSpan({ name: 'twp-root' }, () => { + startSpan({ name: 'twp-child' }, () => { + const data = getTraceData(); + + // We are not head of trace: a continued `sentry-trace`-only trace must not + // gain locally fabricated client fields at depth either. + expect(data.baggage ?? '').not.toContain('sentry-environment'); + expect(data.baggage ?? '').not.toContain('sentry-public_key'); + }); + }); + }); + + it('preserves a continued trace DSC transaction when starting a TwP span', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + dsc: { + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + transaction: 'upstream-root', + sampled: 'true', + sample_rate: '0.5', + }, + }); + + startSpan({ name: 'db.query' }, () => { + const data = getTraceData(); + + // The local span name must not overwrite the frozen DSC of the continued trace. + expect(data.baggage).toContain('sentry-transaction=upstream-root'); + expect(data.baggage).not.toContain('db.query'); + }); + }); + it('allows to pass a span directly', () => { setupClient();