From cbc36c9365a8157969a29878bd6545ec9d6e0389 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 13 Jun 2025 09:24:30 +0200 Subject: [PATCH 1/7] ref(node): Improve span flushing --- packages/core/src/index.ts | 1 + packages/core/src/utils/debounce.ts | 75 +++++ packages/core/test/lib/utils/debounce.test.ts | 276 ++++++++++++++++++ packages/opentelemetry/src/spanExporter.ts | 98 +++---- packages/replay-internal/src/util/debounce.ts | 48 +-- 5 files changed, 392 insertions(+), 106 deletions(-) create mode 100644 packages/core/src/utils/debounce.ts create mode 100644 packages/core/test/lib/utils/debounce.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 986d18a972d2..d418b8a28c19 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -88,6 +88,7 @@ export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; export { getTraceData } from './utils/traceData'; export { getTraceMetaTags } from './utils/meta'; +export { debounce } from './utils/debounce'; export { winterCGHeadersToDict, winterCGRequestToRequestData, diff --git a/packages/core/src/utils/debounce.ts b/packages/core/src/utils/debounce.ts new file mode 100644 index 000000000000..a376dce35a3c --- /dev/null +++ b/packages/core/src/utils/debounce.ts @@ -0,0 +1,75 @@ +type DebouncedCallback = { + (): void | unknown; + flush: () => void | unknown; + cancel: () => void; +}; +type CallbackFunction = () => unknown; +type DebounceOptions = { + maxWait?: number; + // This can be overwritten to use a different setTimeout implementation, e.g. to avoid triggering change detection in Angular + setTimeoutImpl?: typeof setTimeout; +}; + +/** + * Heavily simplified debounce function based on lodash.debounce. + * + * This function takes a callback function (@param fun) and delays its invocation + * by @param wait milliseconds. Optionally, a maxWait can be specified in @param options, + * which ensures that the callback is invoked at least once after the specified max. wait time. + * + * @param func the function whose invocation is to be debounced + * @param wait the minimum time until the function is invoked after it was called once + * @param options the options object, which can contain the `maxWait` property + * + * @returns the debounced version of the function, which needs to be called at least once to start the + * debouncing process. Subsequent calls will reset the debouncing timer and, in case @paramfunc + * was already invoked in the meantime, return @param func's return value. + * The debounced function has two additional properties: + * - `flush`: Invokes the debounced function immediately and returns its return value + * - `cancel`: Cancels the debouncing process and resets the debouncing timer + */ +export function debounce(func: CallbackFunction, wait: number, options?: DebounceOptions): DebouncedCallback { + let callbackReturnValue: unknown; + + let timerId: ReturnType | undefined; + let maxTimerId: ReturnType | undefined; + + const maxWait = options?.maxWait ? Math.max(options.maxWait, wait) : 0; + const setTimeoutImpl = options?.setTimeoutImpl || setTimeout; + + function invokeFunc(): unknown { + cancelTimers(); + callbackReturnValue = func(); + return callbackReturnValue; + } + + function cancelTimers(): void { + timerId !== undefined && clearTimeout(timerId); + maxTimerId !== undefined && clearTimeout(maxTimerId); + timerId = maxTimerId = undefined; + } + + function flush(): unknown { + if (timerId !== undefined || maxTimerId !== undefined) { + return invokeFunc(); + } + return callbackReturnValue; + } + + function debounced(): unknown { + if (timerId) { + clearTimeout(timerId); + } + timerId = setTimeoutImpl(invokeFunc, wait); + + if (maxWait && maxTimerId === undefined) { + maxTimerId = setTimeoutImpl(invokeFunc, maxWait); + } + + return callbackReturnValue; + } + + debounced.cancel = cancelTimers; + debounced.flush = flush; + return debounced; +} diff --git a/packages/core/test/lib/utils/debounce.test.ts b/packages/core/test/lib/utils/debounce.test.ts new file mode 100644 index 000000000000..d44371e94b49 --- /dev/null +++ b/packages/core/test/lib/utils/debounce.test.ts @@ -0,0 +1,276 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { debounce } from '../../../src/utils/debounce'; + +describe('Unit | util | debounce', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + it('delay the execution of the passed callback function by the passed minDelay', () => { + const callback = vi.fn(); + const debouncedCallback = debounce(callback, 100); + debouncedCallback(); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(99); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(callback).toHaveBeenCalled(); + }); + + it('should invoke the callback at latest by maxWait, if the option is specified', () => { + const callback = vi.fn(); + const debouncedCallback = debounce(callback, 100, { maxWait: 150 }); + debouncedCallback(); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(98); + expect(callback).not.toHaveBeenCalled(); + + debouncedCallback(); + + vi.advanceTimersByTime(1); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(49); + // at this time, the callback shouldn't be invoked and with a new call, it should be debounced further. + debouncedCallback(); + expect(callback).not.toHaveBeenCalled(); + + // But because the maxWait is reached, the callback should nevertheless be invoked. + vi.advanceTimersByTime(10); + expect(callback).toHaveBeenCalled(); + }); + + it('should not invoke the callback as long as it is debounced and no maxWait option is specified', () => { + const callback = vi.fn(); + const debouncedCallback = debounce(callback, 100); + debouncedCallback(); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(99); + expect(callback).not.toHaveBeenCalled(); + + debouncedCallback(); + + vi.advanceTimersByTime(1); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(98); + debouncedCallback(); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(99); + expect(callback).not.toHaveBeenCalled(); + debouncedCallback(); + + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalled(); + }); + + it('should invoke the callback as soon as callback.flush() is called', () => { + const callback = vi.fn(); + const debouncedCallback = debounce(callback, 100, { maxWait: 200 }); + debouncedCallback(); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(10); + expect(callback).not.toHaveBeenCalled(); + + debouncedCallback.flush(); + expect(callback).toHaveBeenCalled(); + }); + + it('should not invoke the callback, if callback.cancel() is called', () => { + const callback = vi.fn(); + const debouncedCallback = debounce(callback, 100, { maxWait: 200 }); + debouncedCallback(); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(99); + expect(callback).not.toHaveBeenCalled(); + + // If the callback is canceled, it should not be invoked after the minwait + debouncedCallback.cancel(); + vi.advanceTimersByTime(1); + expect(callback).not.toHaveBeenCalled(); + + // And it should also not be invoked after the maxWait + vi.advanceTimersByTime(500); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should return the callback's return value when calling callback.flush()", () => { + const callback = vi.fn().mockReturnValue('foo'); + const debouncedCallback = debounce(callback, 100); + + debouncedCallback(); + + const returnValue = debouncedCallback.flush(); + expect(returnValue).toBe('foo'); + }); + + it('should return the callbacks return value on subsequent calls of the debounced function', () => { + const callback = vi.fn().mockReturnValue('foo'); + const debouncedCallback = debounce(callback, 100); + + const returnValue1 = debouncedCallback(); + expect(returnValue1).toBe(undefined); + expect(callback).not.toHaveBeenCalled(); + + // now we expect the callback to have been invoked + vi.advanceTimersByTime(200); + expect(callback).toHaveBeenCalledTimes(1); + + // calling the debounced function now should return the return value of the callback execution + const returnValue2 = debouncedCallback(); + expect(returnValue2).toBe('foo'); + expect(callback).toHaveBeenCalledTimes(1); + + // and the callback should also be invoked again + vi.advanceTimersByTime(200); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should handle return values of consecutive invocations without maxWait', () => { + let i = 0; + const callback = vi.fn().mockImplementation(() => { + return `foo-${++i}`; + }); + const debouncedCallback = debounce(callback, 100); + + const returnValue0 = debouncedCallback(); + expect(returnValue0).toBe(undefined); + expect(callback).not.toHaveBeenCalled(); + + // now we expect the callback to have been invoked + vi.advanceTimersByTime(200); + expect(callback).toHaveBeenCalledTimes(1); + + // calling the debounced function now should return the return value of the callback execution + const returnValue1 = debouncedCallback(); + expect(returnValue1).toBe('foo-1'); + expect(callback).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1); + const returnValue2 = debouncedCallback(); + expect(returnValue2).toBe('foo-1'); + expect(callback).toHaveBeenCalledTimes(1); + + // and the callback should also be invoked again + vi.advanceTimersByTime(200); + const returnValue3 = debouncedCallback(); + expect(returnValue3).toBe('foo-2'); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should handle return values of consecutive invocations with maxWait', () => { + let i = 0; + const callback = vi.fn().mockImplementation(() => { + return `foo-${++i}`; + }); + const debouncedCallback = debounce(callback, 150, { maxWait: 200 }); + + const returnValue0 = debouncedCallback(); + expect(returnValue0).toBe(undefined); + expect(callback).not.toHaveBeenCalled(); + + // now we expect the callback to have been invoked + vi.advanceTimersByTime(149); + const returnValue1 = debouncedCallback(); + expect(returnValue1).toBe(undefined); + expect(callback).not.toHaveBeenCalled(); + + // calling the debounced function now should return the return value of the callback execution + // as it was executed because of maxWait + vi.advanceTimersByTime(51); + const returnValue2 = debouncedCallback(); + expect(returnValue2).toBe('foo-1'); + expect(callback).toHaveBeenCalledTimes(1); + + // at this point (100ms after the last debounce call), nothing should have happened + vi.advanceTimersByTime(100); + const returnValue3 = debouncedCallback(); + expect(returnValue3).toBe('foo-1'); + expect(callback).toHaveBeenCalledTimes(1); + + // and the callback should now have been invoked again + vi.advanceTimersByTime(150); + const returnValue4 = debouncedCallback(); + expect(returnValue4).toBe('foo-2'); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should handle return values of consecutive invocations after a cancellation', () => { + let i = 0; + const callback = vi.fn().mockImplementation(() => { + return `foo-${++i}`; + }); + const debouncedCallback = debounce(callback, 150, { maxWait: 200 }); + + const returnValue0 = debouncedCallback(); + expect(returnValue0).toBe(undefined); + expect(callback).not.toHaveBeenCalled(); + + // now we expect the callback to have been invoked + vi.advanceTimersByTime(149); + const returnValue1 = debouncedCallback(); + expect(returnValue1).toBe(undefined); + expect(callback).not.toHaveBeenCalled(); + + debouncedCallback.cancel(); + + // calling the debounced function now still return undefined because we cancelled the invocation + vi.advanceTimersByTime(51); + const returnValue2 = debouncedCallback(); + expect(returnValue2).toBe(undefined); + expect(callback).not.toHaveBeenCalled(); + + // and the callback should also be invoked again + vi.advanceTimersByTime(150); + const returnValue3 = debouncedCallback(); + expect(returnValue3).toBe('foo-1'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should handle the return value of calling flush after cancelling', () => { + const callback = vi.fn().mockReturnValue('foo'); + const debouncedCallback = debounce(callback, 100); + + debouncedCallback(); + debouncedCallback.cancel(); + + const returnValue = debouncedCallback.flush(); + expect(returnValue).toBe(undefined); + }); + + it('should handle equal wait and maxWait values and only invoke func once', () => { + const callback = vi.fn().mockReturnValue('foo'); + const debouncedCallback = debounce(callback, 100, { maxWait: 100 }); + + debouncedCallback(); + vi.advanceTimersByTime(25); + debouncedCallback(); + vi.advanceTimersByTime(25); + debouncedCallback(); + vi.advanceTimersByTime(25); + debouncedCallback(); + vi.advanceTimersByTime(25); + + expect(callback).toHaveBeenCalledTimes(1); + + const retval = debouncedCallback(); + expect(retval).toBe('foo'); + + vi.advanceTimersByTime(25); + debouncedCallback(); + vi.advanceTimersByTime(25); + debouncedCallback(); + vi.advanceTimersByTime(25); + debouncedCallback(); + vi.advanceTimersByTime(25); + + expect(callback).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index 3c6b41de60f5..745b73bb48f8 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -14,6 +14,7 @@ import type { import { captureEvent, convertSpanLinksForEnvelope, + debounce, getCapturedScopesOnSpan, getDynamicSamplingContextFromSpan, getStatusMessage, @@ -49,8 +50,6 @@ interface FinishedSpanBucket { * A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions. */ export class SentrySpanExporter { - private _flushTimeout: ReturnType | undefined; - /* * 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 @@ -74,6 +73,7 @@ export class SentrySpanExporter { // Essentially a a set of span ids that are already sent. The values are expiration // times in this cache so we don't hold onto them indefinitely. private _sentSpans: Map; + private _debouncedFlush: ReturnType; 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. */ @@ -84,47 +84,7 @@ export class SentrySpanExporter { this._lastCleanupTimestampInS = Math.floor(Date.now() / 1000); this._spansToBucketEntry = new WeakMap(); this._sentSpans = new Map(); - } - - /** - * Check if a span with the given ID has already been sent using the `_sentSpans` as a cache. - * Purges "expired" spans from the cache upon checking. - * @param spanId The span id to check. - * @returns Whether the span is already sent in the past X seconds. - */ - public isSpanAlreadySent(spanId: string): boolean { - const expirationTime = this._sentSpans.get(spanId); - if (expirationTime) { - if (Date.now() >= expirationTime) { - this._sentSpans.delete(spanId); // Remove expired span - } else { - return true; - } - } - return false; - } - - /** Remove "expired" span id entries from the _sentSpans cache. */ - public flushSentSpanCache(): void { - const currentTimestamp = Date.now(); - // Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297 - for (const [spanId, expirationTime] of this._sentSpans.entries()) { - if (expirationTime <= currentTimestamp) { - this._sentSpans.delete(spanId); - } - } - } - - /** Check if a node is a completed root node or a node whose parent has already been sent */ - public nodeIsCompletedRootNode(node: SpanNode): node is SpanNodeCompleted { - return !!node.span && (!node.parentNode || this.isSpanAlreadySent(node.parentNode.id)); - } - - /** Get all completed root nodes from a list of nodes */ - public getCompletedRootNodes(nodes: SpanNode[]): SpanNodeCompleted[] { - // TODO: We should be able to remove the explicit `node is SpanNodeCompleted` type guard - // once we stop supporting TS < 5.5 - return nodes.filter((node): node is SpanNodeCompleted => this.nodeIsCompletedRootNode(node)); + this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 }); } /** Export a single span. */ @@ -159,26 +119,18 @@ export class SentrySpanExporter { // If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans const localParentId = getLocalParentId(span); - if (!localParentId || this.isSpanAlreadySent(localParentId)) { - 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); + if (!localParentId || this._sentSpans.has(localParentId)) { + this._debouncedFlush(); } } /** Try to flush any pending spans immediately. */ public flush(): void { - this._clearTimeout(); - const finishedSpans: ReadableSpan[] = this._finishedSpanBuckets.flatMap(bucket => bucket ? Array.from(bucket.spans) : [], ); - this.flushSentSpanCache(); + this._flushSentSpanCache(); const sentSpans = this._maybeSend(finishedSpans); const sentSpanCount = sentSpans.size; @@ -197,20 +149,15 @@ export class SentrySpanExporter { bucketEntry.spans.delete(span); } } + // Cancel a pending debounced flush, if there is one + this._debouncedFlush.cancel(); } /** Clear the exporter. */ public clear(): void { this._finishedSpanBuckets = this._finishedSpanBuckets.fill(undefined); - this._clearTimeout(); - } - - /** Clear the flush timeout. */ - private _clearTimeout(): void { - if (this._flushTimeout) { - clearTimeout(this._flushTimeout); - this._flushTimeout = undefined; - } + this._sentSpans.clear(); + this._debouncedFlush.cancel(); } /** @@ -226,7 +173,7 @@ export class SentrySpanExporter { const grouped = groupSpansWithParents(spans); const sentSpans = new Set(); - const rootNodes = this.getCompletedRootNodes(grouped); + const rootNodes = this._getCompletedRootNodes(grouped); for (const root of rootNodes) { const span = root.span; @@ -257,6 +204,29 @@ export class SentrySpanExporter { return sentSpans; } + + /** Remove "expired" span id entries from the _sentSpans cache. */ + private _flushSentSpanCache(): void { + const currentTimestamp = Date.now(); + // Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297 + for (const [spanId, expirationTime] of this._sentSpans.entries()) { + if (expirationTime <= currentTimestamp) { + this._sentSpans.delete(spanId); + } + } + } + + /** Check if a node is a completed root node or a node whose parent has already been sent */ + private _nodeIsCompletedRootNode(node: SpanNode): node is SpanNodeCompleted { + return !!node.span && (!node.parentNode || this._sentSpans.has(node.parentNode.id)); + } + + /** Get all completed root nodes from a list of nodes */ + private _getCompletedRootNodes(nodes: SpanNode[]): SpanNodeCompleted[] { + // TODO: We should be able to remove the explicit `node is SpanNodeCompleted` type guard + // once we stop supporting TS < 5.5 + return nodes.filter((node): node is SpanNodeCompleted => this._nodeIsCompletedRootNode(node)); + } } function parseSpan(span: ReadableSpan): { op?: string; origin?: SpanOrigin; source?: TransactionSource } { diff --git a/packages/replay-internal/src/util/debounce.ts b/packages/replay-internal/src/util/debounce.ts index 8948b937febd..ea357ff1885c 100644 --- a/packages/replay-internal/src/util/debounce.ts +++ b/packages/replay-internal/src/util/debounce.ts @@ -1,3 +1,4 @@ +import { debounce as debounceCore } from '@sentry/core'; import { setTimeout } from '@sentry-internal/browser-utils'; type DebouncedCallback = { @@ -27,46 +28,9 @@ type DebounceOptions = { maxWait?: number }; * - `cancel`: Cancels the debouncing process and resets the debouncing timer */ export function debounce(func: CallbackFunction, wait: number, options?: DebounceOptions): DebouncedCallback { - let callbackReturnValue: unknown; - - let timerId: ReturnType | undefined; - let maxTimerId: ReturnType | undefined; - - const maxWait = options?.maxWait ? Math.max(options.maxWait, wait) : 0; - - function invokeFunc(): unknown { - cancelTimers(); - callbackReturnValue = func(); - return callbackReturnValue; - } - - function cancelTimers(): void { - timerId !== undefined && clearTimeout(timerId); - maxTimerId !== undefined && clearTimeout(maxTimerId); - timerId = maxTimerId = undefined; - } - - function flush(): unknown { - if (timerId !== undefined || maxTimerId !== undefined) { - return invokeFunc(); - } - return callbackReturnValue; - } - - function debounced(): unknown { - if (timerId) { - clearTimeout(timerId); - } - timerId = setTimeout(invokeFunc, wait); - - if (maxWait && maxTimerId === undefined) { - maxTimerId = setTimeout(invokeFunc, maxWait); - } - - return callbackReturnValue; - } - - debounced.cancel = cancelTimers; - debounced.flush = flush; - return debounced; + return debounceCore(func, wait, { + ...options, + // @ts-expect-error - Not quite sure why these types do not match, but this is fine + setTimeoutImpl: setTimeout, + }); } From 46fad73cdadffc469d04e0f368949bd4f3952607 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 16 Jun 2025 09:45:06 +0200 Subject: [PATCH 2/7] better comments --- packages/core/src/utils/debounce.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/debounce.ts b/packages/core/src/utils/debounce.ts index a376dce35a3c..5f936d1e14e2 100644 --- a/packages/core/src/utils/debounce.ts +++ b/packages/core/src/utils/debounce.ts @@ -5,8 +5,9 @@ type DebouncedCallback = { }; type CallbackFunction = () => unknown; type DebounceOptions = { + /** The max. time in ms to wait for the callback to be invoked. */ maxWait?: number; - // This can be overwritten to use a different setTimeout implementation, e.g. to avoid triggering change detection in Angular + /** This can be overwritten to use a different setTimeout implementation, e.g. to avoid triggering change detection in Angular */ setTimeoutImpl?: typeof setTimeout; }; From fbe5353da57b80a6d200aee2ebdf792aebd63051 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 16 Jun 2025 09:44:58 +0200 Subject: [PATCH 3/7] Update packages/opentelemetry/src/spanExporter.ts Co-authored-by: Abhijeet Prasad --- packages/opentelemetry/src/spanExporter.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index 745b73bb48f8..9532e12aad07 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -126,9 +126,14 @@ export class SentrySpanExporter { /** Try to flush any pending spans immediately. */ public flush(): void { - const finishedSpans: ReadableSpan[] = this._finishedSpanBuckets.flatMap(bucket => - bucket ? Array.from(bucket.spans) : [], - ); +const finishedSpans: ReadableSpan[] = []; +for (const bucket of this._finishedSpanBuckets) { + if (bucket) { + for (const span of bucket.spans) { + finishedSpans.push(span); + } + } +} this._flushSentSpanCache(); const sentSpans = this._maybeSend(finishedSpans); From 166318b4dcd6cc07a966f4907f08cf87b2ff9336 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 16 Jun 2025 09:47:26 +0200 Subject: [PATCH 4/7] linting --- packages/opentelemetry/src/spanExporter.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index 9532e12aad07..cae096cec41c 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -126,14 +126,14 @@ export class SentrySpanExporter { /** Try to flush any pending spans immediately. */ public flush(): void { -const finishedSpans: ReadableSpan[] = []; -for (const bucket of this._finishedSpanBuckets) { - if (bucket) { - for (const span of bucket.spans) { - finishedSpans.push(span); + const finishedSpans: ReadableSpan[] = []; + for (const bucket of this._finishedSpanBuckets) { + if (bucket) { + for (const span of bucket.spans) { + finishedSpans.push(span); + } + } } - } -} this._flushSentSpanCache(); const sentSpans = this._maybeSend(finishedSpans); From 004de6fea1bca0d3705ed137b6ea077dcdde2397 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 16 Jun 2025 10:42:28 +0200 Subject: [PATCH 5/7] better comments and name --- packages/opentelemetry/src/spanExporter.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index cae096cec41c..42b60f93ae2c 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -155,6 +155,8 @@ export class SentrySpanExporter { } } // Cancel a pending debounced flush, if there is one + // This can be relevant if we directly export, circumventing the debounce + // in that case, we want to cancel any pending debounced flush this._debouncedFlush.cancel(); } @@ -222,7 +224,7 @@ export class SentrySpanExporter { } /** Check if a node is a completed root node or a node whose parent has already been sent */ - private _nodeIsCompletedRootNode(node: SpanNode): node is SpanNodeCompleted { + private _nodeIsCompletedRootNodeOrHasSentParent(node: SpanNode): node is SpanNodeCompleted { return !!node.span && (!node.parentNode || this._sentSpans.has(node.parentNode.id)); } @@ -230,7 +232,7 @@ export class SentrySpanExporter { private _getCompletedRootNodes(nodes: SpanNode[]): SpanNodeCompleted[] { // TODO: We should be able to remove the explicit `node is SpanNodeCompleted` type guard // once we stop supporting TS < 5.5 - return nodes.filter((node): node is SpanNodeCompleted => this._nodeIsCompletedRootNode(node)); + return nodes.filter((node): node is SpanNodeCompleted => this._nodeIsCompletedRootNodeOrHasSentParent(node)); } } From c6e9af2051811335d94c2a5151f6853ed161c4c0 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 16 Jun 2025 13:49:48 +0200 Subject: [PATCH 6/7] add more comments to clarify things --- packages/opentelemetry/src/spanExporter.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index 42b60f93ae2c..7e6dbedc7d2c 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -73,6 +73,7 @@ export class SentrySpanExporter { // Essentially a a set of span ids that are already sent. The values are expiration // times in this cache so we don't hold onto them indefinitely. private _sentSpans: Map; + /* Internally, we use a debounced flush to give some wiggle room to the span processor to accumulate more spans. */ private _debouncedFlush: ReturnType; public constructor(options?: { @@ -87,7 +88,10 @@ export class SentrySpanExporter { this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 }); } - /** Export a single span. */ + /** + * Export a single span. + * This is called by the span processor whenever a span is ended. + */ public export(span: ReadableSpan): void { const currentTimestampInS = Math.floor(Date.now() / 1000); @@ -124,7 +128,11 @@ export class SentrySpanExporter { } } - /** Try to flush any pending spans immediately. */ + /** + * Try to flush any pending spans immediately. + * This is called internally by the exporter (via _debouncedFlush), + * but can also be triggered externally if we force-flush. + */ public flush(): void { const finishedSpans: ReadableSpan[] = []; for (const bucket of this._finishedSpanBuckets) { @@ -155,12 +163,15 @@ export class SentrySpanExporter { } } // Cancel a pending debounced flush, if there is one - // This can be relevant if we directly export, circumventing the debounce + // This can be relevant if we directly flush, circumventing the debounce // in that case, we want to cancel any pending debounced flush this._debouncedFlush.cancel(); } - /** Clear the exporter. */ + /** + * Clear the exporter. + * This is called when the span processor is shut down. + */ public clear(): void { this._finishedSpanBuckets = this._finishedSpanBuckets.fill(undefined); this._sentSpans.clear(); From 3897b9d36624ec4dfee6a1ef50fd07cd88e0e8f7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 17 Jun 2025 10:07:47 +0200 Subject: [PATCH 7/7] revert change --- packages/opentelemetry/src/spanExporter.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index 7e6dbedc7d2c..6430f0f23da5 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -134,14 +134,7 @@ export class SentrySpanExporter { * but can also be triggered externally if we force-flush. */ public flush(): void { - const finishedSpans: ReadableSpan[] = []; - for (const bucket of this._finishedSpanBuckets) { - if (bucket) { - for (const span of bucket.spans) { - finishedSpans.push(span); - } - } - } + const finishedSpans = this._finishedSpanBuckets.flatMap(bucket => (bucket ? Array.from(bucket.spans) : [])); this._flushSentSpanCache(); const sentSpans = this._maybeSend(finishedSpans);