Skip to content

Commit 1b8455c

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 840dd8f commit 1b8455c

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
@@ -390,37 +390,40 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
390390
/* eslint-disable @typescript-eslint/unified-signatures */
391391

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

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

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

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

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

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

410410
/** @inheritdoc */
411-
public on(hook: 'afterSendEvent', callback: (event: Event, sendResponse: TransportMakeRequestResponse) => void): void;
411+
public on(
412+
hook: 'afterSendEvent',
413+
callback: (event: Event, sendResponse: TransportMakeRequestResponse) => void,
414+
): () => void;
412415

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

416419
/** @inheritdoc */
417-
public on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext, rootSpan?: Span) => void): void;
420+
public on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext, rootSpan?: Span) => void): () => void;
418421

419422
/** @inheritdoc */
420423
public on(
421424
hook: 'beforeSendFeedback',
422425
callback: (feedback: FeedbackEvent, options?: { includeReplay: boolean }) => void,
423-
): void;
426+
): () => void;
424427

425428
/** @inheritdoc */
426429
public on(
@@ -443,23 +446,41 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
443446
options: StartSpanOptions,
444447
traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined },
445448
) => void,
446-
): void;
449+
): () => void;
447450

448451
/** @inheritdoc */
449-
public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): void;
452+
public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void;
450453

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

453-
public on(hook: 'close', callback: () => void): void;
456+
public on(hook: 'close', callback: () => void): () => void;
454457

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

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

465486
/** @inheritdoc */

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2014,4 +2014,59 @@ describe('BaseClient', () => {
20142014
});
20152015
});
20162016
});
2017+
2018+
describe('hook removal with `on`', () => {
2019+
it('should return a cleanup function that, when executed, unregisters a hook', async () => {
2020+
expect.assertions(8);
2021+
2022+
const client = new TestClient(
2023+
getDefaultTestClientOptions({
2024+
dsn: PUBLIC_DSN,
2025+
enableSend: true,
2026+
}),
2027+
);
2028+
2029+
// @ts-expect-error Accessing private transport API
2030+
const mockSend = jest.spyOn(client._transport, 'send').mockImplementation(() => {
2031+
return Promise.resolve({ statusCode: 200 });
2032+
});
2033+
2034+
const errorEvent: Event = { message: 'error' };
2035+
2036+
const callback = jest.fn();
2037+
const removeAfterSendEventListenerFn = client.on('afterSendEvent', callback);
2038+
2039+
// @ts-expect-error Accessing private client API
2040+
expect(client._hooks['afterSendEvent']).toEqual([callback]);
2041+
2042+
client.sendEvent(errorEvent);
2043+
jest.runAllTimers();
2044+
// Wait for two ticks
2045+
// note that for whatever reason, await new Promise(resolve => setTimeout(resolve, 0)) causes the test to hang
2046+
await undefined;
2047+
await undefined;
2048+
2049+
expect(mockSend).toBeCalledTimes(1);
2050+
expect(callback).toBeCalledTimes(1);
2051+
expect(callback).toBeCalledWith(errorEvent, { statusCode: 200 });
2052+
2053+
// Should unregister `afterSendEvent` callback.
2054+
removeAfterSendEventListenerFn();
2055+
// @ts-expect-error Accessing private client API
2056+
expect(client._hooks['afterSendEvent']).toEqual([]);
2057+
2058+
client.sendEvent(errorEvent);
2059+
jest.runAllTimers();
2060+
// Wait for two ticks
2061+
// note that for whatever reason, await new Promise(resolve => setTimeout(resolve, 0)) causes the test to hang
2062+
await undefined;
2063+
await undefined;
2064+
2065+
expect(mockSend).toBeCalledTimes(2);
2066+
// Note that the `callback` has still been called only once and not twice,
2067+
// because we unregistered it.
2068+
expect(callback).toBeCalledTimes(1);
2069+
expect(callback).toBeCalledWith(errorEvent, { statusCode: 200 });
2070+
});
2071+
});
20172072
});

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, rootSpan?: Span) => void): void;
257+
on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext, rootSpan?: Span) => 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)