From c1bf66b838abacfd04fe38700ae75499459e9457 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 27 Jul 2026 14:48:31 +0200 Subject: [PATCH] feat(opentelemetry)!: Simplify `SentryPropagator` --- .../suites/pino/scenario.mjs | 5 +- .../public-api/bindScopeToEmitter/test.ts | 6 +- .../parallel-root-spans-streamed/test.ts | 21 +- .../startSpan/parallel-root-spans/test.ts | 15 +- .../parallel-spans-in-scope-streamed/test.ts | 17 +- .../test.ts | 19 +- .../test.ts | 13 +- .../startSpan/parallel-spans-in-scope/test.ts | 4 +- .../fetch-sampled-no-active-span/test.ts | 8 +- packages/core/src/tracing/trace.ts | 17 +- packages/node/src/sdk/client.ts | 19 +- packages/opentelemetry/README.md | 1 - .../opentelemetry/src/asyncContextStrategy.ts | 8 +- packages/opentelemetry/src/constants.ts | 1 - packages/opentelemetry/src/index.ts | 2 - packages/opentelemetry/src/propagator.ts | 263 ++---- packages/opentelemetry/src/trace.ts | 91 +- packages/opentelemetry/src/tracer.ts | 16 +- .../opentelemetry/src/utils/getTraceData.ts | 71 +- .../test/integration/transactions.test.ts | 2 - .../opentelemetry/test/propagator.test.ts | 797 ++---------------- packages/opentelemetry/test/trace.test.ts | 337 +------- packages/vercel-edge/src/types.ts | 3 - 23 files changed, 280 insertions(+), 1456 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs index 55966552a07f..a1990b9b02f6 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -8,14 +8,15 @@ Sentry.pinoIntegration.untrackLogger(ignoredLogger); ignoredLogger.info('this will not be tracked'); -Sentry.withIsolationScope(() => { +// Each operation runs in its own trace, so logs emitted within them carry distinct trace ids. +Sentry.startNewTrace(() => { Sentry.startSpan({ name: 'startup' }, () => { logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); }); }); setTimeout(() => { - Sentry.withIsolationScope(() => { + Sentry.startNewTrace(() => { Sentry.startSpan({ name: 'later' }, () => { const child = logger.child({ module: 'authentication' }); child.error(new Error('oh no')); diff --git a/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts index 1ce679449100..165eb6cc5fd8 100644 --- a/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts @@ -34,7 +34,9 @@ test('bindScopeToEmitter preserves the active span for listeners firing in a dif expect(childBound?.parent_span_id).toBe(parentSpanId); expect(childBound?.trace_id).toBe(parentTraceId); - // The unbound emitter's listener ran without the parent active -> its own, separate trace. + // The unbound emitter's listener ran without the parent active -> its own root transaction, + // not nested under the parent span. It still shares the isolation scope's propagation context + // trace, matching the core SDK behavior for root spans. expect(childUnbound?.spans).toEqual([]); - expect(childUnbound?.contexts?.trace?.trace_id).not.toBe(parentTraceId); + expect(childUnbound?.contexts?.trace?.parent_span_id).toBeUndefined(); }); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts index 5324d891819d..5f45cccdbf8a 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts @@ -6,25 +6,24 @@ afterAll(() => { }); test('sends manually started streamed parallel root spans in root context', async () => { - expect.assertions(7); + expect.assertions(6); await createRunner(__dirname, 'scenario.ts') - .expect({ span: { items: [{ name: 'test_span_1' }] } }) .expect({ span: spanContainer => { expect(spanContainer).toBeDefined(); - const traceId = spanContainer.items[0]!.trace_id; - expect(traceId).toMatch(/^[0-9a-f]{32}$/); - // It ignores propagation context of the root context - expect(traceId).not.toBe('12345678901234567890123456789012'); - expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); + const span1 = spanContainer.items.find(item => item.name === 'test_span_1'); + const span2 = spanContainer.items.find(item => item.name === 'test_span_2'); + expect(span1).toBeDefined(); + expect(span2).toBeDefined(); - // Different trace ID than the first span - const trace1Id = spanContainer.items[0]!.attributes.spanIdTraceId?.value; - expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + // Both root spans continue the scope's propagation context, matching the core SDK behavior. + expect(span1!.trace_id).toBe('12345678901234567890123456789012'); + expect(span1!.parent_span_id).toBe('1234567890123456'); - expect(trace1Id).not.toBe(traceId); + // Same trace ID for both spans + expect(span2!.trace_id).toBe(span1!.trace_id); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts index e1b8f793d9b6..674cafad1f70 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts @@ -6,7 +6,7 @@ afterAll(() => { }); test('should send manually started parallel root spans in root context', async () => { - expect.assertions(7); + expect.assertions(6); await createRunner(__dirname, 'scenario.ts') .expect({ transaction: { transaction: 'test_span_1' } }) @@ -14,16 +14,15 @@ test('should send manually started parallel root spans in root context', async ( transaction: transaction => { expect(transaction).toBeDefined(); const traceId = transaction.contexts?.trace?.trace_id; - expect(traceId).toBeDefined(); - // It ignores propagation context of the root context - expect(traceId).not.toBe('12345678901234567890123456789012'); - expect(transaction.contexts?.trace?.parent_span_id).toBeUndefined(); + // Both root spans continue the scope's propagation context, matching the core SDK behavior. + expect(traceId).toBe('12345678901234567890123456789012'); + expect(transaction.contexts?.trace?.parent_span_id).toBe('1234567890123456'); - // Different trace ID than the first span + // Same trace ID as the first span const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; - expect(trace1Id).toBeDefined(); - expect(trace1Id).not.toBe(traceId); + expect(trace1Id).toBe('12345678901234567890123456789012'); + expect(trace1Id).toBe(traceId); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts index a0c8ac343edb..2d5de99aae9e 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts @@ -9,19 +9,20 @@ test('sends manually started streamed parallel root spans outside of root contex expect.assertions(6); await createRunner(__dirname, 'scenario.ts') - .expect({ span: { items: [{ name: 'test_span_1' }] } }) .expect({ span: spanContainer => { expect(spanContainer).toBeDefined(); - const traceId = spanContainer.items[0]!.trace_id; - expect(traceId).toMatch(/^[0-9a-f]{32}$/); - expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); - const trace1Id = spanContainer.items[0]!.attributes.spanIdTraceId?.value; - expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + const span1 = spanContainer.items.find(item => item.name === 'test_span_1'); + const span2 = spanContainer.items.find(item => item.name === 'test_span_2'); + expect(span1).toBeDefined(); + expect(span2).toBeDefined(); - // Different trace ID as the first span - expect(trace1Id).not.toBe(traceId); + expect(span1!.trace_id).toMatch(/^[0-9a-f]{32}$/); + expect(span1!.parent_span_id).toBeUndefined(); + + // Same trace ID for both spans - both root spans share the scope's propagation context + expect(span2!.trace_id).toBe(span1!.trace_id); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts index 33f4ed3b3f11..50666c267cdf 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts @@ -9,19 +9,22 @@ test('sends manually started streamed parallel root spans outside of root contex expect.assertions(6); await createRunner(__dirname, 'scenario.ts') - .expect({ span: { items: [{ name: 'test_span_1' }] } }) .expect({ span: spanContainer => { expect(spanContainer).toBeDefined(); - const traceId = spanContainer.items[0]!.trace_id; - expect(traceId).toMatch(/^[0-9a-f]{32}$/); - expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); - const trace1Id = spanContainer.items[0]!.attributes.spanIdTraceId?.value; - expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + const span1 = spanContainer.items.find(item => item.name === 'test_span_1'); + const span2 = spanContainer.items.find(item => item.name === 'test_span_2'); + expect(span1).toBeDefined(); + expect(span2).toBeDefined(); - // Different trace ID as the first span - expect(trace1Id).not.toBe(traceId); + // Both root spans continue the scope's propagation context, including the parentSpanId, + // matching the core SDK behavior. + expect(span1!.trace_id).toBe('12345678901234567890123456789012'); + expect(span1!.parent_span_id).toBe('1234567890123456'); + + // Same trace ID for both spans + expect(span2!.trace_id).toBe(span1!.trace_id); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts index e10a1210a0c9..509c01a41689 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts @@ -12,14 +12,15 @@ test('should send manually started parallel root spans outside of root context w transaction: transaction => { expect(transaction).toBeDefined(); const traceId = transaction.contexts?.trace?.trace_id; - expect(traceId).toBeDefined(); - expect(transaction.contexts?.trace?.parent_span_id).toBeUndefined(); - const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; - expect(trace1Id).toBeDefined(); + // Both root spans continue the scope's propagation context, including the parentSpanId, + // matching the core SDK behavior. + expect(traceId).toBe('12345678901234567890123456789012'); + expect(transaction.contexts?.trace?.parent_span_id).toBe('1234567890123456'); - // Different trace ID as the first span - expect(trace1Id).not.toBe(traceId); + // Same trace ID as the first span + const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; + expect(trace1Id).toBe(traceId); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts index 69fc2bc2774a..2d125160dbc4 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts @@ -20,8 +20,8 @@ test('should send manually started parallel root spans outside of root context', const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; expect(trace1Id).toBeDefined(); - // Different trace ID as the first span - expect(trace1Id).not.toBe(traceId); + // Same trace ID as the first span - both root spans share the scope's propagation context + expect(trace1Id).toBe(traceId); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts index 702a2febd61d..cd7f91dd97f2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts @@ -10,13 +10,13 @@ describe('outgoing fetch', () => { const [SERVER_URL, closeTestServer] = await createTestServer() .get('/api/v0', headers => { expect(headers['baggage']).toEqual(expect.any(String)); - 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['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); }) .get('/api/v1', headers => { expect(headers['baggage']).toEqual(expect.any(String)); - 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['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); }) .get('/api/v2', headers => { expect(headers['baggage']).toBeUndefined(); diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 115f8097b190..544721606327 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -257,8 +257,7 @@ export const continueTrace = ( return withScope(scope => { const propagationContext = propagationContextFromHeaders(sentryTrace, baggage); scope.setPropagationContext(propagationContext); - _setSpanForScope(scope, undefined); - return callback(); + return withActiveSpan(null, callback); }); }; @@ -330,13 +329,15 @@ export function startNewTrace(callback: () => T): T { return acs.startNewTrace(callback); } - return withScope(scope => { - scope.setPropagationContext({ - traceId: generateTraceId(), - sampleRand: safeMathRandom(), + return withActiveSpan(null, () => { + return withScope(scope => { + scope.setPropagationContext({ + traceId: generateTraceId(), + sampleRand: safeMathRandom(), + }); + DEBUG_BUILD && debug.log(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); + return callback(); }); - DEBUG_BUILD && debug.log(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); - return withActiveSpan(null, callback); }); } diff --git a/packages/node/src/sdk/client.ts b/packages/node/src/sdk/client.ts index 7968fb3595cc..a4dc8957bad2 100644 --- a/packages/node/src/sdk/client.ts +++ b/packages/node/src/sdk/client.ts @@ -1,7 +1,7 @@ import * as os from 'node:os'; import type { Tracer } from '@opentelemetry/api'; import { trace } from '@opentelemetry/api'; -import type { DynamicSamplingContext, Scope, ServerRuntimeClientOptions, TraceContext } from '@sentry/core'; +import type { ServerRuntimeClientOptions } from '@sentry/core'; import { _INTERNAL_clearAiProviderSkips, _INTERNAL_flushLogsBuffer, @@ -11,11 +11,7 @@ import { SDK_VERSION, ServerRuntimeClient, } from '@sentry/core'; -import { - type AsyncLocalStorageLookup, - getTraceContextForScope, - type SentryTracerProvider, -} from '@sentry/opentelemetry'; +import { type AsyncLocalStorageLookup, type SentryTracerProvider } from '@sentry/opentelemetry'; import { isMainThread, threadId } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; import type { NodeClientOptions } from '../types'; @@ -170,15 +166,4 @@ export class NodeClient extends ServerRuntimeClient { _INTERNAL_clearAiProviderSkips(); super._setupIntegrations(); } - - /** Custom implementation for OTEL, so we can handle scope-span linking. */ - protected _getTraceInfoFromScope( - scope: Scope | undefined, - ): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { - if (!scope) { - return [undefined, undefined]; - } - - return getTraceContextForScope(this, scope); - } } diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index eb9665d9d555..fd7008659ca0 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -74,7 +74,6 @@ function setupSentry() { // Initialize the provider trace.setGlobalTracerProvider(provider); - propagation.setGlobalPropagator(new SentryPropagator()); context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); setOpenTelemetryContextAsyncContextStrategy(); diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 41cddace030e..39cd7c0b211c 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -13,14 +13,13 @@ import { SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, SENTRY_TRACE_STATE_CHILD_IGNORED, } from './constants'; -import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual, withActiveSpan } from './trace'; +import { startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from './trace'; import type { CurrentScopes } from './types'; import { getContextFromScope, getScopesFromContext } from './utils/contextData'; import { getActiveSpan } from './utils/getActiveSpan'; -import { getTraceData } from './utils/getTraceData'; -import { AsyncLocalStorage } from 'node:async_hooks'; import type { AsyncLocalStorageLookup } from './asyncLocalStorageContextManager'; import { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; +import { AsyncLocalStorage } from 'node:async_hooks'; /** * Sets the async context strategy to use follow the OTEL context under the hood. @@ -119,9 +118,6 @@ export function setOpenTelemetryContextAsyncContextStrategy(): AsyncLocalStorage startSpanManual, startInactiveSpan, getActiveSpan, - getTraceData, - continueTrace, - startNewTrace, // 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, diff --git a/packages/opentelemetry/src/constants.ts b/packages/opentelemetry/src/constants.ts index 5b2b6c4c5211..8e3f8520b5da 100644 --- a/packages/opentelemetry/src/constants.ts +++ b/packages/opentelemetry/src/constants.ts @@ -5,7 +5,6 @@ export const SENTRY_BAGGAGE_HEADER = 'baggage'; export const SENTRY_TRACE_STATE_DSC = 'sentry.dsc'; export const SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING = 'sentry.sampled_not_recording'; -export const SENTRY_TRACE_STATE_URL = 'sentry.url'; /** * A flag marking a context as ignored because the span associated with the context diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 1ae4dff6fa7b..f17239a2e3b3 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -1,7 +1,5 @@ export { getScopesFromContext } from './utils/contextData'; -export { getTraceContextForScope } from './trace'; - export { setupEventContextTrace } from './setupEventContextTrace'; export { SentryPropagator } from './propagator'; diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index 9db3fa6ea995..70c2cbc83775 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -1,207 +1,78 @@ -import type { Baggage, Context, Span, SpanContext, TextMapGetter, TextMapSetter } from '@opentelemetry/api'; -import { context, INVALID_TRACEID, propagation, trace, TraceFlags } from '@opentelemetry/api'; -import { isTracingSuppressed, W3CBaggagePropagator } from '@opentelemetry/core'; -import { URL_FULL } from '@sentry/conventions/attributes'; -import type { Client, continueTrace, DynamicSamplingContext, Scope } from '@sentry/core'; +import type { Context, SpanContext, TextMapGetter, TextMapPropagator, TextMapSetter } from '@opentelemetry/api'; +import { context, trace, TraceFlags } from '@opentelemetry/api'; +import type { continueTrace, DynamicSamplingContext } from '@sentry/core'; import { baggageHeaderToDynamicSamplingContext, - debug, - generateSentryTraceHeader, - generateTraceparentHeader, + consoleSandbox, getClient, getCurrentScope, - getDynamicSamplingContextFromScope, - getDynamicSamplingContextFromSpan, getIsolationScope, - LRUMap, - parseBaggageHeader, + getTraceData, + isTracingSuppressed, propagationContextFromHeaders, - SENTRY_BAGGAGE_KEY_PREFIX, shouldContinueTrace, - shouldPropagateTraceForUrl, - spanToJSON, } from '@sentry/core'; -import { - SENTRY_BAGGAGE_HEADER, - SENTRY_TRACE_HEADER, - SENTRY_TRACE_STATE_DSC, - SENTRY_TRACE_STATE_URL, -} from './constants'; -import { DEBUG_BUILD } from './debug-build'; import { getScopesFromContext, setScopesOnContext } from './utils/contextData'; -import { getSampledForPropagation, getSamplingDecision } from './utils/getSamplingDecision'; import { makeTraceState } from './utils/makeTraceState'; -import { reconcileDscSampled } from './utils/reconcileDscSampled'; + +const SENTRY_TRACE_HEADER = 'sentry-trace'; +const SENTRY_BAGGAGE_HEADER = 'baggage'; +const W3C_TRACEPARENT_HEADER = 'traceparent'; /** - * Injects and extracts `sentry-trace` and `baggage` headers from carriers. + * A minimal OpenTelemetry `TextMapPropagator` that injects and extracts Sentry trace data. + * + * This propagator only supports injecting/extracting from current context, for simplicity sake. + * It will bail and do nothing if using a different context. */ -export class SentryPropagator extends W3CBaggagePropagator { - /** A map of URLs that have already been checked for if they match tracePropagationTargets. */ - private _urlMatchesTargetsMap: LRUMap; - - public constructor() { - super(); - - // We're caching results so we don't have to recompute regexp every time we create a request. - this._urlMatchesTargetsMap = new LRUMap(100); - } - - /** - * @inheritDoc - */ - public inject(context: Context, carrier: unknown, setter: TextMapSetter): void { - if (isTracingSuppressed(context)) { - DEBUG_BUILD && debug.log('[Tracing] Not injecting trace data for url because tracing is suppressed.'); +export class SentryPropagator implements TextMapPropagator { + /** @inheritDoc */ + public inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void { + if (ctx !== context.active()) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + 'SentryPropagator: Injecting with a different context than the active one - this is not supported. Skipping injection.', + ); + }); return; } - const activeSpan = trace.getSpan(context); - const url = activeSpan && getCurrentURL(activeSpan); - - const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {}; - if (!shouldPropagateTraceForUrl(url, tracePropagationTargets, this._urlMatchesTargetsMap)) { - DEBUG_BUILD && - debug.log('[Tracing] Not injecting trace data for url because it does not match tracePropagationTargets:', url); + if (isTracingSuppressed()) { return; } - const existingBaggageHeader = getExistingBaggage(carrier); - const existingSentryTraceHeader = getExistingSentryTrace(carrier); - - let baggage = propagation.getBaggage(context) || propagation.createBaggage({}); - - const { dynamicSamplingContext, traceId, spanId, sampled } = getInjectionData(context); + // Pick trace data from the current scope + const { 'sentry-trace': sentryTrace, baggage, traceparent } = getTraceData(); - if (existingBaggageHeader) { - const baggageEntries = parseBaggageHeader(existingBaggageHeader); - - if (baggageEntries) { - Object.entries(baggageEntries).forEach(([key, value]) => { - if (!existingSentryTraceHeader && key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { - // Edge case: A baggage header with sentry- keys was added previously but no - // sentry-trace header. In this case we remove the old sentry-keys and add new - // ones below. - return; - } - baggage = baggage.setEntry(key, { value }); - }); - } + if (sentryTrace) { + setter.set(carrier, SENTRY_TRACE_HEADER, sentryTrace); } - - if (!existingSentryTraceHeader && dynamicSamplingContext) { - baggage = Object.entries(dynamicSamplingContext).reduce((b, [dscKey, dscValue]) => { - if (dscValue) { - return b.setEntry(`${SENTRY_BAGGAGE_KEY_PREFIX}${dscKey}`, { value: dscValue }); - } - return b; - }, baggage); + if (baggage) { + setter.set(carrier, SENTRY_BAGGAGE_HEADER, baggage); } - - // We also want to avoid setting the default OTEL trace ID, if we get that for whatever reason - if (!existingSentryTraceHeader && traceId && traceId !== INVALID_TRACEID) { - setter.set(carrier, SENTRY_TRACE_HEADER, generateSentryTraceHeader(traceId, spanId, sampled)); - - if (propagateTraceparent) { - setter.set(carrier, 'traceparent', generateTraceparentHeader(traceId, spanId, sampled)); - } + if (traceparent) { + setter.set(carrier, W3C_TRACEPARENT_HEADER, traceparent); } - - super.inject(propagation.setBaggage(context, baggage), carrier, setter); } - /** - * @inheritDoc - */ - public extract(context: Context, carrier: unknown, getter: TextMapGetter): Context { + /** @inheritDoc */ + public extract(ctx: Context, carrier: unknown, getter: TextMapGetter): Context { const maybeSentryTraceHeader: string | string[] | undefined = getter.get(carrier, SENTRY_TRACE_HEADER); const baggage = getter.get(carrier, SENTRY_BAGGAGE_HEADER); - const sentryTrace = maybeSentryTraceHeader - ? Array.isArray(maybeSentryTraceHeader) - ? maybeSentryTraceHeader[0] - : maybeSentryTraceHeader - : undefined; + const sentryTrace = Array.isArray(maybeSentryTraceHeader) ? maybeSentryTraceHeader[0] : maybeSentryTraceHeader; - // Add remote parent span context - // If there is no incoming trace, this will return the context as-is - return ensureScopesOnContext(getContextWithRemoteActiveSpan(context, { sentryTrace, baggage })); + // Add remote parent span context. If there is no incoming trace, this returns the context as-is. + return getContextWithRemoteActiveSpanAndScopes(ctx, { sentryTrace, baggage }); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public fields(): string[] { - return [SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER, 'traceparent']; + return [SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER, W3C_TRACEPARENT_HEADER]; } } -/** - * Get propagation injection data for the given context. - * The additional options can be passed to override the scope and client that is otherwise derived from the context. - */ -export function getInjectionData( - context: Context, - options: { scope?: Scope; client?: Client } = {}, -): { - dynamicSamplingContext: Partial | undefined; - traceId: string | undefined; - spanId: string | undefined; - sampled: boolean | undefined; -} { - const span = trace.getSpan(context); - - // If we have a remote span, the spanId should be considered as the parentSpanId, not spanId itself - // Instead, we use a virtual (generated) spanId for propagation - if (span?.spanContext().isRemote) { - const spanContext = span.spanContext(); - const sampled = getSamplingDecision(spanContext); - const dsc = getDynamicSamplingContextFromSpan(span); - - // When the incoming trace froze its DSC on the trace state, `getDynamicSamplingContextFromSpan` - // returns that DSC verbatim; per the propagation spec it is immutable, so we must not rewrite - // `sampled` or strip `transaction` on it. We only reconcile the DSC that core freshly derives - // from the (binary) span trace flags, which is the sole case that can misrepresent a deferred - // decision as unsampled. - const hasIncomingFrozenDsc = !!spanContext.traceState?.get(SENTRY_TRACE_STATE_DSC); - const dynamicSamplingContext = hasIncomingFrozenDsc ? dsc : reconcileDscSampled(dsc, sampled); - - return { - dynamicSamplingContext, - traceId: spanContext.traceId, - spanId: undefined, - sampled, - }; - } - - // If we have a local span, we just use this - if (span) { - const spanContext = span.spanContext(); - const dynamicSamplingContext = getDynamicSamplingContextFromSpan(span); - - return { - dynamicSamplingContext, - traceId: spanContext.traceId, - spanId: spanContext.spanId, - sampled: getSampledForPropagation(span, options.client), - }; - } - - // Else we try to use the propagation context from the scope - // The only scenario where this should happen is when we neither have a span, nor an incoming trace - const scope = options.scope || getScopesFromContext(context)?.scope || getCurrentScope(); - const client = options.client || getClient(); - - const propagationContext = scope.getPropagationContext(); - const dynamicSamplingContext = client ? getDynamicSamplingContextFromScope(client, scope) : undefined; - return { - dynamicSamplingContext, - traceId: propagationContext.traceId, - spanId: propagationContext.propagationSpanId, - sampled: propagationContext.sampled, - }; -} - function getContextWithRemoteActiveSpan( ctx: Context, { sentryTrace, baggage }: Parameters[0], @@ -238,11 +109,20 @@ export function continueTraceAsRemoteSpan( options: Parameters[0], callback: () => T, ): T { - const ctxWithSpanContext = ensureScopesOnContext(getContextWithRemoteActiveSpan(ctx, options)); + const ctxWithSpanContext = getContextWithRemoteActiveSpanAndScopes(ctx, options); return context.with(ctxWithSpanContext, callback); } +/** + * Build a context that continues an incoming trace as a remote active span, with scopes ensured. + * Unlike `continueTraceAsRemoteSpan`, this returns the context instead of running a callback within it, + * so it can be used to implement an OpenTelemetry propagator's `extract`. + */ +function getContextWithRemoteActiveSpanAndScopes(ctx: Context, options: Parameters[0]): Context { + return ensureScopesOnContext(getContextWithRemoteActiveSpan(ctx, options)); +} + function ensureScopesOnContext(ctx: Context): Context { // If there are no scopes yet on the context, ensure we have them const scopes = getScopesFromContext(ctx); @@ -256,49 +136,6 @@ function ensureScopesOnContext(ctx: Context): Context { return setScopesOnContext(ctx, newScopes); } -/** Try to get the existing baggage header so we can merge this in. */ -function getExistingBaggage(carrier: unknown): string | undefined { - try { - const baggage = (carrier as Record)[SENTRY_BAGGAGE_HEADER]; - return Array.isArray(baggage) ? baggage.join(',') : baggage; - } catch { - return undefined; - } -} - -function getExistingSentryTrace(carrier: unknown): string | string[] | undefined { - try { - return (carrier as Record)[SENTRY_TRACE_HEADER]; - } catch { - return undefined; - } -} - -/** - * It is pretty tricky to get access to the outgoing request URL of a request in the propagator. - * As we only have access to the context of the span to be sent and the carrier (=headers), - * but the span may be unsampled and thus have no attributes. - * - * So we use the following logic: - * 1. If we have an active span, we check if it has a URL attribute. - * 2. Else, if the active span has no URL attribute (e.g. it is unsampled), we check a special trace state (which we set in our sampler). - */ -function getCurrentURL(span: Span): string | undefined { - const spanData = spanToJSON(span).data; - const urlAttribute = spanData[URL_FULL]; - if (typeof urlAttribute === 'string') { - return urlAttribute; - } - - // Also look at the traceState, which we may set in the sampler even for unsampled spans - const urlTraceState = span.spanContext().traceState?.get(SENTRY_TRACE_STATE_URL); - if (urlTraceState) { - return urlTraceState; - } - - return undefined; -} - function generateRemoteSpanContext({ spanId, traceId, diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index 2239127b62fe..c78cf01d546c 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -1,38 +1,24 @@ import type { Context, Span, SpanContext, SpanOptions, TimeInput, Tracer } from '@opentelemetry/api'; import { context, SpanStatusCode, trace, TraceFlags } from '@opentelemetry/api'; import { isTracingSuppressed, suppressTracing } from '@opentelemetry/core'; -import type { - Client, - continueTrace as baseContinueTrace, - DynamicSamplingContext, - Scope, - Span as SentrySpan, - TraceContext, -} from '@sentry/core'; +import type { Client, Scope, Span as SentrySpan } from '@sentry/core'; import { - _INTERNAL_safeMathRandom, - generateSpanId, - generateTraceId, getClient, getCurrentScope, - getDynamicSamplingContextFromScope, getDynamicSamplingContextFromSpan, getRootSpan, - getTraceContextFromScope, handleCallbackErrors, hasSpansEnabled, SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON, - spanToTraceContext, } from '@sentry/core'; -import { SENTRY_TRACE_STATE_DSC } from './constants'; -import { continueTraceAsRemoteSpan } from './propagator'; import type { OpenTelemetrySpanContext } from './types'; import { getContextFromScope } from './utils/contextData'; import { getSamplingDecision } from './utils/getSamplingDecision'; import { makeTraceState } from './utils/makeTraceState'; import { reconcileDscSampled } from './utils/reconcileDscSampled'; +import { SENTRY_TRACE_STATE_DSC } from './constants'; /** * Internal helper for starting spans and manual spans. See {@link startSpan} and {@link startSpanManual} for the public APIs. @@ -70,12 +56,15 @@ function _startSpan(options: OpenTelemetrySpanContext, callback: (span: Span) return context.with(suppressedCtx, () => { return tracer.startActiveSpan(name, spanOptions, suppressedCtx, span => { patchSpanEnd(span); - // Restore the original unsuppressed context for the callback execution - // so that custom OpenTelemetry spans maintain the correct context. + // Run the callback under the original unsuppressed context (so custom OpenTelemetry spans + // created inside are not suppressed) but with our span set as active. Without setting the + // span here, `getActiveSpan()` inside the callback would resolve to a stale ancestor on + // `activeCtx` (e.g. an outer span outside a `startNewTrace`/`continueTrace` boundary), + // and event trace-context would attach to the wrong trace. // We use activeCtx (not ctx) because ctx may be suppressed when onlyIfParent is true // and no parent span exists. Using activeCtx ensures custom OTel spans are never // inadvertently suppressed. - return context.with(activeCtx, () => { + return context.with(trace.setSpan(activeCtx, span), () => { return handleCallbackErrors( () => callback(span), () => { @@ -298,70 +287,6 @@ function getContextForScope(scope?: Scope): Context { return context.active(); } -/** - * Continue a trace from `sentry-trace` and `baggage` values. - * These values can be obtained from incoming request headers, or in the browser from `` - * and `` HTML tags. - * - * Spans started with `startSpan`, `startSpanManual` and `startInactiveSpan`, within the callback will automatically - * be attached to the incoming trace. - * - * This is a custom version of `continueTrace` that is used in OTEL-powered environments. - * It propagates the trace as a remote span, in addition to setting it on the propagation context. - */ -export function continueTrace(options: Parameters[0], callback: () => T): T { - return continueTraceAsRemoteSpan(context.active(), options, callback); -} - -/** - * Start a new trace with a unique traceId, ensuring all spans created within the callback - * share the same traceId. - * - * This is a custom version of `startNewTrace` for OTEL-powered environments. - * It injects the new traceId as a remote span context into the OTEL context, so that - * `startInactiveSpan` and `startSpan` pick it up correctly. - */ -export function startNewTrace(callback: () => T): T { - const traceId = generateTraceId(); - const spanId = generateSpanId(); - - const spanContext: SpanContext = { - traceId, - spanId, - isRemote: true, - traceFlags: TraceFlags.NONE, - }; - - const ctxWithTrace = trace.setSpanContext(context.active(), spanContext); - - return context.with(ctxWithTrace, () => { - getCurrentScope().setPropagationContext({ - traceId, - sampleRand: _INTERNAL_safeMathRandom(), - }); - return callback(); - }); -} - -/** - * Get the trace context for a given scope. - * We have a custom implementation here because we need an OTEL-specific way to get the span from a scope. - */ -export function getTraceContextForScope( - client: Client, - scope: Scope, -): [dynamicSamplingContext: Partial, traceContext: TraceContext] { - const ctx = getContextFromScope(scope); - const span = ctx && trace.getSpan(ctx); - - const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope); - - const dynamicSamplingContext = span - ? getDynamicSamplingContextFromSpan(span) - : getDynamicSamplingContextFromScope(client, scope); - return [dynamicSamplingContext, traceContext]; -} - function getActiveSpanWrapper(parentSpan: Span | SentrySpan | undefined | null): (callback: () => T) => T { return parentSpan !== undefined ? (callback: () => T) => { diff --git a/packages/opentelemetry/src/tracer.ts b/packages/opentelemetry/src/tracer.ts index e20b83a698d7..af0409e0a8b6 100644 --- a/packages/opentelemetry/src/tracer.ts +++ b/packages/opentelemetry/src/tracer.ts @@ -139,12 +139,10 @@ export class SentryTracer implements Tracer { // No parent span and no remote parent: this is a fresh root span. Start a new trace instead of // continuing the scope's (possibly auto-generated) propagation context, matching the OpenTelemetry // SDK where each root span without an incoming trace gets its own trace id. - return startNewTrace(() => - _INTERNAL_startInactiveSpan({ - ...sentryOptions, - parentSpan: hasExplicitContext ? null : undefined, - }), - ); + return _INTERNAL_startInactiveSpan({ + ...sentryOptions, + parentSpan: hasExplicitContext ? null : undefined, + }); } private _startRootSpanWithRemoteParent( @@ -176,7 +174,11 @@ export class SentryTracer implements Tracer { } private _createNonRecordingSpan(parentSpan: OpenTelemetrySpan | undefined): OpenTelemetrySpan { - const span = new SentryNonRecordingSpan({ traceId: parentSpan?.spanContext().traceId }); + // Without a parent, fall back to the current scope's propagation context trace id, so that + // non-recording spans (TwP mode) stay on the trace set by `startNewTrace`/`continueTrace` + // instead of minting a fresh random trace id. Mirrors core's `createChildOrRootSpan` TwP branch. + const traceId = parentSpan?.spanContext().traceId ?? getCurrentScope().getPropagationContext().traceId; + const span = new SentryNonRecordingSpan({ traceId }); // Link to the parent (like core's `createChildOrRootSpan`) so `getRootSpan` and DSC // resolution reach the parent. Non-recording spans no longer carry a `parentSpanId`. if (parentSpan) { diff --git a/packages/opentelemetry/src/utils/getTraceData.ts b/packages/opentelemetry/src/utils/getTraceData.ts index cae4059cf9e1..cc3b6698c9e1 100644 --- a/packages/opentelemetry/src/utils/getTraceData.ts +++ b/packages/opentelemetry/src/utils/getTraceData.ts @@ -1,13 +1,17 @@ import * as api from '@opentelemetry/api'; -import type { Client, Scope, SerializedTraceData, Span } from '@sentry/core'; +import type { Client, DynamicSamplingContext, Scope, SerializedTraceData, Span } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader, generateSentryTraceHeader, generateTraceparentHeader, getCapturedScopesOnSpan, + getClient, + getCurrentScope, + getDynamicSamplingContextFromScope, + getDynamicSamplingContextFromSpan, } from '@sentry/core'; -import { getInjectionData } from '../propagator'; -import { getContextFromScope } from './contextData'; +import { getContextFromScope, getScopesFromContext } from './contextData'; +import { getSampledForPropagation, getSamplingDecision } from './getSamplingDecision'; /** * Otel-specific implementation of `getTraceData`. @@ -27,7 +31,15 @@ export function getTraceData({ ctx = (scope && getContextFromScope(scope)) || api.trace.setSpan(api.context.active(), span); } - const { traceId, spanId, sampled, dynamicSamplingContext } = getInjectionData(ctx, { scope, client }); + const spanToUse = span ?? getSpan(ctx); + const scopeToUse = scope ?? getScopesFromContext(ctx)?.scope ?? getCurrentScope(); + const clientToUse = client ?? getClient(); + + const { traceId, spanId, sampled, dynamicSamplingContext } = getInjectionData({ + scope: scopeToUse, + client: clientToUse, + span: spanToUse, + }); const traceData: SerializedTraceData = { 'sentry-trace': generateSentryTraceHeader(traceId, spanId, sampled), @@ -40,3 +52,54 @@ export function getTraceData({ return traceData; } + +function getSpan(ctx: api.Context): Span | undefined { + return api.trace.getSpan(ctx); +} + +/** + * Get propagation injection data for the given context. + * The additional options can be passed to override the scope and client that is otherwise derived from the context. + */ +export function getInjectionData({ scope, client, span }: { scope: Scope; client?: Client; span?: Span }): { + dynamicSamplingContext: Partial | undefined; + traceId: string | undefined; + spanId: string | undefined; + sampled: boolean | undefined; +} { + // If we have a remote span, the spanId should be considered as the parentSpanId, not spanId itself + // Instead, we use a virtual (generated) spanId for propagation + if (span?.spanContext().isRemote) { + const spanContext = span.spanContext(); + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(span); + + return { + dynamicSamplingContext, + traceId: spanContext.traceId, + spanId: undefined, + sampled: getSamplingDecision(spanContext), // TODO: Do we need to change something here? + }; + } + + // If we have a local span, we just use this + if (span) { + const spanContext = span.spanContext(); + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(span); + + return { + dynamicSamplingContext, + traceId: spanContext.traceId, + spanId: spanContext.spanId, + sampled: getSampledForPropagation(span, client), + }; + } + + const propagationContext = scope.getPropagationContext(); + const dynamicSamplingContext = client ? getDynamicSamplingContextFromScope(client, scope) : undefined; + return { + dynamicSamplingContext, + traceId: propagationContext.traceId, + spanId: propagationContext.propagationSpanId, + sampled: propagationContext.sampled, + }; +} diff --git a/packages/opentelemetry/test/integration/transactions.test.ts b/packages/opentelemetry/test/integration/transactions.test.ts index da4c67e4d5c8..7f7cb6eeb031 100644 --- a/packages/opentelemetry/test/integration/transactions.test.ts +++ b/packages/opentelemetry/test/integration/transactions.test.ts @@ -317,7 +317,6 @@ describe('Integration | Transactions', () => { const client = mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); - // We simulate the correct context we'd normally get from the SentryPropagator context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { startSpan( { @@ -432,7 +431,6 @@ describe('Integration | Transactions', () => { release: '7.0.0', }); - // We simulate the correct context we'd normally get from the SentryPropagator context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { startSpan( { diff --git a/packages/opentelemetry/test/propagator.test.ts b/packages/opentelemetry/test/propagator.test.ts index 706396ddea53..330927e6ddd2 100644 --- a/packages/opentelemetry/test/propagator.test.ts +++ b/packages/opentelemetry/test/propagator.test.ts @@ -1,27 +1,17 @@ -import { - context, - defaultTextMapGetter, - defaultTextMapSetter, - propagation, - ROOT_CONTEXT, - trace, - TraceFlags, -} from '@opentelemetry/api'; -import { suppressTracing } from '@opentelemetry/core'; -import { getCurrentScope, withScope } from '@sentry/core'; +import { context, defaultTextMapGetter, defaultTextMapSetter, ROOT_CONTEXT, trace } from '@opentelemetry/api'; +import { suppressTracing, withScope } from '@sentry/core'; import { beforeEach, describe, expect, it } from 'vitest'; -import { SENTRY_BAGGAGE_HEADER, SENTRY_SCOPES_CONTEXT_KEY, SENTRY_TRACE_HEADER } from '../src/constants'; +import { SENTRY_BAGGAGE_HEADER, SENTRY_TRACE_HEADER } from '../src/constants'; import { SentryPropagator } from '../src/propagator'; -import { getSamplingDecision } from '../src/utils/getSamplingDecision'; -import { makeTraceState } from '../src/utils/makeTraceState'; import { mockSdkInit } from './helpers/mockSdkInit'; +const TRACE_ID = 'd4cda95b652f4a1592b449d5929fda1b'; +const PARENT_SPAN_ID = '6e0c63257de34c93'; + describe('SentryPropagator', () => { const propagator = new SentryPropagator(); - let carrier: { [key: string]: unknown }; beforeEach(() => { - carrier = {}; mockSdkInit({ environment: 'production', release: '1.0.0', @@ -35,754 +25,101 @@ describe('SentryPropagator', () => { }); describe('inject', () => { - describe('without active local span', () => { - it('uses scope propagation context without DSC if no span is found', () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - parentSpanId: '6e0c63257de34c93', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ].sort(), - ); - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-1/); + it('injects sentry-trace and baggage from the scope propagation context', () => { + const carrier: Record = {}; + + withScope(scope => { + scope.setPropagationContext({ + traceId: TRACE_ID, + parentSpanId: PARENT_SPAN_ID, + sampled: true, + sampleRand: 0.42, }); - }); - - it('uses scope propagation context with DSC if no span is found', () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - parentSpanId: '6e0c63257de34c93', - sampled: true, - sampleRand: Math.random(), - dsc: { - transaction: 'sampled-transaction', - sampled: 'false', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }); - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sampled=false', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ].sort(), - ); - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-1/); - }); + propagator.inject(context.active(), carrier, defaultTextMapSetter); }); - it('uses propagation data from current scope if no scope & span is found', () => { - const scope = getCurrentScope(); - const traceId = scope.getPropagationContext().traceId; - - const ctx = trace.deleteSpan(ROOT_CONTEXT).deleteValue(SENTRY_SCOPES_CONTEXT_KEY); - propagator.inject(ctx, carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual([ - 'sentry-environment=production', - 'sentry-public_key=abc', - 'sentry-release=1.0.0', - `sentry-trace_id=${traceId}`, - ]); - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(traceId); - }); + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(new RegExp(`^${TRACE_ID}-[a-f0-9]{16}-1$`)); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toContain(`sentry-trace_id=${TRACE_ID}`); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toContain('sentry-environment=production'); }); - describe('with active span', () => { - it.each([ - [ - 'continues a remote trace without dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - isRemote: true, - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=test', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - true, - ], - [ - 'continues a remote trace with dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - isRemote: true, - traceState: makeTraceState({ - dsc: { - transaction: 'sampled-transaction', - sampled: 'true', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }), - }, - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sampled=true', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - true, - ], - [ - 'continues an unsampled remote trace without dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=test', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - undefined, - ], - [ - 'continues an unsampled remote trace with sampled trace state & without dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ - sampled: false, - }), - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-sampled=false', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', - false, - ], - [ - 'continues an unsampled remote trace with dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ - dsc: { - transaction: 'sampled-transaction', - sampled: 'false', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }), - }, - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sampled=false', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', - false, - ], - [ - 'continues an unsampled remote trace with dsc & sampled trace state', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ - sampled: false, - dsc: { - transaction: 'sampled-transaction', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }), - }, - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', - false, - ], - [ - 'starts a new trace without existing dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - true, - ], - ])('%s', (_name, spanContext, baggage, sentryTrace, samplingDecision) => { - expect(getSamplingDecision(spanContext)).toBe(samplingDecision); + it('injects the trace data of an active span', () => { + const carrier: Record = {}; - context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { - trace.getTracer('test').startActiveSpan('test', span => { - propagator.inject(context.active(), carrier, defaultTextMapSetter); - baggage.forEach(baggageItem => { - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toContainEqual(baggageItem); - }); - expect(carrier[SENTRY_TRACE_HEADER]).toBe(sentryTrace.replace('{{spanId}}', span.spanContext().spanId)); - }); - }); - }); - - it('uses local span over propagation context', () => { - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - isRemote: true, - }), - () => { - trace.getTracer('test').startActiveSpan('test', span => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'TRACE_ID', - parentSpanId: 'PARENT_SPAN_ID', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=test', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), - ].forEach(item => { - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toContainEqual(item); - }); - expect(carrier[SENTRY_TRACE_HEADER]).toBe( - `d4cda95b652f4a1592b449d5929fda1b-${span.spanContext().spanId}-1`, - ); - }); - }); - }, - ); - }); - - it('uses remote span with deferred sampling decision over propagation context', () => { - const carrier: Record = {}; - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - }), - () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'TRACE_ID', - parentSpanId: 'PARENT_SPAN_ID', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ].sort(), - ); - // Used spanId is a random ID, not from the remote span - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}/); - expect(carrier[SENTRY_TRACE_HEADER]).not.toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92'); - }); - }, - ); - }); - - it('preserves a frozen incoming DSC on a directly-injected unsampled remote span', () => { - const carrier: Record = {}; - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - // A definitively-unsampled incoming trace that froze its own DSC, including a transaction name. - traceState: makeTraceState({ - sampled: false, - dsc: { - transaction: 'incoming-transaction', - sampled: 'false', - trace_id: 'd4cda95b652f4a1592b449d5929fda1b', - public_key: 'incoming_public_key', - environment: 'incoming_environment', - release: 'incoming_release', - sample_rate: '0.5', - }, - }), - }), - () => { - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - // The frozen incoming DSC is immutable, so its `transaction` must survive even though the - // trace is unsampled — we must not strip it the way we do for a freshly-derived DSC. - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=incoming_environment', - 'sentry-release=incoming_release', - 'sentry-public_key=incoming_public_key', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=incoming-transaction', - 'sentry-sampled=false', - 'sentry-sample_rate=0.5', - ].sort(), - ); - }, - ); - }); - - it('uses remote span over propagation context', () => { - const carrier: Record = {}; - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ sampled: false }), - }), - () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'TRACE_ID', - parentSpanId: 'PARENT_SPAN_ID', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=false', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ].sort(), - ); - // Used spanId is a random ID, not from the remote span - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-0/); - expect(carrier[SENTRY_TRACE_HEADER]).not.toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0'); - }); - }, - ); - }); - }); - - it('should include existing baggage', () => { - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - }); - - it('should include existing baggage header', () => { - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - - const carrier = { - other: 'header', - baggage: 'foo=bar,other=yes', - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage(); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'other=yes', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - }); - - it('should include existing baggage array header', () => { const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceId: TRACE_ID, spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - - const carrier = { - other: 'header', - baggage: ['foo=bar,other=yes', 'other2=no'], + traceFlags: 1, }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage(); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'other=yes', - 'other2=no', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - }); + const ctx = trace.setSpanContext(ROOT_CONTEXT, spanContext); - it('overwrites existing sentry baggage values and add sentry-trace header if sentry-trace is not set yet', () => { - // This is an edeg case where someone set a baggage header with existing sentry- values but no sentry-trace header. - // There's no evidence this occurs in real-life but if it does, we can assume that this must be some kind of error - // Hence, we overwrite the existing sentry- values with our new ones but keep all other non-sentry values. - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; + context.with(ctx, () => { + propagator.inject(context.active(), carrier, defaultTextMapSetter); + }); - const carrier: Record = { - baggage: 'foo=bar,other=yes,sentry-release=9.9.9,sentry-other=yes', - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage(); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'other=yes', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - expect(carrier[SENTRY_TRACE_HEADER]).toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'); + expect(carrier[SENTRY_TRACE_HEADER]).toBe(`${TRACE_ID}-6e0c63257de34c92-1`); }); - it('should create baggage without propagation context', () => { - const scope = getCurrentScope(); - const traceId = scope.getPropagationContext().traceId; + it('does not inject anything when tracing is suppressed', () => { + const carrier: Record = {}; - const context = ROOT_CONTEXT; - const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe( - `foo=bar,sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=${traceId}`, - ); - expect(carrier[SENTRY_TRACE_HEADER]).toBeDefined(); // whenever we set baggage, we must also set sentry-trace - }); - - it('should NOT set baggage and sentry-trace header if instrumentation is suppressed', () => { const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceId: TRACE_ID, spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, + traceFlags: 1, }; + const ctx = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const context = suppressTracing(trace.setSpanContext(ROOT_CONTEXT, spanContext)); - propagator.inject(context, carrier, defaultTextMapSetter); - expect(carrier[SENTRY_TRACE_HEADER]).toBe(undefined); - expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); - }); - - it("doesn't set baggage header if sentry-trace header is already set", () => { - const carrier: Record = { - [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', - }; - propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); - - expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); - expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); - }); - - describe('traceparent header', () => { - it("doesn't change baggage header if sentry-trace header is already set", () => { - const carrier: Record = { - [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', - [SENTRY_BAGGAGE_HEADER]: 'foo=bar,other=yes,sentry-release=9.9.9', - }; - propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); - - expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe('foo=bar,other=yes,sentry-release=9.9.9'); - expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); - }); - - it("doesn't set traceparent header if sentry-trace header is already set", () => { - mockSdkInit({ propagateTraceparent: true }); - const carrier: Record = { - [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', - }; - propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); - - expect(carrier['traceparent']).toBe(undefined); - expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); - }); - - it('sets traceparent header if propagateTraceparent is true', () => { - mockSdkInit({ - environment: 'production', - release: '1.0.0', - tracesSampleRate: 1, - dsn: 'https://abc@domain/123', - propagateTraceparent: true, + suppressTracing(() => { + context.with(ctx, () => { + propagator.inject(context.active(), carrier, defaultTextMapSetter); }); - - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - expect(carrier['traceparent']).toBe('00-d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-01'); }); - it("doesn't set traceparent header if propagateTraceparent is false", () => { - mockSdkInit({ - environment: 'production', - release: '1.0.0', - tracesSampleRate: 1, - dsn: 'https://abc@domain/123', - propagateTraceparent: false, - }); - const carrier: Record = {}; - propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); - - expect(carrier['traceparent']).toBe(undefined); - expect(carrier[SENTRY_TRACE_HEADER]).toBeDefined(); - }); + expect(carrier[SENTRY_TRACE_HEADER]).toBeUndefined(); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBeUndefined(); }); }); describe('extract', () => { - it('sets data from sentry trace header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({}), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); - }); + it('continues an incoming trace as a remote active span', () => { + const carrier = { + [SENTRY_TRACE_HEADER]: `${TRACE_ID}-${PARENT_SPAN_ID}-1`, + [SENTRY_BAGGAGE_HEADER]: 'sentry-environment=production,sentry-public_key=abc', + }; - it('sets data from negative sampled sentry trace header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({ sampled: false }), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(false); - }); + const extractedContext = propagator.extract(context.active(), carrier, defaultTextMapGetter); - it('sets data from not sampled sentry trace header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({}), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(undefined); + const spanContext = trace.getSpanContext(extractedContext); + expect(spanContext?.traceId).toBe(TRACE_ID); + expect(spanContext?.spanId).toBe(PARENT_SPAN_ID); + expect(spanContext?.isRemote).toBe(true); }); - it('handles undefined sentry trace header', () => { - const sentryTraceHeader = undefined; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual(undefined); - expect(getCurrentScope().getPropagationContext()).toEqual({ - traceId: expect.stringMatching(/[a-f0-9]{32}/), - sampleRand: expect.any(Number), - }); - }); + it('handles an array-valued sentry-trace header', () => { + const carrier = { + [SENTRY_TRACE_HEADER]: [`${TRACE_ID}-${PARENT_SPAN_ID}-1`], + }; - it('sets data from baggage header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; - const baggage = - 'sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-transaction=dsc-transaction,sentry-sample_rand=0.123'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - carrier[SENTRY_BAGGAGE_HEADER] = baggage; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({ - dsc: { - environment: 'production', - release: '1.0.0', - public_key: 'abc', - trace_id: 'd4cda95b652f4a1592b449d5929fda1b', - transaction: 'dsc-transaction', - sample_rand: '0.123', - }, - }), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); - }); + const getter = { + keys: (c: Record) => Object.keys(c), + get: (c: Record, key: string) => c[key] as string | string[] | undefined, + }; - it('handles empty dsc baggage header', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; - const baggage = ''; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - carrier[SENTRY_BAGGAGE_HEADER] = baggage; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({}), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); + const extractedContext = propagator.extract(context.active(), carrier, getter); + + const spanContext = trace.getSpanContext(extractedContext); + expect(spanContext?.traceId).toBe(TRACE_ID); + expect(spanContext?.spanId).toBe(PARENT_SPAN_ID); }); - it('handles when sentry-trace is an empty array', () => { - carrier[SENTRY_TRACE_HEADER] = []; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual(undefined); - expect(getCurrentScope().getPropagationContext()).toEqual({ - traceId: expect.stringMatching(/[a-f0-9]{32}/), - sampleRand: expect.any(Number), - }); + it('returns the context unchanged when there is no incoming trace', () => { + const carrier = {}; + + const extractedContext = propagator.extract(context.active(), carrier, defaultTextMapGetter); + + expect(trace.getSpanContext(extractedContext)).toBeUndefined(); }); }); }); - -function baggageToArray(baggage: unknown): string[] { - return typeof baggage === 'string' ? baggage.split(',').sort() : []; -} diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index 16ac9aebc40b..10c23386d5a3 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -9,20 +9,16 @@ import { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan, getRootSpan, - isTracingSuppressed, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - spanIsSampled, spanToJSON, - suppressTracing, withScope, } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual } from '../src/trace'; +import { startInactiveSpan, startSpan, startSpanManual } from '../src/trace'; import { getActiveSpan } from '../src/utils/getActiveSpan'; -import { getSamplingDecision } from '../src/utils/getSamplingDecision'; import { makeTraceState } from '../src/utils/makeTraceState'; import { isSpan } from './helpers/isSpan'; import { mockSdkInit } from './helpers/mockSdkInit'; @@ -1292,7 +1288,9 @@ describe('trace', () => { const traceId = spanToJSON(span).trace_id; expect(traceId).toMatch(/[a-f0-9]{32}/); expect(spanToJSON(span).parent_span_id).toBe(undefined); - expect(spanToJSON(span).trace_id).not.toEqual(propagationContext.traceId); + // A root span without a parent continues the current scope's propagation context trace, + // matching the core SDK behavior. + expect(spanToJSON(span).trace_id).toEqual(propagationContext.traceId); expect(getDynamicSamplingContextFromSpan(span)).toEqual({ trace_id: traceId, @@ -1306,8 +1304,7 @@ describe('trace', () => { }); }); - // Note: This _should_ never happen, when we have an incoming trace, we should always have a parent span - it('starts new trace, ignoring parentSpanId, if there is no parent', () => { + it('continues the scope propagation context, including parentSpanId, if there is no active span', () => { withScope(scope => { const propagationContext = scope.getPropagationContext(); propagationContext.parentSpanId = '1121201211212012'; @@ -1316,8 +1313,10 @@ describe('trace', () => { expect(span).toBeDefined(); const traceId = spanToJSON(span).trace_id; expect(traceId).toMatch(/[a-f0-9]{32}/); - expect(spanToJSON(span).parent_span_id).toBe(undefined); - expect(spanToJSON(span).trace_id).not.toEqual(propagationContext.traceId); + // The root span continues the scope's trace and inherits the propagation context's + // parentSpanId as its parent, matching the core SDK behavior. + expect(spanToJSON(span).parent_span_id).toBe('1121201211212012'); + expect(spanToJSON(span).trace_id).toEqual(propagationContext.traceId); expect(getDynamicSamplingContextFromSpan(span)).toEqual({ environment: 'production', @@ -1595,7 +1594,6 @@ describe('trace (sampling)', () => { traceFlags: TraceFlags.SAMPLED, }; - // We simulate the correct context we'd normally get from the SentryPropagator context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { // This will def. be sampled because of the tracesSampleRate startSpan({ name: 'outer' }, outerSpan => { @@ -1622,7 +1620,6 @@ describe('trace (sampling)', () => { traceFlags: TraceFlags.NONE, }; - // We simulate the correct context we'd normally get from the SentryPropagator context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { // This will def. be sampled because of the tracesSampleRate startSpan({ name: 'outer' }, outerSpan => { @@ -1780,7 +1777,6 @@ describe('trace (sampling)', () => { traceFlags: TraceFlags.SAMPLED, }; - // We simulate the correct context we'd normally get from the SentryPropagator context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { // This will def. be sampled because of the tracesSampleRate startSpan({ name: 'outer' }, outerSpan => { @@ -1819,219 +1815,6 @@ describe('trace (sampling)', () => { }); }); -describe('continueTrace', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - it('works without trace & baggage data', () => { - const scope = continueTrace({ sentryTrace: undefined, baggage: undefined }, () => { - const span = getActiveSpan()!; - expect(span).toBeUndefined(); - return getCurrentScope(); - }); - - expect(scope.getPropagationContext()).toEqual({ - traceId: expect.any(String), - sampleRand: expect.any(Number), - }); - - expect(scope.getScopeData().sdkProcessingMetadata).toEqual({}); - }); - - it('works with trace data', () => { - continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-0', - baggage: undefined, - }, - () => { - const span = getActiveSpan()!; - expect(span).toBeDefined(); - expect(spanToJSON(span)).toEqual({ - span_id: '1121201211212012', - trace_id: '12312012123120121231201212312012', - data: {}, - start_timestamp: 0, - status: 'ok', - }); - expect(getSamplingDecision(span.spanContext())).toBe(false); - expect(spanIsSampled(span)).toBe(false); - }, - ); - }); - - it('works with trace & baggage data', () => { - continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-1', - baggage: 'sentry-version=1.0,sentry-environment=production', - }, - () => { - const span = getActiveSpan()!; - expect(span).toBeDefined(); - expect(spanToJSON(span)).toEqual({ - span_id: '1121201211212012', - trace_id: '12312012123120121231201212312012', - data: {}, - start_timestamp: 0, - status: 'ok', - }); - expect(getSamplingDecision(span.spanContext())).toBe(true); - expect(spanIsSampled(span)).toBe(true); - }, - ); - }); - - it('works with trace & 3rd party baggage data', () => { - continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-1', - baggage: 'sentry-version=1.0,sentry-environment=production,dogs=great,cats=boring', - }, - () => { - const span = getActiveSpan()!; - expect(span).toBeDefined(); - expect(spanToJSON(span)).toEqual({ - span_id: '1121201211212012', - trace_id: '12312012123120121231201212312012', - data: {}, - start_timestamp: 0, - status: 'ok', - }); - expect(getSamplingDecision(span.spanContext())).toBe(true); - expect(spanIsSampled(span)).toBe(true); - }, - ); - }); - - it('returns response of callback', () => { - const result = continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-0', - baggage: undefined, - }, - () => { - return 'aha'; - }, - ); - - expect(result).toEqual('aha'); - }); -}); - -describe('suppressTracing', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - 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); - }); - }); - - 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); - }); -}); - -describe('isTracingSuppressed', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - 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 }); @@ -2120,108 +1903,6 @@ describe('span.end() timestamp conversion', () => { }); }); -describe('startNewTrace', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - it('sequential startInactiveSpan calls share the same traceId', () => { - startNewTrace(() => { - const propagationContext = getCurrentScope().getPropagationContext(); - - const span1 = startInactiveSpan({ name: 'span-1' }); - const span2 = startInactiveSpan({ name: 'span-2' }); - const span3 = startInactiveSpan({ name: 'span-3' }); - - const traceId1 = span1.spanContext().traceId; - const traceId2 = span2.spanContext().traceId; - const traceId3 = span3.spanContext().traceId; - - expect(traceId1).toBe(propagationContext.traceId); - expect(traceId2).toBe(propagationContext.traceId); - expect(traceId3).toBe(propagationContext.traceId); - - span1.end(); - span2.end(); - span3.end(); - }); - }); - - it('startSpan inside startNewTrace uses the correct traceId', () => { - startNewTrace(() => { - const propagationContext = getCurrentScope().getPropagationContext(); - - startSpan({ name: 'parent-span' }, parentSpan => { - const parentTraceId = parentSpan.spanContext().traceId; - expect(parentTraceId).toBe(propagationContext.traceId); - - const child = startInactiveSpan({ name: 'child-span' }); - expect(child.spanContext().traceId).toBe(propagationContext.traceId); - child.end(); - }); - }); - }); - - it('generates a different traceId than the outer trace', () => { - startSpan({ name: 'outer-span' }, outerSpan => { - const outerTraceId = outerSpan.spanContext().traceId; - - startNewTrace(() => { - const innerSpan = startInactiveSpan({ name: 'inner-span' }); - const innerTraceId = innerSpan.spanContext().traceId; - - expect(innerTraceId).not.toBe(outerTraceId); - - const propagationContext = getCurrentScope().getPropagationContext(); - expect(innerTraceId).toBe(propagationContext.traceId); - - innerSpan.end(); - }); - }); - }); - - it('allows spans to be sampled based on tracesSampleRate', () => { - startNewTrace(() => { - const span = startInactiveSpan({ name: 'sampled-span' }); - // tracesSampleRate is 1 in mockSdkInit, so spans should be sampled - // This verifies that TraceFlags.NONE on the remote span context does not - // cause the sampler to inherit a "not sampled" decision from the parent - expect(spanIsSampled(span)).toBe(true); - span.end(); - }); - }); - - it('samples a forced transaction based on tracesSampleRate', () => { - // `startNewTrace` injects a remote parent with `traceFlags: NONE` and no trace state, i.e. a - // *deferred* decision. A forced transaction under it runs through `getContext`'s simulated-root - // branch, which derives a DSC from that parent. Core naively reads `sampled=false` off the binary - // trace flags; without reconciliation that gets baked into the trace state and the transaction - // wrongly inherits a negative decision despite `tracesSampleRate: 1`. - startNewTrace(() => { - const span = startInactiveSpan({ name: 'forced-transaction', forceTransaction: true }); - expect(spanIsSampled(span)).toBe(true); - span.end(); - }); - }); - - it('does not leak the new traceId to the outer scope', () => { - const outerScope = getCurrentScope(); - const outerTraceId = outerScope.getPropagationContext().traceId; - - startNewTrace(() => { - // Manually set a known traceId on the inner scope to verify it doesn't leak - getCurrentScope().setPropagationContext({ - traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - sampleRand: 0.5, - }); - }); - - const afterTraceId = outerScope.getPropagationContext().traceId; - expect(afterTraceId).toBe(outerTraceId); - expect(afterTraceId).not.toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); - }); -}); - function getSpanName(span: Span): string | undefined { return spanToJSON(span).description; } diff --git a/packages/vercel-edge/src/types.ts b/packages/vercel-edge/src/types.ts index 8aaad22f4226..e086f0960b13 100644 --- a/packages/vercel-edge/src/types.ts +++ b/packages/vercel-edge/src/types.ts @@ -43,10 +43,7 @@ export interface BaseVercelEdgeOptions { /** * If this is set to true, the SDK will not set up OpenTelemetry automatically. * In this case, you _have_ to ensure to set it up correctly yourself, including: - * * The `SentrySpanProcessor` - * * The `SentryPropagator` * * The `SentryContextManager` - * * The `SentrySampler` */ skipOpenTelemetrySetup?: boolean;