From 7f2f88df635cca0ec1b1cdcf35a3caaae6efedda Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 9 Jun 2026 14:36:09 +0200 Subject: [PATCH] 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 57303947249b..66a8e89fea13 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']); + }); });