Skip to content

Commit 5a2041b

Browse files
committed
feat(core): allow unregistering callback through on
This commit updates the return signature of the client's `on` function to be a void function, which, when executed, unregisters a callback. This adjustment is necessary for managing instances where objects are created and destroyed, ensuring that callbacks are properly unregistered to prevent self-referencing in callback closures and facilitate proper garbage collection. Typically, changing a type from `void` to `() => void` (or `VoidFunction`) shouldn't be considered a breaking change because `void` signifies the absence of a return value, implying that the return value of the `on` function should never be used by consumers. Opting for the `on` approach, which returns a cleanup function, "seems" simpler because having another function called `off` requires saving the callback reference for later removal. With our pattern, we encapsulate both the registration and removal of event listeners within a single function call.
1 parent d381ace commit 5a2041b

File tree

3 files changed

+120
-29
lines changed

3 files changed

+120
-29
lines changed

packages/core/src/baseclient.ts

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -388,37 +388,40 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
388388
/* eslint-disable @typescript-eslint/unified-signatures */
389389

390390
/** @inheritdoc */
391-
public on(hook: 'spanStart', callback: (span: Span) => void): void;
391+
public on(hook: 'spanStart', callback: (span: Span) => void): () => void;
392392

393393
/** @inheritdoc */
394-
public on(hook: 'spanEnd', callback: (span: Span) => void): void;
394+
public on(hook: 'spanEnd', callback: (span: Span) => void): () => void;
395395

396396
/** @inheritdoc */
397-
public on(hook: 'idleSpanEnableAutoFinish', callback: (span: Span) => void): void;
397+
public on(hook: 'idleSpanEnableAutoFinish', callback: (span: Span) => void): () => void;
398398

399399
/** @inheritdoc */
400-
public on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): void;
400+
public on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): () => void;
401401

402402
/** @inheritdoc */
403-
public on(hook: 'beforeSendEvent', callback: (event: Event, hint?: EventHint) => void): void;
403+
public on(hook: 'beforeSendEvent', callback: (event: Event, hint?: EventHint) => void): () => void;
404404

405405
/** @inheritdoc */
406-
public on(hook: 'preprocessEvent', callback: (event: Event, hint?: EventHint) => void): void;
406+
public on(hook: 'preprocessEvent', callback: (event: Event, hint?: EventHint) => void): () => void;
407407

408408
/** @inheritdoc */
409-
public on(hook: 'afterSendEvent', callback: (event: Event, sendResponse: TransportMakeRequestResponse) => void): void;
409+
public on(
410+
hook: 'afterSendEvent',
411+
callback: (event: Event, sendResponse: TransportMakeRequestResponse) => void,
412+
): () => void;
410413

411414
/** @inheritdoc */
412-
public on(hook: 'beforeAddBreadcrumb', callback: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => void): void;
415+
public on(hook: 'beforeAddBreadcrumb', callback: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => void): () => void;
413416

414417
/** @inheritdoc */
415-
public on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext) => void): void;
418+
public on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext) => void): () => void;
416419

417420
/** @inheritdoc */
418421
public on(
419422
hook: 'beforeSendFeedback',
420423
callback: (feedback: FeedbackEvent, options?: { includeReplay: boolean }) => void,
421-
): void;
424+
): () => void;
422425

423426
/** @inheritdoc */
424427
public on(
@@ -441,23 +444,41 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
441444
options: StartSpanOptions,
442445
traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined },
443446
) => void,
444-
): void;
447+
): () => void;
445448

446449
/** @inheritdoc */
447-
public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): void;
450+
public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void;
448451

449-
public on(hook: 'flush', callback: () => void): void;
452+
public on(hook: 'flush', callback: () => void): () => void;
450453

451-
public on(hook: 'close', callback: () => void): void;
454+
public on(hook: 'close', callback: () => void): () => void;
452455

453456
/** @inheritdoc */
454-
public on(hook: string, callback: unknown): void {
457+
public on(hook: string, callback: unknown): () => void {
458+
// Note that the code below, with nullish coalescing assignment,
459+
// may reduce the code, so it may be switched to when Node 14 support
460+
// is dropped (the `??=` operator is supported since Node 15).
461+
// (this._hooks[hook] ??= []).push(callback);
455462
if (!this._hooks[hook]) {
456463
this._hooks[hook] = [];
457464
}
458465

459466
// @ts-expect-error We assue the types are correct
460467
this._hooks[hook].push(callback);
468+
469+
// This function returns a callback execution handler that, when invoked,
470+
// deregisters a callback. This is crucial for managing instances where callbacks
471+
// need to be unregistered to prevent self-referencing in callback closures,
472+
// ensuring proper garbage collection.
473+
return () => {
474+
const hooks = this._hooks[hook];
475+
476+
if (hooks) {
477+
// @ts-expect-error We assue the types are correct
478+
const cbIndex = hooks.indexOf(callback);
479+
hooks.splice(cbIndex, 1);
480+
}
481+
};
461482
}
462483

463484
/** @inheritdoc */

packages/core/test/lib/base.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1880,4 +1880,59 @@ describe('BaseClient', () => {
18801880
});
18811881
});
18821882
});
1883+
1884+
describe('hook removal with `on`', () => {
1885+
it('should return a cleanup function that, when executed, unregisters a hook', async () => {
1886+
expect.assertions(8);
1887+
1888+
const client = new TestClient(
1889+
getDefaultTestClientOptions({
1890+
dsn: PUBLIC_DSN,
1891+
enableSend: true,
1892+
}),
1893+
);
1894+
1895+
// @ts-expect-error Accessing private transport API
1896+
const mockSend = jest.spyOn(client._transport, 'send').mockImplementation(() => {
1897+
return Promise.resolve({ statusCode: 200 });
1898+
});
1899+
1900+
const errorEvent: Event = { message: 'error' };
1901+
1902+
const callback = jest.fn();
1903+
const removeAfterSendEventListenerFn = client.on('afterSendEvent', callback);
1904+
1905+
// @ts-expect-error Accessing private client API
1906+
expect(client._hooks['afterSendEvent']).toEqual([callback]);
1907+
1908+
client.sendEvent(errorEvent);
1909+
jest.runAllTimers();
1910+
// Wait for two ticks
1911+
// note that for whatever reason, await new Promise(resolve => setTimeout(resolve, 0)) causes the test to hang
1912+
await undefined;
1913+
await undefined;
1914+
1915+
expect(mockSend).toBeCalledTimes(1);
1916+
expect(callback).toBeCalledTimes(1);
1917+
expect(callback).toBeCalledWith(errorEvent, { statusCode: 200 });
1918+
1919+
// Should unregister `afterSendEvent` callback.
1920+
removeAfterSendEventListenerFn();
1921+
// @ts-expect-error Accessing private client API
1922+
expect(client._hooks['afterSendEvent']).toEqual([]);
1923+
1924+
client.sendEvent(errorEvent);
1925+
jest.runAllTimers();
1926+
// Wait for two ticks
1927+
// note that for whatever reason, await new Promise(resolve => setTimeout(resolve, 0)) causes the test to hang
1928+
await undefined;
1929+
await undefined;
1930+
1931+
expect(mockSend).toBeCalledTimes(2);
1932+
// Note that the `callback` has still been called only once and not twice,
1933+
// because we unregistered it.
1934+
expect(callback).toBeCalledTimes(1);
1935+
expect(callback).toBeCalledWith(errorEvent, { statusCode: 200 });
1936+
});
1937+
});
18831938
});

packages/types/src/client.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,14 @@ export interface Client<O extends ClientOptions = ClientOptions> {
181181
/**
182182
* Register a callback for whenever a span is started.
183183
* Receives the span as argument.
184+
* @returns A function that, when executed, removes the registered callback.
184185
*/
185-
on(hook: 'spanStart', callback: (span: Span) => void): void;
186+
on(hook: 'spanStart', callback: (span: Span) => void): () => void;
186187

187188
/**
188189
* Register a callback before span sampling runs. Receives a `samplingDecision` object argument with a `decision`
189190
* property that can be used to make a sampling decision that will be enforced, before any span sampling runs.
191+
* @returns A function that, when executed, removes the registered callback.
190192
*/
191193
on(
192194
hook: 'beforeSampling',
@@ -204,83 +206,96 @@ export interface Client<O extends ClientOptions = ClientOptions> {
204206
/**
205207
* Register a callback for whenever a span is ended.
206208
* Receives the span as argument.
209+
* @returns A function that, when executed, removes the registered callback.
207210
*/
208-
on(hook: 'spanEnd', callback: (span: Span) => void): void;
211+
on(hook: 'spanEnd', callback: (span: Span) => void): () => void;
209212

210213
/**
211214
* Register a callback for when an idle span is allowed to auto-finish.
215+
* @returns A function that, when executed, removes the registered callback.
212216
*/
213-
on(hook: 'idleSpanEnableAutoFinish', callback: (span: Span) => void): void;
217+
on(hook: 'idleSpanEnableAutoFinish', callback: (span: Span) => void): () => void;
214218

215219
/**
216220
* Register a callback for transaction start and finish.
221+
* @returns A function that, when executed, removes the registered callback.
217222
*/
218-
on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): void;
223+
on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): () => void;
219224

220225
/**
221226
* Register a callback for before sending an event.
222227
* This is called right before an event is sent and should not be used to mutate the event.
223228
* Receives an Event & EventHint as arguments.
229+
* @returns A function that, when executed, removes the registered callback.
224230
*/
225-
on(hook: 'beforeSendEvent', callback: (event: Event, hint?: EventHint | undefined) => void): void;
231+
on(hook: 'beforeSendEvent', callback: (event: Event, hint?: EventHint | undefined) => void): () => void;
226232

227233
/**
228234
* Register a callback for preprocessing an event,
229235
* before it is passed to (global) event processors.
230236
* Receives an Event & EventHint as arguments.
237+
* @returns A function that, when executed, removes the registered callback.
231238
*/
232-
on(hook: 'preprocessEvent', callback: (event: Event, hint?: EventHint | undefined) => void): void;
239+
on(hook: 'preprocessEvent', callback: (event: Event, hint?: EventHint | undefined) => void): () => void;
233240

234241
/**
235242
* Register a callback for when an event has been sent.
243+
* @returns A function that, when executed, removes the registered callback.
236244
*/
237-
on(hook: 'afterSendEvent', callback: (event: Event, sendResponse: TransportMakeRequestResponse) => void): void;
245+
on(hook: 'afterSendEvent', callback: (event: Event, sendResponse: TransportMakeRequestResponse) => void): () => void;
238246

239247
/**
240248
* Register a callback before a breadcrumb is added.
249+
* @returns A function that, when executed, removes the registered callback.
241250
*/
242-
on(hook: 'beforeAddBreadcrumb', callback: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => void): void;
251+
on(hook: 'beforeAddBreadcrumb', callback: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => void): () => void;
243252

244253
/**
245254
* Register a callback when a DSC (Dynamic Sampling Context) is created.
255+
* @returns A function that, when executed, removes the registered callback.
246256
*/
247-
on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext) => void): void;
257+
on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext) => void): () => void;
248258

249259
/**
250260
* Register a callback when a Feedback event has been prepared.
251261
* This should be used to mutate the event. The options argument can hint
252262
* about what kind of mutation it expects.
263+
* @returns A function that, when executed, removes the registered callback.
253264
*/
254265
on(
255266
hook: 'beforeSendFeedback',
256267
callback: (feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void,
257-
): void;
268+
): () => void;
258269

259270
/**
260271
* A hook for the browser tracing integrations to trigger a span start for a page load.
272+
* @returns A function that, when executed, removes the registered callback.
261273
*/
262274
on(
263275
hook: 'startPageLoadSpan',
264276
callback: (
265277
options: StartSpanOptions,
266278
traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined },
267279
) => void,
268-
): void;
280+
): () => void;
269281

270282
/**
271283
* A hook for browser tracing integrations to trigger a span for a navigation.
284+
* @returns A function that, when executed, removes the registered callback.
272285
*/
273-
on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): void;
286+
on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void;
274287

275288
/**
276289
* A hook that is called when the client is flushing
290+
* @returns A function that, when executed, removes the registered callback.
277291
*/
278-
on(hook: 'flush', callback: () => void): void;
292+
on(hook: 'flush', callback: () => void): () => void;
279293

280294
/**
281295
* A hook that is called when the client is closing
296+
* @returns A function that, when executed, removes the registered callback.
282297
*/
283-
on(hook: 'close', callback: () => void): void;
298+
on(hook: 'close', callback: () => void): () => void;
284299

285300
/** Fire a hook whener a span starts. */
286301
emit(hook: 'spanStart', span: Span): void;

0 commit comments

Comments
 (0)