@@ -35,60 +35,121 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };
3535const MAX_SPAN_COUNT = 1000 ;
3636const 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 */
4146export 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
170210function 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-
183218function 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