Skip to content

Commit a4138e9

Browse files
fmorettlforst
andauthored
perf(opentelemetry): Bucket spans for cleanup (#14154)
Co-authored-by: Luca Forstner <[email protected]>
1 parent 3e7969f commit a4138e9

File tree

3 files changed

+151
-99
lines changed

3 files changed

+151
-99
lines changed

packages/node/test/integration/transactions.test.ts

+18-17
Original file line numberDiff line numberDiff line change
@@ -577,17 +577,9 @@ describe('Integration | Transactions', () => {
577577
throw new Error('No exporter found, aborting test...');
578578
}
579579

580-
let innerSpan1Id: string | undefined;
581-
let innerSpan2Id: string | undefined;
582-
583580
void Sentry.startSpan({ name: 'test name' }, async () => {
584-
const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' });
585-
innerSpan1Id = subSpan.spanContext().spanId;
586-
subSpan.end();
587-
588-
Sentry.startSpan({ name: 'inner span 2' }, innerSpan => {
589-
innerSpan2Id = innerSpan.spanContext().spanId;
590-
});
581+
Sentry.startInactiveSpan({ name: 'inner span 1' }).end();
582+
Sentry.startInactiveSpan({ name: 'inner span 2' }).end();
591583

592584
// Pretend this is pending for 10 minutes
593585
await new Promise(resolve => setTimeout(resolve, 10 * 60 * 1000));
@@ -596,7 +588,13 @@ describe('Integration | Transactions', () => {
596588
jest.advanceTimersByTime(1);
597589

598590
// Child-spans have been added to the exporter, but they are pending since they are waiting for their parent
599-
expect(exporter['_finishedSpans'].length).toBe(2);
591+
const finishedSpans1 = [];
592+
exporter['_finishedSpanBuckets'].forEach((bucket: any) => {
593+
if (bucket) {
594+
finishedSpans1.push(...bucket.spans);
595+
}
596+
});
597+
expect(finishedSpans1.length).toBe(2);
600598
expect(beforeSendTransaction).toHaveBeenCalledTimes(0);
601599

602600
// Now wait for 5 mins
@@ -608,18 +606,21 @@ describe('Integration | Transactions', () => {
608606
jest.advanceTimersByTime(1);
609607

610608
// Old spans have been cleared away
611-
expect(exporter['_finishedSpans'].length).toBe(0);
609+
const finishedSpans2 = [];
610+
exporter['_finishedSpanBuckets'].forEach((bucket: any) => {
611+
if (bucket) {
612+
finishedSpans2.push(...bucket.spans);
613+
}
614+
});
615+
expect(finishedSpans2.length).toBe(0);
612616

613617
// Called once for the 'other span'
614618
expect(beforeSendTransaction).toHaveBeenCalledTimes(1);
615619

616620
expect(logs).toEqual(
617621
expect.arrayContaining([
618-
'SpanExporter has 1 unsent spans remaining',
619-
'SpanExporter has 2 unsent spans remaining',
620-
'SpanExporter exported 1 spans, 2 unsent spans remaining',
621-
`SpanExporter dropping span inner span 1 (${innerSpan1Id}) because it is pending for more than 5 minutes.`,
622-
`SpanExporter dropping span inner span 2 (${innerSpan2Id}) because it is pending for more than 5 minutes.`,
622+
'SpanExporter dropped 2 spans because they were pending for more than 300 seconds.',
623+
'SpanExporter exported 1 spans, 0 unsent spans remaining',
623624
]),
624625
);
625626
});

packages/opentelemetry/src/spanExporter.ts

+100-62
Original file line numberDiff line numberDiff line change
@@ -35,60 +35,121 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };
3535
const MAX_SPAN_COUNT = 1000;
3636
const DEFAULT_TIMEOUT = 300; // 5 min
3737

38+
interface FinishedSpanBucket {
39+
timestampInS: number;
40+
spans: Set<ReadableSpan>;
41+
}
42+
3843
/**
3944
* A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions.
4045
*/
4146
export class SentrySpanExporter {
4247
private _flushTimeout: ReturnType<typeof setTimeout> | undefined;
43-
private _finishedSpans: ReadableSpan[];
44-
private _timeout: number;
4548

46-
public constructor(options?: { timeout?: number }) {
47-
this._finishedSpans = [];
48-
this._timeout = options?.timeout || DEFAULT_TIMEOUT;
49+
/*
50+
* A quick explanation on the buckets: We do bucketing of finished spans for efficiency. This span exporter is
51+
* accumulating spans until a root span is encountered and then it flushes all the spans that are descendants of that
52+
* root span. Because it is totally in the realm of possibilities that root spans are never finished, and we don't
53+
* want to accumulate spans indefinitely in memory, we need to periodically evacuate spans. Naively we could simply
54+
* store the spans in an array and each time a new span comes in we could iterate through the entire array and
55+
* evacuate all spans that have an end-timestamp that is older than our limit. This could get quite expensive because
56+
* we would have to iterate a potentially large number of spans every time we evacuate. We want to avoid these large
57+
* bursts of computation.
58+
*
59+
* Instead we go for a bucketing approach and put spans into buckets, based on what second
60+
* (modulo the time limit) the span was put into the exporter. With buckets, when we decide to evacuate, we can
61+
* iterate through the bucket entries instead, which have an upper bound of items, making the evacuation much more
62+
* efficient. Cleaning up also becomes much more efficient since it simply involves de-referencing a bucket within the
63+
* bucket array, and letting garbage collection take care of the rest.
64+
*/
65+
private _finishedSpanBuckets: (FinishedSpanBucket | undefined)[];
66+
private _finishedSpanBucketSize: number;
67+
private _spansToBucketEntry: WeakMap<ReadableSpan, FinishedSpanBucket>;
68+
private _lastCleanupTimestampInS: number;
69+
70+
public constructor(options?: {
71+
/** Lower bound of time in seconds until spans that are buffered but have not been sent as part of a transaction get cleared from memory. */
72+
timeout?: number;
73+
}) {
74+
this._finishedSpanBucketSize = options?.timeout || DEFAULT_TIMEOUT;
75+
this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined);
76+
this._lastCleanupTimestampInS = Math.floor(Date.now() / 1000);
77+
this._spansToBucketEntry = new WeakMap();
4978
}
5079

5180
/** Export a single span. */
5281
public export(span: ReadableSpan): void {
53-
this._finishedSpans.push(span);
54-
55-
// If the span has a local parent ID, we don't need to export anything just yet
56-
if (getLocalParentId(span)) {
57-
const openSpanCount = this._finishedSpans.length;
58-
DEBUG_BUILD && logger.log(`SpanExporter has ${openSpanCount} unsent spans remaining`);
59-
this._cleanupOldSpans();
60-
return;
82+
const currentTimestampInS = Math.floor(Date.now() / 1000);
83+
84+
if (this._lastCleanupTimestampInS !== currentTimestampInS) {
85+
let droppedSpanCount = 0;
86+
this._finishedSpanBuckets.forEach((bucket, i) => {
87+
if (bucket && bucket.timestampInS <= currentTimestampInS - this._finishedSpanBucketSize) {
88+
droppedSpanCount += bucket.spans.size;
89+
this._finishedSpanBuckets[i] = undefined;
90+
}
91+
});
92+
if (droppedSpanCount > 0) {
93+
DEBUG_BUILD &&
94+
logger.log(
95+
`SpanExporter dropped ${droppedSpanCount} spans because they were pending for more than ${this._finishedSpanBucketSize} seconds.`,
96+
);
97+
}
98+
this._lastCleanupTimestampInS = currentTimestampInS;
6199
}
62100

63-
this._clearTimeout();
64-
65-
// If we got a parent span, we try to send the span tree
66-
// Wait a tick for this, to ensure we avoid race conditions
67-
this._flushTimeout = setTimeout(() => {
68-
this.flush();
69-
}, 1);
101+
const currentBucketIndex = currentTimestampInS % this._finishedSpanBucketSize;
102+
const currentBucket = this._finishedSpanBuckets[currentBucketIndex] || {
103+
timestampInS: currentTimestampInS,
104+
spans: new Set(),
105+
};
106+
this._finishedSpanBuckets[currentBucketIndex] = currentBucket;
107+
currentBucket.spans.add(span);
108+
this._spansToBucketEntry.set(span, currentBucket);
109+
110+
// If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans
111+
if (!getLocalParentId(span)) {
112+
this._clearTimeout();
113+
114+
// If we got a parent span, we try to send the span tree
115+
// Wait a tick for this, to ensure we avoid race conditions
116+
this._flushTimeout = setTimeout(() => {
117+
this.flush();
118+
}, 1);
119+
}
70120
}
71121

72122
/** Try to flush any pending spans immediately. */
73123
public flush(): void {
74124
this._clearTimeout();
75125

76-
const openSpanCount = this._finishedSpans.length;
126+
const finishedSpans: ReadableSpan[] = [];
127+
this._finishedSpanBuckets.forEach(bucket => {
128+
if (bucket) {
129+
finishedSpans.push(...bucket.spans);
130+
}
131+
});
132+
133+
const sentSpans = maybeSend(finishedSpans);
77134

78-
const remainingSpans = maybeSend(this._finishedSpans);
135+
const sentSpanCount = sentSpans.size;
79136

80-
const remainingOpenSpanCount = remainingSpans.length;
81-
const sentSpanCount = openSpanCount - remainingOpenSpanCount;
137+
const remainingOpenSpanCount = finishedSpans.length - sentSpanCount;
82138

83139
DEBUG_BUILD &&
84140
logger.log(`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} unsent spans remaining`);
85141

86-
this._cleanupOldSpans(remainingSpans);
142+
sentSpans.forEach(span => {
143+
const bucketEntry = this._spansToBucketEntry.get(span);
144+
if (bucketEntry) {
145+
bucketEntry.spans.delete(span);
146+
}
147+
});
87148
}
88149

89150
/** Clear the exporter. */
90151
public clear(): void {
91-
this._finishedSpans = [];
152+
this._finishedSpanBuckets = this._finishedSpanBuckets.fill(undefined);
92153
this._clearTimeout();
93154
}
94155

@@ -99,52 +160,33 @@ export class SentrySpanExporter {
99160
this._flushTimeout = undefined;
100161
}
101162
}
102-
103-
/**
104-
* Remove any span that is older than 5min.
105-
* We do this to avoid leaking memory.
106-
*/
107-
private _cleanupOldSpans(spans = this._finishedSpans): void {
108-
const currentTimeSeconds = Date.now() / 1000;
109-
this._finishedSpans = spans.filter(span => {
110-
const shouldDrop = shouldCleanupSpan(span, currentTimeSeconds, this._timeout);
111-
DEBUG_BUILD &&
112-
shouldDrop &&
113-
logger.log(
114-
`SpanExporter dropping span ${span.name} (${
115-
span.spanContext().spanId
116-
}) because it is pending for more than 5 minutes.`,
117-
);
118-
return !shouldDrop;
119-
});
120-
}
121163
}
122164

123165
/**
124166
* Send the given spans, but only if they are part of a finished transaction.
125167
*
126-
* Returns the unsent spans.
168+
* Returns the sent spans.
127169
* Spans remain unsent when their parent span is not yet finished.
128170
* This will happen regularly, as child spans are generally finished before their parents.
129171
* But it _could_ also happen because, for whatever reason, a parent span was lost.
130172
* In this case, we'll eventually need to clean this up.
131173
*/
132-
function maybeSend(spans: ReadableSpan[]): ReadableSpan[] {
174+
function maybeSend(spans: ReadableSpan[]): Set<ReadableSpan> {
133175
const grouped = groupSpansWithParents(spans);
134-
const remaining = new Set(grouped);
176+
const sentSpans = new Set<ReadableSpan>();
135177

136178
const rootNodes = getCompletedRootNodes(grouped);
137179

138180
rootNodes.forEach(root => {
139-
remaining.delete(root);
140181
const span = root.span;
182+
sentSpans.add(span);
141183
const transactionEvent = createTransactionForOtelSpan(span);
142184

143185
// We'll recursively add all the child spans to this array
144186
const spans = transactionEvent.spans || [];
145187

146188
root.children.forEach(child => {
147-
createAndFinishSpanForOtelSpan(child, spans, remaining);
189+
createAndFinishSpanForOtelSpan(child, spans, sentSpans);
148190
});
149191

150192
// spans.sort() mutates the array, but we do not use this anymore after this point
@@ -162,9 +204,7 @@ function maybeSend(spans: ReadableSpan[]): ReadableSpan[] {
162204
captureEvent(transactionEvent);
163205
});
164206

165-
return Array.from(remaining)
166-
.map(node => node.span)
167-
.filter((span): span is ReadableSpan => !!span);
207+
return sentSpans;
168208
}
169209

170210
function nodeIsCompletedRootNode(node: SpanNode): node is SpanNodeCompleted {
@@ -175,11 +215,6 @@ function getCompletedRootNodes(nodes: SpanNode[]): SpanNodeCompleted[] {
175215
return nodes.filter(nodeIsCompletedRootNode);
176216
}
177217

178-
function shouldCleanupSpan(span: ReadableSpan, currentTimeSeconds: number, maxStartTimeOffsetSeconds: number): boolean {
179-
const cutoff = currentTimeSeconds - maxStartTimeOffsetSeconds;
180-
return spanTimeInputToSeconds(span.startTime) < cutoff;
181-
}
182-
183218
function parseSpan(span: ReadableSpan): { op?: string; origin?: SpanOrigin; source?: TransactionSource } {
184219
const attributes = span.attributes;
185220

@@ -260,16 +295,19 @@ function createTransactionForOtelSpan(span: ReadableSpan): TransactionEvent {
260295
return transactionEvent;
261296
}
262297

263-
function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], remaining: Set<SpanNode>): void {
264-
remaining.delete(node);
298+
function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentSpans: Set<ReadableSpan>): void {
265299
const span = node.span;
266300

301+
if (span) {
302+
sentSpans.add(span);
303+
}
304+
267305
const shouldDrop = !span;
268306

269307
// If this span should be dropped, we still want to create spans for the children of this
270308
if (shouldDrop) {
271309
node.children.forEach(child => {
272-
createAndFinishSpanForOtelSpan(child, spans, remaining);
310+
createAndFinishSpanForOtelSpan(child, spans, sentSpans);
273311
});
274312
return;
275313
}
@@ -308,7 +346,7 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], remai
308346
spans.push(spanJSON);
309347

310348
node.children.forEach(child => {
311-
createAndFinishSpanForOtelSpan(child, spans, remaining);
349+
createAndFinishSpanForOtelSpan(child, spans, sentSpans);
312350
});
313351
}
314352

0 commit comments

Comments
 (0)