diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index d3255d76b0e9..1f4a6638f577 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,4 +1,6 @@ import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement'; +import type { Primitive } from './types-hoist/misc'; +import { isPrimitive } from './utils/is'; export type RawAttributes = T & ValidatedAttributes; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -127,6 +129,46 @@ export function serializeAttributes( return serializedAttributes; } +/** + * Estimates the serialized byte size of {@link Attributes}, + * with a couple of heuristics for performance. + */ +export function estimateTypedAttributesSizeInBytes(attributes: Attributes | undefined): number { + if (!attributes) { + return 0; + } + let weight = 0; + for (const [key, attr] of Object.entries(attributes)) { + weight += key.length * 2; + weight += attr.type.length * 2; + weight += (attr.unit?.length ?? 0) * 2; + const val = attr.value; + + if (Array.isArray(val)) { + // Assumption: Individual array items have the same type and roughly the same size + // probably not always true but allows us to cut down on runtime + weight += estimatePrimitiveSizeInBytes(val[0]) * val.length; + } else if (isPrimitive(val)) { + weight += estimatePrimitiveSizeInBytes(val); + } else { + // default fallback for anything else (objects) + weight += 100; + } + } + return weight; +} + +function estimatePrimitiveSizeInBytes(value: Primitive): number { + if (typeof value === 'string') { + return value.length * 2; + } else if (typeof value === 'boolean') { + return 4; + } else if (typeof value === 'number') { + return 8; + } + return 0; +} + /** * NOTE: We intentionally do not return anything for non-primitive values: * - array support will come in the future but if we stringify arrays now, diff --git a/packages/core/src/tracing/spans/estimateSize.ts b/packages/core/src/tracing/spans/estimateSize.ts new file mode 100644 index 000000000000..7d5781862d62 --- /dev/null +++ b/packages/core/src/tracing/spans/estimateSize.ts @@ -0,0 +1,37 @@ +import { estimateTypedAttributesSizeInBytes } from '../../attributes'; +import type { SerializedStreamedSpan } from '../../types-hoist/span'; + +/** + * Estimates the serialized byte size of a {@link SerializedStreamedSpan}. + * + * Uses 2 bytes per character as a UTF-16 approximation, and 8 bytes per number. + * The estimate is intentionally conservative and may be slightly lower than the + * actual byte size on the wire. + * We compensate for this by setting the span buffers internal limit well below the limit + * of how large an actual span v2 envelope may be. + */ +export function estimateSerializedSpanSizeInBytes(span: SerializedStreamedSpan): number { + /* + * Fixed-size fields are pre-computed as a constant for performance: + * - two timestamps (8 bytes each = 16) + * - is_segment boolean (5 bytes, assumed false for most spans) + * - trace_id – always 32 hex chars (64 bytes) + * - span_id – always 16 hex chars (32 bytes) + * - parent_span_id – 16 hex chars, assumed present for most spans (32 bytes) + * - status "ok" – most common value (8 bytes) + * = 156 bytes total base + */ + let weight = 156; + weight += span.name.length * 2; + weight += estimateTypedAttributesSizeInBytes(span.attributes); + if (span.links && span.links.length > 0) { + // Assumption: Links are roughly equal in number of attributes + // probably not always true but allows us to cut down on runtime + const firstLink = span.links[0]; + const attributes = firstLink?.attributes; + // Fixed size 100 due to span_id, trace_id and sampled flag (see above) + const linkWeight = 100 + (attributes ? estimateTypedAttributesSizeInBytes(attributes) : 0); + weight += linkWeight * span.links.length; + } + return weight; +} diff --git a/packages/core/src/tracing/spans/spanBuffer.ts b/packages/core/src/tracing/spans/spanBuffer.ts index 761ba076e4d2..d6451acb5e44 100644 --- a/packages/core/src/tracing/spans/spanBuffer.ts +++ b/packages/core/src/tracing/spans/spanBuffer.ts @@ -6,6 +6,7 @@ import { safeUnref } from '../../utils/timer'; import { getDynamicSamplingContextFromSpan } from '../dynamicSamplingContext'; import type { SerializedStreamedSpanWithSegmentSpan } from './captureSpan'; import { createStreamedSpanEnvelope } from './envelope'; +import { estimateSerializedSpanSizeInBytes } from './estimateSize'; /** * We must not send more than 1000 spans in one envelope. @@ -13,6 +14,8 @@ import { createStreamedSpanEnvelope } from './envelope'; */ const MAX_SPANS_PER_ENVELOPE = 1000; +const MAX_TRACE_WEIGHT_IN_BYTES = 5_000_000; + export interface SpanBufferOptions { /** * Max spans per trace before auto-flush @@ -29,6 +32,14 @@ export interface SpanBufferOptions { * @default 5_000 */ flushInterval?: number; + + /** + * Max accumulated byte weight of spans per trace before auto-flush. + * Size is estimated, not exact. Uses 2 bytes per character for strings (UTF-16). + * + * @default 5_000_000 (5 MB) + */ + maxTraceWeightInBytes?: number; } /** @@ -45,23 +56,28 @@ export interface SpanBufferOptions { export class SpanBuffer { /* Bucket spans by their trace id */ private _traceMap: Map>; + private _traceWeightMap: Map; private _flushIntervalId: ReturnType | null; private _client: Client; private _maxSpanLimit: number; private _flushInterval: number; + private _maxTraceWeight: number; public constructor(client: Client, options?: SpanBufferOptions) { this._traceMap = new Map(); + this._traceWeightMap = new Map(); this._client = client; - const { maxSpanLimit, flushInterval } = options ?? {}; + const { maxSpanLimit, flushInterval, maxTraceWeightInBytes } = options ?? {}; this._maxSpanLimit = maxSpanLimit && maxSpanLimit > 0 && maxSpanLimit <= MAX_SPANS_PER_ENVELOPE ? maxSpanLimit : MAX_SPANS_PER_ENVELOPE; this._flushInterval = flushInterval && flushInterval > 0 ? flushInterval : 5_000; + this._maxTraceWeight = + maxTraceWeightInBytes && maxTraceWeightInBytes > 0 ? maxTraceWeightInBytes : MAX_TRACE_WEIGHT_IN_BYTES; this._flushIntervalId = null; this._debounceFlushInterval(); @@ -77,6 +93,7 @@ export class SpanBuffer { clearInterval(this._flushIntervalId); } this._traceMap.clear(); + this._traceWeightMap.clear(); }); } @@ -93,7 +110,10 @@ export class SpanBuffer { this._traceMap.set(traceId, traceBucket); } - if (traceBucket.size >= this._maxSpanLimit) { + const newWeight = (this._traceWeightMap.get(traceId) ?? 0) + estimateSerializedSpanSizeInBytes(spanJSON); + this._traceWeightMap.set(traceId, newWeight); + + if (traceBucket.size >= this._maxSpanLimit || newWeight >= this._maxTraceWeight) { this.flush(traceId); this._debounceFlushInterval(); } @@ -128,7 +148,7 @@ export class SpanBuffer { if (!traceBucket.size) { // we should never get here, given we always add a span when we create a new bucket // and delete the bucket once we flush out the trace - this._traceMap.delete(traceId); + this._removeTrace(traceId); return; } @@ -137,7 +157,7 @@ export class SpanBuffer { const segmentSpan = spans[0]?._segmentSpan; if (!segmentSpan) { DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC'); - this._traceMap.delete(traceId); + this._removeTrace(traceId); return; } @@ -157,7 +177,12 @@ export class SpanBuffer { DEBUG_BUILD && debug.error('Error while sending streamed span envelope:', reason); }); + this._removeTrace(traceId); + } + + private _removeTrace(traceId: string): void { this._traceMap.delete(traceId); + this._traceWeightMap.delete(traceId); } private _debounceFlushInterval(): void { diff --git a/packages/core/test/lib/tracing/spans/estimateSize.test.ts b/packages/core/test/lib/tracing/spans/estimateSize.test.ts new file mode 100644 index 000000000000..35d569691dea --- /dev/null +++ b/packages/core/test/lib/tracing/spans/estimateSize.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import { estimateSerializedSpanSizeInBytes } from '../../../../src/tracing/spans/estimateSize'; +import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span'; + +// Produces a realistic trace_id (32 hex chars) and span_id (16 hex chars) +const TRACE_ID = 'a1b2c3d4e5f607189a0b1c2d3e4f5060'; +const SPAN_ID = 'a1b2c3d4e5f60718'; + +describe('estimateSerializedSpanSizeInBytes', () => { + it('estimates a minimal span (no attributes, no links, no parent) within a reasonable range of JSON.stringify', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'GET /api/users', + start_timestamp: 1740000000.123, + end_timestamp: 1740000001.456, + status: 'ok', + is_segment: true, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBe(184); + expect(actual).toBe(196); + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with a parent_span_id within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + parent_span_id: 'b2c3d4e5f6071890', + name: 'db.query', + start_timestamp: 1740000000.0, + end_timestamp: 1740000000.05, + status: 'ok', + is_segment: false, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBe(172); + expect(actual).toBe(222); + + expect(estimate).toBeLessThanOrEqual(actual * 1.1); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.7); + }); + + it('estimates a span with string attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'GET /api/users', + start_timestamp: 1740000000.0, + end_timestamp: 1740000000.1, + status: 'ok', + is_segment: false, + attributes: { + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: 'https://example.com/api/users?page=1&limit=100' }, + 'http.status_code': { type: 'integer', value: 200 }, + 'db.statement': { type: 'string', value: 'SELECT * FROM users WHERE id = $1' }, + 'sentry.origin': { type: 'string', value: 'auto.http.fetch' }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with numeric attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'process.task', + start_timestamp: 1740000000.0, + end_timestamp: 1740000005.0, + status: 'ok', + is_segment: false, + attributes: { + 'items.count': { type: 'integer', value: 42 }, + 'duration.ms': { type: 'double', value: 5000.5 }, + 'retry.count': { type: 'integer', value: 3 }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with boolean attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'cache.get', + start_timestamp: 1740000000.0, + end_timestamp: 1740000000.002, + status: 'ok', + is_segment: false, + attributes: { + 'cache.hit': { type: 'boolean', value: true }, + 'cache.miss': { type: 'boolean', value: false }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with array attributes within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'batch.process', + start_timestamp: 1740000000.0, + end_timestamp: 1740000002.0, + status: 'ok', + is_segment: false, + attributes: { + 'item.ids': { type: 'string[]', value: ['id-001', 'id-002', 'id-003', 'id-004', 'id-005'] }, + scores: { type: 'double[]', value: [1.1, 2.2, 3.3, 4.4] }, + flags: { type: 'boolean[]', value: [true, false, true] }, + }, + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); + + it('estimates a span with links within a reasonable range', () => { + const span: SerializedStreamedSpan = { + trace_id: TRACE_ID, + span_id: SPAN_ID, + name: 'linked.operation', + start_timestamp: 1740000000.0, + end_timestamp: 1740000001.0, + status: 'ok', + is_segment: true, + links: [ + { + trace_id: 'b2c3d4e5f607189a0b1c2d3e4f506070', + span_id: 'c3d4e5f607189a0b', + sampled: true, + attributes: { + 'sentry.link.type': { type: 'string', value: 'previous_trace' }, + }, + }, + { + trace_id: 'c3d4e5f607189a0b1c2d3e4f50607080', + span_id: 'd4e5f607189a0b1c', + }, + ], + }; + + const estimate = estimateSerializedSpanSizeInBytes(span); + const actual = JSON.stringify(span).length; + + expect(estimate).toBeLessThanOrEqual(actual * 1.2); + expect(estimate).toBeGreaterThanOrEqual(actual * 0.8); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts index 1b654cd400e6..44a6f6f954db 100644 --- a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts +++ b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts @@ -259,4 +259,101 @@ describe('SpanBuffer', () => { expect(sendEnvelopeSpy).not.toHaveBeenCalled(); }); + + describe('weight-based flushing', () => { + function makeSpan( + traceId: string, + spanId: string, + segmentSpan: InstanceType, + overrides: Partial = {}, + ): SerializedStreamedSpanWithSegmentSpan { + return { + trace_id: traceId, + span_id: spanId, + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + ...overrides, + }; + } + + it('flushes a trace when its weight limit is exceeded', () => { + // Use a very small weight threshold so a single span with attributes tips it over + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 200 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + // First span: small, under threshold + buffer.add(makeSpan('trace1', 'span1', segmentSpan, { name: 'a' })); + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + // Second span: has a large name that pushes it over 200 bytes + buffer.add(makeSpan('trace1', 'span2', segmentSpan, { name: 'a'.repeat(80) })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('does not flush when weight stays below the threshold', () => { + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 10_000 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add(makeSpan('trace1', 'span1', segmentSpan)); + buffer.add(makeSpan('trace1', 'span2', segmentSpan)); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); + + it('resets weight tracking after a weight-triggered flush so new spans accumulate fresh weight', () => { + // Base estimate per span is 152 bytes. With threshold 400: + // - big span ('a' * 200): 152 + 200*2 = 552 bytes → exceeds 400, triggers flush + // - small span (name 'b'): 152 + 1*2 = 154 bytes + // - two small spans combined: 308 bytes < 400 → no second flush + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 400 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add(makeSpan('trace1', 'span1', segmentSpan, { name: 'a'.repeat(200) })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + buffer.add(makeSpan('trace1', 'span2', segmentSpan, { name: 'b' })); + buffer.add(makeSpan('trace1', 'span3', segmentSpan, { name: 'c' })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('tracks weight independently per trace', () => { + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 200 }); + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + // trace1 gets a heavy span that exceeds the limit + buffer.add(makeSpan('trace1', 'span1', segmentSpan1, { name: 'a'.repeat(80) })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect((sentEnvelopes[0]?.[1]?.[0]?.[1] as { items: Array<{ trace_id: string }> })?.items[0]?.trace_id).toBe( + 'trace1', + ); + + // trace2 only has a small span and should not be flushed + buffer.add(makeSpan('trace2', 'span2', segmentSpan2, { name: 'b' })); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('estimates spans with attributes as heavier than bare spans', () => { + // Use a threshold that a bare span cannot reach but an attributed span can + const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 300 }); + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + // A span with many string attributes should tip it over + buffer.add( + makeSpan('trace1', 'span1', segmentSpan, { + attributes: { + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: 'https://example.com/api/v1/users?page=1&limit=100' }, + 'db.statement': { type: 'string', value: 'SELECT * FROM users WHERE id = 1' }, + }, + }), + ); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + }); });