diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b05934465dfb..beae21bd3bf8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -384,7 +384,7 @@ export type { ProfileChunkEnvelope, ProfileChunkItem, SpanEnvelope, - SpanV2Envelope, + StreamedSpanEnvelope, SpanItem, LogEnvelope, MetricEnvelope, @@ -453,8 +453,6 @@ export type { SpanContextData, TraceFlag, StreamedSpanJSON, - SerializedSpanContainer, - SerializedSpan, } from './types-hoist/span'; export type { SpanStatus } from './types-hoist/spanStatus'; export type { Log, LogSeverityLevel } from './types-hoist/log'; diff --git a/packages/core/src/tracing/spans/README.md b/packages/core/src/tracing/spans/README.md new file mode 100644 index 000000000000..fa40e8a0ff11 --- /dev/null +++ b/packages/core/src/tracing/spans/README.md @@ -0,0 +1,2 @@ +For now, all span streaming related tracing code is in this sub directory. +Once we get rid of transaction-based tracing, we can clean up and flatten the entire tracing directory. diff --git a/packages/core/src/tracing/spans/envelope.ts b/packages/core/src/tracing/spans/envelope.ts new file mode 100644 index 000000000000..8429b22d7e1c --- /dev/null +++ b/packages/core/src/tracing/spans/envelope.ts @@ -0,0 +1,36 @@ +import type { Client } from '../../client'; +import type { DynamicSamplingContext, SpanContainerItem, StreamedSpanEnvelope } from '../../types-hoist/envelope'; +import type { SerializedStreamedSpan } from '../../types-hoist/span'; +import { dsnToString } from '../../utils/dsn'; +import { createEnvelope, getSdkMetadataForEnvelopeHeader } from '../../utils/envelope'; + +/** + * Creates a span v2 span streaming envelope + */ +export function createStreamedSpanEnvelope( + serializedSpans: Array, + dsc: Partial, + client: Client, +): StreamedSpanEnvelope { + const dsn = client.getDsn(); + const tunnel = client.getOptions().tunnel; + const sdk = getSdkMetadataForEnvelopeHeader(client.getOptions()._metadata); + + const headers: StreamedSpanEnvelope[0] = { + sent_at: new Date().toISOString(), + ...(dscHasRequiredProps(dsc) && { trace: dsc }), + ...(sdk && { sdk }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }; + + const spanContainer: SpanContainerItem = [ + { type: 'span', item_count: serializedSpans.length, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { items: serializedSpans }, + ]; + + return createEnvelope(headers, [spanContainer]); +} + +function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext { + return !!dsc.trace_id && !!dsc.public_key; +} diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 7251f85b5df0..d8b8a1822b04 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -11,7 +11,7 @@ import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, SessionAggregates } from './session'; -import type { SerializedSpanContainer, SpanJSON } from './span'; +import type { SerializedStreamedSpanContainer, SpanJSON } from './span'; // Based on: https://develop.sentry.dev/sdk/envelopes/ @@ -138,7 +138,7 @@ export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; -export type SpanContainerItem = BaseEnvelopeItem; +export type SpanContainerItem = BaseEnvelopeItem; export type LogContainerItem = BaseEnvelopeItem; export type MetricContainerItem = BaseEnvelopeItem; export type RawSecurityItem = BaseEnvelopeItem; @@ -149,7 +149,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext }; type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; -type SpanV2EnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; +type StreamedSpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; type LogEnvelopeHeaders = BaseEnvelopeHeaders; type MetricEnvelopeHeaders = BaseEnvelopeHeaders; export type EventEnvelope = BaseEnvelope< @@ -161,7 +161,7 @@ export type ClientReportEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; -export type SpanV2Envelope = BaseEnvelope; +export type StreamedSpanEnvelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; export type LogEnvelope = BaseEnvelope; @@ -175,7 +175,7 @@ export type Envelope = | ReplayEnvelope | CheckInEnvelope | SpanEnvelope - | SpanV2Envelope + | StreamedSpanEnvelope | RawSecurityEnvelope | LogEnvelope | MetricEnvelope; diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index 8b7ea3e02275..a918cc57859c 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -38,7 +38,7 @@ export type SpanTimeInput = HrTime | number | Date; /** * Intermediate JSON reporesentation of a v2 span, which users and our SDK integrations will interact with. * This is NOT the final serialized JSON span, but an intermediate step still holding raw attributes. - * The final, serialized span is a {@link SerializedSpan}. + * The final, serialized span is a {@link SerializedStreamedSpan}. * Main reason: Make it easier and safer for users to work with attributes. */ export interface StreamedSpanJSON { @@ -60,7 +60,7 @@ export interface StreamedSpanJSON { * The intermediate representation is {@link StreamedSpanJSON}. * Main difference: Attributes are converted to {@link Attributes}, thus including the `type` annotation. */ -export type SerializedSpan = Omit & { +export type SerializedStreamedSpan = Omit & { attributes?: Attributes; links?: SpanLinkJSON[]; }; @@ -68,8 +68,8 @@ export type SerializedSpan = Omit & { /** * Envelope span item container. */ -export type SerializedSpanContainer = { - items: Array; +export type SerializedStreamedSpanContainer = { + items: Array; }; /** A JSON representation of a span. */ diff --git a/packages/core/test/lib/tracing/spans/envelope.test.ts b/packages/core/test/lib/tracing/spans/envelope.test.ts new file mode 100644 index 000000000000..197b7ed40365 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/envelope.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; +import { createStreamedSpanEnvelope } from '../../../../src/tracing/spans/envelope'; +import type { DynamicSamplingContext } from '../../../../src/types-hoist/envelope'; +import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +function createMockSerializedSpan(overrides: Partial = {}): SerializedStreamedSpan { + return { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: 1713859200, + end_timestamp: 1713859201, + status: 'ok', + is_segment: false, + ...overrides, + }; +} + +describe('createStreamedSpanEnvelope', () => { + describe('envelope headers', () => { + it('creates an envelope with sent_at header', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('sent_at', expect.any(String)); + }); + + it('includes trace header when DSC has required props (trace_id and public_key)', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: DynamicSamplingContext = { + trace_id: 'trace-123', + public_key: 'public-key-abc', + sample_rate: '1.0', + release: 'v1.0.0', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('trace', dsc); + }); + + it("does't include trace header when DSC is missing trace_id", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = { + public_key: 'public-key-abc', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('trace'); + }); + + it("does't include trace header when DSC is missing public_key", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = { + trace_id: 'trace-123', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('trace'); + }); + + it('includes SDK info when available in client options', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + _metadata: { + sdk: { name: 'sentry.javascript.browser', version: '8.0.0' }, + }, + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('sdk', { name: 'sentry.javascript.browser', version: '8.0.0' }); + }); + + it("does't include SDK info when not available", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('sdk'); + }); + + it('includes DSN when tunnel and DSN are configured', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + tunnel: 'https://tunnel.example.com', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toHaveProperty('dsn', 'https://abc123@example.sentry.io/456'); + }); + + it("does't include DSN when tunnel is not configured", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('dsn'); + }); + + it("does't include DSN when DSN is not available", () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + tunnel: 'https://tunnel.example.com', + }), + ); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).not.toHaveProperty('dsn'); + }); + + it('includes all headers when all options are provided', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://abc123@example.sentry.io/456', + tunnel: 'https://tunnel.example.com', + _metadata: { + sdk: { name: 'sentry.javascript.node', version: '10.38.0' }, + }, + }), + ); + const dsc: DynamicSamplingContext = { + trace_id: 'trace-123', + public_key: 'public-key-abc', + environment: 'production', + }; + + const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient); + + expect(result[0]).toEqual({ + sent_at: expect.any(String), + trace: dsc, + sdk: { name: 'sentry.javascript.node', version: '10.38.0' }, + dsn: 'https://abc123@example.sentry.io/456', + }); + }); + }); + + describe('envelope item', () => { + it('creates a span container item with correct structure', () => { + const mockSpan = createMockSerializedSpan({ name: 'span-1' }); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { + content_type: 'application/vnd.sentry.items.span.v2+json', + item_count: 1, + type: 'span', + }, + { + items: [mockSpan], + }, + ], + ]); + }); + + it('sets correct item_count for multiple spans', () => { + const mockSpan1 = createMockSerializedSpan({ span_id: 'span-1' }); + const mockSpan2 = createMockSerializedSpan({ span_id: 'span-2' }); + const mockSpan3 = createMockSerializedSpan({ span_id: 'span-3' }); + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan1, mockSpan2, mockSpan3], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { type: 'span', item_count: 3, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { items: [mockSpan1, mockSpan2, mockSpan3] }, + ], + ]); + }); + + it('handles empty spans array', () => { + const mockClient = new TestClient(getDefaultTestClientOptions()); + const dsc: Partial = {}; + + const result = createStreamedSpanEnvelope([], dsc, mockClient); + + expect(result).toEqual([ + { + sent_at: expect.any(String), + }, + [ + [ + { + content_type: 'application/vnd.sentry.items.span.v2+json', + item_count: 0, + type: 'span', + }, + { + items: [], + }, + ], + ], + ]); + }); + }); +});