Skip to content

Commit c253bbe

Browse files
authored
feat: Add Messenger error reporting (#6605)
## Explanation The `Messenger` class in `@metamask/messenger` has been updated to accept an optional `captureException` constructor parameter. This is now used to capture subscriber errors, instead of throwing them in a `setTimeout` (which would cause a crash on mobile). ## References This is the implementation of the 2nd option in this ADR: https://github.com/MetaMask/decisions/blob/main/decisions/core/0016-core-classes-error-reporting.md#optional-captureexception-constructor-parameter-that-inherits-from-parent Relates to #6613 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes
1 parent 7f43ba2 commit c253bbe

File tree

3 files changed

+101
-15
lines changed

3 files changed

+101
-15
lines changed

packages/messenger/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `captureException` constructor parameter ([#6605](https://github.com/MetaMask/core/pull/6605))
13+
- This function will be used to capture any errors thrown from subscribers.
14+
- If this is unset but a parent is provided, `captureException` is inherited from the parent.
15+
16+
### Changed
17+
18+
- Stop re-throwing subscriber errors in a `setTimeout` ([#6605](https://github.com/MetaMask/core/pull/6605))
19+
- Instead errors are captured with `captureException`, or logged to the console.
20+
1021
## [0.2.0]
1122

1223
### Added

packages/messenger/src/Messenger.test.ts

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -810,26 +810,89 @@ describe('Messenger', () => {
810810
expect(selector.callCount).toBe(4);
811811
});
812812

813-
it('throws subscriber errors in a timeout', () => {
814-
const setTimeoutStub = sinon.stub(globalThis, 'setTimeout');
813+
it('captures subscriber errors using captureException', () => {
814+
const captureException = jest.fn();
815815
type MessageEvent = { type: 'Fixture:message'; payload: [string] };
816816
const messenger = new Messenger<'Fixture', never, MessageEvent>({
817+
captureException,
817818
namespace: 'Fixture',
818819
});
820+
const exampleError = new Error('Example error');
819821

820-
const handler = sinon.stub().throws(() => new Error('Example error'));
822+
const handler = sinon.stub().throws(() => exampleError);
821823
messenger.subscribe('Fixture:message', handler);
822824

823825
expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow();
824-
expect(setTimeoutStub.callCount).toBe(1);
825-
const onTimeout = setTimeoutStub.firstCall.args[0];
826-
expect(() => onTimeout()).toThrow('Example error');
826+
expect(captureException).toHaveBeenCalledTimes(1);
827+
expect(captureException).toHaveBeenCalledWith(exampleError);
828+
});
829+
830+
it('captures subscriber thrown non-errors using captureException', () => {
831+
const captureException = jest.fn();
832+
type MessageEvent = { type: 'Fixture:message'; payload: [string] };
833+
const messenger = new Messenger<'Fixture', never, MessageEvent>({
834+
captureException,
835+
namespace: 'Fixture',
836+
});
837+
const exampleException = 'Non-error thrown value';
838+
839+
const handler = sinon.stub().throws(() => exampleException);
840+
messenger.subscribe('Fixture:message', handler);
841+
842+
expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow();
843+
expect(captureException).toHaveBeenCalledTimes(1);
844+
expect(captureException).toHaveBeenCalledWith(
845+
new Error(exampleException),
846+
);
847+
});
848+
849+
it('captures subscriber errors using inherited captureException', () => {
850+
const captureException = jest.fn();
851+
type MessageEvent = { type: 'Fixture:message'; payload: [string] };
852+
const parentMessenger = new Messenger<'Parent', never, MessageEvent>({
853+
captureException,
854+
namespace: 'Parent',
855+
});
856+
const messenger = new Messenger<
857+
'Fixture',
858+
never,
859+
MessageEvent,
860+
typeof parentMessenger
861+
>({
862+
namespace: 'Fixture',
863+
parent: parentMessenger,
864+
});
865+
const exampleError = new Error('Example error');
866+
867+
const handler = sinon.stub().throws(() => exampleError);
868+
messenger.subscribe('Fixture:message', handler);
869+
870+
expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow();
871+
expect(captureException).toHaveBeenCalledTimes(1);
872+
expect(captureException).toHaveBeenCalledWith(exampleError);
873+
});
874+
875+
it('logs subscriber errors to console if no captureException provided', () => {
876+
const consoleError = jest.fn();
877+
jest.spyOn(console, 'error').mockImplementation(consoleError);
878+
type MessageEvent = { type: 'Fixture:message'; payload: [string] };
879+
const messenger = new Messenger<'Fixture', never, MessageEvent>({
880+
namespace: 'Fixture',
881+
});
882+
const exampleError = new Error('Example error');
883+
884+
const handler = sinon.stub().throws(() => exampleError);
885+
messenger.subscribe('Fixture:message', handler);
886+
887+
expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow();
888+
expect(consoleError).toHaveBeenCalledTimes(1);
889+
expect(consoleError).toHaveBeenCalledWith(exampleError);
827890
});
828891

829892
it('continues calling subscribers when one throws', () => {
830-
const setTimeoutStub = sinon.stub(globalThis, 'setTimeout');
831893
type MessageEvent = { type: 'Fixture:message'; payload: [string] };
832894
const messenger = new Messenger<'Fixture', never, MessageEvent>({
895+
captureException: jest.fn(),
833896
namespace: 'Fixture',
834897
});
835898

@@ -844,9 +907,6 @@ describe('Messenger', () => {
844907
expect(handler1.callCount).toBe(1);
845908
expect(handler2.calledWithExactly('hello')).toBe(true);
846909
expect(handler2.callCount).toBe(1);
847-
expect(setTimeoutStub.callCount).toBe(1);
848-
const onTimeout = setTimeoutStub.firstCall.args[0];
849-
expect(() => onTimeout()).toThrow('Example error');
850910
});
851911

852912
it('does not call subscriber after unsubscribing', () => {

packages/messenger/src/Messenger.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ type DelegatedMessenger = Pick<
177177
| '_internalRegisterDelegatedActionHandler'
178178
| '_internalRegisterDelegatedInitialEventPayload'
179179
| '_internalUnregisterDelegatedActionHandler'
180+
| 'captureException'
180181
>;
181182

182183
type StripNamespace<Namespaced extends NamespacedName> =
@@ -254,20 +255,30 @@ export class Messenger<
254255
unknown | undefined
255256
>();
256257

258+
/**
259+
* Reports an error to an error monitoring service.
260+
*
261+
* @param error - The error to report.
262+
*/
263+
readonly captureException?: (error: Error) => void;
264+
257265
/**
258266
* Construct a messenger.
259267
*
260268
* If a parent messenger is given, all actions and events under this messenger's namespace will
261269
* be delegated to the parent automatically.
262270
*
263271
* @param args - Constructor arguments
272+
* @param args.captureException - Reports an error to an error monitoring service.
264273
* @param args.namespace - The messenger namespace.
265274
* @param args.parent - The parent messenger.
266275
*/
267276
constructor({
277+
captureException,
268278
namespace,
269279
parent,
270280
}: {
281+
captureException?: (error: Error) => void;
271282
namespace: Namespace;
272283
parent?: Action['type'] extends MessengerActions<Parent>['type']
273284
? Event['type'] extends MessengerEvents<Parent>['type']
@@ -277,6 +288,7 @@ export class Messenger<
277288
}) {
278289
this.#namespace = namespace;
279290
this.#parent = parent;
291+
this.captureException = captureException ?? this.#parent?.captureException;
280292
}
281293

282294
/**
@@ -529,11 +541,14 @@ export class Messenger<
529541
(handler as GenericEventHandler)(...payload);
530542
}
531543
} catch (error) {
532-
// Throw error after timeout so that it is capured as a console error
533-
// (and by Sentry) without interrupting the event publishing.
534-
setTimeout(() => {
535-
throw error;
536-
});
544+
// Capture error without interrupting the event publishing.
545+
if (this.captureException) {
546+
this.captureException(
547+
error instanceof Error ? error : new Error(String(error)),
548+
);
549+
} else {
550+
console.error(error);
551+
}
537552
}
538553
}
539554
}

0 commit comments

Comments
 (0)