@@ -35,60 +35,121 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };
35
35
const MAX_SPAN_COUNT = 1000 ;
36
36
const DEFAULT_TIMEOUT = 300 ; // 5 min
37
37
38
+ interface FinishedSpanBucket {
39
+ timestampInS : number ;
40
+ spans : Set < ReadableSpan > ;
41
+ }
42
+
38
43
/**
39
44
* A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions.
40
45
*/
41
46
export class SentrySpanExporter {
42
47
private _flushTimeout : ReturnType < typeof setTimeout > | undefined ;
43
- private _finishedSpans : ReadableSpan [ ] ;
44
- private _timeout : number ;
45
48
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 ( ) ;
49
78
}
50
79
51
80
/** Export a single span. */
52
81
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 ;
61
99
}
62
100
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
+ }
70
120
}
71
121
72
122
/** Try to flush any pending spans immediately. */
73
123
public flush ( ) : void {
74
124
this . _clearTimeout ( ) ;
75
125
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 ) ;
77
134
78
- const remainingSpans = maybeSend ( this . _finishedSpans ) ;
135
+ const sentSpanCount = sentSpans . size ;
79
136
80
- const remainingOpenSpanCount = remainingSpans . length ;
81
- const sentSpanCount = openSpanCount - remainingOpenSpanCount ;
137
+ const remainingOpenSpanCount = finishedSpans . length - sentSpanCount ;
82
138
83
139
DEBUG_BUILD &&
84
140
logger . log ( `SpanExporter exported ${ sentSpanCount } spans, ${ remainingOpenSpanCount } unsent spans remaining` ) ;
85
141
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
+ } ) ;
87
148
}
88
149
89
150
/** Clear the exporter. */
90
151
public clear ( ) : void {
91
- this . _finishedSpans = [ ] ;
152
+ this . _finishedSpanBuckets = this . _finishedSpanBuckets . fill ( undefined ) ;
92
153
this . _clearTimeout ( ) ;
93
154
}
94
155
@@ -99,52 +160,33 @@ export class SentrySpanExporter {
99
160
this . _flushTimeout = undefined ;
100
161
}
101
162
}
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
- }
121
163
}
122
164
123
165
/**
124
166
* Send the given spans, but only if they are part of a finished transaction.
125
167
*
126
- * Returns the unsent spans.
168
+ * Returns the sent spans.
127
169
* Spans remain unsent when their parent span is not yet finished.
128
170
* This will happen regularly, as child spans are generally finished before their parents.
129
171
* But it _could_ also happen because, for whatever reason, a parent span was lost.
130
172
* In this case, we'll eventually need to clean this up.
131
173
*/
132
- function maybeSend ( spans : ReadableSpan [ ] ) : ReadableSpan [ ] {
174
+ function maybeSend ( spans : ReadableSpan [ ] ) : Set < ReadableSpan > {
133
175
const grouped = groupSpansWithParents ( spans ) ;
134
- const remaining = new Set ( grouped ) ;
176
+ const sentSpans = new Set < ReadableSpan > ( ) ;
135
177
136
178
const rootNodes = getCompletedRootNodes ( grouped ) ;
137
179
138
180
rootNodes . forEach ( root => {
139
- remaining . delete ( root ) ;
140
181
const span = root . span ;
182
+ sentSpans . add ( span ) ;
141
183
const transactionEvent = createTransactionForOtelSpan ( span ) ;
142
184
143
185
// We'll recursively add all the child spans to this array
144
186
const spans = transactionEvent . spans || [ ] ;
145
187
146
188
root . children . forEach ( child => {
147
- createAndFinishSpanForOtelSpan ( child , spans , remaining ) ;
189
+ createAndFinishSpanForOtelSpan ( child , spans , sentSpans ) ;
148
190
} ) ;
149
191
150
192
// spans.sort() mutates the array, but we do not use this anymore after this point
@@ -162,9 +204,7 @@ function maybeSend(spans: ReadableSpan[]): ReadableSpan[] {
162
204
captureEvent ( transactionEvent ) ;
163
205
} ) ;
164
206
165
- return Array . from ( remaining )
166
- . map ( node => node . span )
167
- . filter ( ( span ) : span is ReadableSpan => ! ! span ) ;
207
+ return sentSpans ;
168
208
}
169
209
170
210
function nodeIsCompletedRootNode ( node : SpanNode ) : node is SpanNodeCompleted {
@@ -175,11 +215,6 @@ function getCompletedRootNodes(nodes: SpanNode[]): SpanNodeCompleted[] {
175
215
return nodes . filter ( nodeIsCompletedRootNode ) ;
176
216
}
177
217
178
- function shouldCleanupSpan ( span : ReadableSpan , currentTimeSeconds : number , maxStartTimeOffsetSeconds : number ) : boolean {
179
- const cutoff = currentTimeSeconds - maxStartTimeOffsetSeconds ;
180
- return spanTimeInputToSeconds ( span . startTime ) < cutoff ;
181
- }
182
-
183
218
function parseSpan ( span : ReadableSpan ) : { op ?: string ; origin ?: SpanOrigin ; source ?: TransactionSource } {
184
219
const attributes = span . attributes ;
185
220
@@ -260,16 +295,19 @@ function createTransactionForOtelSpan(span: ReadableSpan): TransactionEvent {
260
295
return transactionEvent ;
261
296
}
262
297
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 {
265
299
const span = node . span ;
266
300
301
+ if ( span ) {
302
+ sentSpans . add ( span ) ;
303
+ }
304
+
267
305
const shouldDrop = ! span ;
268
306
269
307
// If this span should be dropped, we still want to create spans for the children of this
270
308
if ( shouldDrop ) {
271
309
node . children . forEach ( child => {
272
- createAndFinishSpanForOtelSpan ( child , spans , remaining ) ;
310
+ createAndFinishSpanForOtelSpan ( child , spans , sentSpans ) ;
273
311
} ) ;
274
312
return ;
275
313
}
@@ -308,7 +346,7 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], remai
308
346
spans . push ( spanJSON ) ;
309
347
310
348
node . children . forEach ( child => {
311
- createAndFinishSpanForOtelSpan ( child , spans , remaining ) ;
349
+ createAndFinishSpanForOtelSpan ( child , spans , sentSpans ) ;
312
350
} ) ;
313
351
}
314
352
0 commit comments