Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/shared-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export {
spanToJSON,
spanToStreamedSpanJSON,
spanIsSampled,
spanIsSentrySpan,
spanToTraceContext,
getSpanDescendants,
getStatusMessage,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export {
getCapturedScopesOnSpan,
markSpanForOtelSourceInference,
spanShouldInferOtelSource,
markSpanSourceAsExplicit,
spanSourceWasExplicitlySet,
} from './utils';
export { startIdleSpan, TRACING_DEFAULTS } from './idleSpan';
export { SentrySpan } from './sentrySpan';
Expand All @@ -13,6 +15,7 @@ export { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from './spanstat
export {
startSpan,
startInactiveSpan,
_INTERNAL_startInactiveSpan,
startSpanManual,
continueTrace,
withActiveSpan,
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/tracing/sentrySpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
}

Expand All @@ -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;
}

Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/tracing/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -499,6 +513,7 @@ function _startRootSpan(
name,
parentSampled: finalParentSampled,
attributes: finalAttributes,
normalizedRequest: isolationScope.getScopeData().sdkProcessingMetadata.normalizedRequest,
parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate),
},
currentPropagationContext.sampleRand,
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/tracing/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,20 @@ 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<Scope>;
};

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. */
Expand Down Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion packages/core/src/types/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion packages/core/src/utils/spanUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
32 changes: 31 additions & 1 deletion packages/core/test/lib/tracing/sentrySpan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({});
Expand Down
14 changes: 14 additions & 0 deletions packages/core/test/lib/tracing/trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
30 changes: 30 additions & 0 deletions packages/opentelemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
useSentryTracerProvider: 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/)
120 changes: 120 additions & 0 deletions packages/opentelemetry/src/applyOtelSpanData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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,
spanSourceWasExplicitlySet,
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;
// 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 =
canInferSource && attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom'
? { ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: undefined }
: attributes;
const inferred = inferSpanData(spanJSON.description || '<unknown>', 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.
// 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' &&
(options.finalizeStatus || inferred.source !== 'url') &&
(spanJSON.parent_span_id === undefined || kind === SpanKind.SERVER);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the comment is pretty good but does not really explain this part of the condition?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the comment to explain it in 40d8abc


if (shouldApplyInferredSource && (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === undefined || canInferSource)) {
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' || canInferSource)
) {
span.updateName(inferred.description);
}
Comment thread
andreiborza marked this conversation as resolved.
}

/** 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;
Comment thread
sentry[bot] marked this conversation as resolved.
}

if (status !== 'ok' && !isStatusErrorMessageValid(status)) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
sentry[bot] marked this conversation as resolved.

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;
}
}
5 changes: 2 additions & 3 deletions packages/opentelemetry/src/custom/client.ts
Original file line number Diff line number Diff line change
@@ -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, 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)
Expand All @@ -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: OpenTelemetryTracerProvider | undefined;
private _tracer: Tracer | undefined;

public constructor(...args: any[]) {
Expand Down
5 changes: 4 additions & 1 deletion packages/opentelemetry/src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { OpenTelemetryTracerProvider } from './types';

export { openTelemetrySetupCheck } from './utils/setupCheck';
export { openTelemetrySetupCheck, setIsSetup } from './utils/setupCheck';

export { getSentryResource } from './resource';

Expand Down
Loading
Loading