From 473b1d513da388b92e96510710ea40467143407c Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 24 Jun 2026 13:10:57 +0200 Subject: [PATCH 1/6] Track Snap lifecycle analytics events in SnapController Migrates client-side analytics event tracking for Snap install, update, and uninstall flows from the MetaMask clients into SnapController, so all clients get consistent analytics coverage without each having to subscribe to the events separately. The controller now subscribes to its own lifecycle events (snapInstallStarted, snapInstallFailed, snapInstalled, snapUpdated, snapUninstalled) and calls AnalyticsController:trackEvent directly via the messenger, using SnapRegistryController:getMetadata to enrich events with the snap category. Preinstalled Snaps are excluded from tracking. --- packages/snaps-controllers/package.json | 1 + .../src/snaps/SnapController.test.tsx | 927 +++++++++++++----- .../src/snaps/SnapController.ts | 169 +++- .../src/test-utils/controller.tsx | 7 +- yarn.lock | 14 + 5 files changed, 830 insertions(+), 288 deletions(-) diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index 69578c10bd..5fce34ddff 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -81,6 +81,7 @@ "test:watch": "jest --watch" }, "dependencies": { + "@metamask/analytics-controller": "^1.1.1", "@metamask/approval-controller": "^9.0.2", "@metamask/base-controller": "^9.1.0", "@metamask/json-rpc-engine": "^10.5.0", diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index db25e1fe9c..d64cc04ba6 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -519,7 +519,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -574,7 +574,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -665,7 +665,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -744,7 +744,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -794,7 +794,7 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: expectedSnapObject, }); - expect(options.messenger.call).toHaveBeenCalledTimes(10); + expect(options.messenger.call).toHaveBeenCalledTimes(14); expect(options.messenger.call).toHaveBeenNthCalledWith( 1, @@ -816,7 +816,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 2, + 4, 'SnapRegistryController:get', { [MOCK_SNAP_ID]: { @@ -828,7 +828,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 3, + 5, 'StorageService:setItem', controllerName, MOCK_SNAP_ID, @@ -836,7 +836,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 4, + 6, 'SubjectMetadataController:addSubjectMetadata', { subjectType: SubjectType.Snap, @@ -848,7 +848,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -861,13 +861,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:grantPermissions', expect.any(Object), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:addRequest', expect.objectContaining({ requestData: { @@ -886,13 +886,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 8, + 10, 'ExecutionService:executeSnap', expect.any(Object), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -1272,7 +1272,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 3, + 5, 'StorageService:setItem', controllerName, MOCK_SNAP_ID, @@ -1280,7 +1280,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -1293,7 +1293,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 12, + 16, 'StorageService:removeItem', controllerName, MOCK_SNAP_ID, @@ -1334,10 +1334,10 @@ describe('SnapController', () => { }), ).rejects.toThrow('User rejected the request.'); - expect(messengerCallMock).toHaveBeenCalledTimes(12); + expect(messengerCallMock).toHaveBeenCalledTimes(16); expect(messengerCallMock).toHaveBeenNthCalledWith( - 3, + 5, 'StorageService:setItem', controllerName, MOCK_SNAP_ID, @@ -1345,7 +1345,7 @@ describe('SnapController', () => { ); expect(messengerCallMock).toHaveBeenNthCalledWith( - 6, + 8, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -1358,7 +1358,7 @@ describe('SnapController', () => { ); expect(messengerCallMock).toHaveBeenNthCalledWith( - 12, + 16, 'StorageService:removeItem', controllerName, MOCK_SNAP_ID, @@ -2042,7 +2042,7 @@ describe('SnapController', () => { expect(await promise).toBe('foo'); expect(options.messenger.call).toHaveBeenNthCalledWith( - 8, + 10, 'ExecutionService:executeSnap', expect.objectContaining({ snapId: MOCK_SNAP_ID, @@ -2050,7 +2050,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 19, + 24, 'ExecutionService:executeSnap', expect.objectContaining({ snapId: MOCK_SNAP_ID, @@ -5371,7 +5371,7 @@ describe('SnapController', () => { }, }); - expect(options.messenger.call).toHaveBeenCalledTimes(7); + expect(options.messenger.call).toHaveBeenCalledTimes(8); expect(options.messenger.call).toHaveBeenCalledWith( 'ExecutionService:handleRpcRequest', MOCK_SNAP_ID, @@ -5633,7 +5633,7 @@ describe('SnapController', () => { expect(result).toStrictEqual({ [MOCK_LOCAL_SNAP_ID]: truncatedSnap }); - expect(options.messenger.call).toHaveBeenCalledTimes(13); + expect(options.messenger.call).toHaveBeenCalledTimes(17); expect(options.messenger.call).toHaveBeenNthCalledWith( 2, @@ -5656,7 +5656,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 8, + 10, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -5669,7 +5669,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'PermissionController:grantPermissions', { approvedPermissions: permissions, @@ -5686,7 +5686,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 10, + 12, 'ApprovalController:addRequest', expect.objectContaining({ type: SNAP_APPROVAL_RESULT, @@ -5706,13 +5706,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 11, + 13, 'ExecutionService:executeSnap', expect.objectContaining({}), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 12, + 14, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -5782,7 +5782,7 @@ describe('SnapController', () => { [MOCK_LOCAL_SNAP_ID]: truncatedSnap, }); - expect(options.messenger.call).toHaveBeenCalledTimes(23); + expect(options.messenger.call).toHaveBeenCalledTimes(31); expect(options.messenger.call).toHaveBeenNthCalledWith( 1, @@ -5805,7 +5805,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -5818,7 +5818,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:grantPermissions', { approvedPermissions: permissions, @@ -5835,7 +5835,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -5856,13 +5856,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 8, + 10, 'ExecutionService:executeSnap', expect.anything(), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -5874,7 +5874,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 11, + 15, 'ApprovalController:addRequest', expect.objectContaining({ type: SNAP_APPROVAL_INSTALL, @@ -5894,13 +5894,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 12, + 18, 'ExecutionService:terminateSnap', MOCK_LOCAL_SNAP_ID, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 18, + 24, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -5913,7 +5913,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 19, + 25, 'PermissionController:grantPermissions', { approvedPermissions: permissions, @@ -5930,7 +5930,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 20, + 26, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -5951,13 +5951,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 21, + 27, 'ExecutionService:executeSnap', expect.objectContaining({ snapId: MOCK_LOCAL_SNAP_ID }), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 22, + 28, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -6031,7 +6031,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -7391,7 +7391,7 @@ describe('SnapController', () => { expect(result).toStrictEqual({ [MOCK_SNAP_ID]: truncatedSnap, }); - expect(options.messenger.call).toHaveBeenCalledTimes(10); + expect(options.messenger.call).toHaveBeenCalledTimes(14); expect(options.messenger.call).toHaveBeenNthCalledWith( 1, @@ -7411,7 +7411,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -7424,7 +7424,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:grantPermissions', { approvedPermissions: permissions, @@ -7441,7 +7441,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -7462,13 +7462,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 8, + 10, 'ExecutionService:executeSnap', expect.objectContaining({}), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -7681,7 +7681,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -7715,7 +7715,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -7809,7 +7809,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -7826,7 +7826,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -7893,7 +7893,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 10, + 12, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -7968,7 +7968,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 10, + 12, 'PermissionController:revokePermissions', { [MOCK_SNAP_ID]: [SnapEndowments.Rpc, 'snap_dialog'], @@ -7976,7 +7976,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 11, + 13, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -8068,10 +8068,10 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: { version: newVersionRange }, }); - expect(options.messenger.call).toHaveBeenCalledTimes(22); + expect(options.messenger.call).toHaveBeenCalledTimes(30); expect(options.messenger.call).toHaveBeenNthCalledWith( - 3, + 5, 'StorageService:setItem', controllerName, MOCK_SNAP_ID, @@ -8081,7 +8081,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 4, + 6, 'SubjectMetadataController:addSubjectMetadata', { subjectType: SubjectType.Snap, @@ -8093,7 +8093,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 12, + 16, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -8115,13 +8115,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 14, + 20, 'PermissionController:getPermissions', MOCK_SNAP_ID, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 15, + 21, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -8140,7 +8140,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 16, + 22, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -8161,7 +8161,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 18, + 24, 'StorageService:setItem', controllerName, MOCK_SNAP_ID, @@ -8169,7 +8169,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 19, + 25, 'SubjectMetadataController:addSubjectMetadata', { subjectType: SubjectType.Snap, @@ -8181,13 +8181,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 20, + 26, 'ExecutionService:executeSnap', expect.objectContaining({}), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 21, + 27, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -8255,7 +8255,7 @@ describe('SnapController', () => { }), ).rejects.toThrow(errorMessage); - expect(options.messenger.call).toHaveBeenCalledTimes(3); + expect(options.messenger.call).toHaveBeenCalledTimes(7); expect(options.messenger.call).toHaveBeenCalledWith( 'ApprovalController:updateRequestState', @@ -8302,7 +8302,7 @@ describe('SnapController', () => { }), ).rejects.toThrow('foo'); - expect(options.messenger.call).toHaveBeenCalledTimes(3); + expect(options.messenger.call).toHaveBeenCalledTimes(7); expect(detect).toHaveBeenCalledTimes(1); expect(detect).toHaveBeenCalledWith( MOCK_SNAP_ID, @@ -8462,7 +8462,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 26, + 36, 'StorageService:setItem', controllerName, snapId3, @@ -8470,7 +8470,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 39, + 51, 'StorageService:setItem', controllerName, snapId1, @@ -8478,7 +8478,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 50, + 64, 'StorageService:setItem', controllerName, snapId2, @@ -8486,7 +8486,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 53, + 67, 'PermissionController:revokePermissions', { [MOCK_ORIGIN]: [WALLET_SNAP_PERMISSION_KEY], @@ -8494,14 +8494,14 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 62, + 78, 'StorageService:removeItem', controllerName, snapId3, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 64, + 82, 'StorageService:setItem', controllerName, snapId1, @@ -8509,7 +8509,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 65, + 83, 'StorageService:setItem', controllerName, snapId2, @@ -8517,7 +8517,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 67, + 85, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -8787,133 +8787,304 @@ describe('SnapController', () => { snapController.destroy(); }); - }); - it('throws if the Snap source code is too large', async () => { - const { manifest, sourceCode, svgIcon, localizationFiles } = - await getMockSnapFilesWithUpdatedChecksum({ - sourceCode: 'a'.repeat(64_000_001), - }); + it('throws if the Snap source code is too large', async () => { + const { manifest, sourceCode, svgIcon, localizationFiles } = + await getMockSnapFilesWithUpdatedChecksum({ + sourceCode: 'a'.repeat(64_000_001), + }); - const snapController = await getSnapController( - getSnapControllerOptions({ - detectSnapLocation: loopbackDetect({ - manifest, - files: [sourceCode, svgIcon as VirtualFile, ...localizationFiles], + const snapController = await getSnapController( + getSnapControllerOptions({ + detectSnapLocation: loopbackDetect({ + manifest, + files: [sourceCode, svgIcon as VirtualFile, ...localizationFiles], + }), }), - }), - ); - - await expect( - snapController.installSnaps(MOCK_ORIGIN, { - [MOCK_SNAP_ID]: {}, - }), - ).rejects.toThrow( - 'Failed to fetch snap "npm:@metamask/example-snap": Snap source code must be smaller than 64 MB..', - ); - - snapController.destroy(); - }); + ); - describe('Updating Snaps', () => { - it("throws an error if new version doesn't match version range", async () => { - const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ - manifest: getSnapManifest({ - version: '1.1.0' as SemVerVersion, + await expect( + snapController.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: {}, }), - }); - const detectSnapLocation = loopbackDetect({ - manifest: manifest.result, - }); + ).rejects.toThrow( + 'Failed to fetch snap "npm:@metamask/example-snap": Snap source code must be smaller than 64 MB..', + ); + snapController.destroy(); + }); + + it('tracks `Snap Install Started` when a Snap installation starts', async () => { const options = getSnapControllerOptions({ state: { snaps: getPersistedSnapsState(), }, - detectSnapLocation, }); - const controller = await getSnapController(options); - const onSnapUpdated = jest.fn(); - - const snap = controller.getSnapExpect(MOCK_SNAP_ID); - - options.messenger.subscribe('SnapController:snapUpdated', onSnapUpdated); + const snapController = await getSnapController(options); - const newSnap = controller.getSnap(MOCK_SNAP_ID); + options.messenger.publish( + 'SnapController:snapInstallStarted', + MOCK_SNAP_ID, + MOCK_ORIGIN, + false, + ); - await expect( - controller.installSnaps(MOCK_ORIGIN, { - [MOCK_SNAP_ID]: { version: '1.2.0' }, - }), - ).rejects.toThrow( - `Version mismatch. Manifest for "npm:@metamask/example-snap" specifies version "1.1.0" which doesn't satisfy requested version range "1.2.0".`, + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Install Started', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, ); - expect(newSnap?.version).toStrictEqual(snap.version); - expect(onSnapUpdated).not.toHaveBeenCalled(); - controller.destroy(); + snapController.destroy(); }); - it('throws an error if the new version of the snap is blocked', async () => { - const rootMessenger = getRootMessenger(); - const registry = new MockSnapRegistryController(rootMessenger); - - const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ - manifest: getSnapManifest({ - version: '1.1.0' as SemVerVersion, - }), - }); - const detectSnapLocation = loopbackDetect({ - manifest: manifest.result, + it('tracks `Snap Install Failed` when a Snap installation fails', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, }); - const controller = await getSnapController( - getSnapControllerOptions({ - rootMessenger, - state: { - snaps: getPersistedSnapsState(), - }, - detectSnapLocation, - }), - ); + const snapController = await getSnapController(options); - registry.get.mockResolvedValueOnce({ - [MOCK_SNAP_ID]: { status: SnapRegistryStatus.Blocked }, - }); + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + false, + 'Installation failed.', + ); - await expect( - controller.installSnaps(MOCK_ORIGIN, { - [MOCK_SNAP_ID]: { version: '1.1.0' }, - }), - ).rejects.toThrow('Cannot install version "1.1.0" of snap'); + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Install Failed', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); - controller.destroy(); + snapController.destroy(); }); - it('does not update on older snap version downloaded', async () => { - const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ - manifest: getSnapManifest({ - version: '0.9.0' as SemVerVersion, - }), - }); - const detectSnapLocation = loopbackDetect({ - manifest: manifest.result, - }); - + it('tracks `Snap Install Rejected` when a Snap installation is rejected', async () => { const options = getSnapControllerOptions({ state: { snaps: getPersistedSnapsState(), }, - detectSnapLocation, }); - const controller = await getSnapController(options); - const onSnapUpdated = jest.fn(); - - const snap = controller.getSnapExpect(MOCK_SNAP_ID); - - options.messenger.subscribe('SnapController:snapUpdated', onSnapUpdated); + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + false, + 'User rejected the request.', + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Install Rejected', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + + it('tracks `Snap Installed` when a Snap is installed', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstalled', + getTruncatedSnap(), + MOCK_ORIGIN, + false, + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Installed', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + version: '1.0.0', + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + + it('does not track `Snap Installed` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstalled', + getTruncatedSnap(), + MOCK_ORIGIN, + true, + ); + + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Installed' }), + ); + + snapController.destroy(); + }); + }); + + describe('Updating Snaps', () => { + it("throws an error if new version doesn't match version range", async () => { + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + version: '1.1.0' as SemVerVersion, + }), + }); + const detectSnapLocation = loopbackDetect({ + manifest: manifest.result, + }); + + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + detectSnapLocation, + }); + + const controller = await getSnapController(options); + const onSnapUpdated = jest.fn(); + + const snap = controller.getSnapExpect(MOCK_SNAP_ID); + + options.messenger.subscribe('SnapController:snapUpdated', onSnapUpdated); + + const newSnap = controller.getSnap(MOCK_SNAP_ID); + + await expect( + controller.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: { version: '1.2.0' }, + }), + ).rejects.toThrow( + `Version mismatch. Manifest for "npm:@metamask/example-snap" specifies version "1.1.0" which doesn't satisfy requested version range "1.2.0".`, + ); + expect(newSnap?.version).toStrictEqual(snap.version); + expect(onSnapUpdated).not.toHaveBeenCalled(); + + controller.destroy(); + }); + + it('throws an error if the new version of the snap is blocked', async () => { + const rootMessenger = getRootMessenger(); + const registry = new MockSnapRegistryController(rootMessenger); + + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + version: '1.1.0' as SemVerVersion, + }), + }); + const detectSnapLocation = loopbackDetect({ + manifest: manifest.result, + }); + + const controller = await getSnapController( + getSnapControllerOptions({ + rootMessenger, + state: { + snaps: getPersistedSnapsState(), + }, + detectSnapLocation, + }), + ); + + registry.get.mockResolvedValueOnce({ + [MOCK_SNAP_ID]: { status: SnapRegistryStatus.Blocked }, + }); + + await expect( + controller.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: { version: '1.1.0' }, + }), + ).rejects.toThrow('Cannot install version "1.1.0" of snap'); + + controller.destroy(); + }); + + it('does not update on older snap version downloaded', async () => { + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + version: '0.9.0' as SemVerVersion, + }), + }); + const detectSnapLocation = loopbackDetect({ + manifest: manifest.result, + }); + + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + detectSnapLocation, + }); + + const controller = await getSnapController(options); + const onSnapUpdated = jest.fn(); + + const snap = controller.getSnapExpect(MOCK_SNAP_ID); + + options.messenger.subscribe('SnapController:snapUpdated', onSnapUpdated); const publishSpy = jest.spyOn(options.messenger, 'publish'); @@ -8997,10 +9168,10 @@ describe('SnapController', () => { date: expect.any(Number), }, ]); - expect(callActionSpy).toHaveBeenCalledTimes(22); + expect(callActionSpy).toHaveBeenCalledTimes(30); expect(callActionSpy).toHaveBeenNthCalledWith( - 12, + 16, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -9022,13 +9193,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 14, + 20, 'PermissionController:getPermissions', MOCK_SNAP_ID, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 15, + 21, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9047,7 +9218,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 16, + 22, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -9068,13 +9239,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 20, + 26, 'ExecutionService:executeSnap', expect.objectContaining({}), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 21, + 27, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9161,9 +9332,9 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: { version: '1.1.0' }, }); - expect(callActionSpy).toHaveBeenCalledTimes(22); + expect(callActionSpy).toHaveBeenCalledTimes(30); expect(callActionSpy).toHaveBeenNthCalledWith( - 12, + 16, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -9185,7 +9356,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 15, + 21, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9276,9 +9447,9 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: { version: '1.1.0' }, }); - expect(callActionSpy).toHaveBeenCalledTimes(23); + expect(callActionSpy).toHaveBeenCalledTimes(31); expect(callActionSpy).toHaveBeenNthCalledWith( - 12, + 16, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -9300,7 +9471,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 15, + 21, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9324,7 +9495,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 20, + 26, 'PermissionController:revokePermissions', { [MOCK_SNAP_ID]: ['endowment:ethereum-provider', 'endowment:caip25'], @@ -9414,7 +9585,7 @@ describe('SnapController', () => { const isRunning = controller.isSnapRunning(MOCK_SNAP_ID); - expect(callActionSpy).toHaveBeenCalledTimes(15); + expect(callActionSpy).toHaveBeenCalledTimes(19); expect(callActionSpy).toHaveBeenNthCalledWith( 3, @@ -9445,13 +9616,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:getPermissions', MOCK_SNAP_ID, ); expect(callActionSpy).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9470,7 +9641,7 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 8, + 10, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -9491,13 +9662,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 9, + 11, 'ExecutionService:terminateSnap', MOCK_SNAP_ID, ); expect(callActionSpy).toHaveBeenNthCalledWith( - 14, + 16, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9577,7 +9748,7 @@ describe('SnapController', () => { const newSnap = controller.getSnap(MOCK_SNAP_ID); expect(newSnap?.version).toBe('1.0.0'); - expect(callActionSpy).toHaveBeenCalledTimes(6); + expect(callActionSpy).toHaveBeenCalledTimes(10); expect(callActionSpy).toHaveBeenNthCalledWith( 2, @@ -9602,13 +9773,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 4, + 6, 'PermissionController:getPermissions', MOCK_SNAP_ID, ); expect(callActionSpy).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9757,10 +9928,10 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: { version: '1.1.0' }, }); - expect(callActionSpy).toHaveBeenCalledTimes(24); + expect(callActionSpy).toHaveBeenCalledTimes(32); expect(callActionSpy).toHaveBeenNthCalledWith( - 12, + 16, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -9782,7 +9953,7 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 15, + 21, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9807,7 +9978,7 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 16, + 22, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -9829,13 +10000,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 20, + 26, 'PermissionController:revokePermissions', { [MOCK_SNAP_ID]: ['snap_manageState'] }, ); expect(callActionSpy).toHaveBeenNthCalledWith( - 21, + 27, 'PermissionController:grantPermissions', { approvedPermissions: { 'endowment:network-access': {} }, @@ -9852,13 +10023,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 22, + 28, 'ExecutionService:executeSnap', expect.anything(), ); expect(callActionSpy).toHaveBeenNthCalledWith( - 23, + 29, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -10148,6 +10319,182 @@ describe('SnapController', () => { controller.destroy(); }); + + it('tracks `Snap Update Started` when a Snap update starts', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallStarted', + MOCK_SNAP_ID, + MOCK_ORIGIN, + true, + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Update Started', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + + it('tracks `Snap Update Failed` when a Snap update fails', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + true, + 'Update failed.', + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Update Failed', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + + it('tracks `Snap Update Rejected` when a Snap update is rejected', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + true, + 'User rejected the request.', + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Update Rejected', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + + it('tracks `Snap Updated` when a Snap is updated', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapUpdated', + getTruncatedSnap(), + '0.9.0', + MOCK_ORIGIN, + false, + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Updated', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + // eslint-disable-next-line @typescript-eslint/naming-convention + old_version: '0.9.0', + // eslint-disable-next-line @typescript-eslint/naming-convention + new_version: '1.0.0', + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + + it('does not track `Snap Updated` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapUpdated', + getTruncatedSnap(), + '0.9.0', + MOCK_ORIGIN, + true, + ); + + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Updated' }), + ); + + snapController.destroy(); + }); }); describe('removeSnap', () => { @@ -10185,7 +10532,7 @@ describe('SnapController', () => { }); await snapController.removeSnap(MOCK_SNAP_ID); - expect(callActionSpy).toHaveBeenCalledTimes(6); + expect(callActionSpy).toHaveBeenCalledTimes(8); expect(callActionSpy).toHaveBeenNthCalledWith( 5, 'PermissionController:revokePermissions', @@ -10237,7 +10584,7 @@ describe('SnapController', () => { }); await snapController.removeSnap(MOCK_SNAP_ID); - expect(callActionSpy).toHaveBeenCalledTimes(7); + expect(callActionSpy).toHaveBeenCalledTimes(9); expect(callActionSpy).toHaveBeenNthCalledWith( 6, 'PermissionController:updateCaveat', @@ -10288,7 +10635,7 @@ describe('SnapController', () => { }); await snapController.removeSnap(MOCK_SNAP_ID); - expect(callActionSpy).toHaveBeenCalledTimes(7); + expect(callActionSpy).toHaveBeenCalledTimes(9); expect(callActionSpy).toHaveBeenNthCalledWith( 5, 'PermissionController:revokePermissions', @@ -10348,6 +10695,40 @@ describe('SnapController', () => { snapController.destroy(); }); + + it('tracks `Snap Uninstalled` when a Snap is uninstalled', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapUninstalled', + getTruncatedSnap(), + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Uninstalled', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + version: '1.0.0', + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); }); describe('enableSnap', () => { @@ -10796,7 +11177,7 @@ describe('SnapController', () => { expect(updatedSnap.preinstalled).toBe(true); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'StorageService:setItem', controllerName, snapId, @@ -10804,13 +11185,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'PermissionController:revokePermissions', { [snapId]: [SnapEndowments.Rpc, SnapEndowments.LifecycleHooks] }, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 10, + 12, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -10897,7 +11278,7 @@ describe('SnapController', () => { expect(updatedSnap.preinstalled).toBe(true); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'StorageService:setItem', controllerName, snapId, @@ -10905,13 +11286,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'PermissionController:revokePermissions', { [snapId]: [SnapEndowments.Rpc, SnapEndowments.LifecycleHooks] }, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 10, + 12, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -11356,20 +11737,20 @@ describe('SnapController', () => { }); it('should track event for allowed handler', async () => { - const mockTrackEvent = jest.fn(); const rootMessenger = getRootMessenger(); const executionEnvironmentStub = new ExecutionEnvironmentStub( getNodeEESMessenger(rootMessenger), ) as unknown as NodeThreadExecutionService; + const options = getSnapControllerOptions({ + rootMessenger, + state: { + snaps: getPersistedSnapsState(), + }, + }); + const [snapController] = await getSnapControllerWithEES( - getSnapControllerOptions({ - rootMessenger, - trackEvent: mockTrackEvent, - state: { - snaps: getPersistedSnapsState(), - }, - }), + options, executionEnvironmentStub, ); @@ -11388,25 +11769,28 @@ describe('SnapController', () => { }, }); - expect(mockTrackEvent).toHaveBeenCalledTimes(1); - expect(mockTrackEvent).toHaveBeenCalledWith({ - event: 'Snap Export Used', - category: 'Snaps', - properties: { - export: 'onRpcRequest', - origin: 'https://example.com', - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: undefined, - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: 'npm:@metamask/example-snap', - success: true, + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Export Used', + properties: { + export: 'onRpcRequest', + origin: 'https://example.com', + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: 'npm:@metamask/example-snap', + success: true, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, }, - }); + ); snapController.destroy(); }); it('should not track event for disallowed handler', async () => { - const mockTrackEvent = jest.fn(); const rootMessenger = getRootMessenger(); rootMessenger.registerActionHandler( @@ -11428,15 +11812,16 @@ describe('SnapController', () => { getNodeEESMessenger(rootMessenger), ) as unknown as NodeThreadExecutionService; + const options = getSnapControllerOptions({ + environmentEndowmentPermissions: ['endowment:cronjob'], + rootMessenger, + state: { + snaps: getPersistedSnapsState(), + }, + }); + const [snapController] = await getSnapControllerWithEES( - getSnapControllerOptions({ - environmentEndowmentPermissions: ['endowment:cronjob'], - rootMessenger, - trackEvent: mockTrackEvent, - state: { - snaps: getPersistedSnapsState(), - }, - }), + options, executionEnvironmentStub, ); @@ -11455,17 +11840,26 @@ describe('SnapController', () => { }, }); - expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.any(Object), + ); snapController.destroy(); }); it('should properly handle error when MetaMetrics hook throws an error', async () => { const log = jest.spyOn(console, 'error').mockImplementation(); const error = new Error('MetaMetrics hook error'); - const mockTrackEvent = jest.fn().mockImplementation(() => { - throw error; - }); const rootMessenger = getRootMessenger(); + + rootMessenger.unregisterActionHandler('AnalyticsController:trackEvent'); + rootMessenger.registerActionHandler( + 'AnalyticsController:trackEvent', + () => { + throw error; + }, + ); + const executionEnvironmentStub = new ExecutionEnvironmentStub( getNodeEESMessenger(rootMessenger), ) as unknown as NodeThreadExecutionService; @@ -11473,7 +11867,6 @@ describe('SnapController', () => { const [snapController] = await getSnapControllerWithEES( getSnapControllerOptions({ rootMessenger, - trackEvent: mockTrackEvent, state: { snaps: getPersistedSnapsState(), }, @@ -11496,7 +11889,6 @@ describe('SnapController', () => { }, }); - expect(mockTrackEvent).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith( expect.stringContaining( 'Error when calling MetaMetrics hook for snap', @@ -11506,22 +11898,22 @@ describe('SnapController', () => { }); it('should not track event for preinstalled snap', async () => { - const mockTrackEvent = jest.fn(); const rootMessenger = getRootMessenger(); const executionEnvironmentStub = new ExecutionEnvironmentStub( getNodeEESMessenger(rootMessenger), ) as unknown as NodeThreadExecutionService; + const options = getSnapControllerOptions({ + rootMessenger, + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ preinstalled: true }), + ), + }, + }); + const [snapController] = await getSnapControllerWithEES( - getSnapControllerOptions({ - rootMessenger, - trackEvent: mockTrackEvent, - state: { - snaps: getPersistedSnapsState( - getPersistedSnapObject({ preinstalled: true }), - ), - }, - }), + options, executionEnvironmentStub, ); @@ -11540,7 +11932,10 @@ describe('SnapController', () => { }, }); - expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.any(Object), + ); snapController.destroy(); }); }); @@ -12781,13 +13176,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ExecutionService:executeSnap', expect.any(Object), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'ExecutionService:handleRpcRequest', MOCK_SNAP_ID, { @@ -12830,7 +13225,7 @@ describe('SnapController', () => { await new Promise((resolve) => setTimeout(resolve, 10)); - expect(options.messenger.call).toHaveBeenCalledTimes(2); + expect(options.messenger.call).toHaveBeenCalledTimes(4); expect(options.messenger.call).not.toHaveBeenCalledWith( 'ExecutionService:handleRpcRequest', MOCK_SNAP_ID, @@ -12934,13 +13329,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ExecutionService:executeSnap', expect.any(Object), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'ExecutionService:handleRpcRequest', MOCK_SNAP_ID, { @@ -12983,7 +13378,7 @@ describe('SnapController', () => { await new Promise((resolve) => setTimeout(resolve, 10)); - expect(options.messenger.call).toHaveBeenCalledTimes(2); + expect(options.messenger.call).toHaveBeenCalledTimes(4); expect(options.messenger.call).not.toHaveBeenCalledWith( 'ExecutionService:handleRpcRequest', MOCK_SNAP_ID, diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 81afb51af7..4e17dc13cc 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -1,3 +1,4 @@ +import type { AnalyticsControllerTrackEventAction } from '@metamask/analytics-controller'; import { ORIGIN_METAMASK, type ApprovalControllerAddRequestAction, @@ -513,6 +514,7 @@ export type SnapControllerEvents = | SnapControllerStateChangeEvent; type AllowedActions = + | AnalyticsControllerTrackEventAction | PermissionControllerGetEndowmentsAction | PermissionControllerGetPermissionsAction | PermissionControllerGetSubjectNamesAction @@ -544,7 +546,10 @@ type AllowedActions = type AllowedEvents = | ExecutionServiceEvents + | SnapControllerSnapInstallStartedEvent + | SnapControllerSnapInstallFailedEvent | SnapControllerSnapInstalledEvent + | SnapControllerSnapUninstalledEvent | SnapControllerSnapUpdatedEvent | KeyringControllerLockEvent | SnapRegistryControllerRegistryUpdatedEvent; @@ -677,11 +682,6 @@ export type SnapControllerArgs = { */ clientCryptography?: CryptographicFunctions; - /** - * MetaMetrics event tracking hook. - */ - trackEvent: TrackEventHook; - /** * A hook that returns a promise that resolves when the onboarding has completed. * @@ -708,14 +708,6 @@ type SetSnapArgs = Omit & { hideSnapBranding?: boolean; }; -type TrackingEventPayload = { - event: string; - category: string; - properties: Record; -}; - -type TrackEventHook = (event: TrackingEventPayload) => void; - const defaultState: SnapControllerState = { snaps: {}, snapStates: {}, @@ -800,8 +792,6 @@ export class SnapController extends BaseController< readonly #preinstalledSnaps: PreinstalledSnap[] | null; - readonly #trackEvent: TrackEventHook; - readonly #trackSnapExport: ReturnType; readonly #ensureOnboardingComplete: () => Promise; @@ -829,7 +819,6 @@ export class SnapController extends BaseController< getMnemonicSeed, getFeatureFlags = () => ({}), clientCryptography, - trackEvent, ensureOnboardingComplete, }: SnapControllerArgs) { super({ @@ -917,7 +906,6 @@ export class SnapController extends BaseController< this._onOutboundResponse = this._onOutboundResponse.bind(this); this.#rollbackSnapshots = new Map(); this.#snapsRuntimeData = new Map(); - this.#trackEvent = trackEvent; this.#ensureOnboardingComplete = ensureOnboardingComplete; this.#pollForLastRequestStatus(); @@ -969,6 +957,142 @@ export class SnapController extends BaseController< }, ); + this.messenger.subscribe( + 'SnapController:snapInstallStarted', + (snapId, origin, isUpdate) => { + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snapId, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: isUpdate ? 'Snap Update Started' : 'Snap Install Started', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snapId, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }, + ); + + this.messenger.subscribe( + 'SnapController:snapInstallFailed', + (snapId, origin, isUpdate, error) => { + const isRejected = error.includes('User rejected the request.'); + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snapId, + ); + + // eslint-disable-next-line no-nested-ternary + const name = isUpdate + ? isRejected + ? 'Snap Update Rejected' + : 'Snap Update Failed' + : isRejected + ? 'Snap Install Rejected' + : 'Snap Install Failed'; + + this.messenger.call('AnalyticsController:trackEvent', { + name, + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snapId, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }, + ); + + this.messenger.subscribe( + 'SnapController:snapInstalled', + (snap, origin, preinstalled) => { + if (preinstalled) { + return; + } + + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Installed', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + version: snap.version, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }, + ); + + this.messenger.subscribe( + 'SnapController:snapUpdated', + (snap, oldVersion, origin, preinstalled) => { + if (preinstalled) { + return; + } + + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Updated', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + // eslint-disable-next-line @typescript-eslint/naming-convention + old_version: oldVersion, + // eslint-disable-next-line @typescript-eslint/naming-convention + new_version: snap.version, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }, + ); + + this.messenger.subscribe('SnapController:snapUninstalled', (snap) => { + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Uninstalled', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + version: snap.version, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }); + this.messenger.subscribe( 'KeyringController:lock', this.#handleLock.bind(this), @@ -997,18 +1121,21 @@ export class SnapController extends BaseController< 'SnapRegistryController:getMetadata', snapId, ); - this.#trackEvent({ - event: 'Snap Export Used', - category: 'Snaps', + + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Export Used', properties: { // eslint-disable-next-line @typescript-eslint/naming-convention snap_id: snapId, export: handler, // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: snapMetadata?.category, + snap_category: snapMetadata?.category ?? null, success, origin, }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, }); }, ); diff --git a/packages/snaps-controllers/src/test-utils/controller.tsx b/packages/snaps-controllers/src/test-utils/controller.tsx index 33b7942d63..8fbf53c449 100644 --- a/packages/snaps-controllers/src/test-utils/controller.tsx +++ b/packages/snaps-controllers/src/test-utils/controller.tsx @@ -460,6 +460,11 @@ export const getRootMessenger = () => { () => undefined, ); + messenger.registerActionHandler( + 'AnalyticsController:trackEvent', + () => undefined, + ); + jest.spyOn(messenger, 'call'); return messenger; @@ -503,6 +508,7 @@ export const getSnapControllerMessenger = ( 'StorageService:getItem', 'StorageService:removeItem', 'StorageService:clear', + 'AnalyticsController:trackEvent', ], events: [ 'ExecutionService:unhandledError', @@ -600,7 +606,6 @@ export const getSnapControllerOptions = ({ Promise.resolve(TEST_SECRET_RECOVERY_PHRASE_SEED_BYTES), clientCryptography: {}, encryptor: getSnapControllerEncryptor(), - trackEvent: jest.fn(), ensureOnboardingComplete: jest.fn().mockResolvedValue(undefined), ...opts, } as SnapControllerConstructorParamsWithStorage & { diff --git a/yarn.lock b/yarn.lock index f37621133c..3b9018760c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2805,6 +2805,19 @@ __metadata: languageName: node linkType: hard +"@metamask/analytics-controller@npm:^1.1.1": + version: 1.1.1 + resolution: "@metamask/analytics-controller@npm:1.1.1" + dependencies: + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/utils": "npm:^11.9.0" + lodash: "npm:^4.17.21" + uuid: "npm:^8.3.2" + checksum: 10/2a64ac0e397a27ac029a324646ebd91c8f9afa6ae2d1b5786042eaf5ad9b4b946c85b6d311ad75319562f8952649c54e80a9e8efb37879bef82ee1b5afe3513b + languageName: node + linkType: hard + "@metamask/api-specs@npm:^0.14.0": version: 0.14.0 resolution: "@metamask/api-specs@npm:0.14.0" @@ -4217,6 +4230,7 @@ __metadata: resolution: "@metamask/snaps-controllers@workspace:packages/snaps-controllers" dependencies: "@lavamoat/allow-scripts": "npm:^4.0.0" + "@metamask/analytics-controller": "npm:^1.1.1" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.1" "@metamask/base-controller": "npm:^9.1.0" From 906fb845ed0a9fb5c28b8348d6a39cdb5a6f7252 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 24 Jun 2026 14:21:15 +0200 Subject: [PATCH 2/6] Address PR review comments on analytics subscribers Add `preinstalled` flag to `snapInstallStarted` and `snapInstallFailed` event payloads so subscribers can guard against tracking OTA updates of preinstalled Snaps. Add try/catch to all analytics subscribers so exceptions from `AnalyticsController:trackEvent` cannot break install/update flows. --- .../src/snaps/SnapController.test.tsx | 132 +++++++++- .../src/snaps/SnapController.ts | 248 +++++++++++------- 2 files changed, 277 insertions(+), 103 deletions(-) diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index d64cc04ba6..8116fbb210 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -908,6 +908,7 @@ describe('SnapController', () => { MOCK_SNAP_ID, MOCK_ORIGIN, false, + false, ); expect(options.messenger.publish).toHaveBeenCalledWith( 'SnapController:snapInstalled', @@ -8828,6 +8829,7 @@ describe('SnapController', () => { MOCK_SNAP_ID, MOCK_ORIGIN, false, + false, ); expect(options.messenger.call).toHaveBeenCalledWith( @@ -8850,6 +8852,33 @@ describe('SnapController', () => { snapController.destroy(); }); + it('does not track `Snap Install Started` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ preinstalled: true }), + ), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallStarted', + MOCK_SNAP_ID, + MOCK_ORIGIN, + false, + true, + ); + + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Install Started' }), + ); + + snapController.destroy(); + }); + it('tracks `Snap Install Failed` when a Snap installation fails', async () => { const options = getSnapControllerOptions({ state: { @@ -8865,6 +8894,7 @@ describe('SnapController', () => { MOCK_ORIGIN, false, 'Installation failed.', + false, ); expect(options.messenger.call).toHaveBeenCalledWith( @@ -8902,6 +8932,7 @@ describe('SnapController', () => { MOCK_ORIGIN, false, 'User rejected the request.', + false, ); expect(options.messenger.call).toHaveBeenCalledWith( @@ -8924,6 +8955,34 @@ describe('SnapController', () => { snapController.destroy(); }); + it('does not track `Snap Install Failed` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ preinstalled: true }), + ), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + false, + 'Installation failed.', + true, + ); + + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Install Failed' }), + ); + + snapController.destroy(); + }); + it('tracks `Snap Installed` when a Snap is installed', async () => { const options = getSnapControllerOptions({ state: { @@ -9103,6 +9162,7 @@ describe('SnapController', () => { MOCK_SNAP_ID, MOCK_ORIGIN, true, + false, ); expect(newSnap?.version).toStrictEqual(snap.version); expect(onSnapUpdated).not.toHaveBeenCalled(); @@ -9112,6 +9172,7 @@ describe('SnapController', () => { MOCK_ORIGIN, true, errorMessage, + false, ); controller.destroy(); @@ -9258,6 +9319,7 @@ describe('SnapController', () => { MOCK_SNAP_ID, MOCK_ORIGIN, true, + false, ); expect(onSnapUpdated).toHaveBeenCalledTimes(1); expect(onSnapUpdated).toHaveBeenCalledWith( @@ -10334,6 +10396,7 @@ describe('SnapController', () => { MOCK_SNAP_ID, MOCK_ORIGIN, true, + false, ); expect(options.messenger.call).toHaveBeenCalledWith( @@ -10356,6 +10419,33 @@ describe('SnapController', () => { snapController.destroy(); }); + it('does not track `Snap Update Started` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ preinstalled: true }), + ), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallStarted', + MOCK_SNAP_ID, + MOCK_ORIGIN, + true, + true, + ); + + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Update Started' }), + ); + + snapController.destroy(); + }); + it('tracks `Snap Update Failed` when a Snap update fails', async () => { const options = getSnapControllerOptions({ state: { @@ -10371,6 +10461,7 @@ describe('SnapController', () => { MOCK_ORIGIN, true, 'Update failed.', + false, ); expect(options.messenger.call).toHaveBeenCalledWith( @@ -10408,6 +10499,7 @@ describe('SnapController', () => { MOCK_ORIGIN, true, 'User rejected the request.', + false, ); expect(options.messenger.call).toHaveBeenCalledWith( @@ -10495,6 +10587,34 @@ describe('SnapController', () => { snapController.destroy(); }); + + it('does not track `Snap Update Failed` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ preinstalled: true }), + ), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + true, + 'Update failed.', + true, + ); + + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Update Failed' }), + ); + + snapController.destroy(); + }); }); describe('removeSnap', () => { @@ -11177,7 +11297,7 @@ describe('SnapController', () => { expect(updatedSnap.preinstalled).toBe(true); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 7, 'StorageService:setItem', controllerName, snapId, @@ -11185,13 +11305,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 11, + 9, 'PermissionController:revokePermissions', { [snapId]: [SnapEndowments.Rpc, SnapEndowments.LifecycleHooks] }, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 12, + 10, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -11278,7 +11398,7 @@ describe('SnapController', () => { expect(updatedSnap.preinstalled).toBe(true); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 7, 'StorageService:setItem', controllerName, snapId, @@ -11286,13 +11406,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 11, + 9, 'PermissionController:revokePermissions', { [snapId]: [SnapEndowments.Rpc, SnapEndowments.LifecycleHooks] }, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 12, + 10, 'PermissionController:grantPermissions', { approvedPermissions: { diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 4e17dc13cc..834e44eb7c 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -403,7 +403,12 @@ export type SnapControllerSnapBlockedEvent = { */ export type SnapControllerSnapInstallStartedEvent = { type: `${typeof controllerName}:snapInstallStarted`; - payload: [snapId: SnapId, origin: string, isUpdate: boolean]; + payload: [ + snapId: SnapId, + origin: string, + isUpdate: boolean, + preinstalled: boolean, + ]; }; /** @@ -411,7 +416,13 @@ export type SnapControllerSnapInstallStartedEvent = { */ export type SnapControllerSnapInstallFailedEvent = { type: `${typeof controllerName}:snapInstallFailed`; - payload: [snapId: SnapId, origin: string, isUpdate: boolean, error: string]; + payload: [ + snapId: SnapId, + origin: string, + isUpdate: boolean, + error: string, + preinstalled: boolean, + ]; }; /** @@ -959,110 +970,165 @@ export class SnapController extends BaseController< this.messenger.subscribe( 'SnapController:snapInstallStarted', - (snapId, origin, isUpdate) => { - const snapMetadata = this.messenger.call( - 'SnapRegistryController:getMetadata', - snapId, - ); - this.messenger.call('AnalyticsController:trackEvent', { - name: isUpdate ? 'Snap Update Started' : 'Snap Install Started', - properties: { - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snapId, - origin, - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: snapMetadata?.category ?? null, - }, - sensitiveProperties: {}, - saveDataRecording: false, - hasProperties: true, - }); + (snapId, origin, isUpdate, preinstalled) => { + try { + if (preinstalled) { + return; + } + + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snapId, + ); + + this.messenger.call('AnalyticsController:trackEvent', { + name: isUpdate ? 'Snap Update Started' : 'Snap Install Started', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snapId, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + } catch (error) { + logError( + `Error tracking analytics for Snap "${snapId}": ${getErrorMessage(error)}`, + ); + } }, ); this.messenger.subscribe( 'SnapController:snapInstallFailed', - (snapId, origin, isUpdate, error) => { - const isRejected = error.includes('User rejected the request.'); - const snapMetadata = this.messenger.call( - 'SnapRegistryController:getMetadata', - snapId, - ); + (snapId, origin, isUpdate, error, preinstalled) => { + try { + if (preinstalled) { + return; + } - // eslint-disable-next-line no-nested-ternary - const name = isUpdate - ? isRejected - ? 'Snap Update Rejected' - : 'Snap Update Failed' - : isRejected - ? 'Snap Install Rejected' - : 'Snap Install Failed'; + const isRejected = error.includes('User rejected the request.'); + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snapId, + ); - this.messenger.call('AnalyticsController:trackEvent', { - name, - properties: { - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snapId, - origin, - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: snapMetadata?.category ?? null, - }, - sensitiveProperties: {}, - saveDataRecording: false, - hasProperties: true, - }); + // eslint-disable-next-line no-nested-ternary + const name = isUpdate + ? isRejected + ? 'Snap Update Rejected' + : 'Snap Update Failed' + : isRejected + ? 'Snap Install Rejected' + : 'Snap Install Failed'; + + this.messenger.call('AnalyticsController:trackEvent', { + name, + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snapId, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + } catch (trackError) { + logError( + `Error tracking analytics for Snap "${snapId}": ${getErrorMessage(trackError)}`, + ); + } }, ); this.messenger.subscribe( 'SnapController:snapInstalled', (snap, origin, preinstalled) => { - if (preinstalled) { - return; - } + try { + if (preinstalled) { + return; + } - const snapMetadata = this.messenger.call( - 'SnapRegistryController:getMetadata', - snap.id, - ); - this.messenger.call('AnalyticsController:trackEvent', { - name: 'Snap Installed', - properties: { - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snap.id, - version: snap.version, - origin, - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: snapMetadata?.category ?? null, - }, - sensitiveProperties: {}, - saveDataRecording: false, - hasProperties: true, - }); + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Installed', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + version: snap.version, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + } catch (error) { + logError( + `Error tracking analytics for Snap "${snap.id}": ${getErrorMessage(error)}`, + ); + } }, ); this.messenger.subscribe( 'SnapController:snapUpdated', (snap, oldVersion, origin, preinstalled) => { - if (preinstalled) { - return; + try { + if (preinstalled) { + return; + } + + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Updated', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + // eslint-disable-next-line @typescript-eslint/naming-convention + old_version: oldVersion, + // eslint-disable-next-line @typescript-eslint/naming-convention + new_version: snap.version, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + } catch (error) { + logError( + `Error tracking analytics for Snap "${snap.id}": ${getErrorMessage(error)}`, + ); } + }, + ); + this.messenger.subscribe('SnapController:snapUninstalled', (snap) => { + try { const snapMetadata = this.messenger.call( 'SnapRegistryController:getMetadata', snap.id, ); this.messenger.call('AnalyticsController:trackEvent', { - name: 'Snap Updated', + name: 'Snap Uninstalled', properties: { // eslint-disable-next-line @typescript-eslint/naming-convention snap_id: snap.id, - // eslint-disable-next-line @typescript-eslint/naming-convention - old_version: oldVersion, - // eslint-disable-next-line @typescript-eslint/naming-convention - new_version: snap.version, - origin, + version: snap.version, // eslint-disable-next-line @typescript-eslint/naming-convention snap_category: snapMetadata?.category ?? null, }, @@ -1070,27 +1136,11 @@ export class SnapController extends BaseController< saveDataRecording: false, hasProperties: true, }); - }, - ); - - this.messenger.subscribe('SnapController:snapUninstalled', (snap) => { - const snapMetadata = this.messenger.call( - 'SnapRegistryController:getMetadata', - snap.id, - ); - this.messenger.call('AnalyticsController:trackEvent', { - name: 'Snap Uninstalled', - properties: { - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snap.id, - version: snap.version, - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: snapMetadata?.category ?? null, - }, - sensitiveProperties: {}, - saveDataRecording: false, - hasProperties: true, - }); + } catch (error) { + logError( + `Error tracking analytics for Snap "${snap.id}": ${getErrorMessage(error)}`, + ); + } }); this.messenger.subscribe( @@ -2892,6 +2942,7 @@ export class SnapController extends BaseController< snapId, origin, false, + false, ); // Existing snaps must be stopped before overwriting @@ -2951,6 +3002,7 @@ export class SnapController extends BaseController< origin, false, errorString, + false, ); throw error; @@ -3060,6 +3112,7 @@ export class SnapController extends BaseController< snapId, origin, true, + preinstalled ?? false, ); const oldManifest = snap.manifest; @@ -3222,6 +3275,7 @@ export class SnapController extends BaseController< origin, true, errorString, + preinstalled ?? false, ); throw error; From c846f167e4aa91698f1ea2adb369f2d34c2c1897 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 24 Jun 2026 14:32:41 +0200 Subject: [PATCH 3/6] Merge duplicate `snapInstalled` and `snapUpdated` subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each event had two separate subscribers — one for lifecycle hooks and one for analytics. Combine them into a single subscriber per event. --- packages/snaps-controllers/coverage.json | 4 +- .../src/snaps/SnapController.ts | 132 ++++++++---------- 2 files changed, 63 insertions(+), 73 deletions(-) diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 428f6713f9..d3637a7234 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { "branches": 94.97, "functions": 98.78, - "lines": 98.63, - "statements": 98.32 + "lines": 98.45, + "statements": 98.18 } diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 834e44eb7c..343932dac8 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -940,52 +940,32 @@ export class SnapController extends BaseController< this.messenger.subscribe( 'SnapController:snapInstalled', - ({ id }, origin) => { - this.#callLifecycleHook(origin, id, HandlerType.OnInstall).catch( + (snap: TruncatedSnap, origin: string, preinstalled: boolean) => { + this.#callLifecycleHook(origin, snap.id, HandlerType.OnInstall).catch( (error) => { logError( - `Error when calling \`onInstall\` lifecycle hook for snap "${id}": ${getErrorMessage( + `Error when calling \`onInstall\` lifecycle hook for snap "${snap.id}": ${getErrorMessage( error, )}`, ); }, ); - }, - ); - this.messenger.subscribe( - 'SnapController:snapUpdated', - ({ id }, _oldVersion, origin) => { - this.#callLifecycleHook(origin, id, HandlerType.OnUpdate).catch( - (error) => { - logError( - `Error when calling \`onUpdate\` lifecycle hook for snap "${id}": ${getErrorMessage( - error, - )}`, - ); - }, - ); - }, - ); + if (preinstalled) { + return; + } - this.messenger.subscribe( - 'SnapController:snapInstallStarted', - (snapId, origin, isUpdate, preinstalled) => { try { - if (preinstalled) { - return; - } - const snapMetadata = this.messenger.call( 'SnapRegistryController:getMetadata', - snapId, + snap.id, ); - this.messenger.call('AnalyticsController:trackEvent', { - name: isUpdate ? 'Snap Update Started' : 'Snap Install Started', + name: 'Snap Installed', properties: { // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snapId, + snap_id: snap.id, + version: snap.version, origin, // eslint-disable-next-line @typescript-eslint/naming-convention snap_category: snapMetadata?.category ?? null, @@ -996,40 +976,43 @@ export class SnapController extends BaseController< }); } catch (error) { logError( - `Error tracking analytics for Snap "${snapId}": ${getErrorMessage(error)}`, + `Error tracking analytics for Snap "${snap.id}": ${getErrorMessage(error)}`, ); } }, ); this.messenger.subscribe( - 'SnapController:snapInstallFailed', - (snapId, origin, isUpdate, error, preinstalled) => { - try { - if (preinstalled) { - return; - } + 'SnapController:snapUpdated', + (snap, oldVersion, origin, preinstalled) => { + this.#callLifecycleHook(origin, snap.id, HandlerType.OnUpdate).catch( + (error) => { + logError( + `Error when calling \`onUpdate\` lifecycle hook for snap "${snap.id}": ${getErrorMessage( + error, + )}`, + ); + }, + ); - const isRejected = error.includes('User rejected the request.'); + if (preinstalled) { + return; + } + + try { const snapMetadata = this.messenger.call( 'SnapRegistryController:getMetadata', - snapId, + snap.id, ); - - // eslint-disable-next-line no-nested-ternary - const name = isUpdate - ? isRejected - ? 'Snap Update Rejected' - : 'Snap Update Failed' - : isRejected - ? 'Snap Install Rejected' - : 'Snap Install Failed'; - this.messenger.call('AnalyticsController:trackEvent', { - name, + name: 'Snap Updated', properties: { // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snapId, + snap_id: snap.id, + // eslint-disable-next-line @typescript-eslint/naming-convention + old_version: oldVersion, + // eslint-disable-next-line @typescript-eslint/naming-convention + new_version: snap.version, origin, // eslint-disable-next-line @typescript-eslint/naming-convention snap_category: snapMetadata?.category ?? null, @@ -1038,17 +1021,17 @@ export class SnapController extends BaseController< saveDataRecording: false, hasProperties: true, }); - } catch (trackError) { + } catch (error) { logError( - `Error tracking analytics for Snap "${snapId}": ${getErrorMessage(trackError)}`, + `Error tracking analytics for Snap "${snap.id}": ${getErrorMessage(error)}`, ); } }, ); this.messenger.subscribe( - 'SnapController:snapInstalled', - (snap, origin, preinstalled) => { + 'SnapController:snapInstallStarted', + (snapId, origin, isUpdate, preinstalled) => { try { if (preinstalled) { return; @@ -1056,14 +1039,14 @@ export class SnapController extends BaseController< const snapMetadata = this.messenger.call( 'SnapRegistryController:getMetadata', - snap.id, + snapId, ); + this.messenger.call('AnalyticsController:trackEvent', { - name: 'Snap Installed', + name: isUpdate ? 'Snap Update Started' : 'Snap Install Started', properties: { // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snap.id, - version: snap.version, + snap_id: snapId, origin, // eslint-disable-next-line @typescript-eslint/naming-convention snap_category: snapMetadata?.category ?? null, @@ -1074,33 +1057,40 @@ export class SnapController extends BaseController< }); } catch (error) { logError( - `Error tracking analytics for Snap "${snap.id}": ${getErrorMessage(error)}`, + `Error tracking analytics for Snap "${snapId}": ${getErrorMessage(error)}`, ); } }, ); this.messenger.subscribe( - 'SnapController:snapUpdated', - (snap, oldVersion, origin, preinstalled) => { + 'SnapController:snapInstallFailed', + (snapId, origin, isUpdate, error, preinstalled) => { try { if (preinstalled) { return; } + const isRejected = error.includes('User rejected the request.'); const snapMetadata = this.messenger.call( 'SnapRegistryController:getMetadata', - snap.id, + snapId, ); + + // eslint-disable-next-line no-nested-ternary + const name = isUpdate + ? isRejected + ? 'Snap Update Rejected' + : 'Snap Update Failed' + : isRejected + ? 'Snap Install Rejected' + : 'Snap Install Failed'; + this.messenger.call('AnalyticsController:trackEvent', { - name: 'Snap Updated', + name, properties: { // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snap.id, - // eslint-disable-next-line @typescript-eslint/naming-convention - old_version: oldVersion, - // eslint-disable-next-line @typescript-eslint/naming-convention - new_version: snap.version, + snap_id: snapId, origin, // eslint-disable-next-line @typescript-eslint/naming-convention snap_category: snapMetadata?.category ?? null, @@ -1109,9 +1099,9 @@ export class SnapController extends BaseController< saveDataRecording: false, hasProperties: true, }); - } catch (error) { + } catch (trackError) { logError( - `Error tracking analytics for Snap "${snap.id}": ${getErrorMessage(error)}`, + `Error tracking analytics for Snap "${snapId}": ${getErrorMessage(trackError)}`, ); } }, From d26a772c65426fa8413e091ccc08981e86230f86 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 24 Jun 2026 15:21:03 +0200 Subject: [PATCH 4/6] Remove try/catch from analytics subscribers Analytics errors should be swallowed silently without logging, matching the pattern used in MetaMask clients. --- .../src/snaps/SnapController.ts | 224 ++++++++---------- 1 file changed, 97 insertions(+), 127 deletions(-) diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 343932dac8..31ebca1a9e 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -955,30 +955,24 @@ export class SnapController extends BaseController< return; } - try { - const snapMetadata = this.messenger.call( - 'SnapRegistryController:getMetadata', - snap.id, - ); - this.messenger.call('AnalyticsController:trackEvent', { - name: 'Snap Installed', - properties: { - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snap.id, - version: snap.version, - origin, - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: snapMetadata?.category ?? null, - }, - sensitiveProperties: {}, - saveDataRecording: false, - hasProperties: true, - }); - } catch (error) { - logError( - `Error tracking analytics for Snap "${snap.id}": ${getErrorMessage(error)}`, - ); - } + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Installed', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + version: snap.version, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); }, ); @@ -999,126 +993,86 @@ export class SnapController extends BaseController< return; } - try { - const snapMetadata = this.messenger.call( - 'SnapRegistryController:getMetadata', - snap.id, - ); - this.messenger.call('AnalyticsController:trackEvent', { - name: 'Snap Updated', - properties: { - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snap.id, - // eslint-disable-next-line @typescript-eslint/naming-convention - old_version: oldVersion, - // eslint-disable-next-line @typescript-eslint/naming-convention - new_version: snap.version, - origin, - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: snapMetadata?.category ?? null, - }, - sensitiveProperties: {}, - saveDataRecording: false, - hasProperties: true, - }); - } catch (error) { - logError( - `Error tracking analytics for Snap "${snap.id}": ${getErrorMessage(error)}`, - ); - } + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Updated', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + // eslint-disable-next-line @typescript-eslint/naming-convention + old_version: oldVersion, + // eslint-disable-next-line @typescript-eslint/naming-convention + new_version: snap.version, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); }, ); this.messenger.subscribe( 'SnapController:snapInstallStarted', (snapId, origin, isUpdate, preinstalled) => { - try { - if (preinstalled) { - return; - } + if (preinstalled) { + return; + } - const snapMetadata = this.messenger.call( - 'SnapRegistryController:getMetadata', - snapId, - ); + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snapId, + ); - this.messenger.call('AnalyticsController:trackEvent', { - name: isUpdate ? 'Snap Update Started' : 'Snap Install Started', - properties: { - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snapId, - origin, - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: snapMetadata?.category ?? null, - }, - sensitiveProperties: {}, - saveDataRecording: false, - hasProperties: true, - }); - } catch (error) { - logError( - `Error tracking analytics for Snap "${snapId}": ${getErrorMessage(error)}`, - ); - } + this.messenger.call('AnalyticsController:trackEvent', { + name: isUpdate ? 'Snap Update Started' : 'Snap Install Started', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snapId, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); }, ); this.messenger.subscribe( 'SnapController:snapInstallFailed', (snapId, origin, isUpdate, error, preinstalled) => { - try { - if (preinstalled) { - return; - } - - const isRejected = error.includes('User rejected the request.'); - const snapMetadata = this.messenger.call( - 'SnapRegistryController:getMetadata', - snapId, - ); - - // eslint-disable-next-line no-nested-ternary - const name = isUpdate - ? isRejected - ? 'Snap Update Rejected' - : 'Snap Update Failed' - : isRejected - ? 'Snap Install Rejected' - : 'Snap Install Failed'; - - this.messenger.call('AnalyticsController:trackEvent', { - name, - properties: { - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snapId, - origin, - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: snapMetadata?.category ?? null, - }, - sensitiveProperties: {}, - saveDataRecording: false, - hasProperties: true, - }); - } catch (trackError) { - logError( - `Error tracking analytics for Snap "${snapId}": ${getErrorMessage(trackError)}`, - ); + if (preinstalled) { + return; } - }, - ); - this.messenger.subscribe('SnapController:snapUninstalled', (snap) => { - try { + const isRejected = error.includes('User rejected the request.'); const snapMetadata = this.messenger.call( 'SnapRegistryController:getMetadata', - snap.id, + snapId, ); + + // eslint-disable-next-line no-nested-ternary + const name = isUpdate + ? isRejected + ? 'Snap Update Rejected' + : 'Snap Update Failed' + : isRejected + ? 'Snap Install Rejected' + : 'Snap Install Failed'; + this.messenger.call('AnalyticsController:trackEvent', { - name: 'Snap Uninstalled', + name, properties: { // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: snap.id, - version: snap.version, + snap_id: snapId, + origin, // eslint-disable-next-line @typescript-eslint/naming-convention snap_category: snapMetadata?.category ?? null, }, @@ -1126,11 +1080,27 @@ export class SnapController extends BaseController< saveDataRecording: false, hasProperties: true, }); - } catch (error) { - logError( - `Error tracking analytics for Snap "${snap.id}": ${getErrorMessage(error)}`, - ); - } + }, + ); + + this.messenger.subscribe('SnapController:snapUninstalled', (snap) => { + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Uninstalled', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + version: snap.version, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); }); this.messenger.subscribe( From 7473727786baf2bc397541b8a660555abbd2338e Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 24 Jun 2026 16:16:37 +0200 Subject: [PATCH 5/6] Move "throws if the Snap source code is too large" test back --- .../src/snaps/SnapController.test.tsx | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 8116fbb210..15e6cd0405 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -8789,32 +8789,6 @@ describe('SnapController', () => { snapController.destroy(); }); - it('throws if the Snap source code is too large', async () => { - const { manifest, sourceCode, svgIcon, localizationFiles } = - await getMockSnapFilesWithUpdatedChecksum({ - sourceCode: 'a'.repeat(64_000_001), - }); - - const snapController = await getSnapController( - getSnapControllerOptions({ - detectSnapLocation: loopbackDetect({ - manifest, - files: [sourceCode, svgIcon as VirtualFile, ...localizationFiles], - }), - }), - ); - - await expect( - snapController.installSnaps(MOCK_ORIGIN, { - [MOCK_SNAP_ID]: {}, - }), - ).rejects.toThrow( - 'Failed to fetch snap "npm:@metamask/example-snap": Snap source code must be smaller than 64 MB..', - ); - - snapController.destroy(); - }); - it('tracks `Snap Install Started` when a Snap installation starts', async () => { const options = getSnapControllerOptions({ state: { @@ -9045,6 +9019,32 @@ describe('SnapController', () => { }); }); + it('throws if the Snap source code is too large', async () => { + const { manifest, sourceCode, svgIcon, localizationFiles } = + await getMockSnapFilesWithUpdatedChecksum({ + sourceCode: 'a'.repeat(64_000_001), + }); + + const snapController = await getSnapController( + getSnapControllerOptions({ + detectSnapLocation: loopbackDetect({ + manifest, + files: [sourceCode, svgIcon as VirtualFile, ...localizationFiles], + }), + }), + ); + + await expect( + snapController.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: {}, + }), + ).rejects.toThrow( + 'Failed to fetch snap "npm:@metamask/example-snap": Snap source code must be smaller than 64 MB..', + ); + + snapController.destroy(); + }); + describe('Updating Snaps', () => { it("throws an error if new version doesn't match version range", async () => { const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ From 1b9e38284ce56dd2db7621afd442ce10d271256e Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 25 Jun 2026 10:24:28 +0200 Subject: [PATCH 6/6] Update packages/snaps-controllers/src/snaps/SnapController.test.tsx Co-authored-by: Frederik Bolding --- packages/snaps-controllers/src/snaps/SnapController.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 15e6cd0405..e64c72663f 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -11967,7 +11967,7 @@ describe('SnapController', () => { snapController.destroy(); }); - it('should properly handle error when MetaMetrics hook throws an error', async () => { + it('should properly handle error when AnalyticsController throws an error', async () => { const log = jest.spyOn(console, 'error').mockImplementation(); const error = new Error('MetaMetrics hook error'); const rootMessenger = getRootMessenger();