Skip to content

Commit 5006f1a

Browse files
committed
Add Snap export handler usage tracking
Use registry for metadata Replace controller with hook approach
1 parent 9a467b6 commit 5006f1a

File tree

3 files changed

+218
-1
lines changed

3 files changed

+218
-1
lines changed

packages/snaps-controllers/src/snaps/SnapController.ts

+54
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,9 @@ import {
176176
hasTimedOut,
177177
permissionsDiff,
178178
setDiff,
179+
throttleTracking,
179180
withTimeout,
181+
isTrackableHandler,
180182
} from '../utils';
181183

182184
export const controllerName = 'SnapController';
@@ -775,6 +777,11 @@ type SnapControllerArgs = {
775777
* object to fall back to the default cryptographic functions.
776778
*/
777779
clientCryptography?: CryptographicFunctions;
780+
781+
/**
782+
* MetaMetrics event tracking hook.
783+
*/
784+
trackEvent: TrackEventHook;
778785
};
779786

780787
type AddSnapArgs = {
@@ -795,6 +802,14 @@ type SetSnapArgs = Omit<AddSnapArgs, 'location' | 'versionRange'> & {
795802
hideSnapBranding?: boolean;
796803
};
797804

805+
type TrackingEventPayload = {
806+
event: string;
807+
category: string;
808+
properties: Record<string, Json>;
809+
};
810+
811+
type TrackEventHook = (event: TrackingEventPayload) => void;
812+
798813
const defaultState: SnapControllerState = {
799814
snaps: {},
800815
snapStates: {},
@@ -880,6 +895,10 @@ export class SnapController extends BaseController<
880895

881896
readonly #preinstalledSnaps: PreinstalledSnap[] | null;
882897

898+
readonly #trackEvent: TrackEventHook;
899+
900+
readonly #trackSnapExport: ReturnType<typeof throttleTracking>;
901+
883902
constructor({
884903
closeAllConnections,
885904
messenger,
@@ -898,6 +917,7 @@ export class SnapController extends BaseController<
898917
getMnemonicSeed,
899918
getFeatureFlags = () => ({}),
900919
clientCryptography,
920+
trackEvent,
901921
}: SnapControllerArgs) {
902922
super({
903923
messenger,
@@ -960,6 +980,7 @@ export class SnapController extends BaseController<
960980
this._onOutboundResponse = this._onOutboundResponse.bind(this);
961981
this.#rollbackSnapshots = new Map();
962982
this.#snapsRuntimeData = new Map();
983+
this.#trackEvent = trackEvent;
963984

964985
this.#pollForLastRequestStatus();
965986

@@ -1025,6 +1046,30 @@ export class SnapController extends BaseController<
10251046
Object.values(this.state?.snaps ?? {}).forEach((snap) =>
10261047
this.#setupRuntime(snap.id),
10271048
);
1049+
1050+
this.#trackSnapExport = throttleTracking(
1051+
async (
1052+
snapId: SnapId,
1053+
handler: string,
1054+
success: boolean,
1055+
origin: string,
1056+
) => {
1057+
const snapMetadata = await this.getRegistryMetadata(snapId);
1058+
this.#trackEvent({
1059+
event: 'SnapExportUsed',
1060+
category: 'Snaps',
1061+
properties: {
1062+
// eslint-disable-next-line @typescript-eslint/naming-convention
1063+
snap_id: snapId,
1064+
export: handler,
1065+
// eslint-disable-next-line @typescript-eslint/naming-convention
1066+
snap_category: snapMetadata?.category ?? null,
1067+
success,
1068+
origin,
1069+
},
1070+
});
1071+
},
1072+
);
10281073
}
10291074

10301075
/**
@@ -3584,10 +3629,19 @@ export class SnapController extends BaseController<
35843629

35853630
this.#recordSnapRpcRequestFinish(snapId, transformedRequest.id);
35863631

3632+
if (isTrackableHandler(handlerType)) {
3633+
await this.#trackSnapExport(snapId, handlerType, true, origin);
3634+
}
3635+
35873636
return transformedResult;
35883637
} catch (error) {
35893638
// We flag the RPC request as finished early since termination may affect pending requests
35903639
this.#recordSnapRpcRequestFinish(snapId, transformedRequest.id);
3640+
3641+
if (isTrackableHandler(handlerType)) {
3642+
await this.#trackSnapExport(snapId, handlerType, false, origin);
3643+
}
3644+
35913645
const [jsonRpcError, handled] = unwrapError(error);
35923646

35933647
if (!handled) {

packages/snaps-controllers/src/utils.test.ts

+98-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { VirtualFile } from '@metamask/snaps-utils';
1+
import { HandlerType, VirtualFile } from '@metamask/snaps-utils';
22
import {
33
getMockSnapFiles,
44
getSnapManifest,
@@ -20,6 +20,7 @@ import {
2020
getSnapFiles,
2121
permissionsDiff,
2222
setDiff,
23+
throttleTracking,
2324
} from './utils';
2425
import { SnapEndowments } from '../../snaps-rpc-methods/src/endowments';
2526

@@ -221,3 +222,99 @@ describe('debouncePersistState', () => {
221222
expect(fn).toHaveBeenNthCalledWith(4, MOCK_LOCAL_SNAP_ID, {}, false);
222223
});
223224
});
225+
226+
describe('throttleTracking', () => {
227+
beforeAll(() => {
228+
jest.useFakeTimers();
229+
});
230+
231+
afterAll(() => {
232+
jest.useRealTimers();
233+
});
234+
235+
it('throttles tracking calls based on unique combinations of snapId, handler, and origin', async () => {
236+
const fn = jest.fn();
237+
const throttled = throttleTracking(fn, 1000);
238+
239+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
240+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
241+
await throttled(MOCK_SNAP_ID, HandlerType.OnRpcRequest, true, 'origin1');
242+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin2');
243+
244+
expect(fn).toHaveBeenCalledTimes(3);
245+
expect(fn).toHaveBeenNthCalledWith(
246+
1,
247+
MOCK_SNAP_ID,
248+
HandlerType.OnHomePage,
249+
true,
250+
'origin1',
251+
);
252+
expect(fn).toHaveBeenNthCalledWith(
253+
2,
254+
MOCK_SNAP_ID,
255+
HandlerType.OnRpcRequest,
256+
true,
257+
'origin1',
258+
);
259+
expect(fn).toHaveBeenNthCalledWith(
260+
3,
261+
MOCK_SNAP_ID,
262+
HandlerType.OnHomePage,
263+
true,
264+
'origin2',
265+
);
266+
267+
jest.advanceTimersByTime(500);
268+
269+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
270+
await throttled(MOCK_SNAP_ID, HandlerType.OnRpcRequest, true, 'origin1');
271+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin2');
272+
273+
expect(fn).toHaveBeenCalledTimes(3);
274+
275+
jest.advanceTimersByTime(600);
276+
277+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
278+
await throttled(MOCK_SNAP_ID, HandlerType.OnRpcRequest, true, 'origin1');
279+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin2');
280+
281+
expect(fn).toHaveBeenCalledTimes(6);
282+
expect(fn).toHaveBeenNthCalledWith(
283+
4,
284+
MOCK_SNAP_ID,
285+
HandlerType.OnHomePage,
286+
true,
287+
'origin1',
288+
);
289+
expect(fn).toHaveBeenNthCalledWith(
290+
5,
291+
MOCK_SNAP_ID,
292+
HandlerType.OnRpcRequest,
293+
true,
294+
'origin1',
295+
);
296+
expect(fn).toHaveBeenNthCalledWith(
297+
6,
298+
MOCK_SNAP_ID,
299+
HandlerType.OnHomePage,
300+
true,
301+
'origin2',
302+
);
303+
});
304+
305+
it('uses default timeout of 60000ms when no timeout is specified', async () => {
306+
const fn = jest.fn();
307+
const throttled = throttleTracking(fn);
308+
309+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
310+
expect(fn).toHaveBeenCalledTimes(1);
311+
312+
jest.advanceTimersByTime(59999);
313+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
314+
expect(fn).toHaveBeenCalledTimes(1);
315+
316+
jest.advanceTimersByTime(2);
317+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
318+
expect(fn).toHaveBeenCalledTimes(2);
319+
});
320+
});

packages/snaps-controllers/src/utils.ts

+66
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getValidatedLocalizationFiles,
88
validateAuxiliaryFiles,
99
validateFetchedSnap,
10+
HandlerType,
1011
} from '@metamask/snaps-utils';
1112
import type { Json } from '@metamask/utils';
1213
import deepEqual from 'fast-deep-equal';
@@ -375,3 +376,68 @@ export function debouncePersistState(
375376
);
376377
};
377378
}
379+
380+
/**
381+
* Handlers allowed for tracking.
382+
*/
383+
export const TRACKABLE_HANDLERS = [
384+
HandlerType.OnHomePage,
385+
HandlerType.OnInstall,
386+
HandlerType.OnNameLookup,
387+
HandlerType.OnRpcRequest,
388+
HandlerType.OnSignature,
389+
HandlerType.OnTransaction,
390+
HandlerType.OnUpdate,
391+
] as const;
392+
393+
/**
394+
* A union type representing all possible trackable handler types.
395+
*/
396+
export type TrackableHandler = (typeof TRACKABLE_HANDLERS)[number];
397+
398+
/**
399+
* Throttles event tracking calls per unique combination of parameters.
400+
*
401+
* @param fn - The tracking function to throttle.
402+
* @param timeout - The timeout in milliseconds. Defaults to 60000 (1 minute).
403+
* @returns The throttled function.
404+
*/
405+
export function throttleTracking(
406+
fn: (
407+
snapId: SnapId,
408+
handler: TrackableHandler,
409+
success: boolean,
410+
origin: string,
411+
) => Promise<void>,
412+
timeout = 60000,
413+
) {
414+
const previousCalls = new Map<string, number>();
415+
416+
return async (
417+
snapId: SnapId,
418+
handler: TrackableHandler,
419+
success: boolean,
420+
origin: string,
421+
): Promise<void> => {
422+
const key = `${snapId}${handler}${origin}`;
423+
const now = Date.now();
424+
const lastCall = previousCalls.get(key) ?? 0;
425+
426+
if (now - lastCall >= timeout) {
427+
previousCalls.set(key, now);
428+
await fn(snapId, handler, success, origin);
429+
}
430+
};
431+
}
432+
433+
/**
434+
* Whether the handler type if allowed for tracking.
435+
*
436+
* @param handler Type of a handler.
437+
* @returns True if handler is allowed for tracking, false otherwise.
438+
*/
439+
export function isTrackableHandler(
440+
handler: HandlerType,
441+
): handler is TrackableHandler {
442+
return TRACKABLE_HANDLERS.includes(handler as TrackableHandler);
443+
}

0 commit comments

Comments
 (0)