Skip to content

Commit 7f2f88d

Browse files
committed
feat(opentelemetry): Add SentryTraceProvider
Add a minimal OpenTelemetry `TracerProvider` that creates native Sentry spans instead of bridging through the full OTel SDK.
1 parent 2c93acc commit 7f2f88d

17 files changed

Lines changed: 643 additions & 20 deletions

packages/core/src/tracing/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from './spanstat
1313
export {
1414
startSpan,
1515
startInactiveSpan,
16+
_INTERNAL_startInactiveSpan,
1617
startSpanManual,
1718
continueTrace,
1819
withActiveSpan,

packages/core/src/tracing/sentrySpan.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export class SentrySpan implements Span {
143143
* @hidden
144144
* @internal
145145
*/
146-
public recordException(_exception: unknown, _time?: number | undefined): void {
146+
public recordException(_exception: unknown, _time?: SpanTimeInput | undefined): void {
147147
// noop
148148
}
149149

packages/core/src/tracing/trace.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,20 @@ export function startInactiveSpan(options: StartSpanOptions): Span {
181181
return acs.startInactiveSpan(options);
182182
}
183183

184+
return _startInactiveSpanImpl(options);
185+
}
186+
187+
/**
188+
* Internal version of startInactiveSpan that bypasses the ACS check.
189+
* Used by SentryTracerProvider to create spans without triggering recursion
190+
* through ACS overrides.
191+
* @hidden
192+
*/
193+
export function _INTERNAL_startInactiveSpan(options: StartSpanOptions): Span {
194+
return _startInactiveSpanImpl(options);
195+
}
196+
197+
function _startInactiveSpanImpl(options: StartSpanOptions): Span {
184198
const spanArguments = parseSentrySpanArguments(options);
185199
const { forceTransaction, parentSpan: customParentSpan } = options;
186200

packages/core/src/types/span.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,5 +319,5 @@ export interface Span {
319319
/**
320320
* NOT USED IN SENTRY, only added for compliance with OTEL Span interface
321321
*/
322-
recordException(exception: unknown, time?: number): void;
322+
recordException(exception: unknown, time?: SpanTimeInput): void;
323323
}

packages/opentelemetry/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,36 @@ function setupSentry() {
8585
A full setup example can be found in
8686
[node-experimental](https://github.com/getsentry/sentry-javascript/blob/develop/packages/node-experimental).
8787

88+
## Experimental Sentry Tracer Provider
89+
90+
`SentryTracerProvider` is an experimental minimal OpenTelemetry tracer provider which creates native Sentry spans directly.
91+
It is useful when code uses the global OpenTelemetry API and you do not need the full OpenTelemetry SDK span processor
92+
and exporter pipeline.
93+
94+
```js
95+
import { trace } from '@opentelemetry/api';
96+
import { SentryTracerProvider } from '@sentry/opentelemetry';
97+
98+
trace.setGlobalTracerProvider(new SentryTracerProvider());
99+
100+
const span = trace.getTracer('example').startSpan('work');
101+
span.end();
102+
```
103+
104+
In `@sentry/node`, this provider can be enabled with the experimental option:
105+
106+
```js
107+
Sentry.init({
108+
dsn: 'xxx',
109+
_experiments: {
110+
useSentryTraceProvider: true,
111+
},
112+
});
113+
```
114+
115+
When this provider is enabled, additional OpenTelemetry span processors are ignored because Sentry spans are created
116+
directly. OpenTelemetry logs and metrics are not handled by this provider.
117+
88118
## Links
89119

90120
- [Official SDK Docs](https://docs.sentry.io/quickstart/)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { SpanKind } from '@opentelemetry/api';
2+
import { HTTP_RESPONSE_STATUS_CODE, HTTP_STATUS_CODE } from '@sentry/conventions/attributes';
3+
import {
4+
addNonEnumerableProperty,
5+
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
6+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
7+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
8+
spanShouldInferOtelSource,
9+
spanToJSON,
10+
SPAN_STATUS_ERROR,
11+
SPAN_STATUS_OK,
12+
} from '@sentry/core';
13+
import type { Span, SpanAttributes } from '@sentry/core';
14+
import { inferStatusFromAttributes, isStatusErrorMessageValid } from './utils/mapStatus';
15+
import { inferSpanData } from './utils/parseSpanDescription';
16+
17+
type SentrySpanWithOtelKind = Span & { kind?: SpanKind };
18+
19+
/**
20+
* Backfill a native Sentry span with the data the OpenTelemetry SDK pipeline would otherwise derive
21+
* from OTel semantic attributes: `sentry.op`, `sentry.source`, the span name, `otel.kind`, and status.
22+
*
23+
* On the OTel SDK provider this happens in the `SentrySpanProcessor`/`SentrySpanExporter` while
24+
* converting `ReadableSpan`s to Sentry payloads (via `parseSpanDescription` + `mapStatus`).
25+
* `SentryTracerProvider` creates native Sentry spans directly and never goes through that pipeline,
26+
* so the same inference has to run here instead — once at span start, and again at span end
27+
* (`finalizeStatus`, once attributes like `http.route` and the status code are available).
28+
*/
29+
export function applyOtelSpanData(span: Span, options: { finalizeStatus?: boolean } = {}): void {
30+
const spanJSON = spanToJSON(span);
31+
const attributes = spanJSON.data;
32+
const kind = (span as SentrySpanWithOtelKind).kind ?? SpanKind.INTERNAL;
33+
const mayInferSource = spanShouldInferOtelSource(span);
34+
const hasCustomSpanName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME] !== undefined;
35+
const attributesForInference =
36+
mayInferSource && !hasCustomSpanName && attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom'
37+
? { ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: undefined }
38+
: attributes;
39+
const inferred = inferSpanData(spanJSON.description || '<unknown>', attributesForInference, kind);
40+
41+
if (kind !== SpanKind.INTERNAL && attributes['otel.kind'] === undefined) {
42+
span.setAttribute('otel.kind', SpanKind[kind]);
43+
}
44+
45+
if (inferred.op && attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === undefined) {
46+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, inferred.op);
47+
}
48+
49+
// Don't apply 'url' source at creation time — only at span end (finalizeStatus).
50+
// At creation, http.route may not be set yet, so inference falls back to 'url'.
51+
// Keeping the default 'custom' source from _startRootSpan allows
52+
// enhanceDscWithOpenTelemetryRootSpanName to include the transaction name in
53+
// the DSC. At span end, http.route is typically available and inference returns
54+
// 'route' instead. If it's still 'url', it's applied then.
55+
const shouldApplyInferredSource =
56+
inferred.source !== undefined &&
57+
inferred.source !== 'custom' &&
58+
(options.finalizeStatus || inferred.source !== 'url') &&
59+
(spanJSON.parent_span_id === undefined || kind === SpanKind.SERVER);
60+
61+
if (
62+
shouldApplyInferredSource &&
63+
(attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === undefined || (mayInferSource && !hasCustomSpanName))
64+
) {
65+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, inferred.source);
66+
}
67+
68+
if (inferred.data) {
69+
Object.entries(inferred.data).forEach(([key, value]) => {
70+
if (value !== undefined && attributes[key] === undefined) {
71+
span.setAttribute(key, value);
72+
}
73+
});
74+
}
75+
76+
if (options.finalizeStatus) {
77+
applyOtelCompatibilityAttributes(span, attributes);
78+
applyOtelSpanStatus(span, attributes, spanJSON.status);
79+
}
80+
81+
if (
82+
inferred.description !== spanJSON.description &&
83+
(attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] !== 'custom' || (mayInferSource && !hasCustomSpanName))
84+
) {
85+
addNonEnumerableProperty(span as Span & { _name?: string }, '_name', inferred.description);
86+
}
87+
}
88+
89+
/** Stash the OTel span kind on a Sentry span so {@link applyOtelSpanData} can read it. */
90+
export function applyOtelSpanKind(span: Span, kind: SpanKind | undefined): void {
91+
addNonEnumerableProperty(span as SentrySpanWithOtelKind, 'kind', kind ?? SpanKind.INTERNAL);
92+
}
93+
94+
function applyOtelSpanStatus(span: Span, attributes: SpanAttributes, status: string | undefined): void {
95+
if (status === undefined) {
96+
span.setStatus(inferStatusFromAttributes(attributes) || { code: SPAN_STATUS_OK });
97+
return;
98+
}
99+
100+
if (status !== 'ok' && !isStatusErrorMessageValid(status)) {
101+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
102+
}
103+
}
104+
105+
function applyOtelCompatibilityAttributes(span: Span, attributes: SpanAttributes): void {
106+
// `http.status_code` is the deprecated legacy attribute, read for backward compatibility.
107+
// eslint-disable-next-line typescript/no-deprecated
108+
const legacyHttpStatusCode = attributes[HTTP_STATUS_CODE];
109+
110+
if (attributes[HTTP_RESPONSE_STATUS_CODE] === undefined && legacyHttpStatusCode !== undefined) {
111+
span.setAttribute(HTTP_RESPONSE_STATUS_CODE, legacyHttpStatusCode);
112+
attributes[HTTP_RESPONSE_STATUS_CODE] = legacyHttpStatusCode;
113+
}
114+
}

packages/opentelemetry/src/custom/client.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { Tracer } from '@opentelemetry/api';
22
import { trace } from '@opentelemetry/api';
3-
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
43
import type { Client } from '@sentry/core';
54
import { SDK_VERSION } from '@sentry/core';
6-
import type { OpenTelemetryClient as OpenTelemetryClientInterface } from '../types';
5+
import type { OpenTelemetryClient as OpenTelemetryClientInterface, OpenTelemetryTraceProvider } from '../types';
76

87
// Typescript complains if we do not use `...args: any[]` for the mixin, with:
98
// A mixin class must have a constructor with a single rest parameter of type 'any[]'.ts(2545)
@@ -23,7 +22,7 @@ export function wrapClientClass<
2322
>(ClientClass: ClassConstructor): WrappedClassConstructor {
2423
// @ts-expect-error We just assume that this is non-abstract, if you pass in an abstract class this would make it non-abstract
2524
class OpenTelemetryClient extends ClientClass implements OpenTelemetryClientInterface {
26-
public traceProvider: BasicTracerProvider | undefined;
25+
public traceProvider: OpenTelemetryTraceProvider | undefined;
2726
private _tracer: Tracer | undefined;
2827

2928
public constructor(...args: any[]) {

packages/opentelemetry/src/exports.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@ export { wrapContextManagerClass } from './contextManager';
4646
export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator';
4747
export { SentrySpanProcessor } from './spanProcessor';
4848
export { SentrySampler, wrapSamplingDecision } from './sampler';
49+
export { applyOtelSpanData } from './applyOtelSpanData';
50+
export { SentryTracerProvider } from './tracerProvider';
51+
export type { OpenTelemetryTraceProvider } from './types';
4952

50-
export { openTelemetrySetupCheck } from './utils/setupCheck';
53+
export { openTelemetrySetupCheck, setIsSetup } from './utils/setupCheck';
5154

5255
export { getSentryResource } from './resource';
5356

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { Context, Span as OpenTelemetrySpan, SpanOptions, Tracer } from '@opentelemetry/api';
2+
import { context, trace } from '@opentelemetry/api';
3+
import { isTracingSuppressed } from '@opentelemetry/core';
4+
import {
5+
_INTERNAL_safeMathRandom,
6+
_INTERNAL_setSpanForScope,
7+
_INTERNAL_startInactiveSpan,
8+
addChildSpanToSpan,
9+
getCapturedScopesOnSpan,
10+
getCurrentScope,
11+
getDynamicSamplingContextFromSpan,
12+
getIsolationScope,
13+
markSpanForOtelSourceInference,
14+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
15+
SentryNonRecordingSpan,
16+
setCapturedScopesOnSpan,
17+
startNewTrace,
18+
withScope,
19+
} from '@sentry/core';
20+
import type { Span, SpanAttributes, SpanLink } from '@sentry/core';
21+
import { applyOtelSpanData, applyOtelSpanKind } from './applyOtelSpanData';
22+
import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY } from './constants';
23+
import { getSamplingDecision } from './utils/getSamplingDecision';
24+
25+
export class SentryTracer implements Tracer {
26+
/** @inheritdoc */
27+
public startSpan(name: string, options: SpanOptions = {}, ctx?: Context): OpenTelemetrySpan {
28+
const parentContext = ctx || context.active();
29+
const parentSpan = options.root ? undefined : trace.getSpan(parentContext);
30+
31+
if (isTracingSuppressed(parentContext)) {
32+
return this._createNonRecordingSpan(parentSpan);
33+
}
34+
35+
const span = this._startSentrySpan(name, options, parentSpan, ctx !== undefined);
36+
37+
applyOtelSpanKind(span, options.kind);
38+
if (options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === undefined) {
39+
markSpanForOtelSourceInference(span);
40+
}
41+
applyOtelSpanData(span);
42+
return span as OpenTelemetrySpan;
43+
}
44+
45+
/** @inheritdoc */
46+
public startActiveSpan<F extends (span: OpenTelemetrySpan) => unknown>(name: string, fn: F): ReturnType<F>;
47+
public startActiveSpan<F extends (span: OpenTelemetrySpan) => unknown>(
48+
name: string,
49+
options: SpanOptions,
50+
fn: F,
51+
): ReturnType<F>;
52+
public startActiveSpan<F extends (span: OpenTelemetrySpan) => unknown>(
53+
name: string,
54+
options: SpanOptions,
55+
ctx: Context,
56+
fn: F,
57+
): ReturnType<F>;
58+
public startActiveSpan<F extends (span: OpenTelemetrySpan) => unknown>(
59+
name: string,
60+
optionsOrFn: SpanOptions | F,
61+
contextOrFn?: Context | F,
62+
fn?: F,
63+
): ReturnType<F> {
64+
const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn;
65+
const ctx = typeof contextOrFn === 'function' || contextOrFn === undefined ? context.active() : contextOrFn;
66+
const callback = (
67+
typeof optionsOrFn === 'function' ? optionsOrFn : typeof contextOrFn === 'function' ? contextOrFn : fn
68+
) as F;
69+
70+
const span = this.startSpan(name, options, ctx);
71+
let ctxWithSpan = trace.setSpan(ctx, span);
72+
73+
const capturedIsolationScope = getCapturedScopesOnSpan(span as unknown as Span).isolationScope;
74+
if (capturedIsolationScope) {
75+
ctxWithSpan = ctxWithSpan.setValue(SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, capturedIsolationScope);
76+
}
77+
78+
return context.with(ctxWithSpan, () => {
79+
_INTERNAL_setSpanForScope(getCurrentScope(), span as unknown as Span);
80+
return callback(span) as ReturnType<F>;
81+
});
82+
}
83+
84+
private _startSentrySpan(
85+
name: string,
86+
options: SpanOptions,
87+
parentSpan: OpenTelemetrySpan | undefined,
88+
hasExplicitContext: boolean,
89+
): Span {
90+
const sentryOptions = {
91+
name,
92+
attributes: options.attributes as SpanAttributes | undefined,
93+
links: options.links as SpanLink[] | undefined,
94+
startTime: options.startTime,
95+
};
96+
97+
if (options.root) {
98+
return startNewTrace(() => _INTERNAL_startInactiveSpan({ ...sentryOptions, parentSpan: null }));
99+
}
100+
101+
if (parentSpan?.spanContext().isRemote) {
102+
return this._startRootSpanWithRemoteParent(sentryOptions, parentSpan);
103+
}
104+
105+
if (parentSpan) {
106+
return _INTERNAL_startInactiveSpan({ ...sentryOptions, parentSpan: parentSpan as unknown as Span });
107+
}
108+
109+
return _INTERNAL_startInactiveSpan({
110+
...sentryOptions,
111+
parentSpan: hasExplicitContext ? null : undefined,
112+
});
113+
}
114+
115+
private _startRootSpanWithRemoteParent(
116+
options: Parameters<typeof _INTERNAL_startInactiveSpan>[0],
117+
parentSpan: OpenTelemetrySpan,
118+
): Span {
119+
const { spanId, traceId } = parentSpan.spanContext();
120+
const dsc = getDynamicSamplingContextFromSpan(parentSpan as unknown as Span);
121+
const sampleRand = typeof dsc.sample_rand === 'string' ? Number(dsc.sample_rand) : undefined;
122+
123+
return withScope(scope => {
124+
scope.setPropagationContext({
125+
traceId,
126+
parentSpanId: spanId,
127+
sampled: getSamplingDecision(parentSpan.spanContext()),
128+
dsc,
129+
sampleRand:
130+
typeof sampleRand === 'number' && !Number.isNaN(sampleRand) ? sampleRand : _INTERNAL_safeMathRandom(),
131+
});
132+
_INTERNAL_setSpanForScope(scope, undefined);
133+
134+
return _INTERNAL_startInactiveSpan({ ...options, parentSpan: null });
135+
});
136+
}
137+
138+
private _createNonRecordingSpan(parentSpan: OpenTelemetrySpan | undefined): OpenTelemetrySpan {
139+
const span = new SentryNonRecordingSpan({ traceId: parentSpan?.spanContext().traceId });
140+
// Link to the parent (like core's `createChildOrRootSpan`) so `getRootSpan` and DSC
141+
// resolution reach the parent. Non-recording spans no longer carry a `parentSpanId`.
142+
if (parentSpan) {
143+
addChildSpanToSpan(parentSpan as unknown as Span, span);
144+
}
145+
// Capture the scopes (mirroring `createChildOrRootSpan`) so `startActiveSpan` can
146+
// fork the isolation scope onto the OTel context for work inside a suppressed span.
147+
setCapturedScopesOnSpan(span, getCurrentScope(), getIsolationScope());
148+
return span as OpenTelemetrySpan;
149+
}
150+
}

0 commit comments

Comments
 (0)