Skip to content

ref(node): Improve span flushing #16577

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

Merged
merged 7 commits into from
Jun 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/utils/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
type DebouncedCallback = {
(): void | unknown;
flush: () => void | unknown;
cancel: () => void;
};
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 */
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<typeof setTimeout> | undefined;
let maxTimerId: ReturnType<typeof setTimeout> | undefined;

const maxWait = options?.maxWait ? Math.max(options.maxWait, wait) : 0;
const setTimeoutImpl = options?.setTimeoutImpl || setTimeout;

function invokeFunc(): unknown {
cancelTimers();
callbackReturnValue = func();
return callbackReturnValue;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just giving my two cents, it's not a massive deal.
I feel like we could be saving a line by assigning the value at the same time as we return it: 🙂

Suggested change
return callbackReturnValue;
return callbackReturnValue = func();

I personally find this more readable

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

usually minification algorithms (terser) will do this for us anyway, I prefer the new line because it usually make git blame a bitter easier to use.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explanation! that makes sense!
I haven't thought about this.

I guess it would be easier to debug too now that I think about it

}

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;
}
276 changes: 276 additions & 0 deletions packages/core/test/lib/utils/debounce.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading