-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
perf(opentelemetry): Bucket spans for cleanup #14154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fcc75b6
70cc5dc
01f8d68
c807a30
ec96525
ced2588
877e8f5
65c18f0
8832fbf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -35,60 +35,121 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan }; | |
const MAX_SPAN_COUNT = 1000; | ||
const DEFAULT_TIMEOUT = 300; // 5 min | ||
|
||
interface FinishedSpanBucket { | ||
timestampInS: number; | ||
spans: Set<ReadableSpan>; | ||
} | ||
|
||
/** | ||
* A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions. | ||
*/ | ||
export class SentrySpanExporter { | ||
private _flushTimeout: ReturnType<typeof setTimeout> | undefined; | ||
private _finishedSpans: ReadableSpan[]; | ||
private _timeout: number; | ||
|
||
public constructor(options?: { timeout?: number }) { | ||
this._finishedSpans = []; | ||
this._timeout = options?.timeout || DEFAULT_TIMEOUT; | ||
/* | ||
* A quick explanation on the buckets: We do bucketing of finished spans for efficiency. This span exporter is | ||
* accumulating spans until a root span is encountered and then it flushes all the spans that are descendants of that | ||
* root span. Because it is totally in the realm of possibilities that root spans are never finished, and we don't | ||
* want to accumulate spans indefinitely in memory, we need to periodically evacuate spans. Naively we could simply | ||
* store the spans in an array and each time a new span comes in we could iterate through the entire array and | ||
* evacuate all spans that have an end-timestamp that is older than our limit. This could get quite expensive because | ||
* we would have to iterate a potentially large number of spans every time we evacuate. We want to avoid these large | ||
* bursts of computation. | ||
* | ||
* Instead we go for a bucketing approach and put spans into buckets, based on what second | ||
* (modulo the time limit) the span was put into the exporter. With buckets, when we decide to evacuate, we can | ||
* iterate through the bucket entries instead, which have an upper bound of items, making the evacuation much more | ||
* efficient. Cleaning up also becomes much more efficient since it simply involves de-referencing a bucket within the | ||
* bucket array, and letting garbage collection take care of the rest. | ||
*/ | ||
private _finishedSpanBuckets: (FinishedSpanBucket | undefined)[]; | ||
private _finishedSpanBucketSize: number; | ||
private _spansToBucketEntry: WeakMap<ReadableSpan, FinishedSpanBucket>; | ||
private _lastCleanupTimestampInS: number; | ||
|
||
public constructor(options?: { | ||
/** 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. */ | ||
timeout?: number; | ||
}) { | ||
this._finishedSpanBucketSize = options?.timeout || DEFAULT_TIMEOUT; | ||
this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined); | ||
this._lastCleanupTimestampInS = Math.floor(Date.now() / 1000); | ||
this._spansToBucketEntry = new WeakMap(); | ||
} | ||
|
||
/** Export a single span. */ | ||
public export(span: ReadableSpan): void { | ||
this._finishedSpans.push(span); | ||
|
||
// If the span has a local parent ID, we don't need to export anything just yet | ||
if (getLocalParentId(span)) { | ||
const openSpanCount = this._finishedSpans.length; | ||
DEBUG_BUILD && logger.log(`SpanExporter has ${openSpanCount} unsent spans remaining`); | ||
this._cleanupOldSpans(); | ||
return; | ||
const currentTimestampInS = Math.floor(Date.now() / 1000); | ||
|
||
if (this._lastCleanupTimestampInS !== currentTimestampInS) { | ||
let droppedSpanCount = 0; | ||
this._finishedSpanBuckets.forEach((bucket, i) => { | ||
if (bucket && bucket.timestampInS <= currentTimestampInS - this._finishedSpanBucketSize) { | ||
droppedSpanCount += bucket.spans.size; | ||
this._finishedSpanBuckets[i] = undefined; | ||
} | ||
}); | ||
if (droppedSpanCount > 0) { | ||
DEBUG_BUILD && | ||
logger.log( | ||
`SpanExporter dropped ${droppedSpanCount} spans because they were pending for more than ${this._finishedSpanBucketSize} seconds.`, | ||
); | ||
} | ||
this._lastCleanupTimestampInS = currentTimestampInS; | ||
} | ||
|
||
this._clearTimeout(); | ||
|
||
// If we got a parent span, we try to send the span tree | ||
// Wait a tick for this, to ensure we avoid race conditions | ||
this._flushTimeout = setTimeout(() => { | ||
this.flush(); | ||
}, 1); | ||
const currentBucketIndex = currentTimestampInS % this._finishedSpanBucketSize; | ||
const currentBucket = this._finishedSpanBuckets[currentBucketIndex] || { | ||
timestampInS: currentTimestampInS, | ||
spans: new Set(), | ||
}; | ||
this._finishedSpanBuckets[currentBucketIndex] = currentBucket; | ||
currentBucket.spans.add(span); | ||
this._spansToBucketEntry.set(span, currentBucket); | ||
|
||
// If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans | ||
if (!getLocalParentId(span)) { | ||
this._clearTimeout(); | ||
|
||
// If we got a parent span, we try to send the span tree | ||
// Wait a tick for this, to ensure we avoid race conditions | ||
this._flushTimeout = setTimeout(() => { | ||
this.flush(); | ||
}, 1); | ||
} | ||
} | ||
|
||
/** Try to flush any pending spans immediately. */ | ||
public flush(): void { | ||
this._clearTimeout(); | ||
|
||
const openSpanCount = this._finishedSpans.length; | ||
const finishedSpans: ReadableSpan[] = []; | ||
this._finishedSpanBuckets.forEach(bucket => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l: Can we instead add a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I will not add a public function for this. This is an implementation detail that should not be exposed. I can however extract this functionality into a private fn, which I also do not particularly like since we do this operation exactly one time (apart from in tests, which is whatever). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah I do not mean a public function, but just making it a private method on the class, then it is also not exposed but we can use it in tests. right now we duplicate the logic in the test so if that runs apart we may not notice (it's really a nit so feel free to disregard). In the tests we could then do, instead of: const finishedSpans1 = [];
exporter['_finishedSpanBuckets'].forEach((bucket: any) => {
if (bucket) {
finishedSpans1.push(...bucket.spans);
}
}); just const finishedSpans1 = exporter['_finishedSpans']; or something like this. |
||
if (bucket) { | ||
finishedSpans.push(...bucket.spans); | ||
} | ||
}); | ||
|
||
const sentSpans = maybeSend(finishedSpans); | ||
|
||
const remainingSpans = maybeSend(this._finishedSpans); | ||
const sentSpanCount = sentSpans.size; | ||
|
||
const remainingOpenSpanCount = remainingSpans.length; | ||
const sentSpanCount = openSpanCount - remainingOpenSpanCount; | ||
const remainingOpenSpanCount = finishedSpans.length - sentSpanCount; | ||
|
||
DEBUG_BUILD && | ||
logger.log(`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} unsent spans remaining`); | ||
|
||
this._cleanupOldSpans(remainingSpans); | ||
sentSpans.forEach(span => { | ||
const bucketEntry = this._spansToBucketEntry.get(span); | ||
if (bucketEntry) { | ||
bucketEntry.spans.delete(span); | ||
} | ||
}); | ||
} | ||
|
||
/** Clear the exporter. */ | ||
public clear(): void { | ||
this._finishedSpans = []; | ||
this._finishedSpanBuckets = this._finishedSpanBuckets.fill(undefined); | ||
this._clearTimeout(); | ||
} | ||
|
||
|
@@ -99,52 +160,33 @@ export class SentrySpanExporter { | |
this._flushTimeout = undefined; | ||
} | ||
} | ||
|
||
/** | ||
* Remove any span that is older than 5min. | ||
* We do this to avoid leaking memory. | ||
*/ | ||
private _cleanupOldSpans(spans = this._finishedSpans): void { | ||
const currentTimeSeconds = Date.now() / 1000; | ||
this._finishedSpans = spans.filter(span => { | ||
const shouldDrop = shouldCleanupSpan(span, currentTimeSeconds, this._timeout); | ||
DEBUG_BUILD && | ||
shouldDrop && | ||
logger.log( | ||
`SpanExporter dropping span ${span.name} (${ | ||
span.spanContext().spanId | ||
}) because it is pending for more than 5 minutes.`, | ||
); | ||
return !shouldDrop; | ||
}); | ||
} | ||
} | ||
|
||
/** | ||
* Send the given spans, but only if they are part of a finished transaction. | ||
* | ||
* Returns the unsent spans. | ||
* Returns the sent spans. | ||
* Spans remain unsent when their parent span is not yet finished. | ||
* This will happen regularly, as child spans are generally finished before their parents. | ||
* But it _could_ also happen because, for whatever reason, a parent span was lost. | ||
* In this case, we'll eventually need to clean this up. | ||
*/ | ||
function maybeSend(spans: ReadableSpan[]): ReadableSpan[] { | ||
function maybeSend(spans: ReadableSpan[]): Set<ReadableSpan> { | ||
const grouped = groupSpansWithParents(spans); | ||
const remaining = new Set(grouped); | ||
const sentSpans = new Set<ReadableSpan>(); | ||
|
||
const rootNodes = getCompletedRootNodes(grouped); | ||
|
||
rootNodes.forEach(root => { | ||
remaining.delete(root); | ||
const span = root.span; | ||
sentSpans.add(span); | ||
const transactionEvent = createTransactionForOtelSpan(span); | ||
|
||
// We'll recursively add all the child spans to this array | ||
const spans = transactionEvent.spans || []; | ||
|
||
root.children.forEach(child => { | ||
createAndFinishSpanForOtelSpan(child, spans, remaining); | ||
createAndFinishSpanForOtelSpan(child, spans, sentSpans); | ||
}); | ||
|
||
// spans.sort() mutates the array, but we do not use this anymore after this point | ||
|
@@ -162,9 +204,7 @@ function maybeSend(spans: ReadableSpan[]): ReadableSpan[] { | |
captureEvent(transactionEvent); | ||
}); | ||
|
||
return Array.from(remaining) | ||
.map(node => node.span) | ||
.filter((span): span is ReadableSpan => !!span); | ||
return sentSpans; | ||
} | ||
|
||
function nodeIsCompletedRootNode(node: SpanNode): node is SpanNodeCompleted { | ||
|
@@ -175,11 +215,6 @@ function getCompletedRootNodes(nodes: SpanNode[]): SpanNodeCompleted[] { | |
return nodes.filter(nodeIsCompletedRootNode); | ||
} | ||
|
||
function shouldCleanupSpan(span: ReadableSpan, currentTimeSeconds: number, maxStartTimeOffsetSeconds: number): boolean { | ||
const cutoff = currentTimeSeconds - maxStartTimeOffsetSeconds; | ||
return spanTimeInputToSeconds(span.startTime) < cutoff; | ||
} | ||
|
||
function parseSpan(span: ReadableSpan): { op?: string; origin?: SpanOrigin; source?: TransactionSource } { | ||
const attributes = span.attributes; | ||
|
||
|
@@ -260,16 +295,19 @@ function createTransactionForOtelSpan(span: ReadableSpan): TransactionEvent { | |
return transactionEvent; | ||
} | ||
|
||
function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], remaining: Set<SpanNode>): void { | ||
remaining.delete(node); | ||
function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentSpans: Set<ReadableSpan>): void { | ||
const span = node.span; | ||
|
||
if (span) { | ||
sentSpans.add(span); | ||
} | ||
|
||
const shouldDrop = !span; | ||
|
||
// If this span should be dropped, we still want to create spans for the children of this | ||
if (shouldDrop) { | ||
node.children.forEach(child => { | ||
createAndFinishSpanForOtelSpan(child, spans, remaining); | ||
createAndFinishSpanForOtelSpan(child, spans, sentSpans); | ||
}); | ||
return; | ||
} | ||
|
@@ -308,7 +346,7 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], remai | |
spans.push(spanJSON); | ||
|
||
node.children.forEach(child => { | ||
createAndFinishSpanForOtelSpan(child, spans, remaining); | ||
createAndFinishSpanForOtelSpan(child, spans, sentSpans); | ||
}); | ||
} | ||
|
||
|
Uh oh!
There was an error while loading. Please reload this page.