Skip to content

Commit e5c1208

Browse files
committed
feat(core): Add weight-based flushing to span buffer (#19579)
Adds weight-based flushing and span size estimation to the span buffer. Behaviour: - tracks weight independently per trace - weight estimation follows the same strategy we use for logs and metrics. I optimized the calculation, adding fixed sizes for as many fields as possible. Only span name, attributes and links are computed dynamically, with the same assumptions and considerations as in logs and metrics. - My tests show that the size estimation roughly compares to factor 0.8 to 1.2 to the real sizes, depending on data on spans (no, few, many, primitive, array attributes and links, etc.) - For now, the limit is set to 5MB which is half of the 10MB Relay accepts for span envelopes.
1 parent 9610b94 commit e5c1208

File tree

5 files changed

+382
-4
lines changed

5 files changed

+382
-4
lines changed

packages/core/src/attributes.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement';
2+
import type { Primitive } from './types-hoist/misc';
3+
import { isPrimitive } from './utils/is';
24

35
export type RawAttributes<T> = T & ValidatedAttributes<T>;
46
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -127,6 +129,46 @@ export function serializeAttributes<T>(
127129
return serializedAttributes;
128130
}
129131

132+
/**
133+
* Estimates the serialized byte size of {@link Attributes},
134+
* with a couple of heuristics for performance.
135+
*/
136+
export function estimateTypedAttributesSizeInBytes(attributes: Attributes | undefined): number {
137+
if (!attributes) {
138+
return 0;
139+
}
140+
let weight = 0;
141+
for (const [key, attr] of Object.entries(attributes)) {
142+
weight += key.length * 2;
143+
weight += attr.type.length * 2;
144+
weight += (attr.unit?.length ?? 0) * 2;
145+
const val = attr.value;
146+
147+
if (Array.isArray(val)) {
148+
// Assumption: Individual array items have the same type and roughly the same size
149+
// probably not always true but allows us to cut down on runtime
150+
weight += estimatePrimitiveSizeInBytes(val[0]) * val.length;
151+
} else if (isPrimitive(val)) {
152+
weight += estimatePrimitiveSizeInBytes(val);
153+
} else {
154+
// default fallback for anything else (objects)
155+
weight += 100;
156+
}
157+
}
158+
return weight;
159+
}
160+
161+
function estimatePrimitiveSizeInBytes(value: Primitive): number {
162+
if (typeof value === 'string') {
163+
return value.length * 2;
164+
} else if (typeof value === 'boolean') {
165+
return 4;
166+
} else if (typeof value === 'number') {
167+
return 8;
168+
}
169+
return 0;
170+
}
171+
130172
/**
131173
* NOTE: We intentionally do not return anything for non-primitive values:
132174
* - array support will come in the future but if we stringify arrays now,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { estimateTypedAttributesSizeInBytes } from '../../attributes';
2+
import type { SerializedStreamedSpan } from '../../types-hoist/span';
3+
4+
/**
5+
* Estimates the serialized byte size of a {@link SerializedStreamedSpan}.
6+
*
7+
* Uses 2 bytes per character as a UTF-16 approximation, and 8 bytes per number.
8+
* The estimate is intentionally conservative and may be slightly lower than the
9+
* actual byte size on the wire.
10+
* We compensate for this by setting the span buffers internal limit well below the limit
11+
* of how large an actual span v2 envelope may be.
12+
*/
13+
export function estimateSerializedSpanSizeInBytes(span: SerializedStreamedSpan): number {
14+
/*
15+
* Fixed-size fields are pre-computed as a constant for performance:
16+
* - two timestamps (8 bytes each = 16)
17+
* - is_segment boolean (5 bytes, assumed false for most spans)
18+
* - trace_id – always 32 hex chars (64 bytes)
19+
* - span_id – always 16 hex chars (32 bytes)
20+
* - parent_span_id – 16 hex chars, assumed present for most spans (32 bytes)
21+
* - status "ok" – most common value (8 bytes)
22+
* = 156 bytes total base
23+
*/
24+
let weight = 156;
25+
weight += span.name.length * 2;
26+
weight += estimateTypedAttributesSizeInBytes(span.attributes);
27+
if (span.links && span.links.length > 0) {
28+
// Assumption: Links are roughly equal in number of attributes
29+
// probably not always true but allows us to cut down on runtime
30+
const firstLink = span.links[0];
31+
const attributes = firstLink?.attributes;
32+
// Fixed size 100 due to span_id, trace_id and sampled flag (see above)
33+
const linkWeight = 100 + (attributes ? estimateTypedAttributesSizeInBytes(attributes) : 0);
34+
weight += linkWeight * span.links.length;
35+
}
36+
return weight;
37+
}

packages/core/src/tracing/spans/spanBuffer.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import { safeUnref } from '../../utils/timer';
66
import { getDynamicSamplingContextFromSpan } from '../dynamicSamplingContext';
77
import type { SerializedStreamedSpanWithSegmentSpan } from './captureSpan';
88
import { createStreamedSpanEnvelope } from './envelope';
9+
import { estimateSerializedSpanSizeInBytes } from './estimateSize';
910

1011
/**
1112
* We must not send more than 1000 spans in one envelope.
1213
* Otherwise the envelope is dropped by Relay.
1314
*/
1415
const MAX_SPANS_PER_ENVELOPE = 1000;
1516

17+
const MAX_TRACE_WEIGHT_IN_BYTES = 5_000_000;
18+
1619
export interface SpanBufferOptions {
1720
/**
1821
* Max spans per trace before auto-flush
@@ -29,6 +32,14 @@ export interface SpanBufferOptions {
2932
* @default 5_000
3033
*/
3134
flushInterval?: number;
35+
36+
/**
37+
* Max accumulated byte weight of spans per trace before auto-flush.
38+
* Size is estimated, not exact. Uses 2 bytes per character for strings (UTF-16).
39+
*
40+
* @default 5_000_000 (5 MB)
41+
*/
42+
maxTraceWeightInBytes?: number;
3243
}
3344

3445
/**
@@ -45,23 +56,28 @@ export interface SpanBufferOptions {
4556
export class SpanBuffer {
4657
/* Bucket spans by their trace id */
4758
private _traceMap: Map<string, Set<SerializedStreamedSpanWithSegmentSpan>>;
59+
private _traceWeightMap: Map<string, number>;
4860

4961
private _flushIntervalId: ReturnType<typeof setInterval> | null;
5062
private _client: Client;
5163
private _maxSpanLimit: number;
5264
private _flushInterval: number;
65+
private _maxTraceWeight: number;
5366

5467
public constructor(client: Client, options?: SpanBufferOptions) {
5568
this._traceMap = new Map();
69+
this._traceWeightMap = new Map();
5670
this._client = client;
5771

58-
const { maxSpanLimit, flushInterval } = options ?? {};
72+
const { maxSpanLimit, flushInterval, maxTraceWeightInBytes } = options ?? {};
5973

6074
this._maxSpanLimit =
6175
maxSpanLimit && maxSpanLimit > 0 && maxSpanLimit <= MAX_SPANS_PER_ENVELOPE
6276
? maxSpanLimit
6377
: MAX_SPANS_PER_ENVELOPE;
6478
this._flushInterval = flushInterval && flushInterval > 0 ? flushInterval : 5_000;
79+
this._maxTraceWeight =
80+
maxTraceWeightInBytes && maxTraceWeightInBytes > 0 ? maxTraceWeightInBytes : MAX_TRACE_WEIGHT_IN_BYTES;
6581

6682
this._flushIntervalId = null;
6783
this._debounceFlushInterval();
@@ -77,6 +93,7 @@ export class SpanBuffer {
7793
clearInterval(this._flushIntervalId);
7894
}
7995
this._traceMap.clear();
96+
this._traceWeightMap.clear();
8097
});
8198
}
8299

@@ -93,7 +110,10 @@ export class SpanBuffer {
93110
this._traceMap.set(traceId, traceBucket);
94111
}
95112

96-
if (traceBucket.size >= this._maxSpanLimit) {
113+
const newWeight = (this._traceWeightMap.get(traceId) ?? 0) + estimateSerializedSpanSizeInBytes(spanJSON);
114+
this._traceWeightMap.set(traceId, newWeight);
115+
116+
if (traceBucket.size >= this._maxSpanLimit || newWeight >= this._maxTraceWeight) {
97117
this.flush(traceId);
98118
this._debounceFlushInterval();
99119
}
@@ -128,7 +148,7 @@ export class SpanBuffer {
128148
if (!traceBucket.size) {
129149
// we should never get here, given we always add a span when we create a new bucket
130150
// and delete the bucket once we flush out the trace
131-
this._traceMap.delete(traceId);
151+
this._removeTrace(traceId);
132152
return;
133153
}
134154

@@ -137,7 +157,7 @@ export class SpanBuffer {
137157
const segmentSpan = spans[0]?._segmentSpan;
138158
if (!segmentSpan) {
139159
DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC');
140-
this._traceMap.delete(traceId);
160+
this._removeTrace(traceId);
141161
return;
142162
}
143163

@@ -157,7 +177,12 @@ export class SpanBuffer {
157177
DEBUG_BUILD && debug.error('Error while sending streamed span envelope:', reason);
158178
});
159179

180+
this._removeTrace(traceId);
181+
}
182+
183+
private _removeTrace(traceId: string): void {
160184
this._traceMap.delete(traceId);
185+
this._traceWeightMap.delete(traceId);
161186
}
162187

163188
private _debounceFlushInterval(): void {
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { estimateSerializedSpanSizeInBytes } from '../../../../src/tracing/spans/estimateSize';
3+
import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span';
4+
5+
// Produces a realistic trace_id (32 hex chars) and span_id (16 hex chars)
6+
const TRACE_ID = 'a1b2c3d4e5f607189a0b1c2d3e4f5060';
7+
const SPAN_ID = 'a1b2c3d4e5f60718';
8+
9+
describe('estimateSerializedSpanSizeInBytes', () => {
10+
it('estimates a minimal span (no attributes, no links, no parent) within a reasonable range of JSON.stringify', () => {
11+
const span: SerializedStreamedSpan = {
12+
trace_id: TRACE_ID,
13+
span_id: SPAN_ID,
14+
name: 'GET /api/users',
15+
start_timestamp: 1740000000.123,
16+
end_timestamp: 1740000001.456,
17+
status: 'ok',
18+
is_segment: true,
19+
};
20+
21+
const estimate = estimateSerializedSpanSizeInBytes(span);
22+
const actual = JSON.stringify(span).length;
23+
24+
expect(estimate).toBe(184);
25+
expect(actual).toBe(196);
26+
27+
expect(estimate).toBeLessThanOrEqual(actual * 1.2);
28+
expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
29+
});
30+
31+
it('estimates a span with a parent_span_id within a reasonable range', () => {
32+
const span: SerializedStreamedSpan = {
33+
trace_id: TRACE_ID,
34+
span_id: SPAN_ID,
35+
parent_span_id: 'b2c3d4e5f6071890',
36+
name: 'db.query',
37+
start_timestamp: 1740000000.0,
38+
end_timestamp: 1740000000.05,
39+
status: 'ok',
40+
is_segment: false,
41+
};
42+
43+
const estimate = estimateSerializedSpanSizeInBytes(span);
44+
const actual = JSON.stringify(span).length;
45+
46+
expect(estimate).toBe(172);
47+
expect(actual).toBe(222);
48+
49+
expect(estimate).toBeLessThanOrEqual(actual * 1.1);
50+
expect(estimate).toBeGreaterThanOrEqual(actual * 0.7);
51+
});
52+
53+
it('estimates a span with string attributes within a reasonable range', () => {
54+
const span: SerializedStreamedSpan = {
55+
trace_id: TRACE_ID,
56+
span_id: SPAN_ID,
57+
name: 'GET /api/users',
58+
start_timestamp: 1740000000.0,
59+
end_timestamp: 1740000000.1,
60+
status: 'ok',
61+
is_segment: false,
62+
attributes: {
63+
'http.method': { type: 'string', value: 'GET' },
64+
'http.url': { type: 'string', value: 'https://example.com/api/users?page=1&limit=100' },
65+
'http.status_code': { type: 'integer', value: 200 },
66+
'db.statement': { type: 'string', value: 'SELECT * FROM users WHERE id = $1' },
67+
'sentry.origin': { type: 'string', value: 'auto.http.fetch' },
68+
},
69+
};
70+
71+
const estimate = estimateSerializedSpanSizeInBytes(span);
72+
const actual = JSON.stringify(span).length;
73+
74+
expect(estimate).toBeLessThanOrEqual(actual * 1.2);
75+
expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
76+
});
77+
78+
it('estimates a span with numeric attributes within a reasonable range', () => {
79+
const span: SerializedStreamedSpan = {
80+
trace_id: TRACE_ID,
81+
span_id: SPAN_ID,
82+
name: 'process.task',
83+
start_timestamp: 1740000000.0,
84+
end_timestamp: 1740000005.0,
85+
status: 'ok',
86+
is_segment: false,
87+
attributes: {
88+
'items.count': { type: 'integer', value: 42 },
89+
'duration.ms': { type: 'double', value: 5000.5 },
90+
'retry.count': { type: 'integer', value: 3 },
91+
},
92+
};
93+
94+
const estimate = estimateSerializedSpanSizeInBytes(span);
95+
const actual = JSON.stringify(span).length;
96+
97+
expect(estimate).toBeLessThanOrEqual(actual * 1.2);
98+
expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
99+
});
100+
101+
it('estimates a span with boolean attributes within a reasonable range', () => {
102+
const span: SerializedStreamedSpan = {
103+
trace_id: TRACE_ID,
104+
span_id: SPAN_ID,
105+
name: 'cache.get',
106+
start_timestamp: 1740000000.0,
107+
end_timestamp: 1740000000.002,
108+
status: 'ok',
109+
is_segment: false,
110+
attributes: {
111+
'cache.hit': { type: 'boolean', value: true },
112+
'cache.miss': { type: 'boolean', value: false },
113+
},
114+
};
115+
116+
const estimate = estimateSerializedSpanSizeInBytes(span);
117+
const actual = JSON.stringify(span).length;
118+
119+
expect(estimate).toBeLessThanOrEqual(actual * 1.2);
120+
expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
121+
});
122+
123+
it('estimates a span with array attributes within a reasonable range', () => {
124+
const span: SerializedStreamedSpan = {
125+
trace_id: TRACE_ID,
126+
span_id: SPAN_ID,
127+
name: 'batch.process',
128+
start_timestamp: 1740000000.0,
129+
end_timestamp: 1740000002.0,
130+
status: 'ok',
131+
is_segment: false,
132+
attributes: {
133+
'item.ids': { type: 'string[]', value: ['id-001', 'id-002', 'id-003', 'id-004', 'id-005'] },
134+
scores: { type: 'double[]', value: [1.1, 2.2, 3.3, 4.4] },
135+
flags: { type: 'boolean[]', value: [true, false, true] },
136+
},
137+
};
138+
139+
const estimate = estimateSerializedSpanSizeInBytes(span);
140+
const actual = JSON.stringify(span).length;
141+
142+
expect(estimate).toBeLessThanOrEqual(actual * 1.2);
143+
expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
144+
});
145+
146+
it('estimates a span with links within a reasonable range', () => {
147+
const span: SerializedStreamedSpan = {
148+
trace_id: TRACE_ID,
149+
span_id: SPAN_ID,
150+
name: 'linked.operation',
151+
start_timestamp: 1740000000.0,
152+
end_timestamp: 1740000001.0,
153+
status: 'ok',
154+
is_segment: true,
155+
links: [
156+
{
157+
trace_id: 'b2c3d4e5f607189a0b1c2d3e4f506070',
158+
span_id: 'c3d4e5f607189a0b',
159+
sampled: true,
160+
attributes: {
161+
'sentry.link.type': { type: 'string', value: 'previous_trace' },
162+
},
163+
},
164+
{
165+
trace_id: 'c3d4e5f607189a0b1c2d3e4f50607080',
166+
span_id: 'd4e5f607189a0b1c',
167+
},
168+
],
169+
};
170+
171+
const estimate = estimateSerializedSpanSizeInBytes(span);
172+
const actual = JSON.stringify(span).length;
173+
174+
expect(estimate).toBeLessThanOrEqual(actual * 1.2);
175+
expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
176+
});
177+
});

0 commit comments

Comments
 (0)