From 5f110a441cb9483816a4ac66714245e46450401a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 9 Jun 2026 14:36:09 +0200 Subject: [PATCH 01/16] feat(opentelemetry): Add SentryTraceProvider Add a minimal OpenTelemetry `TracerProvider` that creates native Sentry spans instead of bridging through the full OTel SDK. --- packages/core/src/tracing/index.ts | 1 + packages/core/src/tracing/sentrySpan.ts | 2 +- packages/core/src/tracing/trace.ts | 14 ++ packages/core/src/types/span.ts | 2 +- packages/opentelemetry/README.md | 30 +++ .../opentelemetry/src/applyOtelSpanData.ts | 114 +++++++++ packages/opentelemetry/src/custom/client.ts | 5 +- packages/opentelemetry/src/exports.ts | 5 +- packages/opentelemetry/src/tracer.ts | 150 +++++++++++ packages/opentelemetry/src/tracerProvider.ts | 39 +++ packages/opentelemetry/src/types.ts | 9 +- ...enhanceDscWithOpenTelemetryRootSpanName.ts | 9 +- packages/opentelemetry/src/utils/mapStatus.ts | 4 +- .../src/utils/parseSpanDescription.ts | 21 +- .../opentelemetry/src/utils/setupCheck.ts | 7 +- .../opentelemetry/test/tracerProvider.test.ts | 232 ++++++++++++++++++ .../test/utils/setupCheck.test.ts | 19 +- 17 files changed, 643 insertions(+), 20 deletions(-) create mode 100644 packages/opentelemetry/src/applyOtelSpanData.ts create mode 100644 packages/opentelemetry/src/tracer.ts create mode 100644 packages/opentelemetry/src/tracerProvider.ts create mode 100644 packages/opentelemetry/test/tracerProvider.test.ts diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index ffd40cc00406..5278c8a3afc1 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -13,6 +13,7 @@ export { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from './spanstat export { startSpan, startInactiveSpan, + _INTERNAL_startInactiveSpan, startSpanManual, continueTrace, withActiveSpan, diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 8387c788d5fd..390ca3eb90e6 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -143,7 +143,7 @@ export class SentrySpan implements Span { * @hidden * @internal */ - public recordException(_exception: unknown, _time?: number | undefined): void { + public recordException(_exception: unknown, _time?: SpanTimeInput | undefined): void { // noop } diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 7a31217fe51a..e958e6f2421c 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -181,6 +181,20 @@ export function startInactiveSpan(options: StartSpanOptions): Span { return acs.startInactiveSpan(options); } + return _startInactiveSpanImpl(options); +} + +/** + * Internal version of startInactiveSpan that bypasses the ACS check. + * Used by SentryTracerProvider to create spans without triggering recursion + * through ACS overrides. + * @hidden + */ +export function _INTERNAL_startInactiveSpan(options: StartSpanOptions): Span { + return _startInactiveSpanImpl(options); +} + +function _startInactiveSpanImpl(options: StartSpanOptions): Span { const spanArguments = parseSentrySpanArguments(options); const { forceTransaction, parentSpan: customParentSpan } = options; diff --git a/packages/core/src/types/span.ts b/packages/core/src/types/span.ts index 26dbbf9d29a4..1e44809a8fa0 100644 --- a/packages/core/src/types/span.ts +++ b/packages/core/src/types/span.ts @@ -319,5 +319,5 @@ export interface Span { /** * NOT USED IN SENTRY, only added for compliance with OTEL Span interface */ - recordException(exception: unknown, time?: number): void; + recordException(exception: unknown, time?: SpanTimeInput): void; } diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 18f2589a8701..b955b6121921 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -85,6 +85,36 @@ function setupSentry() { A full setup example can be found in [node-experimental](https://github.com/getsentry/sentry-javascript/blob/develop/packages/node-experimental). +## Experimental Sentry Tracer Provider + +`SentryTracerProvider` is an experimental minimal OpenTelemetry tracer provider which creates native Sentry spans directly. +It is useful when code uses the global OpenTelemetry API and you do not need the full OpenTelemetry SDK span processor +and exporter pipeline. + +```js +import { trace } from '@opentelemetry/api'; +import { SentryTracerProvider } from '@sentry/opentelemetry'; + +trace.setGlobalTracerProvider(new SentryTracerProvider()); + +const span = trace.getTracer('example').startSpan('work'); +span.end(); +``` + +In `@sentry/node`, this provider can be enabled with the experimental option: + +```js +Sentry.init({ + dsn: 'xxx', + _experiments: { + useSentryTraceProvider: true, + }, +}); +``` + +When this provider is enabled, additional OpenTelemetry span processors are ignored because Sentry spans are created +directly. OpenTelemetry logs and metrics are not handled by this provider. + ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) diff --git a/packages/opentelemetry/src/applyOtelSpanData.ts b/packages/opentelemetry/src/applyOtelSpanData.ts new file mode 100644 index 000000000000..47580f14ef8c --- /dev/null +++ b/packages/opentelemetry/src/applyOtelSpanData.ts @@ -0,0 +1,114 @@ +import { SpanKind } from '@opentelemetry/api'; +import { HTTP_RESPONSE_STATUS_CODE, HTTP_STATUS_CODE } from '@sentry/conventions/attributes'; +import { + addNonEnumerableProperty, + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanShouldInferOtelSource, + spanToJSON, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, +} from '@sentry/core'; +import type { Span, SpanAttributes } from '@sentry/core'; +import { inferStatusFromAttributes, isStatusErrorMessageValid } from './utils/mapStatus'; +import { inferSpanData } from './utils/parseSpanDescription'; + +type SentrySpanWithOtelKind = Span & { kind?: SpanKind }; + +/** + * Backfill a native Sentry span with the data the OpenTelemetry SDK pipeline would otherwise derive + * from OTel semantic attributes: `sentry.op`, `sentry.source`, the span name, `otel.kind`, and status. + * + * On the OTel SDK provider this happens in the `SentrySpanProcessor`/`SentrySpanExporter` while + * converting `ReadableSpan`s to Sentry payloads (via `parseSpanDescription` + `mapStatus`). + * `SentryTracerProvider` creates native Sentry spans directly and never goes through that pipeline, + * so the same inference has to run here instead — once at span start, and again at span end + * (`finalizeStatus`, once attributes like `http.route` and the status code are available). + */ +export function applyOtelSpanData(span: Span, options: { finalizeStatus?: boolean } = {}): void { + const spanJSON = spanToJSON(span); + const attributes = spanJSON.data; + const kind = (span as SentrySpanWithOtelKind).kind ?? SpanKind.INTERNAL; + const mayInferSource = spanShouldInferOtelSource(span); + const hasCustomSpanName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME] !== undefined; + const attributesForInference = + mayInferSource && !hasCustomSpanName && attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom' + ? { ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: undefined } + : attributes; + const inferred = inferSpanData(spanJSON.description || '', attributesForInference, kind); + + if (kind !== SpanKind.INTERNAL && attributes['otel.kind'] === undefined) { + span.setAttribute('otel.kind', SpanKind[kind]); + } + + if (inferred.op && attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === undefined) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, inferred.op); + } + + // Don't apply 'url' source at creation time — only at span end (finalizeStatus). + // At creation, http.route may not be set yet, so inference falls back to 'url'. + // Keeping the default 'custom' source from _startRootSpan allows + // enhanceDscWithOpenTelemetryRootSpanName to include the transaction name in + // the DSC. At span end, http.route is typically available and inference returns + // 'route' instead. If it's still 'url', it's applied then. + const shouldApplyInferredSource = + inferred.source !== undefined && + inferred.source !== 'custom' && + (options.finalizeStatus || inferred.source !== 'url') && + (spanJSON.parent_span_id === undefined || kind === SpanKind.SERVER); + + if ( + shouldApplyInferredSource && + (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === undefined || (mayInferSource && !hasCustomSpanName)) + ) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, inferred.source); + } + + if (inferred.data) { + Object.entries(inferred.data).forEach(([key, value]) => { + if (value !== undefined && attributes[key] === undefined) { + span.setAttribute(key, value); + } + }); + } + + if (options.finalizeStatus) { + applyOtelCompatibilityAttributes(span, attributes); + applyOtelSpanStatus(span, attributes, spanJSON.status); + } + + if ( + inferred.description !== spanJSON.description && + (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] !== 'custom' || (mayInferSource && !hasCustomSpanName)) + ) { + addNonEnumerableProperty(span as Span & { _name?: string }, '_name', inferred.description); + } +} + +/** Stash the OTel span kind on a Sentry span so {@link applyOtelSpanData} can read it. */ +export function applyOtelSpanKind(span: Span, kind: SpanKind | undefined): void { + addNonEnumerableProperty(span as SentrySpanWithOtelKind, 'kind', kind ?? SpanKind.INTERNAL); +} + +function applyOtelSpanStatus(span: Span, attributes: SpanAttributes, status: string | undefined): void { + if (status === undefined) { + span.setStatus(inferStatusFromAttributes(attributes) || { code: SPAN_STATUS_OK }); + return; + } + + if (status !== 'ok' && !isStatusErrorMessageValid(status)) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } +} + +function applyOtelCompatibilityAttributes(span: Span, attributes: SpanAttributes): void { + // `http.status_code` is the deprecated legacy attribute, read for backward compatibility. + // eslint-disable-next-line typescript/no-deprecated + const legacyHttpStatusCode = attributes[HTTP_STATUS_CODE]; + + if (attributes[HTTP_RESPONSE_STATUS_CODE] === undefined && legacyHttpStatusCode !== undefined) { + span.setAttribute(HTTP_RESPONSE_STATUS_CODE, legacyHttpStatusCode); + attributes[HTTP_RESPONSE_STATUS_CODE] = legacyHttpStatusCode; + } +} diff --git a/packages/opentelemetry/src/custom/client.ts b/packages/opentelemetry/src/custom/client.ts index a1f0e4792048..ed97faae4f62 100644 --- a/packages/opentelemetry/src/custom/client.ts +++ b/packages/opentelemetry/src/custom/client.ts @@ -1,9 +1,8 @@ import type { Tracer } from '@opentelemetry/api'; import { trace } from '@opentelemetry/api'; -import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import type { Client } from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; -import type { OpenTelemetryClient as OpenTelemetryClientInterface } from '../types'; +import type { OpenTelemetryClient as OpenTelemetryClientInterface, OpenTelemetryTraceProvider } from '../types'; // Typescript complains if we do not use `...args: any[]` for the mixin, with: // A mixin class must have a constructor with a single rest parameter of type 'any[]'.ts(2545) @@ -23,7 +22,7 @@ export function wrapClientClass< >(ClientClass: ClassConstructor): WrappedClassConstructor { // @ts-expect-error We just assume that this is non-abstract, if you pass in an abstract class this would make it non-abstract class OpenTelemetryClient extends ClientClass implements OpenTelemetryClientInterface { - public traceProvider: BasicTracerProvider | undefined; + public traceProvider: OpenTelemetryTraceProvider | undefined; private _tracer: Tracer | undefined; public constructor(...args: any[]) { diff --git a/packages/opentelemetry/src/exports.ts b/packages/opentelemetry/src/exports.ts index 5eac9d3a7a4a..18f5ea29a98b 100644 --- a/packages/opentelemetry/src/exports.ts +++ b/packages/opentelemetry/src/exports.ts @@ -46,8 +46,11 @@ export { wrapContextManagerClass } from './contextManager'; export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator'; export { SentrySpanProcessor } from './spanProcessor'; export { SentrySampler, wrapSamplingDecision } from './sampler'; +export { applyOtelSpanData } from './applyOtelSpanData'; +export { SentryTracerProvider } from './tracerProvider'; +export type { OpenTelemetryTraceProvider } from './types'; -export { openTelemetrySetupCheck } from './utils/setupCheck'; +export { openTelemetrySetupCheck, setIsSetup } from './utils/setupCheck'; export { getSentryResource } from './resource'; diff --git a/packages/opentelemetry/src/tracer.ts b/packages/opentelemetry/src/tracer.ts new file mode 100644 index 000000000000..e1255e1dc471 --- /dev/null +++ b/packages/opentelemetry/src/tracer.ts @@ -0,0 +1,150 @@ +import type { Context, Span as OpenTelemetrySpan, SpanOptions, Tracer } from '@opentelemetry/api'; +import { context, trace } from '@opentelemetry/api'; +import { isTracingSuppressed } from '@opentelemetry/core'; +import { + _INTERNAL_safeMathRandom, + _INTERNAL_setSpanForScope, + _INTERNAL_startInactiveSpan, + addChildSpanToSpan, + getCapturedScopesOnSpan, + getCurrentScope, + getDynamicSamplingContextFromSpan, + getIsolationScope, + markSpanForOtelSourceInference, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SentryNonRecordingSpan, + setCapturedScopesOnSpan, + startNewTrace, + withScope, +} from '@sentry/core'; +import type { Span, SpanAttributes, SpanLink } from '@sentry/core'; +import { applyOtelSpanData, applyOtelSpanKind } from './applyOtelSpanData'; +import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY } from './constants'; +import { getSamplingDecision } from './utils/getSamplingDecision'; + +export class SentryTracer implements Tracer { + /** @inheritdoc */ + public startSpan(name: string, options: SpanOptions = {}, ctx?: Context): OpenTelemetrySpan { + const parentContext = ctx || context.active(); + const parentSpan = options.root ? undefined : trace.getSpan(parentContext); + + if (isTracingSuppressed(parentContext)) { + return this._createNonRecordingSpan(parentSpan); + } + + const span = this._startSentrySpan(name, options, parentSpan, ctx !== undefined); + + applyOtelSpanKind(span, options.kind); + if (options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === undefined) { + markSpanForOtelSourceInference(span); + } + applyOtelSpanData(span); + return span as OpenTelemetrySpan; + } + + /** @inheritdoc */ + public startActiveSpan unknown>(name: string, fn: F): ReturnType; + public startActiveSpan unknown>( + name: string, + options: SpanOptions, + fn: F, + ): ReturnType; + public startActiveSpan unknown>( + name: string, + options: SpanOptions, + ctx: Context, + fn: F, + ): ReturnType; + public startActiveSpan unknown>( + name: string, + optionsOrFn: SpanOptions | F, + contextOrFn?: Context | F, + fn?: F, + ): ReturnType { + const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn; + const ctx = typeof contextOrFn === 'function' || contextOrFn === undefined ? context.active() : contextOrFn; + const callback = ( + typeof optionsOrFn === 'function' ? optionsOrFn : typeof contextOrFn === 'function' ? contextOrFn : fn + ) as F; + + const span = this.startSpan(name, options, ctx); + let ctxWithSpan = trace.setSpan(ctx, span); + + const capturedIsolationScope = getCapturedScopesOnSpan(span as unknown as Span).isolationScope; + if (capturedIsolationScope) { + ctxWithSpan = ctxWithSpan.setValue(SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, capturedIsolationScope); + } + + return context.with(ctxWithSpan, () => { + _INTERNAL_setSpanForScope(getCurrentScope(), span as unknown as Span); + return callback(span) as ReturnType; + }); + } + + private _startSentrySpan( + name: string, + options: SpanOptions, + parentSpan: OpenTelemetrySpan | undefined, + hasExplicitContext: boolean, + ): Span { + const sentryOptions = { + name, + attributes: options.attributes as SpanAttributes | undefined, + links: options.links as SpanLink[] | undefined, + startTime: options.startTime, + }; + + if (options.root) { + return startNewTrace(() => _INTERNAL_startInactiveSpan({ ...sentryOptions, parentSpan: null })); + } + + if (parentSpan?.spanContext().isRemote) { + return this._startRootSpanWithRemoteParent(sentryOptions, parentSpan); + } + + if (parentSpan) { + return _INTERNAL_startInactiveSpan({ ...sentryOptions, parentSpan: parentSpan as unknown as Span }); + } + + return _INTERNAL_startInactiveSpan({ + ...sentryOptions, + parentSpan: hasExplicitContext ? null : undefined, + }); + } + + private _startRootSpanWithRemoteParent( + options: Parameters[0], + parentSpan: OpenTelemetrySpan, + ): Span { + const { spanId, traceId } = parentSpan.spanContext(); + const dsc = getDynamicSamplingContextFromSpan(parentSpan as unknown as Span); + const sampleRand = typeof dsc.sample_rand === 'string' ? Number(dsc.sample_rand) : undefined; + + return withScope(scope => { + scope.setPropagationContext({ + traceId, + parentSpanId: spanId, + sampled: getSamplingDecision(parentSpan.spanContext()), + dsc, + sampleRand: + typeof sampleRand === 'number' && !Number.isNaN(sampleRand) ? sampleRand : _INTERNAL_safeMathRandom(), + }); + _INTERNAL_setSpanForScope(scope, undefined); + + return _INTERNAL_startInactiveSpan({ ...options, parentSpan: null }); + }); + } + + private _createNonRecordingSpan(parentSpan: OpenTelemetrySpan | undefined): OpenTelemetrySpan { + const span = new SentryNonRecordingSpan({ traceId: parentSpan?.spanContext().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) { + addChildSpanToSpan(parentSpan as unknown as Span, span); + } + // Capture the scopes (mirroring `createChildOrRootSpan`) so `startActiveSpan` can + // fork the isolation scope onto the OTel context for work inside a suppressed span. + setCapturedScopesOnSpan(span, getCurrentScope(), getIsolationScope()); + return span as OpenTelemetrySpan; + } +} diff --git a/packages/opentelemetry/src/tracerProvider.ts b/packages/opentelemetry/src/tracerProvider.ts new file mode 100644 index 000000000000..e86edd5af68a --- /dev/null +++ b/packages/opentelemetry/src/tracerProvider.ts @@ -0,0 +1,39 @@ +import type { Tracer, TracerOptions, TracerProvider } from '@opentelemetry/api'; +import type { SpanAttributes } from '@sentry/core'; +import { SentryTracer } from './tracer'; + +/** + * A minimal OpenTelemetry TracerProvider which creates native Sentry spans. + */ +export class SentryTracerProvider implements TracerProvider { + public readonly resource?: { attributes: SpanAttributes }; + + private readonly _tracers = new Map(); + + public constructor(options: { resource?: { attributes: SpanAttributes } } = {}) { + this.resource = options.resource; + } + + /** @inheritdoc */ + public getTracer(name: string, version?: string, options?: TracerOptions): Tracer { + const key = JSON.stringify([name, version, options]); + const cachedTracer = this._tracers.get(key); + if (cachedTracer) { + return cachedTracer; + } + + const tracer = new SentryTracer(); + this._tracers.set(key, tracer); + return tracer; + } + + /** Compatibility with SDK tracer providers. */ + public forceFlush(): Promise { + return Promise.resolve(); + } + + /** Compatibility with SDK tracer providers. */ + public shutdown(): Promise { + return Promise.resolve(); + } +} diff --git a/packages/opentelemetry/src/types.ts b/packages/opentelemetry/src/types.ts index 807e9b1d857f..4563f7b8c72f 100644 --- a/packages/opentelemetry/src/types.ts +++ b/packages/opentelemetry/src/types.ts @@ -1,10 +1,15 @@ -import type { Span as WriteableSpan, SpanKind, Tracer } from '@opentelemetry/api'; +import type { Span as WriteableSpan, SpanKind, Tracer, TracerProvider } from '@opentelemetry/api'; import type { BasicTracerProvider, ReadableSpan } from '@opentelemetry/sdk-trace-base'; import type { Scope, Span, StartSpanOptions } from '@sentry/core'; +export interface OpenTelemetryTraceProvider extends TracerProvider { + forceFlush(): Promise; + shutdown(): Promise; +} + export interface OpenTelemetryClient { tracer: Tracer; - traceProvider: BasicTracerProvider | undefined; + traceProvider: BasicTracerProvider | OpenTelemetryTraceProvider | undefined; } export interface OpenTelemetrySpanContext extends StartSpanOptions { diff --git a/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts b/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts index 7fb080119d3b..028dba699ab8 100644 --- a/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts +++ b/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts @@ -2,7 +2,6 @@ import type { Client } from '@sentry/core'; import { hasSpansEnabled, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core'; import { getSamplingDecision } from './getSamplingDecision'; import { parseSpanDescription } from './parseSpanDescription'; -import { spanHasName } from './spanTypes'; /** * Setup a DSC handler on the passed client, @@ -24,9 +23,11 @@ export function enhanceDscWithOpenTelemetryRootSpanName(client: Client): void { const attributes = jsonSpan.data; const source = attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - const { description } = spanHasName(rootSpan) ? parseSpanDescription(rootSpan) : { description: undefined }; - if (source !== 'url' && description) { - dsc.transaction = description; + if (jsonSpan.description) { + const { description } = parseSpanDescription(rootSpan); + if (source !== 'url' && description) { + dsc.transaction = description; + } } // Also ensure sampling decision is correctly inferred diff --git a/packages/opentelemetry/src/utils/mapStatus.ts b/packages/opentelemetry/src/utils/mapStatus.ts index 7597bcd17b30..5ebd31c912c9 100644 --- a/packages/opentelemetry/src/utils/mapStatus.ts +++ b/packages/opentelemetry/src/utils/mapStatus.ts @@ -25,7 +25,7 @@ const canonicalGrpcErrorCodesMap: Record = { '16': 'unauthenticated', } as const; -const isStatusErrorMessageValid = (message: string): boolean => { +export const isStatusErrorMessageValid = (message: string): boolean => { return Object.values(canonicalGrpcErrorCodesMap).includes(message as SpanStatus['message']); }; @@ -72,7 +72,7 @@ export function mapStatus(span: AbstractSpan): SpanStatus { } } -function inferStatusFromAttributes(attributes: SpanAttributes): SpanStatus | undefined { +export function inferStatusFromAttributes(attributes: SpanAttributes): SpanStatus | undefined { // If the span status is UNSET, we try to infer it from HTTP or GRPC status codes. // eslint-disable-next-line typescript/no-deprecated diff --git a/packages/opentelemetry/src/utils/parseSpanDescription.ts b/packages/opentelemetry/src/utils/parseSpanDescription.ts index ea06acb3a060..b4af5085f6c7 100644 --- a/packages/opentelemetry/src/utils/parseSpanDescription.ts +++ b/packages/opentelemetry/src/utils/parseSpanDescription.ts @@ -14,7 +14,7 @@ import { RPC_SERVICE, URL_FULL, } from '@sentry/conventions/attributes'; -import type { SpanAttributes, TransactionSource } from '@sentry/core'; +import type { Span, SpanAttributes, TransactionSource } from '@sentry/core'; import { getSanitizedUrlString, parseUrl, @@ -22,6 +22,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanToJSON, stripUrlQueryAndFragment, } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '../semanticAttributes'; @@ -104,10 +105,22 @@ export function inferSpanData(spanName: string, attributes: SpanAttributes, kind * Based on https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/7422ce2a06337f68a59b552b8c5a2ac125d6bae5/exporter/sentryexporter/sentry_exporter.go#L306 */ export function parseSpanDescription(span: AbstractSpan): SpanDescription { - const attributes = spanHasAttributes(span) ? span.attributes : {}; - const name = spanHasName(span) ? span.name : ''; - const kind = getSpanKind(span); + let attributes: Attributes; + let name: string; + + // TODO(v11): Once the OTel SDK provider is removed and SentryTracerProvider is the only path, + // every span is a native Sentry span — drop this `spanHasAttributes` (OTel ReadableSpan) branch + // and keep only the `spanToJSON()` path below. + if (spanHasAttributes(span)) { + attributes = span.attributes; + name = spanHasName(span) ? span.name : ''; + } else { + const json = typeof (span as Span).spanContext === 'function' ? spanToJSON(span as Span) : undefined; + attributes = json?.data || {}; + name = spanHasName(span) ? span.name : json?.description || ''; + } + const kind = getSpanKind(span); return inferSpanData(name, attributes, kind); } diff --git a/packages/opentelemetry/src/utils/setupCheck.ts b/packages/opentelemetry/src/utils/setupCheck.ts index 66bc7b445f83..4ac3e07db1fe 100644 --- a/packages/opentelemetry/src/utils/setupCheck.ts +++ b/packages/opentelemetry/src/utils/setupCheck.ts @@ -1,4 +1,9 @@ -type OpenTelemetryElement = 'SentrySpanProcessor' | 'SentryContextManager' | 'SentryPropagator' | 'SentrySampler'; +type OpenTelemetryElement = + | 'SentrySpanProcessor' + | 'SentryContextManager' + | 'SentryPropagator' + | 'SentrySampler' + | 'SentryTracerProvider'; const setupElements = new Set(); diff --git a/packages/opentelemetry/test/tracerProvider.test.ts b/packages/opentelemetry/test/tracerProvider.test.ts new file mode 100644 index 000000000000..bc10abce1370 --- /dev/null +++ b/packages/opentelemetry/test/tracerProvider.test.ts @@ -0,0 +1,232 @@ +import { context, SpanKind, trace, TraceFlags } from '@opentelemetry/api'; +import { suppressTracing } from '@opentelemetry/core'; +import { + getActiveSpan, + getCapturedScopesOnSpan, + getRootSpan, + spanToJSON, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpanManual, + type Span, + withIsolationScope, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SentryAsyncLocalStorageContextManager } from '../src/asyncLocalStorageContextManager'; +import { setOpenTelemetryContextAsyncContextStrategy } from '../src/asyncContextStrategy'; +import { applyOtelSpanData } from '../src/applyOtelSpanData'; +import { SentryTracerProvider } from '../src/tracerProvider'; +import { cleanupOtel } from './helpers/mockSdkInit'; +import { init as initTestClient } from './helpers/TestClient'; + +describe('SentryTracerProvider', () => { + beforeEach(() => { + (global as { __SENTRY__?: unknown }).__SENTRY__ = {}; + setOpenTelemetryContextAsyncContextStrategy(); + initTestClient({ tracesSampleRate: 1 }); + context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); + trace.setGlobalTracerProvider(new SentryTracerProvider()); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('creates Sentry spans from the global OpenTelemetry tracer', () => { + const span = trace.getTracer('test').startSpan('SELECT users', { + attributes: { + 'db.system.name': 'postgresql', + 'db.statement': 'SELECT * FROM users', + }, + }); + + expect(spanToJSON(span as Span)).toEqual({ + data: { + 'sentry.origin': 'manual', + 'sentry.op': 'db', + 'sentry.sample_rate': 1, + 'sentry.source': 'task', + 'db.system.name': 'postgresql', + 'db.statement': 'SELECT * FROM users', + }, + description: 'SELECT * FROM users', + op: 'db', + origin: 'manual', + parent_span_id: undefined, + span_id: span.spanContext().spanId, + start_timestamp: expect.any(Number), + status: undefined, + timestamp: undefined, + trace_id: span.spanContext().traceId, + profile_id: undefined, + exclusive_time: undefined, + measurements: undefined, + is_segment: undefined, + segment_id: undefined, + links: undefined, + }); + }); + + it('parents inactive spans to the active OpenTelemetry span', () => { + trace.getTracer('test').startActiveSpan('parent', parent => { + const child = trace.getTracer('test').startSpan('child'); + + expect(spanToJSON(child as Span).parent_span_id).toBe(parent.spanContext().spanId); + }); + }); + + it('links non-recording spans to a suppressed active parent', () => { + trace.getTracer('test').startActiveSpan('parent', parent => { + const suppressedContext = suppressTracing(context.active()); + const child = trace.getTracer('test').startSpan('child', {}, suppressedContext); + + expect(child.isRecording()).toBe(false); + expect(spanToJSON(child as Span).trace_id).toBe(parent.spanContext().traceId); + // Non-recording spans no longer carry a `parent_span_id` under the scope-based + // sampling model; the child is instead linked to the parent's span tree. + expect(getRootSpan(child as Span)).toBe(getRootSpan(parent as unknown as Span)); + + parent.end(); + }); + }); + + it('captures scopes on suppressed spans so startActiveSpan can fork the isolation scope', () => { + withIsolationScope(isolationScope => { + const suppressedContext = suppressTracing(context.active()); + const span = trace.getTracer('test').startSpan('child', {}, suppressedContext); + + // Without captured scopes, startActiveSpan cannot fork the isolation scope onto the context. + expect(getCapturedScopesOnSpan(span as unknown as Span).isolationScope).toBe(isolationScope); + }); + }); + + it('sets active OpenTelemetry spans on the Sentry scope', () => { + trace.getTracer('test').startActiveSpan('parent', parent => { + expect(getActiveSpan()).toBe(parent); + }); + }); + + it('syncs manual OpenTelemetry context switches onto the Sentry scope', () => { + const tracer = trace.getTracer('test'); + + tracer.startActiveSpan('parent', parent => { + const child = tracer.startSpan('child'); + const childContext = trace.setSpan(context.active(), child); + + context.with(childContext, () => { + expect(getActiveSpan()).toBe(child); + }); + + expect(getActiveSpan()).toBe(parent); + + child.end(); + parent.end(); + }); + }); + + it('parents core spans to the active OpenTelemetry span', () => { + trace.getTracer('test').startActiveSpan('parent', parent => { + startSpanManual({ name: 'child' }, child => { + expect(spanToJSON(child).parent_span_id).toBe(parent.spanContext().spanId); + child.end(); + }); + }); + }); + + it('continues remote OpenTelemetry span contexts as root Sentry spans', () => { + const remoteContext = trace.setSpanContext(context.active(), { + traceId: '12312012123120121231201212312012', + spanId: '1121201211212012', + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + }); + + const span = trace.getTracer('test').startSpan('server', { kind: SpanKind.SERVER }, remoteContext); + const json = spanToJSON(span as Span); + + expect(json.trace_id).toBe('12312012123120121231201212312012'); + expect(json.parent_span_id).toBe('1121201211212012'); + expect(json.data?.['otel.kind']).toBe('SERVER'); + }); + + it('finalizes span statuses like the OpenTelemetry exporter', () => { + const okSpan = trace.getTracer('test').startSpan('ok'); + applyOtelSpanData(okSpan as Span, { finalizeStatus: true }); + expect(spanToJSON(okSpan as Span).status).toBe('ok'); + + const httpErrorSpan = trace.getTracer('test').startSpan('http-error'); + httpErrorSpan.setAttribute('http.response.status_code', 500); + applyOtelSpanData(httpErrorSpan as Span, { finalizeStatus: true }); + expect(spanToJSON(httpErrorSpan as Span).status).toBe('internal_error'); + + const legacyHttpErrorSpan = trace.getTracer('test').startSpan('legacy-http-error'); + legacyHttpErrorSpan.setAttribute('http.status_code', 500); + applyOtelSpanData(legacyHttpErrorSpan as Span, { finalizeStatus: true }); + expect(spanToJSON(legacyHttpErrorSpan as Span).status).toBe('internal_error'); + expect(spanToJSON(legacyHttpErrorSpan as Span).data).toMatchObject({ + 'http.response.status_code': 500, + 'http.status_code': 500, + }); + + const customErrorSpan = trace.getTracer('test').startSpan('custom-error'); + customErrorSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'This is a custom error' }); + applyOtelSpanData(customErrorSpan as Span, { finalizeStatus: true }); + expect(spanToJSON(customErrorSpan as Span).status).toBe('internal_error'); + }); + + it('preserves an explicit OK status when finalizing', () => { + const span = trace.getTracer('test').startSpan('explicit-ok'); + span.setStatus({ code: SPAN_STATUS_OK }); + + applyOtelSpanData(span as Span, { finalizeStatus: true }); + + expect(spanToJSON(span as Span).status).toBe('ok'); + }); + + it('keeps default custom source on provider-created spans', () => { + const span = trace.getTracer('test').startSpan('custom-source'); + span.setAttribute('sentry.source', 'custom'); + + applyOtelSpanData(span as Span, { finalizeStatus: true }); + + expect(spanToJSON(span as Span).data?.['sentry.source']).toBe('custom'); + }); + + it('infers route source, op, and name for HTTP server spans', () => { + const span = trace.getTracer('test').startSpan('GET', { + kind: SpanKind.SERVER, + attributes: { + 'http.method': 'GET', + 'http.route': '/my-path/:id', + }, + }); + + const json = spanToJSON(span as Span); + expect(json.op).toBe('http.server'); + expect(json.data?.['sentry.source']).toBe('route'); + expect(json.description).toBe('GET /my-path/:id'); + }); + + it('defers url source to span end, keeping custom for the DSC at creation', () => { + const span = trace.getTracer('test').startSpan('POST', { + kind: SpanKind.SERVER, + attributes: { + 'http.method': 'POST', + 'http.url': 'https://www.example.com/my-path', + 'http.target': '/my-path', + }, + }); + + // At creation op and name are inferred, but the `url` source is intentionally + // deferred so the default `custom` source survives for the DSC transaction name + // (http.route is often not available yet at this point). + const atCreation = spanToJSON(span as Span); + expect(atCreation.op).toBe('http.server'); + expect(atCreation.description).toBe('POST /my-path'); + expect(atCreation.data?.['sentry.source']).toBe('custom'); + + // At span end the inferred `url` source is applied. + applyOtelSpanData(span as Span, { finalizeStatus: true }); + expect(spanToJSON(span as Span).data?.['sentry.source']).toBe('url'); + }); +}); diff --git a/packages/opentelemetry/test/utils/setupCheck.test.ts b/packages/opentelemetry/test/utils/setupCheck.test.ts index 526945108ba7..16533c265793 100644 --- a/packages/opentelemetry/test/utils/setupCheck.test.ts +++ b/packages/opentelemetry/test/utils/setupCheck.test.ts @@ -2,7 +2,8 @@ import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SentrySampler } from '../../src/sampler'; import { SentrySpanProcessor } from '../../src/spanProcessor'; -import { openTelemetrySetupCheck } from '../../src/utils/setupCheck'; +import { SentryTracerProvider } from '../../src/tracerProvider'; +import { openTelemetrySetupCheck, setIsSetup } from '../../src/utils/setupCheck'; import { setupOtel } from '../helpers/initOtel'; import { cleanupOtel } from '../helpers/mockSdkInit'; import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; @@ -41,4 +42,20 @@ describe('openTelemetrySetupCheck', () => { const setup = openTelemetrySetupCheck(); expect(setup).toEqual(['SentrySampler', 'SentrySpanProcessor']); }); + + it('does not mark SentryTracerProvider as set up on construction', () => { + // Construction must not mark setup — that only happens once the provider is + // successfully registered as the global tracer provider. Otherwise setup + // validation would skip required checks even when registration failed. + new SentryTracerProvider(); + + expect(openTelemetrySetupCheck()).toEqual([]); + }); + + it('returns SentryTracerProvider setup once it is marked as set up', () => { + setIsSetup('SentryTracerProvider'); + + const setup = openTelemetrySetupCheck(); + expect(setup).toEqual(['SentryTracerProvider']); + }); }); From dcb066e543d1e544e177057aa4c9df82b80bc2b1 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 22 Jun 2026 20:30:15 +0200 Subject: [PATCH 02/16] Explain segment-root source guard --- packages/opentelemetry/src/applyOtelSpanData.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opentelemetry/src/applyOtelSpanData.ts b/packages/opentelemetry/src/applyOtelSpanData.ts index 47580f14ef8c..bda5c217723a 100644 --- a/packages/opentelemetry/src/applyOtelSpanData.ts +++ b/packages/opentelemetry/src/applyOtelSpanData.ts @@ -46,12 +46,15 @@ export function applyOtelSpanData(span: Span, options: { finalizeStatus?: boolea span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, inferred.op); } - // Don't apply 'url' source at creation time — only at span end (finalizeStatus). + // Don't apply 'url' source at creation time, only at span end (finalizeStatus). // At creation, http.route may not be set yet, so inference falls back to 'url'. // Keeping the default 'custom' source from _startRootSpan allows // enhanceDscWithOpenTelemetryRootSpanName to include the transaction name in // the DSC. At span end, http.route is typically available and inference returns // 'route' instead. If it's still 'url', it's applied then. + // We also only set `source` on segment roots (spans that become transactions): + // those with no parent, plus SERVER spans, which are the segment root even when + // continuing a distributed trace (where they carry a remote `parent_span_id`). const shouldApplyInferredSource = inferred.source !== undefined && inferred.source !== 'custom' && From af7946d53c4eaa530e1fac9b5accd38eb9236792 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 22 Jun 2026 20:43:38 +0200 Subject: [PATCH 03/16] Use updateName instead of writing _name directly --- packages/opentelemetry/src/applyOtelSpanData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opentelemetry/src/applyOtelSpanData.ts b/packages/opentelemetry/src/applyOtelSpanData.ts index bda5c217723a..e0a9c6c4aac2 100644 --- a/packages/opentelemetry/src/applyOtelSpanData.ts +++ b/packages/opentelemetry/src/applyOtelSpanData.ts @@ -85,7 +85,7 @@ export function applyOtelSpanData(span: Span, options: { finalizeStatus?: boolea inferred.description !== spanJSON.description && (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] !== 'custom' || (mayInferSource && !hasCustomSpanName)) ) { - addNonEnumerableProperty(span as Span & { _name?: string }, '_name', inferred.description); + span.updateName(inferred.description); } } From 3b31965d63a1873336fa017e0b30af4d13dcc494 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 22 Jun 2026 21:30:07 +0200 Subject: [PATCH 04/16] Drop redudandant isolation scope pinning in startActiveSpan --- packages/opentelemetry/src/tracer.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/opentelemetry/src/tracer.ts b/packages/opentelemetry/src/tracer.ts index e1255e1dc471..fe804d41718b 100644 --- a/packages/opentelemetry/src/tracer.ts +++ b/packages/opentelemetry/src/tracer.ts @@ -6,7 +6,6 @@ import { _INTERNAL_setSpanForScope, _INTERNAL_startInactiveSpan, addChildSpanToSpan, - getCapturedScopesOnSpan, getCurrentScope, getDynamicSamplingContextFromSpan, getIsolationScope, @@ -19,7 +18,6 @@ import { } from '@sentry/core'; import type { Span, SpanAttributes, SpanLink } from '@sentry/core'; import { applyOtelSpanData, applyOtelSpanKind } from './applyOtelSpanData'; -import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY } from './constants'; import { getSamplingDecision } from './utils/getSamplingDecision'; export class SentryTracer implements Tracer { @@ -68,12 +66,7 @@ export class SentryTracer implements Tracer { ) as F; const span = this.startSpan(name, options, ctx); - let ctxWithSpan = trace.setSpan(ctx, span); - - const capturedIsolationScope = getCapturedScopesOnSpan(span as unknown as Span).isolationScope; - if (capturedIsolationScope) { - ctxWithSpan = ctxWithSpan.setValue(SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, capturedIsolationScope); - } + const ctxWithSpan = trace.setSpan(ctx, span); return context.with(ctxWithSpan, () => { _INTERNAL_setSpanForScope(getCurrentScope(), span as unknown as Span); From 23bd3c0360f92831c06c120f654d0e5f160547e3 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 23 Jun 2026 00:08:06 +0200 Subject: [PATCH 05/16] Fix naming differences --- packages/opentelemetry/README.md | 2 +- packages/opentelemetry/src/custom/client.ts | 4 ++-- packages/opentelemetry/src/exports.ts | 2 +- packages/opentelemetry/src/types.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index b955b6121921..265a761c9a0b 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -107,7 +107,7 @@ In `@sentry/node`, this provider can be enabled with the experimental option: Sentry.init({ dsn: 'xxx', _experiments: { - useSentryTraceProvider: true, + useSentryTracerProvider: true, }, }); ``` diff --git a/packages/opentelemetry/src/custom/client.ts b/packages/opentelemetry/src/custom/client.ts index ed97faae4f62..15a4e84bf5b2 100644 --- a/packages/opentelemetry/src/custom/client.ts +++ b/packages/opentelemetry/src/custom/client.ts @@ -2,7 +2,7 @@ import type { Tracer } from '@opentelemetry/api'; import { trace } from '@opentelemetry/api'; import type { Client } from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; -import type { OpenTelemetryClient as OpenTelemetryClientInterface, OpenTelemetryTraceProvider } from '../types'; +import type { OpenTelemetryClient as OpenTelemetryClientInterface, OpenTelemetryTracerProvider } from '../types'; // Typescript complains if we do not use `...args: any[]` for the mixin, with: // A mixin class must have a constructor with a single rest parameter of type 'any[]'.ts(2545) @@ -22,7 +22,7 @@ export function wrapClientClass< >(ClientClass: ClassConstructor): WrappedClassConstructor { // @ts-expect-error We just assume that this is non-abstract, if you pass in an abstract class this would make it non-abstract class OpenTelemetryClient extends ClientClass implements OpenTelemetryClientInterface { - public traceProvider: OpenTelemetryTraceProvider | undefined; + public traceProvider: OpenTelemetryTracerProvider | undefined; private _tracer: Tracer | undefined; public constructor(...args: any[]) { diff --git a/packages/opentelemetry/src/exports.ts b/packages/opentelemetry/src/exports.ts index 18f5ea29a98b..ea53353f8e7c 100644 --- a/packages/opentelemetry/src/exports.ts +++ b/packages/opentelemetry/src/exports.ts @@ -48,7 +48,7 @@ export { SentrySpanProcessor } from './spanProcessor'; export { SentrySampler, wrapSamplingDecision } from './sampler'; export { applyOtelSpanData } from './applyOtelSpanData'; export { SentryTracerProvider } from './tracerProvider'; -export type { OpenTelemetryTraceProvider } from './types'; +export type { OpenTelemetryTracerProvider } from './types'; export { openTelemetrySetupCheck, setIsSetup } from './utils/setupCheck'; diff --git a/packages/opentelemetry/src/types.ts b/packages/opentelemetry/src/types.ts index 4563f7b8c72f..1061d7e00730 100644 --- a/packages/opentelemetry/src/types.ts +++ b/packages/opentelemetry/src/types.ts @@ -2,14 +2,14 @@ import type { Span as WriteableSpan, SpanKind, Tracer, TracerProvider } from '@o import type { BasicTracerProvider, ReadableSpan } from '@opentelemetry/sdk-trace-base'; import type { Scope, Span, StartSpanOptions } from '@sentry/core'; -export interface OpenTelemetryTraceProvider extends TracerProvider { +export interface OpenTelemetryTracerProvider extends TracerProvider { forceFlush(): Promise; shutdown(): Promise; } export interface OpenTelemetryClient { tracer: Tracer; - traceProvider: BasicTracerProvider | OpenTelemetryTraceProvider | undefined; + traceProvider: BasicTracerProvider | OpenTelemetryTracerProvider | undefined; } export interface OpenTelemetrySpanContext extends StartSpanOptions { From 63d71bd49c737f8964c6126d60a17490ec3f6416 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 23 Jun 2026 13:01:57 +0200 Subject: [PATCH 06/16] Reinstate isolation scope pinning in startActiveSpan --- packages/opentelemetry/src/tracer.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/opentelemetry/src/tracer.ts b/packages/opentelemetry/src/tracer.ts index fe804d41718b..ae49f9d41249 100644 --- a/packages/opentelemetry/src/tracer.ts +++ b/packages/opentelemetry/src/tracer.ts @@ -6,6 +6,7 @@ import { _INTERNAL_setSpanForScope, _INTERNAL_startInactiveSpan, addChildSpanToSpan, + getCapturedScopesOnSpan, getCurrentScope, getDynamicSamplingContextFromSpan, getIsolationScope, @@ -18,6 +19,7 @@ import { } from '@sentry/core'; import type { Span, SpanAttributes, SpanLink } from '@sentry/core'; import { applyOtelSpanData, applyOtelSpanKind } from './applyOtelSpanData'; +import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY } from './constants'; import { getSamplingDecision } from './utils/getSamplingDecision'; export class SentryTracer implements Tracer { @@ -66,7 +68,15 @@ export class SentryTracer implements Tracer { ) as F; const span = this.startSpan(name, options, ctx); - const ctxWithSpan = trace.setSpan(ctx, span); + let ctxWithSpan = trace.setSpan(ctx, span); + + // Run the span's callback under the isolation scope captured when the span was created, so scope state + // used or set during the span (tags, breadcrumbs, captured errors) belongs to that span and stays + // isolated from other concurrent work. Without this it can land on a different isolation scope. + const capturedIsolationScope = getCapturedScopesOnSpan(span as unknown as Span).isolationScope; + if (capturedIsolationScope) { + ctxWithSpan = ctxWithSpan.setValue(SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, capturedIsolationScope); + } return context.with(ctxWithSpan, () => { _INTERNAL_setSpanForScope(getCurrentScope(), span as unknown as Span); From 92985238c5cfed6bc31dcb0bf185cec78860845f Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 00:28:49 +0200 Subject: [PATCH 07/16] Start a new trace for parentless root spans in SentryTracerProvider A root span with no parent and no remote (incoming) parent previously continued the scope's propagation context, so manually-started parallel root spans in the same scope all collapsed into a single shared trace. The OpenTelemetry SDK instead mints a fresh trace id per such root span. Wrap the no-parent branch of `_startSentrySpan` in `startNewTrace` (matching the existing `options.root` branch) so each parentless root span gets its own trace. Incoming traces are unaffected, since `continueTrace` sets a remote parent and takes the `_startRootSpanWithRemoteParent` branch instead. --- packages/opentelemetry/src/tracer.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/opentelemetry/src/tracer.ts b/packages/opentelemetry/src/tracer.ts index ae49f9d41249..5815eb20261a 100644 --- a/packages/opentelemetry/src/tracer.ts +++ b/packages/opentelemetry/src/tracer.ts @@ -109,10 +109,15 @@ export class SentryTracer implements Tracer { return _INTERNAL_startInactiveSpan({ ...sentryOptions, parentSpan: parentSpan as unknown as Span }); } - return _INTERNAL_startInactiveSpan({ - ...sentryOptions, - parentSpan: hasExplicitContext ? null : undefined, - }); + // 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, + }), + ); } private _startRootSpanWithRemoteParent( From 8fa354dd8560c5a1f29c9b7e19f6565ac908484a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 12:48:25 +0200 Subject: [PATCH 08/16] Don't freeze an incomplete DSC when continuing a baggageless remote trace When `SentryTracer` continues a remote trace whose incoming headers carried no baggage, `_startRootSpanWithRemoteParent` froze a derived-but-incomplete dynamic sampling context (missing `sample_rand` and `transaction`) onto the span, which then propagated downstream. Only freeze the DSC when the remote parent actually carried one (its trace state has the `sentry.dsc` key); otherwise leave it unset so it is derived dynamically from the span, matching the OpenTelemetry SDK path, which never freezes the DSC there and resolves it lazily (picking up `transaction` and `sample_rand`). --- packages/opentelemetry/src/tracer.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/opentelemetry/src/tracer.ts b/packages/opentelemetry/src/tracer.ts index 5815eb20261a..94f42237782a 100644 --- a/packages/opentelemetry/src/tracer.ts +++ b/packages/opentelemetry/src/tracer.ts @@ -19,7 +19,7 @@ import { } from '@sentry/core'; import type { Span, SpanAttributes, SpanLink } from '@sentry/core'; import { applyOtelSpanData, applyOtelSpanKind } from './applyOtelSpanData'; -import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY } from './constants'; +import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, SENTRY_TRACE_STATE_DSC } from './constants'; import { getSamplingDecision } from './utils/getSamplingDecision'; export class SentryTracer implements Tracer { @@ -124,16 +124,21 @@ export class SentryTracer implements Tracer { options: Parameters[0], parentSpan: OpenTelemetrySpan, ): Span { - const { spanId, traceId } = parentSpan.spanContext(); + const { spanId, traceId, traceState } = parentSpan.spanContext(); const dsc = getDynamicSamplingContextFromSpan(parentSpan as unknown as Span); const sampleRand = typeof dsc.sample_rand === 'string' ? Number(dsc.sample_rand) : undefined; + // Only freeze the DSC when the remote parent actually carried one (i.e. there was incoming + // baggage). Otherwise leave it unset so it is derived dynamically from the span — picking up the + // span's `transaction` name and the generated `sample_rand` — matching the OpenTelemetry SDK. + const hasIncomingDsc = !!traceState?.get(SENTRY_TRACE_STATE_DSC); + return withScope(scope => { scope.setPropagationContext({ traceId, parentSpanId: spanId, sampled: getSamplingDecision(parentSpan.spanContext()), - dsc, + dsc: hasIncomingDsc ? dsc : undefined, sampleRand: typeof sampleRand === 'number' && !Number.isNaN(sampleRand) ? sampleRand : _INTERNAL_safeMathRandom(), }); From 572302d28610f6b6a7233c4134705a33ae45b27e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 16:44:38 +0200 Subject: [PATCH 09/16] Pass normalizedRequest to the sampling context for root spans --- packages/core/src/tracing/trace.ts | 1 + packages/core/test/lib/tracing/trace.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index e958e6f2421c..ab2c744ee9c1 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -513,6 +513,7 @@ function _startRootSpan( name, parentSampled: finalParentSampled, attributes: finalAttributes, + normalizedRequest: isolationScope.getScopeData().sdkProcessingMetadata.normalizedRequest, parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate), }, currentPropagationContext.sampleRand, diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index f2e605a7e4de..a15cb9c7abdf 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -801,6 +801,20 @@ describe('startSpan', () => { inheritOrSampleWith: expect.any(Function), }); }); + + it('passes normalizedRequest from the isolation scope to the sampling context', () => { + const options = getDefaultTestClientOptions({ tracesSampler }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + const normalizedRequest = { url: '/test?query=123', method: 'GET', query_string: 'query=123' }; + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest }); + + startSpan({ name: 'outer' }, () => {}); + + expect(tracesSampler).toHaveBeenLastCalledWith(expect.objectContaining({ normalizedRequest })); + }); }); it('includes the scope at the time the span was started when finished', async () => { From 90a6c5c676015d067feeb7bafc0a95f7185db818 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 19:01:17 +0200 Subject: [PATCH 10/16] Strip leading ? and # from inferred http.query and http.fragment --- .../src/utils/parseSpanDescription.ts | 6 ++++-- .../test/utils/parseSpanDescription.test.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/opentelemetry/src/utils/parseSpanDescription.ts b/packages/opentelemetry/src/utils/parseSpanDescription.ts index b4af5085f6c7..3dedd1a69cda 100644 --- a/packages/opentelemetry/src/utils/parseSpanDescription.ts +++ b/packages/opentelemetry/src/utils/parseSpanDescription.ts @@ -196,10 +196,12 @@ export function descriptionForHttpMethod( data.url = url; } if (query) { - data['http.query'] = query; + // Strip the leading `?`/`#` (the `URL.search`/`URL.hash` prefix) so the attribute matches the + // canonical format the OTel SDK exporter emits (`getData` in `spanExporter.ts` slices these too). + data['http.query'] = query.slice(1); } if (fragment) { - data['http.fragment'] = fragment; + data['http.fragment'] = fragment.slice(1); } // If the span kind is neither client nor server, we use the original name diff --git a/packages/opentelemetry/test/utils/parseSpanDescription.test.ts b/packages/opentelemetry/test/utils/parseSpanDescription.test.ts index 3a35dc4ff72a..3036d315568e 100644 --- a/packages/opentelemetry/test/utils/parseSpanDescription.test.ts +++ b/packages/opentelemetry/test/utils/parseSpanDescription.test.ts @@ -543,6 +543,27 @@ describe('descriptionForHttpMethod', () => { source: 'component', }, ], + [ + 'strips the leading `?`/`#` from http.query and http.fragment', + 'GET', + { + [HTTP_METHOD]: 'GET', + [HTTP_URL]: 'https://www.example.com/my-path?id=1#section', + [HTTP_TARGET]: '/my-path?id=1#section', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'GET https://www.example.com/my-path', + data: { + url: 'https://www.example.com/my-path', + 'http.query': 'id=1', + 'http.fragment': 'section', + }, + source: 'url', + }, + ], ])('%s', (_, httpMethod, attributes, name, kind, expected) => { const actual = descriptionForHttpMethod({ attributes, kind, name }, httpMethod); expect(actual).toEqual(expected); From 1e8fbea7e285506d3d88352f77ed18b523e5508b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 22:18:48 +0200 Subject: [PATCH 11/16] Propagate the sampling decision for native unsampled spans --- packages/core/src/tracing/index.ts | 2 +- packages/opentelemetry/src/propagator.ts | 4 +- ...enhanceDscWithOpenTelemetryRootSpanName.ts | 26 ++++++----- .../src/utils/getSamplingDecision.ts | 44 ++++++++++++++++++- 4 files changed, 60 insertions(+), 16 deletions(-) diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 5278c8a3afc1..e037c057911f 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -7,7 +7,7 @@ export { } from './utils'; export { startIdleSpan, TRACING_DEFAULTS } from './idleSpan'; export { SentrySpan } from './sentrySpan'; -export { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; +export { SentryNonRecordingSpan, spanIsNonRecordingSpan } from './sentryNonRecordingSpan'; export { setHttpStatus, getSpanStatusFromHttpCode } from './spanstatus'; export { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from './spanstatus'; export { diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index 2e0b3d0fa9cf..c22fde1fe750 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -24,7 +24,7 @@ import { import { SENTRY_BAGGAGE_HEADER, SENTRY_TRACE_HEADER, SENTRY_TRACE_STATE_URL } from './constants'; import { DEBUG_BUILD } from './debug-build'; import { getScopesFromContext, setScopesOnContext } from './utils/contextData'; -import { getSamplingDecision } from './utils/getSamplingDecision'; +import { getSampledForPropagation, getSamplingDecision } from './utils/getSamplingDecision'; import { makeTraceState } from './utils/makeTraceState'; import { setIsSetup } from './utils/setupCheck'; @@ -173,7 +173,7 @@ export function getInjectionData( dynamicSamplingContext, traceId: spanContext.traceId, spanId: spanContext.spanId, - sampled: getSamplingDecision(spanContext), // TODO: Do we need to change something here? + sampled: getSampledForPropagation(span, options.client), }; } diff --git a/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts b/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts index 028dba699ab8..14353f60d993 100644 --- a/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts +++ b/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts @@ -1,6 +1,6 @@ import type { Client } from '@sentry/core'; import { hasSpansEnabled, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core'; -import { getSamplingDecision } from './getSamplingDecision'; +import { getSampledForPropagation } from './getSamplingDecision'; import { parseSpanDescription } from './parseSpanDescription'; /** @@ -13,28 +13,30 @@ export function enhanceDscWithOpenTelemetryRootSpanName(client: Client): void { return; } - // We want to overwrite the transaction on the DSC that is created by default in core - // The reason for this is that we want to infer the span name, not use the initial one - // Otherwise, we'll get names like "GET" instead of e.g. "GET /foo" - // `parseSpanDescription` takes the attributes of the span into account for the name - // This mutates the passed-in DSC - const jsonSpan = spanToJSON(rootSpan); const attributes = jsonSpan.data; const source = attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - if (jsonSpan.description) { + const sampled = getSampledForPropagation(rootSpan, client); + + // We want to overwrite the transaction on the DSC that is created by default in core, so that we + // infer the span name (e.g. "GET /foo" instead of "GET"); `parseSpanDescription` reads the span + // attributes. This mutates the passed-in DSC. + // A negatively sampled trace carries no transaction name in its DSC, matching the OTel SDK whose + // unsampled spans are nameless non-recording spans. Core derives one from the span name, so we + // drop it here for native (SentryTracerProvider) spans that do have a name. + if (sampled === false) { + delete dsc.transaction; + } else if (jsonSpan.description) { const { description } = parseSpanDescription(rootSpan); if (source !== 'url' && description) { dsc.transaction = description; } } - // Also ensure sampling decision is correctly inferred - // In core, we use `spanIsSampled`, which just looks at the trace flags - // but in OTEL, we use a slightly more complex logic to be able to differntiate between unsampled and deferred sampling + // Only write the sampling decision in tracing mode. In TwP mode it is deferred (read from the + // scope/incoming trace state), so we leave any value core already resolved untouched. if (hasSpansEnabled()) { - const sampled = getSamplingDecision(rootSpan.spanContext()); dsc.sampled = sampled == undefined ? undefined : String(sampled); } }); diff --git a/packages/opentelemetry/src/utils/getSamplingDecision.ts b/packages/opentelemetry/src/utils/getSamplingDecision.ts index 216b5249224e..dbd0fa5b348d 100644 --- a/packages/opentelemetry/src/utils/getSamplingDecision.ts +++ b/packages/opentelemetry/src/utils/getSamplingDecision.ts @@ -1,6 +1,13 @@ import type { SpanContext } from '@opentelemetry/api'; import { TraceFlags } from '@opentelemetry/api'; -import { baggageHeaderToDynamicSamplingContext } from '@sentry/core'; +import type { Client, Span } from '@sentry/core'; +import { + baggageHeaderToDynamicSamplingContext, + getRootSpan, + hasSpansEnabled, + spanIsNonRecordingSpan, + spanIsSampled, +} from '@sentry/core'; import { SENTRY_TRACE_STATE_DSC, SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING } from '../constants'; /** @@ -40,3 +47,38 @@ export function getSamplingDecision(spanContext: SpanContext): boolean | undefin return undefined; } + +/** + * Resolve a span's sampling decision for trace propagation, also handling native Sentry spans. + * + * Prefer the OpenTelemetry trace state via {@link getSamplingDecision}. Native Sentry spans (created + * by the `SentryTracerProvider`) don't carry that trace state, so when it's absent we fall back to the + * span's own decision via `spanIsSampled` — but only for an *explicit* decision. An explicit decision + * always originates at a real `SentrySpan` root (a negatively sampled root, or a child of one). A + * non-recording placeholder root (an orphan/suppressed span, or a TwP placeholder) and a remote span + * have a *deferred* decision that lives elsewhere (the scope, or the incoming trace state), so we + * return `undefined` and leave the decision deferred rather than wrongly asserting `-0`. + * + * TODO(v11): Once the OTel SDK provider is gone and every local span is a native Sentry span, the + * trace-state lookup only matters for remote (incoming) spans; the local path always reads the span's + * own decision, so the "native-vs-OTel-SDK span" framing can be dropped (local → span, remote → trace state). + */ +export function getSampledForPropagation(span: Span, client: Client | undefined): boolean | undefined { + const spanContext = span.spanContext(); + + // Prefer the OTel trace state: it carries the decision for OTel SDK spans and for remote (incoming) + // spans, and unambiguously separates sampled / unsampled / deferred. + const samplingDecision = getSamplingDecision(spanContext); + if (samplingDecision !== undefined) { + return samplingDecision; + } + + // No trace state — this is a native local span. Only read its own decision (`spanIsSampled`) when + // that decision is explicit: skip TwP (deferred), remote spans (decision is in the incoming trace + // state, incl. a deferred one), and non-recording placeholder roots (orphan/suppressed — deferred). + if (!hasSpansEnabled(client?.getOptions()) || spanContext.isRemote || spanIsNonRecordingSpan(getRootSpan(span))) { + return undefined; + } + + return spanIsSampled(span); +} From ca1221a00e485b3f86caf6936642621d6fc0d0f8 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 24 Jun 2026 23:16:59 +0200 Subject: [PATCH 12/16] Only apply the native-span sampling fallback to SentrySpans --- packages/core/src/shared-exports.ts | 1 + packages/core/src/tracing/index.ts | 2 +- packages/core/src/utils/spanUtils.ts | 2 +- .../opentelemetry/src/utils/getSamplingDecision.ts | 12 +++++++----- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 97cc2cb40b7d..b62466a778d9 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -97,6 +97,7 @@ export { spanToJSON, spanToStreamedSpanJSON, spanIsSampled, + spanIsSentrySpan, spanToTraceContext, getSpanDescendants, getStatusMessage, diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index e037c057911f..5278c8a3afc1 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -7,7 +7,7 @@ export { } from './utils'; export { startIdleSpan, TRACING_DEFAULTS } from './idleSpan'; export { SentrySpan } from './sentrySpan'; -export { SentryNonRecordingSpan, spanIsNonRecordingSpan } from './sentryNonRecordingSpan'; +export { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; export { setHttpStatus, getSpanStatusFromHttpCode } from './spanstatus'; export { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from './spanstatus'; export { diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 9f495ef7b30e..78c8e0410454 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -297,7 +297,7 @@ export interface OpenTelemetrySdkTraceBaseSpan extends Span { * Sadly, due to circular dependency checks we cannot actually import the Span class here and check for instanceof. * :( So instead we approximate this by checking if it has the `getSpanJSON` method. */ -function spanIsSentrySpan(span: Span): span is SentrySpan { +export function spanIsSentrySpan(span: Span): span is SentrySpan { return typeof (span as SentrySpan).getSpanJSON === 'function'; } diff --git a/packages/opentelemetry/src/utils/getSamplingDecision.ts b/packages/opentelemetry/src/utils/getSamplingDecision.ts index dbd0fa5b348d..6089126b04a0 100644 --- a/packages/opentelemetry/src/utils/getSamplingDecision.ts +++ b/packages/opentelemetry/src/utils/getSamplingDecision.ts @@ -5,8 +5,8 @@ import { baggageHeaderToDynamicSamplingContext, getRootSpan, hasSpansEnabled, - spanIsNonRecordingSpan, spanIsSampled, + spanIsSentrySpan, } from '@sentry/core'; import { SENTRY_TRACE_STATE_DSC, SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING } from '../constants'; @@ -73,10 +73,12 @@ export function getSampledForPropagation(span: Span, client: Client | undefined) return samplingDecision; } - // No trace state — this is a native local span. Only read its own decision (`spanIsSampled`) when - // that decision is explicit: skip TwP (deferred), remote spans (decision is in the incoming trace - // state, incl. a deferred one), and non-recording placeholder roots (orphan/suppressed — deferred). - if (!hasSpansEnabled(client?.getOptions()) || spanContext.isRemote || spanIsNonRecordingSpan(getRootSpan(span))) { + // No trace state in it. Only read the span's own decision (`spanIsSampled`) when it's an explicit + // one, which lives on a native recording `SentrySpan` root (created by the SentryTracerProvider). + // Everything else defers: TwP (deferred), remote spans (decision is in the incoming trace state), + // and non-recording placeholder roots — whether a Sentry orphan/suppressed span or, on the OTel SDK + // path, an OpenTelemetry `NonRecordingSpan` (which `spanIsSentrySpan` also excludes). + if (!hasSpansEnabled(client?.getOptions()) || spanContext.isRemote || !spanIsSentrySpan(getRootSpan(span))) { return undefined; } From 8937a0f3bd12b88b4f010bd9377f87dedee8cd44 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 25 Jun 2026 00:31:42 +0200 Subject: [PATCH 13/16] Respect an explicitly set source on spans marked for OTel source inference --- packages/core/src/tracing/index.ts | 2 ++ packages/core/src/tracing/sentrySpan.ts | 8 ++++- packages/core/src/tracing/utils.ts | 22 +++++++++++++ .../core/test/lib/tracing/sentrySpan.test.ts | 32 ++++++++++++++++++- .../opentelemetry/src/applyOtelSpanData.ts | 15 +++++---- 5 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 5278c8a3afc1..a17c95bbc82c 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -4,6 +4,8 @@ export { getCapturedScopesOnSpan, markSpanForOtelSourceInference, spanShouldInferOtelSource, + markSpanSourceAsExplicit, + spanSourceWasExplicitlySet, } from './utils'; export { startIdleSpan, TRACING_DEFAULTS } from './idleSpan'; export { SentrySpan } from './sentrySpan'; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 390ca3eb90e6..35c71a12c0d6 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -46,7 +46,7 @@ import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanEnd } from './logSpans'; import { timedEventsToMeasurements } from './measurement'; import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; -import { getCapturedScopesOnSpan, spanShouldInferOtelSource } from './utils'; +import { getCapturedScopesOnSpan, markSpanSourceAsExplicit, spanShouldInferOtelSource } from './utils'; const MAX_SPAN_COUNT = 1000; @@ -166,6 +166,12 @@ export class SentrySpan implements Span { this._attributes[key] = value; } + // Setting the source on a span branded for OTel-style inference means user code is choosing it + // explicitly, so flag it to keep `applyOtelSpanData` from overriding it with an inferred source. + if (key === SEMANTIC_ATTRIBUTE_SENTRY_SOURCE && value !== undefined && spanShouldInferOtelSource(this)) { + markSpanSourceAsExplicit(this); + } + return this; } diff --git a/packages/core/src/tracing/utils.ts b/packages/core/src/tracing/utils.ts index 9de3a6f4ae77..03b9300ffa61 100644 --- a/packages/core/src/tracing/utils.ts +++ b/packages/core/src/tracing/utils.ts @@ -12,6 +12,12 @@ const ISOLATION_SCOPE_ON_START_SPAN_FIELD = '_sentryIsolationScope'; // so the key is shared across duplicated copies of `@sentry/core`. const OTEL_SOURCE_INFERENCE_SPAN_FIELD = Symbol.for('sentry.otelSourceInference'); +// Brand marking a span (otherwise subject to OTel-style source inference, see above) whose +// `sentry.source` was explicitly set by user code after creation, so `applyOtelSpanData` stops +// inferring and respects the chosen source and name. This is what tells a user-set `custom` source +// apart from the default `custom` that `_startRootSpan` stamps on every root span. +const OTEL_SOURCE_EXPLICITLY_SET_SPAN_FIELD = Symbol.for('sentry.otelSourceExplicitlySet'); + type SpanWithScopes = Span & { [SCOPE_ON_START_SPAN_FIELD]?: Scope; [ISOLATION_SCOPE_ON_START_SPAN_FIELD]?: MaybeWeakRef; @@ -19,6 +25,7 @@ type SpanWithScopes = Span & { type SpanWithOtelSourceInference = Span & { [OTEL_SOURCE_INFERENCE_SPAN_FIELD]?: boolean; + [OTEL_SOURCE_EXPLICITLY_SET_SPAN_FIELD]?: boolean; }; /** Store the scope & isolation scope for a span, which can the be used when it is finished. */ @@ -57,3 +64,18 @@ export function markSpanForOtelSourceInference(span: Span): void { export function spanShouldInferOtelSource(span: Span): boolean { return (span as SpanWithOtelSourceInference)[OTEL_SOURCE_INFERENCE_SPAN_FIELD] === true; } + +/** + * Mark that user code explicitly set `sentry.source` on a span subject to OTel-style inference, so + * `applyOtelSpanData` keeps that source (and name) instead of overriding it. Set by `SentrySpan` + * when `setAttribute` writes the source on an already-branded span (the default `custom` source is + * stamped at construction, before the brand, so it doesn't trip this). + */ +export function markSpanSourceAsExplicit(span: Span): void { + addNonEnumerableProperty(span, OTEL_SOURCE_EXPLICITLY_SET_SPAN_FIELD, true); +} + +/** Whether user code explicitly set `sentry.source` on a span (see {@link markSpanSourceAsExplicit}). */ +export function spanSourceWasExplicitlySet(span: Span): boolean { + return (span as SpanWithOtelSourceInference)[OTEL_SOURCE_EXPLICITLY_SET_SPAN_FIELD] === true; +} diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index dfb7840b4125..26acdb660e53 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -4,7 +4,7 @@ import { setCurrentClient } from '../../../src/sdk'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '../../../src/semanticAttributes'; import { SentrySpan } from '../../../src/tracing/sentrySpan'; import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus'; -import { markSpanForOtelSourceInference } from '../../../src/tracing/utils'; +import { markSpanForOtelSourceInference, spanSourceWasExplicitlySet } from '../../../src/tracing/utils'; import type { SpanJSON } from '../../../src/types/span'; import { spanToJSON, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils'; import { timestampInSeconds } from '../../../src/utils/time'; @@ -61,6 +61,36 @@ describe('SentrySpan', () => { }); }); + describe('explicit source', () => { + it('flags a source set on a span marked for OTel source inference as explicit', () => { + const span = new SentrySpan({ name: 'original name' }); + markSpanForOtelSourceInference(span); + expect(spanSourceWasExplicitlySet(span)).toBe(false); + + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'custom'); + + expect(spanSourceWasExplicitlySet(span)).toBe(true); + }); + + it('does not flag the default source set at construction (before the inference brand) as explicit', () => { + const span = new SentrySpan({ + name: 'original name', + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, + }); + markSpanForOtelSourceInference(span); + + expect(spanSourceWasExplicitlySet(span)).toBe(false); + }); + + it('does not flag a source set on a span that is not marked for OTel source inference', () => { + const span = new SentrySpan({ name: 'original name' }); + + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'custom'); + + expect(spanSourceWasExplicitlySet(span)).toBe(false); + }); + }); + describe('setters', () => { test('setName', () => { const span = new SentrySpan({}); diff --git a/packages/opentelemetry/src/applyOtelSpanData.ts b/packages/opentelemetry/src/applyOtelSpanData.ts index e0a9c6c4aac2..3aae74c24b0a 100644 --- a/packages/opentelemetry/src/applyOtelSpanData.ts +++ b/packages/opentelemetry/src/applyOtelSpanData.ts @@ -6,6 +6,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanShouldInferOtelSource, + spanSourceWasExplicitlySet, spanToJSON, SPAN_STATUS_ERROR, SPAN_STATUS_OK, @@ -32,8 +33,13 @@ export function applyOtelSpanData(span: Span, options: { finalizeStatus?: boolea const kind = (span as SentrySpanWithOtelKind).kind ?? SpanKind.INTERNAL; const mayInferSource = spanShouldInferOtelSource(span); const hasCustomSpanName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME] !== undefined; + // We may only infer the source/name when the span is OTel-branded and user code hasn't already + // chosen them: either via `updateSpanName` (which sets `sentry.custom_span_name`) or by explicitly + // setting `sentry.source`. Without the explicit-source check we couldn't tell a user-set `custom` + // apart from the default `custom` stamped on every root span at span start, and would override it. + const canInferSource = mayInferSource && !hasCustomSpanName && !spanSourceWasExplicitlySet(span); const attributesForInference = - mayInferSource && !hasCustomSpanName && attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom' + canInferSource && attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom' ? { ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: undefined } : attributes; const inferred = inferSpanData(spanJSON.description || '', attributesForInference, kind); @@ -61,10 +67,7 @@ export function applyOtelSpanData(span: Span, options: { finalizeStatus?: boolea (options.finalizeStatus || inferred.source !== 'url') && (spanJSON.parent_span_id === undefined || kind === SpanKind.SERVER); - if ( - shouldApplyInferredSource && - (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === undefined || (mayInferSource && !hasCustomSpanName)) - ) { + if (shouldApplyInferredSource && (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === undefined || canInferSource)) { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, inferred.source); } @@ -83,7 +86,7 @@ export function applyOtelSpanData(span: Span, options: { finalizeStatus?: boolea if ( inferred.description !== spanJSON.description && - (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] !== 'custom' || (mayInferSource && !hasCustomSpanName)) + (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] !== 'custom' || canInferSource) ) { span.updateName(inferred.description); } From d3272b258128c964b4e36e52078888a52b8081bb Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 25 Jun 2026 11:12:57 +0200 Subject: [PATCH 14/16] Extract and export the streamed-span backfill helper --- packages/opentelemetry/src/exports.ts | 1 + packages/opentelemetry/src/spanProcessor.ts | 38 +--------------- .../src/utils/backfillStreamedSpanData.ts | 43 +++++++++++++++++++ 3 files changed, 46 insertions(+), 36 deletions(-) create mode 100644 packages/opentelemetry/src/utils/backfillStreamedSpanData.ts diff --git a/packages/opentelemetry/src/exports.ts b/packages/opentelemetry/src/exports.ts index ea53353f8e7c..409a6c2d1acd 100644 --- a/packages/opentelemetry/src/exports.ts +++ b/packages/opentelemetry/src/exports.ts @@ -47,6 +47,7 @@ export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator'; export { SentrySpanProcessor } from './spanProcessor'; export { SentrySampler, wrapSamplingDecision } from './sampler'; export { applyOtelSpanData } from './applyOtelSpanData'; +export { backfillStreamedSpanDataFromOtel } from './utils/backfillStreamedSpanData'; export { SentryTracerProvider } from './tracerProvider'; export type { OpenTelemetryTracerProvider } from './types'; diff --git a/packages/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts index fb33d0daf4c5..f22ef13767f0 100644 --- a/packages/opentelemetry/src/spanProcessor.ts +++ b/packages/opentelemetry/src/spanProcessor.ts @@ -1,7 +1,7 @@ import type { Context } from '@opentelemetry/api'; import { ROOT_CONTEXT, trace } from '@opentelemetry/api'; import type { ReadableSpan, Span, SpanProcessor as SpanProcessorInterface } from '@opentelemetry/sdk-trace-base'; -import type { Client, SpanAttributes, StreamedSpanJSON } from '@sentry/core'; +import type { Client } from '@sentry/core'; import { addChildSpanToSpan, getClient, @@ -10,17 +10,12 @@ import { hasSpanStreamingEnabled, logSpanEnd, logSpanStart, - safeSetSpanJSONAttributes, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, - SPAN_KIND, - spanKindToName, } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from './semanticAttributes'; import { SentrySpanExporter } from './spanExporter'; +import { backfillStreamedSpanDataFromOtel } from './utils/backfillStreamedSpanData'; import { getScopesFromContext } from './utils/contextData'; -import { inferSpanData } from './utils/parseSpanDescription'; import { setIsSetup } from './utils/setupCheck'; /** * Converts OpenTelemetry Spans to Sentry Spans and sends them to Sentry via @@ -111,32 +106,3 @@ export class SentrySpanProcessor implements SpanProcessorInterface { } } } - -/** - * Backfill op, source, name and data on a streamed span JSON from OTel semantic conventions. - * Mirrors the inference the {@link SentrySpanExporter} applies to non-streamed spans via `getSpanData`. - * Explicitly set attributes are preserved via `safeSetSpanJSONAttributes`. - */ -function backfillStreamedSpanDataFromOtel(spanJSON: StreamedSpanJSON, hint?: { spanKind?: number }): void { - const attributes = spanJSON.attributes; - if (!attributes) { - return; - } - - const kind = hint?.spanKind ?? SPAN_KIND.INTERNAL; - const { op, description, source, data } = inferSpanData(spanJSON.name, attributes as unknown as SpanAttributes, kind); - - spanJSON.name = description; - - safeSetSpanJSONAttributes(spanJSON, { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, - ...data, - }); - - if (kind !== SPAN_KIND.INTERNAL) { - safeSetSpanJSONAttributes(spanJSON, { - 'otel.kind': spanKindToName(kind), - }); - } -} diff --git a/packages/opentelemetry/src/utils/backfillStreamedSpanData.ts b/packages/opentelemetry/src/utils/backfillStreamedSpanData.ts new file mode 100644 index 000000000000..3f30182e6c4a --- /dev/null +++ b/packages/opentelemetry/src/utils/backfillStreamedSpanData.ts @@ -0,0 +1,43 @@ +import { SpanKind } from '@opentelemetry/api'; +import type { SpanAttributes, StreamedSpanJSON } from '@sentry/core'; +import { + safeSetSpanJSONAttributes, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { inferSpanData } from './parseSpanDescription'; + +/** + * Backfill op, source, name and data on a streamed span JSON from OTel semantic conventions. + * Mirrors the inference the {@link SentrySpanExporter} applies to non-streamed spans via `getSpanData`. + * Explicitly set attributes are preserved via `safeSetSpanJSONAttributes`. + * + * Runs as a `preprocessSpan` subscriber (streamed-only) on both span pipelines: the OTel SDK + * `SentrySpanProcessor` and the `SentryTracerProvider`. On the latter, `applyOtelSpanData` has already + * inferred most data on the native span; this fills the remaining gap (per-span `sentry.source` on + * child spans, which `applyOtelSpanData` only sets on segment roots). `inferSpanData` is deterministic + * on the same attributes, so re-running it here is a no-op for already-inferred fields. + */ +export function backfillStreamedSpanDataFromOtel(spanJSON: StreamedSpanJSON, hint?: { spanKind?: number }): void { + const attributes = spanJSON.attributes; + if (!attributes) { + return; + } + + const kind = hint?.spanKind ?? SpanKind.INTERNAL; + const { op, description, source, data } = inferSpanData(spanJSON.name, attributes as unknown as SpanAttributes, kind); + + spanJSON.name = description; + + safeSetSpanJSONAttributes(spanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + ...data, + }); + + if (kind !== SpanKind.INTERNAL) { + safeSetSpanJSONAttributes(spanJSON, { + 'otel.kind': SpanKind[kind], + }); + } +} From ccf5b725988b24d73973e08cc28631090165a845 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 25 Jun 2026 14:38:05 +0200 Subject: [PATCH 15/16] Use spanKindToName in the streamed-span backfill --- .../opentelemetry/src/utils/backfillStreamedSpanData.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opentelemetry/src/utils/backfillStreamedSpanData.ts b/packages/opentelemetry/src/utils/backfillStreamedSpanData.ts index 3f30182e6c4a..037f5fd1fc1f 100644 --- a/packages/opentelemetry/src/utils/backfillStreamedSpanData.ts +++ b/packages/opentelemetry/src/utils/backfillStreamedSpanData.ts @@ -1,9 +1,10 @@ -import { SpanKind } from '@opentelemetry/api'; import type { SpanAttributes, StreamedSpanJSON } from '@sentry/core'; import { safeSetSpanJSONAttributes, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SPAN_KIND, + spanKindToName, } from '@sentry/core'; import { inferSpanData } from './parseSpanDescription'; @@ -24,7 +25,7 @@ export function backfillStreamedSpanDataFromOtel(spanJSON: StreamedSpanJSON, hin return; } - const kind = hint?.spanKind ?? SpanKind.INTERNAL; + const kind = hint?.spanKind ?? SPAN_KIND.INTERNAL; const { op, description, source, data } = inferSpanData(spanJSON.name, attributes as unknown as SpanAttributes, kind); spanJSON.name = description; @@ -35,9 +36,9 @@ export function backfillStreamedSpanDataFromOtel(spanJSON: StreamedSpanJSON, hin ...data, }); - if (kind !== SpanKind.INTERNAL) { + if (kind !== SPAN_KIND.INTERNAL) { safeSetSpanJSONAttributes(spanJSON, { - 'otel.kind': SpanKind[kind], + 'otel.kind': spanKindToName(kind), }); } } From 47bb97debd48704282ed39ec69d98ea5496e5057 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 25 Jun 2026 15:36:19 +0200 Subject: [PATCH 16/16] Re-parent children of ignored spans instead of dropping the subtree --- packages/core/src/tracing/index.ts | 1 + packages/core/src/tracing/trace.ts | 11 ++++++++--- packages/opentelemetry/src/tracer.ts | 11 +++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index a17c95bbc82c..9a437e23b8ea 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -21,6 +21,7 @@ export { withActiveSpan, suppressTracing, startNewTrace, + spanIsIgnored, SUPPRESS_TRACING_KEY, } from './trace'; export { bindScopeToEmitter } from './bindScopeToEmitter'; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index ab2c744ee9c1..6a45c106ce6e 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -82,7 +82,7 @@ export function startSpan(options: StartSpanOptions, callback: (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. - if (!_isIgnoredSpan(activeSpan) || !parentSpan) { + if (!spanIsIgnored(activeSpan) || !parentSpan) { _setSpanForScope(scope, activeSpan); } @@ -144,7 +144,7 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S // 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) { + if (!spanIsIgnored(activeSpan) || !parentSpan) { _setSpanForScope(scope, activeSpan); } @@ -654,7 +654,12 @@ function _shouldIgnoreStreamedSpan(client: Client | undefined, spanArguments: Se ); } -function _isIgnoredSpan(span: Span): span is SentryNonRecordingSpan { +/** + * Whether a span is an ignored (`ignoreSpans`) placeholder. Such a span must not be set as the active + * span when it has a parent, so its children attach to that parent and get re-parented rather than + * dropped with it. Shared with the OTel-based provider so both span pipelines apply the same rule. + */ +export function spanIsIgnored(span: Span): span is SentryNonRecordingSpan { return spanIsNonRecordingSpan(span) && span.dropReason === 'ignored'; } diff --git a/packages/opentelemetry/src/tracer.ts b/packages/opentelemetry/src/tracer.ts index 94f42237782a..0919236526e7 100644 --- a/packages/opentelemetry/src/tracer.ts +++ b/packages/opentelemetry/src/tracer.ts @@ -14,6 +14,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SentryNonRecordingSpan, setCapturedScopesOnSpan, + spanIsIgnored, startNewTrace, withScope, } from '@sentry/core'; @@ -68,6 +69,16 @@ export class SentryTracer implements Tracer { ) as F; const span = this.startSpan(name, options, ctx); + + // Mirror core's `startSpan`: an ignored (`ignoreSpans`) span that has a parent must not become the + // active span. Otherwise its children would attach to it and, since it's non-recording, be dropped + // along with it (cascading the drop down the whole subtree). Leaving the parent active lets the + // children attach to it and get re-parented instead. An ignored root span has no parent and still + // becomes active, so its subtree is dropped as intended. + if (spanIsIgnored(span as unknown as Span) && trace.getSpan(ctx)) { + return context.with(ctx, () => callback(span)) as ReturnType; + } + let ctxWithSpan = trace.setSpan(ctx, span); // Run the span's callback under the isolation scope captured when the span was created, so scope state