Skip to content

Commit 930ceea

Browse files
Lms24cursoragentJPeer264
committed
feat(core): Add span serialization utilities (#19140)
This PR adds span JSON conversion and serialization helpers for span streaming: * `spanToStreamedSpanJSON`: Converts a `Span` instance to a JSON object used as intermediate representation as outlined in #19100 * Adds `SentrySpan::getStreamedSpanJSON` method to convert our own spans * Directly converts any OTel spans * This is analogous to how `spanToJSON` works today. * `spanJsonToSerializedSpan`: Converts a `StreamedSpanJSON` into the final `SerializedSpan` to be sent to Sentry. This PR also adds unit tests for both helpers. ref #17836 --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Jan Peer Stöcklmair <jan.peer@sentry.io>
1 parent 80dd3e0 commit 930ceea

File tree

4 files changed

+381
-15
lines changed

4 files changed

+381
-15
lines changed

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,13 @@ export {
8585
convertSpanLinksForEnvelope,
8686
spanToTraceHeader,
8787
spanToJSON,
88+
spanToStreamedSpanJSON,
8889
spanIsSampled,
8990
spanToTraceContext,
9091
getSpanDescendants,
9192
getStatusMessage,
9293
getRootSpan,
94+
INTERNAL_getSegmentSpan,
9395
getActiveSpan,
9496
addChildSpanToSpan,
9597
spanTimeInputToSeconds,

packages/core/src/tracing/sentrySpan.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-lines */
12
import { getClient, getCurrentScope } from '../currentScopes';
23
import { DEBUG_BUILD } from '../debug-build';
34
import { createSpanEnvelope } from '../envelope';
@@ -21,6 +22,7 @@ import type {
2122
SpanJSON,
2223
SpanOrigin,
2324
SpanTimeInput,
25+
StreamedSpanJSON,
2426
} from '../types-hoist/span';
2527
import type { SpanStatus } from '../types-hoist/spanStatus';
2628
import type { TimedEvent } from '../types-hoist/timedEvent';
@@ -29,8 +31,10 @@ import { generateSpanId, generateTraceId } from '../utils/propagationContext';
2931
import {
3032
convertSpanLinksForEnvelope,
3133
getRootSpan,
34+
getSimpleStatusMessage,
3235
getSpanDescendants,
3336
getStatusMessage,
37+
getStreamedSpanLinks,
3438
spanTimeInputToSeconds,
3539
spanToJSON,
3640
spanToTransactionTraceContext,
@@ -241,6 +245,30 @@ export class SentrySpan implements Span {
241245
};
242246
}
243247

248+
/**
249+
* Get {@link StreamedSpanJSON} representation of this span.
250+
*
251+
* @hidden
252+
* @internal This method is purely for internal purposes and should not be used outside
253+
* of SDK code. If you need to get a JSON representation of a span,
254+
* use `spanToStreamedSpanJSON(span)` instead.
255+
*/
256+
public getStreamedSpanJSON(): StreamedSpanJSON {
257+
return {
258+
name: this._name ?? '',
259+
span_id: this._spanId,
260+
trace_id: this._traceId,
261+
parent_span_id: this._parentSpanId,
262+
start_timestamp: this._startTime,
263+
// just in case _endTime is not set, we use the start time (i.e. duration 0)
264+
end_timestamp: this._endTime ?? this._startTime,
265+
is_segment: this._isStandaloneSpan || this === getRootSpan(this),
266+
status: getSimpleStatusMessage(this._status),
267+
attributes: this._attributes,
268+
links: getStreamedSpanLinks(this._links),
269+
};
270+
}
271+
244272
/** @inheritdoc */
245273
public isRecording(): boolean {
246274
return !this._endTime && !!this._sampled;

packages/core/src/utils/spanUtils.ts

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { getAsyncContextStrategy } from '../asyncContext';
2+
import type { RawAttributes } from '../attributes';
3+
import { serializeAttributes } from '../attributes';
24
import { getMainCarrier } from '../carrier';
35
import { getCurrentScope } from '../currentScopes';
46
import {
@@ -12,7 +14,15 @@ import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus';
1214
import { getCapturedScopesOnSpan } from '../tracing/utils';
1315
import type { TraceContext } from '../types-hoist/context';
1416
import type { SpanLink, SpanLinkJSON } from '../types-hoist/link';
15-
import type { Span, SpanAttributes, SpanJSON, SpanOrigin, SpanTimeInput } from '../types-hoist/span';
17+
import type {
18+
SerializedSpan,
19+
Span,
20+
SpanAttributes,
21+
SpanJSON,
22+
SpanOrigin,
23+
SpanTimeInput,
24+
StreamedSpanJSON,
25+
} from '../types-hoist/span';
1626
import type { SpanStatus } from '../types-hoist/spanStatus';
1727
import { addNonEnumerableProperty } from '../utils/object';
1828
import { generateSpanId } from '../utils/propagationContext';
@@ -105,6 +115,27 @@ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[]
105115
}
106116
}
107117

118+
/**
119+
* Converts the span links array to a flattened version with serialized attributes for V2 spans.
120+
*
121+
* If the links array is empty, it returns `undefined` so the empty value can be dropped before it's sent.
122+
*/
123+
export function getStreamedSpanLinks(
124+
links?: SpanLink[],
125+
): SpanLinkJSON<RawAttributes<Record<string, unknown>>>[] | undefined {
126+
if (links?.length) {
127+
return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({
128+
span_id: spanId,
129+
trace_id: traceId,
130+
sampled: traceFlags === TRACE_FLAG_SAMPLED,
131+
attributes,
132+
...restContext,
133+
}));
134+
} else {
135+
return undefined;
136+
}
137+
}
138+
108139
/**
109140
* Convert a span time input into a timestamp in seconds.
110141
*/
@@ -150,23 +181,12 @@ export function spanToJSON(span: Span): SpanJSON {
150181
if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) {
151182
const { attributes, startTime, name, endTime, status, links } = span;
152183

153-
// In preparation for the next major of OpenTelemetry, we want to support
154-
// looking up the parent span id according to the new API
155-
// In OTel v1, the parent span id is accessed as `parentSpanId`
156-
// In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext`
157-
const parentSpanId =
158-
'parentSpanId' in span
159-
? span.parentSpanId
160-
: 'parentSpanContext' in span
161-
? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId
162-
: undefined;
163-
164184
return {
165185
span_id,
166186
trace_id,
167187
data: attributes,
168188
description: name,
169-
parent_span_id: parentSpanId,
189+
parent_span_id: getOtelParentSpanId(span),
170190
start_timestamp: spanTimeInputToSeconds(startTime),
171191
// This is [0,0] by default in OTEL, in which case we want to interpret this as no end time
172192
timestamp: spanTimeInputToSeconds(endTime) || undefined,
@@ -187,6 +207,77 @@ export function spanToJSON(span: Span): SpanJSON {
187207
};
188208
}
189209

210+
/**
211+
* Convert a span to the intermediate {@link StreamedSpanJSON} representation.
212+
*/
213+
export function spanToStreamedSpanJSON(span: Span): StreamedSpanJSON {
214+
if (spanIsSentrySpan(span)) {
215+
return span.getStreamedSpanJSON();
216+
}
217+
218+
const { spanId: span_id, traceId: trace_id } = span.spanContext();
219+
220+
// Handle a span from @opentelemetry/sdk-base-trace's `Span` class
221+
if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) {
222+
const { attributes, startTime, name, endTime, status, links } = span;
223+
224+
return {
225+
name,
226+
span_id,
227+
trace_id,
228+
parent_span_id: getOtelParentSpanId(span),
229+
start_timestamp: spanTimeInputToSeconds(startTime),
230+
end_timestamp: spanTimeInputToSeconds(endTime),
231+
is_segment: span === INTERNAL_getSegmentSpan(span),
232+
status: getSimpleStatusMessage(status),
233+
attributes,
234+
links: getStreamedSpanLinks(links),
235+
};
236+
}
237+
238+
// Finally, as a fallback, at least we have `spanContext()`....
239+
// This should not actually happen in reality, but we need to handle it for type safety.
240+
return {
241+
span_id,
242+
trace_id,
243+
start_timestamp: 0,
244+
name: '',
245+
end_timestamp: 0,
246+
status: 'ok',
247+
is_segment: span === INTERNAL_getSegmentSpan(span),
248+
};
249+
}
250+
251+
/**
252+
* In preparation for the next major of OpenTelemetry, we want to support
253+
* looking up the parent span id according to the new API
254+
* In OTel v1, the parent span id is accessed as `parentSpanId`
255+
* In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext`
256+
*/
257+
function getOtelParentSpanId(span: OpenTelemetrySdkTraceBaseSpan): string | undefined {
258+
return 'parentSpanId' in span
259+
? span.parentSpanId
260+
: 'parentSpanContext' in span
261+
? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId
262+
: undefined;
263+
}
264+
265+
/**
266+
* Converts a {@link StreamedSpanJSON} to a {@link SerializedSpan}.
267+
* This is the final serialized span format that is sent to Sentry.
268+
* The returned serilaized spans must not be consumed by users or SDK integrations.
269+
*/
270+
export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedSpan {
271+
return {
272+
...spanJson,
273+
attributes: serializeAttributes(spanJson.attributes),
274+
links: spanJson.links?.map(link => ({
275+
...link,
276+
attributes: serializeAttributes(link.attributes),
277+
})),
278+
};
279+
}
280+
190281
function spanIsOpenTelemetrySdkTraceBaseSpan(span: Span): span is OpenTelemetrySdkTraceBaseSpan {
191282
const castSpan = span as Partial<OpenTelemetrySdkTraceBaseSpan>;
192283
return !!castSpan.attributes && !!castSpan.startTime && !!castSpan.name && !!castSpan.endTime && !!castSpan.status;
@@ -237,6 +328,13 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef
237328
return status.message || 'internal_error';
238329
}
239330

331+
/**
332+
* Convert the various statuses to the simple onces expected by Sentry for steamed spans ('ok' is default).
333+
*/
334+
export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' {
335+
return !status || status.code === SPAN_STATUS_OK || status.code === SPAN_STATUS_UNSET ? 'ok' : 'error';
336+
}
337+
240338
const CHILD_SPANS_FIELD = '_sentryChildSpans';
241339
const ROOT_SPAN_FIELD = '_sentryRootSpan';
242340

@@ -298,7 +396,12 @@ export function getSpanDescendants(span: SpanWithPotentialChildren): Span[] {
298396
/**
299397
* Returns the root span of a given span.
300398
*/
301-
export function getRootSpan(span: SpanWithPotentialChildren): Span {
399+
export const getRootSpan = INTERNAL_getSegmentSpan;
400+
401+
/**
402+
* Returns the segment span of a given span.
403+
*/
404+
export function INTERNAL_getSegmentSpan(span: SpanWithPotentialChildren): Span {
302405
return span[ROOT_SPAN_FIELD] || span;
303406
}
304407

0 commit comments

Comments
 (0)