Skip to content
Merged
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
4 changes: 1 addition & 3 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ export type {
ProfileChunkEnvelope,
ProfileChunkItem,
SpanEnvelope,
SpanV2Envelope,
StreamedSpanEnvelope,
SpanItem,
LogEnvelope,
MetricEnvelope,
Expand Down Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/tracing/spans/README.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions packages/core/src/tracing/spans/envelope.ts
Original file line number Diff line number Diff line change
@@ -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<SerializedStreamedSpan>,
dsc: Partial<DynamicSamplingContext>,
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<StreamedSpanEnvelope>(headers, [spanContainer]);
}

function dscHasRequiredProps(dsc: Partial<DynamicSamplingContext>): dsc is DynamicSamplingContext {
return !!dsc.trace_id && !!dsc.public_key;
}
10 changes: 5 additions & 5 deletions packages/core/src/types-hoist/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down Expand Up @@ -138,7 +138,7 @@ export type FeedbackItem = BaseEnvelopeItem<FeedbackItemHeaders, FeedbackEvent>;
export type ProfileItem = BaseEnvelopeItem<ProfileItemHeaders, Profile>;
export type ProfileChunkItem = BaseEnvelopeItem<ProfileChunkItemHeaders, ProfileChunk>;
export type SpanItem = BaseEnvelopeItem<SpanItemHeaders, Partial<SpanJSON>>;
export type SpanContainerItem = BaseEnvelopeItem<SpanContainerItemHeaders, SerializedSpanContainer>;
export type SpanContainerItem = BaseEnvelopeItem<SpanContainerItemHeaders, SerializedStreamedSpanContainer>;
export type LogContainerItem = BaseEnvelopeItem<LogContainerItemHeaders, SerializedLogContainer>;
export type MetricContainerItem = BaseEnvelopeItem<MetricContainerItemHeaders, SerializedMetricContainer>;
export type RawSecurityItem = BaseEnvelopeItem<RawSecurityHeaders, LegacyCSPReport>;
Expand All @@ -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<
Expand All @@ -161,7 +161,7 @@ export type ClientReportEnvelope = BaseEnvelope<ClientReportEnvelopeHeaders, Cli
export type ReplayEnvelope = [ReplayEnvelopeHeaders, [ReplayEventItem, ReplayRecordingItem]];
export type CheckInEnvelope = BaseEnvelope<CheckInEnvelopeHeaders, CheckInItem>;
export type SpanEnvelope = BaseEnvelope<SpanEnvelopeHeaders, SpanItem>;
export type SpanV2Envelope = BaseEnvelope<SpanV2EnvelopeHeaders, SpanContainerItem>;
export type StreamedSpanEnvelope = BaseEnvelope<StreamedSpanEnvelopeHeaders, SpanContainerItem>;
export type ProfileChunkEnvelope = BaseEnvelope<BaseEnvelopeHeaders, ProfileChunkItem>;
export type RawSecurityEnvelope = BaseEnvelope<BaseEnvelopeHeaders, RawSecurityItem>;
export type LogEnvelope = BaseEnvelope<LogEnvelopeHeaders, LogContainerItem>;
Expand All @@ -175,7 +175,7 @@ export type Envelope =
| ReplayEnvelope
| CheckInEnvelope
| SpanEnvelope
| SpanV2Envelope
| StreamedSpanEnvelope
| RawSecurityEnvelope
| LogEnvelope
| MetricEnvelope;
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/types-hoist/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -60,16 +60,16 @@ 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<StreamedSpanJSON, 'attributes' | 'links'> & {
export type SerializedStreamedSpan = Omit<StreamedSpanJSON, 'attributes' | 'links'> & {
attributes?: Attributes;
links?: SpanLinkJSON<Attributes>[];
};

/**
* Envelope span item container.
*/
export type SerializedSpanContainer = {
items: Array<SerializedSpan>;
export type SerializedStreamedSpanContainer = {
items: Array<SerializedStreamedSpan>;
};

/** A JSON representation of a span. */
Expand Down
232 changes: 232 additions & 0 deletions packages/core/test/lib/tracing/spans/envelope.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<DynamicSamplingContext> = {};

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<DynamicSamplingContext> = {
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<DynamicSamplingContext> = {
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<DynamicSamplingContext> = {};

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<DynamicSamplingContext> = {};

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<DynamicSamplingContext> = {};

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<DynamicSamplingContext> = {};

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<DynamicSamplingContext> = {};

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<DynamicSamplingContext> = {};

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<DynamicSamplingContext> = {};

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<DynamicSamplingContext> = {};

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: [],
},
],
],
]);
});
});
});
Loading