From f52bc142b6c258db74c833337ed586f7f91b70bd Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 25 Jun 2026 13:59:01 +0200 Subject: [PATCH 1/2] Track analytics for OTA Snap updates Emits `SnapController:snapUpdated` after automatic (OTA) updates of preinstalled Snaps. Adds `ota`, `client_version`, and `client_type` properties to the `Snap Updated` analytics event, and adds `clientConfig` to `SnapControllerArgs` so the controller has access to the client type and version. --- .../src/cronjob/CronjobController.test.ts | 1 + .../src/snaps/SnapController.test.tsx | 93 ++++++++++++++++++- .../src/snaps/SnapController.ts | 36 +++++-- .../src/snaps/registry/index.ts | 1 + .../src/test-utils/controller.tsx | 5 + .../src/websocket/WebSocketService.test.ts | 1 + 6 files changed, 127 insertions(+), 10 deletions(-) diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts index 8389151178..3ec745717c 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts @@ -779,6 +779,7 @@ describe('CronjobController', () => { snapInfo.version, MOCK_ORIGIN, false, + false, ); expect(cronjobController.state.events).toStrictEqual({ diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index e64c72663f..c6e9ff0932 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -6651,6 +6651,7 @@ describe('SnapController', () => { '1.0.0', METAMASK_ORIGIN, true, + false, ); const result = await snapController.handleRequest({ @@ -9327,6 +9328,7 @@ describe('SnapController', () => { '1.0.0', MOCK_ORIGIN, false, + false, ); controller.destroy(); @@ -10537,23 +10539,25 @@ describe('SnapController', () => { '0.9.0', MOCK_ORIGIN, false, + false, ); expect(options.messenger.call).toHaveBeenCalledWith( 'AnalyticsController:trackEvent', { name: 'Snap Updated', + /* eslint-disable @typescript-eslint/naming-convention */ 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, + ota: false, + client_version: '1.0.0', + client_type: 'extension', }, + /* eslint-enable @typescript-eslint/naming-convention */ sensitiveProperties: {}, saveDataRecording: false, hasProperties: true, @@ -10578,6 +10582,7 @@ describe('SnapController', () => { '0.9.0', MOCK_ORIGIN, true, + false, ); expect(options.messenger.call).not.toHaveBeenCalledWith( @@ -11330,6 +11335,84 @@ describe('SnapController', () => { snapController.destroy(); }); + it('tracks `Snap Updated` with `ota: true` when a preinstalled Snap is OTA-updated', async () => { + const rootMessenger = getRootMessenger(); + const registry = new MockSnapRegistryController(rootMessenger); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({ + [SnapEndowments.Rpc]: MOCK_RPC_ORIGINS_PERMISSION, + [SnapEndowments.LifecycleHooks]: MOCK_LIFECYCLE_HOOKS_PERMISSION, + }), + ); + + const snapId = 'npm:@metamask/jsx-example-snap' as SnapId; + + const mockSnap = getPersistedSnapObject({ + id: snapId, + preinstalled: true, + }); + + const updateVersion = '1.2.1'; + + registry.resolveVersion.mockResolvedValue(updateVersion); + const fetchFunction = jest.fn().mockResolvedValueOnce({ + // eslint-disable-next-line no-restricted-globals + headers: new Headers({ 'content-length': '5477' }), + ok: true, + body: Readable.toWeb( + createReadStream( + path.resolve( + __dirname, + `../../test/fixtures/metamask-jsx-example-snap-${updateVersion}.tgz`, + ), + ), + ), + }); + + const options = getSnapControllerOptions({ + rootMessenger, + state: { + snaps: getPersistedSnapsState(mockSnap), + }, + fetchFunction, + featureFlags: { + autoUpdatePreinstalledSnaps: true, + }, + }); + + const snapController = await getSnapController(options); + + await snapController.updateRegistry(); + await waitForStateChange(options.messenger); + await sleep(100); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Updated', + /* eslint-disable @typescript-eslint/naming-convention */ + properties: { + snap_id: snapId, + old_version: mockSnap.version, + new_version: updateVersion, + origin: METAMASK_ORIGIN, + snap_category: null, + ota: true, + client_version: '1.0.0', + client_type: 'extension', + }, + /* eslint-enable @typescript-eslint/naming-convention */ + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + it('retries updating preinstalled Snaps', async () => { const rootMessenger = getRootMessenger(); const registry = new MockSnapRegistryController(rootMessenger); @@ -13437,6 +13520,7 @@ describe('SnapController', () => { '0.9.0', MOCK_ORIGIN, false, + false, ); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -13544,6 +13628,7 @@ describe('SnapController', () => { '0.9.0', MOCK_ORIGIN, false, + false, ); await new Promise((resolve) => setTimeout(resolve, 10)); diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 31ebca1a9e..2c5b71bf57 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -162,6 +162,7 @@ import { import type { SnapLocation } from './location'; import { detectSnapLocation } from './location'; import type { + ClientConfig, SnapRegistryControllerGetAction, SnapRegistryControllerGetMetadataAction, SnapRegistryControllerResolveVersionAction, @@ -460,6 +461,7 @@ export type SnapControllerSnapUpdatedEvent = { oldVersion: string, origin: string, preinstalled: boolean, + ota: boolean, ]; }; @@ -699,6 +701,11 @@ export type SnapControllerArgs = { * @returns A promise that resolves when onboarding is complete. */ ensureOnboardingComplete: () => Promise; + + /** + * The client configuration, containing the client type and version. + */ + clientConfig: ClientConfig; }; type AddSnapArgs = { @@ -787,6 +794,8 @@ export class SnapController extends BaseController< readonly #clientCryptography: CryptographicFunctions | undefined; + readonly #clientConfig: ClientConfig; + readonly #detectSnapLocation: typeof detectSnapLocation; readonly #snapsRuntimeData: Map; @@ -831,6 +840,7 @@ export class SnapController extends BaseController< getFeatureFlags = () => ({}), clientCryptography, ensureOnboardingComplete, + clientConfig, }: SnapControllerArgs) { super({ messenger, @@ -911,6 +921,7 @@ export class SnapController extends BaseController< this.#getMnemonicSeed = getMnemonicSeed; this.#getFeatureFlags = getFeatureFlags; this.#clientCryptography = clientCryptography; + this.#clientConfig = clientConfig; this.#preinstalledSnaps = preinstalledSnaps; this._onUnhandledSnapError = this._onUnhandledSnapError.bind(this); this._onOutboundRequest = this._onOutboundRequest.bind(this); @@ -978,7 +989,7 @@ export class SnapController extends BaseController< this.messenger.subscribe( 'SnapController:snapUpdated', - (snap, oldVersion, origin, preinstalled) => { + (snap, oldVersion, origin, preinstalled, ota) => { this.#callLifecycleHook(origin, snap.id, HandlerType.OnUpdate).catch( (error) => { logError( @@ -989,7 +1000,7 @@ export class SnapController extends BaseController< }, ); - if (preinstalled) { + if (preinstalled && !ota) { return; } @@ -999,17 +1010,18 @@ export class SnapController extends BaseController< ); this.messenger.call('AnalyticsController:trackEvent', { name: 'Snap Updated', + /* eslint-disable @typescript-eslint/naming-convention */ 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, + ota, + client_version: this.#clientConfig.version, + client_type: this.#clientConfig.type, }, + /* eslint-enable @typescript-eslint/naming-convention */ sensitiveProperties: {}, saveDataRecording: false, hasProperties: true, @@ -1485,6 +1497,7 @@ export class SnapController extends BaseController< existingSnap.version, METAMASK_ORIGIN, true, + false, ); } else if (!isMissingSource) { this.messenger.publish( @@ -1641,6 +1654,7 @@ export class SnapController extends BaseController< resolvedVersion !== preinstalledVersionRange && gtVersion(resolvedVersion as unknown as SemVerVersion, snap.version) ) { + const oldVersion = snap.version; const location = this.#detectSnapLocation(snap.id, { versionRange: resolvedVersion, fetch: this.#fetchFunction, @@ -1655,6 +1669,15 @@ export class SnapController extends BaseController< versionRange: resolvedVersion, automaticUpdate: true, }); + + this.messenger.publish( + 'SnapController:snapUpdated', + this.#getTruncatedSnapExpect(snap.id), + oldVersion, + ORIGIN_METAMASK, + true, + true, + ); } }), ); @@ -2836,6 +2859,7 @@ export class SnapController extends BaseController< oldVersion, origin, false, + false, ), ); diff --git a/packages/snaps-controllers/src/snaps/registry/index.ts b/packages/snaps-controllers/src/snaps/registry/index.ts index 4fb3ca5cc6..18b612ce29 100644 --- a/packages/snaps-controllers/src/snaps/registry/index.ts +++ b/packages/snaps-controllers/src/snaps/registry/index.ts @@ -1,4 +1,5 @@ export type { + ClientConfig, SnapRegistryControllerActions, SnapRegistryControllerEvents, SnapRegistryControllerArgs, diff --git a/packages/snaps-controllers/src/test-utils/controller.tsx b/packages/snaps-controllers/src/test-utils/controller.tsx index 8fbf53c449..7cf2054804 100644 --- a/packages/snaps-controllers/src/test-utils/controller.tsx +++ b/packages/snaps-controllers/src/test-utils/controller.tsx @@ -64,6 +64,7 @@ import type { MultichainRoutingServiceMessenger } from '../multichain/Multichain import type { ExecutionService, ExecutionServiceMessenger } from '../services'; import type { SnapRegistryControllerMessenger } from '../snaps'; import { SnapController } from '../snaps'; +import type { ClientConfig } from '../snaps/registry'; import type { PersistedSnapControllerState, SnapControllerMessenger, @@ -607,6 +608,10 @@ export const getSnapControllerOptions = ({ clientCryptography: {}, encryptor: getSnapControllerEncryptor(), ensureOnboardingComplete: jest.fn().mockResolvedValue(undefined), + clientConfig: { + type: 'extension', + version: '1.0.0', + } as ClientConfig, ...opts, } as SnapControllerConstructorParamsWithStorage & { rootMessenger: ReturnType; diff --git a/packages/snaps-controllers/src/websocket/WebSocketService.test.ts b/packages/snaps-controllers/src/websocket/WebSocketService.test.ts index 6af27e29a3..72f6b575ad 100644 --- a/packages/snaps-controllers/src/websocket/WebSocketService.test.ts +++ b/packages/snaps-controllers/src/websocket/WebSocketService.test.ts @@ -488,6 +488,7 @@ describe('WebSocketService', () => { '1.0.0', MOCK_ORIGIN, false, + false, ); expect( From 7ea3aad1c3bc505ac918f8c436b5d971e6017e48 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 25 Jun 2026 14:33:07 +0200 Subject: [PATCH 2/2] Narrow type cast --- packages/snaps-controllers/src/test-utils/controller.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/snaps-controllers/src/test-utils/controller.tsx b/packages/snaps-controllers/src/test-utils/controller.tsx index 7cf2054804..eb1d127487 100644 --- a/packages/snaps-controllers/src/test-utils/controller.tsx +++ b/packages/snaps-controllers/src/test-utils/controller.tsx @@ -49,7 +49,7 @@ import { InMemoryStorageAdapter, StorageService, } from '@metamask/storage-service'; -import type { Json } from '@metamask/utils'; +import type { Json, SemVerVersion } from '@metamask/utils'; import { MOCK_CRONJOB_PERMISSION } from './cronjob'; import { getNodeEES, getNodeEESMessenger } from './execution-environment'; @@ -64,7 +64,6 @@ import type { MultichainRoutingServiceMessenger } from '../multichain/Multichain import type { ExecutionService, ExecutionServiceMessenger } from '../services'; import type { SnapRegistryControllerMessenger } from '../snaps'; import { SnapController } from '../snaps'; -import type { ClientConfig } from '../snaps/registry'; import type { PersistedSnapControllerState, SnapControllerMessenger, @@ -610,8 +609,8 @@ export const getSnapControllerOptions = ({ ensureOnboardingComplete: jest.fn().mockResolvedValue(undefined), clientConfig: { type: 'extension', - version: '1.0.0', - } as ClientConfig, + version: '1.0.0' as SemVerVersion, + }, ...opts, } as SnapControllerConstructorParamsWithStorage & { rootMessenger: ReturnType;