Skip to content

Commit cb5265c

Browse files
mydeaAbhiPrasad
andauthored
ref(node): Improve span flushing (#16577)
Follow up to #16416, slightly moving things around and making some small improvements to performance and logic (some not related to that PR but another improvement we noticed when BYK looked int this): 1. Actually debounce the flushing properly, including a maxWait of 100ms. Previously, it was technically possible to debounce flushing forever, if a span was flushed every ms. Now, at least after 100ms it should _always_ flush. 2. Simplify overhead by avoid checking for the 5min timeout of sent spans. As this check `isSpanAlreadySent` has been called for every single span that ended, it is quite high impact, and meant a little bit of additional work (although it should not necessarily run _too_ often, but still). Instead, we now simply check for `map.has(id)` which should be good enough for what we want to achieve, IMHO. We still clean up the sent spans when flushing, so old stuff should still go away eventually. 3. Made stuff that is not needed as public API private on the span exporter class. this is technically breaking but I think it is OK, this should not be public API surface as it does not need to be called from outside. For this I moved the already existing `debounce` function from replay to core. We re-implement this in replay with a different setTimeout impl. which is needed for some angular stuff. --------- Co-authored-by: Abhijeet Prasad <[email protected]>
1 parent d302703 commit cb5265c

File tree

5 files changed

+408
-110
lines changed

5 files changed

+408
-110
lines changed

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export { parseSampleRate } from './utils/parseSampleRate';
8888
export { applySdkMetadata } from './utils/sdkMetadata';
8989
export { getTraceData } from './utils/traceData';
9090
export { getTraceMetaTags } from './utils/meta';
91+
export { debounce } from './utils/debounce';
9192
export {
9293
winterCGHeadersToDict,
9394
winterCGRequestToRequestData,

packages/core/src/utils/debounce.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
type DebouncedCallback = {
2+
(): void | unknown;
3+
flush: () => void | unknown;
4+
cancel: () => void;
5+
};
6+
type CallbackFunction = () => unknown;
7+
type DebounceOptions = {
8+
/** The max. time in ms to wait for the callback to be invoked. */
9+
maxWait?: number;
10+
/** This can be overwritten to use a different setTimeout implementation, e.g. to avoid triggering change detection in Angular */
11+
setTimeoutImpl?: typeof setTimeout;
12+
};
13+
14+
/**
15+
* Heavily simplified debounce function based on lodash.debounce.
16+
*
17+
* This function takes a callback function (@param fun) and delays its invocation
18+
* by @param wait milliseconds. Optionally, a maxWait can be specified in @param options,
19+
* which ensures that the callback is invoked at least once after the specified max. wait time.
20+
*
21+
* @param func the function whose invocation is to be debounced
22+
* @param wait the minimum time until the function is invoked after it was called once
23+
* @param options the options object, which can contain the `maxWait` property
24+
*
25+
* @returns the debounced version of the function, which needs to be called at least once to start the
26+
* debouncing process. Subsequent calls will reset the debouncing timer and, in case @paramfunc
27+
* was already invoked in the meantime, return @param func's return value.
28+
* The debounced function has two additional properties:
29+
* - `flush`: Invokes the debounced function immediately and returns its return value
30+
* - `cancel`: Cancels the debouncing process and resets the debouncing timer
31+
*/
32+
export function debounce(func: CallbackFunction, wait: number, options?: DebounceOptions): DebouncedCallback {
33+
let callbackReturnValue: unknown;
34+
35+
let timerId: ReturnType<typeof setTimeout> | undefined;
36+
let maxTimerId: ReturnType<typeof setTimeout> | undefined;
37+
38+
const maxWait = options?.maxWait ? Math.max(options.maxWait, wait) : 0;
39+
const setTimeoutImpl = options?.setTimeoutImpl || setTimeout;
40+
41+
function invokeFunc(): unknown {
42+
cancelTimers();
43+
callbackReturnValue = func();
44+
return callbackReturnValue;
45+
}
46+
47+
function cancelTimers(): void {
48+
timerId !== undefined && clearTimeout(timerId);
49+
maxTimerId !== undefined && clearTimeout(maxTimerId);
50+
timerId = maxTimerId = undefined;
51+
}
52+
53+
function flush(): unknown {
54+
if (timerId !== undefined || maxTimerId !== undefined) {
55+
return invokeFunc();
56+
}
57+
return callbackReturnValue;
58+
}
59+
60+
function debounced(): unknown {
61+
if (timerId) {
62+
clearTimeout(timerId);
63+
}
64+
timerId = setTimeoutImpl(invokeFunc, wait);
65+
66+
if (maxWait && maxTimerId === undefined) {
67+
maxTimerId = setTimeoutImpl(invokeFunc, maxWait);
68+
}
69+
70+
return callbackReturnValue;
71+
}
72+
73+
debounced.cancel = cancelTimers;
74+
debounced.flush = flush;
75+
return debounced;
76+
}
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { beforeAll, describe, expect, it, vi } from 'vitest';
2+
import { debounce } from '../../../src/utils/debounce';
3+
4+
describe('Unit | util | debounce', () => {
5+
beforeAll(() => {
6+
vi.useFakeTimers();
7+
});
8+
9+
it('delay the execution of the passed callback function by the passed minDelay', () => {
10+
const callback = vi.fn();
11+
const debouncedCallback = debounce(callback, 100);
12+
debouncedCallback();
13+
expect(callback).not.toHaveBeenCalled();
14+
15+
vi.advanceTimersByTime(99);
16+
expect(callback).not.toHaveBeenCalled();
17+
18+
vi.advanceTimersByTime(1);
19+
expect(callback).toHaveBeenCalled();
20+
});
21+
22+
it('should invoke the callback at latest by maxWait, if the option is specified', () => {
23+
const callback = vi.fn();
24+
const debouncedCallback = debounce(callback, 100, { maxWait: 150 });
25+
debouncedCallback();
26+
expect(callback).not.toHaveBeenCalled();
27+
28+
vi.advanceTimersByTime(98);
29+
expect(callback).not.toHaveBeenCalled();
30+
31+
debouncedCallback();
32+
33+
vi.advanceTimersByTime(1);
34+
expect(callback).not.toHaveBeenCalled();
35+
36+
vi.advanceTimersByTime(49);
37+
// at this time, the callback shouldn't be invoked and with a new call, it should be debounced further.
38+
debouncedCallback();
39+
expect(callback).not.toHaveBeenCalled();
40+
41+
// But because the maxWait is reached, the callback should nevertheless be invoked.
42+
vi.advanceTimersByTime(10);
43+
expect(callback).toHaveBeenCalled();
44+
});
45+
46+
it('should not invoke the callback as long as it is debounced and no maxWait option is specified', () => {
47+
const callback = vi.fn();
48+
const debouncedCallback = debounce(callback, 100);
49+
debouncedCallback();
50+
expect(callback).not.toHaveBeenCalled();
51+
52+
vi.advanceTimersByTime(99);
53+
expect(callback).not.toHaveBeenCalled();
54+
55+
debouncedCallback();
56+
57+
vi.advanceTimersByTime(1);
58+
expect(callback).not.toHaveBeenCalled();
59+
60+
vi.advanceTimersByTime(98);
61+
debouncedCallback();
62+
expect(callback).not.toHaveBeenCalled();
63+
64+
vi.advanceTimersByTime(99);
65+
expect(callback).not.toHaveBeenCalled();
66+
debouncedCallback();
67+
68+
vi.advanceTimersByTime(100);
69+
expect(callback).toHaveBeenCalled();
70+
});
71+
72+
it('should invoke the callback as soon as callback.flush() is called', () => {
73+
const callback = vi.fn();
74+
const debouncedCallback = debounce(callback, 100, { maxWait: 200 });
75+
debouncedCallback();
76+
expect(callback).not.toHaveBeenCalled();
77+
78+
vi.advanceTimersByTime(10);
79+
expect(callback).not.toHaveBeenCalled();
80+
81+
debouncedCallback.flush();
82+
expect(callback).toHaveBeenCalled();
83+
});
84+
85+
it('should not invoke the callback, if callback.cancel() is called', () => {
86+
const callback = vi.fn();
87+
const debouncedCallback = debounce(callback, 100, { maxWait: 200 });
88+
debouncedCallback();
89+
expect(callback).not.toHaveBeenCalled();
90+
91+
vi.advanceTimersByTime(99);
92+
expect(callback).not.toHaveBeenCalled();
93+
94+
// If the callback is canceled, it should not be invoked after the minwait
95+
debouncedCallback.cancel();
96+
vi.advanceTimersByTime(1);
97+
expect(callback).not.toHaveBeenCalled();
98+
99+
// And it should also not be invoked after the maxWait
100+
vi.advanceTimersByTime(500);
101+
expect(callback).not.toHaveBeenCalled();
102+
});
103+
104+
it("should return the callback's return value when calling callback.flush()", () => {
105+
const callback = vi.fn().mockReturnValue('foo');
106+
const debouncedCallback = debounce(callback, 100);
107+
108+
debouncedCallback();
109+
110+
const returnValue = debouncedCallback.flush();
111+
expect(returnValue).toBe('foo');
112+
});
113+
114+
it('should return the callbacks return value on subsequent calls of the debounced function', () => {
115+
const callback = vi.fn().mockReturnValue('foo');
116+
const debouncedCallback = debounce(callback, 100);
117+
118+
const returnValue1 = debouncedCallback();
119+
expect(returnValue1).toBe(undefined);
120+
expect(callback).not.toHaveBeenCalled();
121+
122+
// now we expect the callback to have been invoked
123+
vi.advanceTimersByTime(200);
124+
expect(callback).toHaveBeenCalledTimes(1);
125+
126+
// calling the debounced function now should return the return value of the callback execution
127+
const returnValue2 = debouncedCallback();
128+
expect(returnValue2).toBe('foo');
129+
expect(callback).toHaveBeenCalledTimes(1);
130+
131+
// and the callback should also be invoked again
132+
vi.advanceTimersByTime(200);
133+
expect(callback).toHaveBeenCalledTimes(2);
134+
});
135+
136+
it('should handle return values of consecutive invocations without maxWait', () => {
137+
let i = 0;
138+
const callback = vi.fn().mockImplementation(() => {
139+
return `foo-${++i}`;
140+
});
141+
const debouncedCallback = debounce(callback, 100);
142+
143+
const returnValue0 = debouncedCallback();
144+
expect(returnValue0).toBe(undefined);
145+
expect(callback).not.toHaveBeenCalled();
146+
147+
// now we expect the callback to have been invoked
148+
vi.advanceTimersByTime(200);
149+
expect(callback).toHaveBeenCalledTimes(1);
150+
151+
// calling the debounced function now should return the return value of the callback execution
152+
const returnValue1 = debouncedCallback();
153+
expect(returnValue1).toBe('foo-1');
154+
expect(callback).toHaveBeenCalledTimes(1);
155+
156+
vi.advanceTimersByTime(1);
157+
const returnValue2 = debouncedCallback();
158+
expect(returnValue2).toBe('foo-1');
159+
expect(callback).toHaveBeenCalledTimes(1);
160+
161+
// and the callback should also be invoked again
162+
vi.advanceTimersByTime(200);
163+
const returnValue3 = debouncedCallback();
164+
expect(returnValue3).toBe('foo-2');
165+
expect(callback).toHaveBeenCalledTimes(2);
166+
});
167+
168+
it('should handle return values of consecutive invocations with maxWait', () => {
169+
let i = 0;
170+
const callback = vi.fn().mockImplementation(() => {
171+
return `foo-${++i}`;
172+
});
173+
const debouncedCallback = debounce(callback, 150, { maxWait: 200 });
174+
175+
const returnValue0 = debouncedCallback();
176+
expect(returnValue0).toBe(undefined);
177+
expect(callback).not.toHaveBeenCalled();
178+
179+
// now we expect the callback to have been invoked
180+
vi.advanceTimersByTime(149);
181+
const returnValue1 = debouncedCallback();
182+
expect(returnValue1).toBe(undefined);
183+
expect(callback).not.toHaveBeenCalled();
184+
185+
// calling the debounced function now should return the return value of the callback execution
186+
// as it was executed because of maxWait
187+
vi.advanceTimersByTime(51);
188+
const returnValue2 = debouncedCallback();
189+
expect(returnValue2).toBe('foo-1');
190+
expect(callback).toHaveBeenCalledTimes(1);
191+
192+
// at this point (100ms after the last debounce call), nothing should have happened
193+
vi.advanceTimersByTime(100);
194+
const returnValue3 = debouncedCallback();
195+
expect(returnValue3).toBe('foo-1');
196+
expect(callback).toHaveBeenCalledTimes(1);
197+
198+
// and the callback should now have been invoked again
199+
vi.advanceTimersByTime(150);
200+
const returnValue4 = debouncedCallback();
201+
expect(returnValue4).toBe('foo-2');
202+
expect(callback).toHaveBeenCalledTimes(2);
203+
});
204+
205+
it('should handle return values of consecutive invocations after a cancellation', () => {
206+
let i = 0;
207+
const callback = vi.fn().mockImplementation(() => {
208+
return `foo-${++i}`;
209+
});
210+
const debouncedCallback = debounce(callback, 150, { maxWait: 200 });
211+
212+
const returnValue0 = debouncedCallback();
213+
expect(returnValue0).toBe(undefined);
214+
expect(callback).not.toHaveBeenCalled();
215+
216+
// now we expect the callback to have been invoked
217+
vi.advanceTimersByTime(149);
218+
const returnValue1 = debouncedCallback();
219+
expect(returnValue1).toBe(undefined);
220+
expect(callback).not.toHaveBeenCalled();
221+
222+
debouncedCallback.cancel();
223+
224+
// calling the debounced function now still return undefined because we cancelled the invocation
225+
vi.advanceTimersByTime(51);
226+
const returnValue2 = debouncedCallback();
227+
expect(returnValue2).toBe(undefined);
228+
expect(callback).not.toHaveBeenCalled();
229+
230+
// and the callback should also be invoked again
231+
vi.advanceTimersByTime(150);
232+
const returnValue3 = debouncedCallback();
233+
expect(returnValue3).toBe('foo-1');
234+
expect(callback).toHaveBeenCalledTimes(1);
235+
});
236+
237+
it('should handle the return value of calling flush after cancelling', () => {
238+
const callback = vi.fn().mockReturnValue('foo');
239+
const debouncedCallback = debounce(callback, 100);
240+
241+
debouncedCallback();
242+
debouncedCallback.cancel();
243+
244+
const returnValue = debouncedCallback.flush();
245+
expect(returnValue).toBe(undefined);
246+
});
247+
248+
it('should handle equal wait and maxWait values and only invoke func once', () => {
249+
const callback = vi.fn().mockReturnValue('foo');
250+
const debouncedCallback = debounce(callback, 100, { maxWait: 100 });
251+
252+
debouncedCallback();
253+
vi.advanceTimersByTime(25);
254+
debouncedCallback();
255+
vi.advanceTimersByTime(25);
256+
debouncedCallback();
257+
vi.advanceTimersByTime(25);
258+
debouncedCallback();
259+
vi.advanceTimersByTime(25);
260+
261+
expect(callback).toHaveBeenCalledTimes(1);
262+
263+
const retval = debouncedCallback();
264+
expect(retval).toBe('foo');
265+
266+
vi.advanceTimersByTime(25);
267+
debouncedCallback();
268+
vi.advanceTimersByTime(25);
269+
debouncedCallback();
270+
vi.advanceTimersByTime(25);
271+
debouncedCallback();
272+
vi.advanceTimersByTime(25);
273+
274+
expect(callback).toHaveBeenCalledTimes(2);
275+
});
276+
});

0 commit comments

Comments
 (0)