diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f03f6b9779e9..e0e9097bbc53 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -76,6 +76,7 @@ export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize } from './utils/parameterize'; export { addAutoIpAddressToSession, addAutoIpAddressToUser } from './utils/ipAddress'; export { + convertSpanLinksForEnvelope, spanToTraceHeader, spanToJSON, spanIsSampled, diff --git a/packages/core/src/tracing/sentryNonRecordingSpan.ts b/packages/core/src/tracing/sentryNonRecordingSpan.ts index e7c9a8e9ac41..b4a5a7c8cd61 100644 --- a/packages/core/src/tracing/sentryNonRecordingSpan.ts +++ b/packages/core/src/tracing/sentryNonRecordingSpan.ts @@ -69,24 +69,12 @@ export class SentryNonRecordingSpan implements Span { return this; } - /** - * This should generally not be used, - * but we need it for being compliant with the OTEL Span interface. - * - * @hidden - * @internal - */ + /** @inheritDoc */ public addLink(_link: unknown): this { return this; } - /** - * This should generally not be used, - * but we need it for being compliant with the OTEL Span interface. - * - * @hidden - * @internal - */ + /** @inheritDoc */ public addLinks(_links: unknown[]): this { return this; } diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 74478f79903f..ddf036f88cdb 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -109,24 +109,12 @@ export class SentrySpan implements Span { } } - /** - * This should generally not be used, - * but it is needed for being compliant with the OTEL Span interface. - * - * @hidden - * @internal - */ + /** @inheritDoc */ public addLink(_link: unknown): this { return this; } - /** - * This should generally not be used, - * but it is needed for being compliant with the OTEL Span interface. - * - * @hidden - * @internal - */ + /** @inheritDoc */ public addLinks(_links: unknown[]): this { return this; } diff --git a/packages/core/src/types-hoist/link.ts b/packages/core/src/types-hoist/link.ts new file mode 100644 index 000000000000..a330dc108b00 --- /dev/null +++ b/packages/core/src/types-hoist/link.ts @@ -0,0 +1,30 @@ +import type { SpanAttributeValue, SpanContextData } from './span'; + +type SpanLinkAttributes = { + /** + * Setting the link type to 'previous_trace' helps the Sentry product linking to the previous trace + */ + 'sentry.link.type'?: string | 'previous_trace'; +} & Record; + +export interface SpanLink { + /** + * Contains the SpanContext of the span to link to + */ + context: SpanContextData; + /** + * A key-value pair with primitive values or an array of primitive values + */ + attributes?: SpanLinkAttributes; +} + +/** + * Link interface for the event envelope item. It's a flattened representation of `SpanLink`. + * Can include additional fields defined by OTel. + */ +export interface SpanLinkJSON extends Record { + span_id: string; + trace_id: string; + sampled?: boolean; + attributes?: SpanLinkAttributes; +} diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index c74d00e54f97..2b82aab74934 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -1,3 +1,4 @@ +import type { SpanLink, SpanLinkJSON } from './link'; import type { Measurements } from './measurement'; import type { HrTime } from './opentelemetry'; import type { SpanStatus } from './spanStatus'; @@ -50,6 +51,7 @@ export interface SpanJSON { measurements?: Measurements; is_segment?: boolean; segment_id?: string; + links?: SpanLinkJSON[]; } // These are aligned with OpenTelemetry trace flags @@ -249,14 +251,21 @@ export interface Span { addEvent(name: string, attributesOrStartTime?: SpanAttributes | SpanTimeInput, startTime?: SpanTimeInput): this; /** - * NOT USED IN SENTRY, only added for compliance with OTEL Span interface + * Associates this span with a related span. Links can reference spans from the same or different trace + * and are typically used for batch operations, cross-trace scenarios, or scatter/gather patterns. + * + * Prefer setting links directly when starting a span (e.g. `Sentry.startSpan()`) as some context information is only available during span creation. + * @param link - The link containing the context of the span to link to and optional attributes */ - addLink(link: unknown): this; + addLink(link: SpanLink): this; /** - * NOT USED IN SENTRY, only added for compliance with OTEL Span interface + * Associates this span with multiple related spans. See {@link addLink} for more details. + * + * Prefer setting links directly when starting a span (e.g. `Sentry.startSpan()`) as some context information is only available during span creation. + * @param links - Array of links to associate with this span */ - addLinks(links: unknown): this; + addLinks(links: SpanLink[]): this; /** * NOT USED IN SENTRY, only added for compliance with OTEL Span interface diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 41435e8be373..fcf4aa1857e3 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -19,6 +19,7 @@ import type { SpanTimeInput, TraceContext, } from '../types-hoist'; +import type { SpanLink, SpanLinkJSON } from '../types-hoist/link'; import { consoleSandbox } from '../utils-hoist/logger'; import { addNonEnumerableProperty, dropUndefinedKeys } from '../utils-hoist/object'; import { generateSpanId } from '../utils-hoist/propagationContext'; @@ -81,6 +82,25 @@ export function spanToTraceHeader(span: Span): string { return generateSentryTraceHeader(traceId, spanId, sampled); } +/** + * Converts the span links array to a flattened version to be sent within an envelope. + * + * If the links array is empty, it returns `undefined` so the empty value can be dropped before it's sent. + */ +export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] | undefined { + if (links && links.length > 0) { + return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({ + span_id: spanId, + trace_id: traceId, + sampled: traceFlags === TRACE_FLAG_SAMPLED, + attributes, + ...restContext, + })); + } else { + return undefined; + } +} + /** * Convert a span time input into a timestamp in seconds. */ diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index 8139460e8304..acbac2b35dbd 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -7,6 +7,7 @@ import { SPAN_STATUS_UNSET, SentrySpan, TRACEPARENT_REGEXP, + convertSpanLinksForEnvelope, setCurrentClient, spanToTraceHeader, startInactiveSpan, @@ -14,7 +15,9 @@ import { timestampInSeconds, } from '../../../src'; import type { Span, SpanAttributes, SpanStatus, SpanTimeInput } from '../../../src/types-hoist'; +import type { SpanLink } from '../../../src/types-hoist/link'; import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils'; +import { TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils'; import { getRootSpan, spanIsSampled, @@ -156,6 +159,115 @@ describe('spanToTraceContext', () => { }); }); +describe('convertSpanLinksForEnvelope', () => { + it('returns undefined for undefined input', () => { + expect(convertSpanLinksForEnvelope(undefined)).toBeUndefined(); + }); + + it('returns undefined for empty array input', () => { + expect(convertSpanLinksForEnvelope([])).toBeUndefined(); + }); + + it('converts a single span link to a flattened envelope item', () => { + const links: SpanLink[] = [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + ]; + + const result = convertSpanLinksForEnvelope(links); + + result?.forEach(item => expect(item).not.toHaveProperty('context')); + expect(result).toEqual([ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + ]); + }); + + it('converts multiple span links to a flattened envelope item', () => { + const links: SpanLink[] = [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + { + context: { + spanId: 'span2', + traceId: 'trace2', + traceFlags: TRACE_FLAG_NONE, + }, + attributes: { + 'sentry.link.type': 'another_trace', + }, + }, + ]; + + const result = convertSpanLinksForEnvelope(links); + + result?.forEach(item => expect(item).not.toHaveProperty('context')); + expect(result).toEqual([ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + { + span_id: 'span2', + trace_id: 'trace2', + sampled: false, + attributes: { + 'sentry.link.type': 'another_trace', + }, + }, + ]); + }); + + it('handles span links without attributes', () => { + const links: SpanLink[] = [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + }, + ]; + + const result = convertSpanLinksForEnvelope(links); + + result?.forEach(item => expect(item).not.toHaveProperty('context')); + expect(result).toEqual([ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + }, + ]); + }); +}); + describe('spanTimeInputToSeconds', () => { it('works with undefined', () => { const now = timestampInSeconds();