Skip to content

Commit 0e45126

Browse files
committed
feat(core): Add weight-based flushing to span buffer
1 parent 434307b commit 0e45126

5 files changed

Lines changed: 382 additions & 4 deletions

File tree

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 { 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)