Skip to content

Commit 1aeb847

Browse files
committed
Add Snap export handler usage tracking
Use registry for metadata Replace controller with hook approach Fix broken unit tests Add unit tests
1 parent 88142f9 commit 1aeb847

File tree

5 files changed

+349
-2
lines changed

5 files changed

+349
-2
lines changed

packages/snaps-controllers/src/snaps/SnapController.test.tsx

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4702,7 +4702,7 @@ describe('SnapController', () => {
47024702
},
47034703
});
47044704

4705-
expect(rootMessenger.call).toHaveBeenCalledTimes(4);
4705+
expect(rootMessenger.call).toHaveBeenCalledTimes(5);
47064706
expect(rootMessenger.call).toHaveBeenCalledWith(
47074707
'ExecutionService:handleRpcRequest',
47084708
MOCK_SNAP_ID,
@@ -11061,4 +11061,108 @@ describe('SnapController', () => {
1106111061
snapController.destroy();
1106211062
});
1106311063
});
11064+
11065+
describe('SnapController:trackEvent', () => {
11066+
it('should track event for allowed handler', async () => {
11067+
const mockTrackEvent = jest.fn();
11068+
const rootMessenger = getControllerMessenger();
11069+
const executionEnvironmentStub = new ExecutionEnvironmentStub(
11070+
getNodeEESMessenger(rootMessenger),
11071+
) as unknown as NodeThreadExecutionService;
11072+
11073+
const [snapController] = getSnapControllerWithEES(
11074+
getSnapControllerWithEESOptions({
11075+
rootMessenger,
11076+
trackEvent: mockTrackEvent,
11077+
state: {
11078+
snaps: getPersistedSnapsState(),
11079+
},
11080+
}),
11081+
executionEnvironmentStub,
11082+
);
11083+
11084+
const snap = snapController.getExpect(MOCK_SNAP_ID);
11085+
await snapController.startSnap(snap.id);
11086+
11087+
await snapController.handleRequest({
11088+
snapId: snap.id,
11089+
origin: MOCK_ORIGIN,
11090+
handler: HandlerType.OnRpcRequest,
11091+
request: {
11092+
jsonrpc: '2.0',
11093+
method: 'test',
11094+
params: {},
11095+
id: 1,
11096+
},
11097+
});
11098+
11099+
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
11100+
expect(mockTrackEvent).toHaveBeenCalledWith({
11101+
category: 'Snaps',
11102+
event: 'SnapExportUsed',
11103+
properties: {
11104+
export: 'onRpcRequest',
11105+
origin: 'https://example.com',
11106+
// eslint-disable-next-line @typescript-eslint/naming-convention
11107+
snap_category: null,
11108+
// eslint-disable-next-line @typescript-eslint/naming-convention
11109+
snap_id: 'npm:@metamask/example-snap',
11110+
success: true,
11111+
},
11112+
});
11113+
snapController.destroy();
11114+
});
11115+
11116+
it('should not track event for disallowed handler', async () => {
11117+
const mockTrackEvent = jest.fn();
11118+
const rootMessenger = getControllerMessenger();
11119+
11120+
rootMessenger.registerActionHandler(
11121+
'PermissionController:getPermissions',
11122+
() => ({
11123+
[SnapEndowments.Cronjob]: {
11124+
caveats: [{ type: SnapCaveatType.SnapCronjob, value: '* * * * *' }],
11125+
date: 1664187844588,
11126+
id: 'izn0WGUO8cvq_jqvLQuQP',
11127+
invoker: MOCK_SNAP_ID,
11128+
parentCapability: SnapEndowments.Cronjob,
11129+
},
11130+
}),
11131+
);
11132+
11133+
const executionEnvironmentStub = new ExecutionEnvironmentStub(
11134+
getNodeEESMessenger(rootMessenger),
11135+
) as unknown as NodeThreadExecutionService;
11136+
11137+
const [snapController] = getSnapControllerWithEES(
11138+
getSnapControllerWithEESOptions({
11139+
environmentEndowmentPermissions: ['endowment:cronjob'],
11140+
rootMessenger,
11141+
trackEvent: mockTrackEvent,
11142+
state: {
11143+
snaps: getPersistedSnapsState(),
11144+
},
11145+
}),
11146+
executionEnvironmentStub,
11147+
);
11148+
11149+
const snap = snapController.getExpect(MOCK_SNAP_ID);
11150+
await snapController.startSnap(snap.id);
11151+
11152+
await snapController.handleRequest({
11153+
snapId: snap.id,
11154+
origin: MOCK_ORIGIN,
11155+
handler: HandlerType.OnCronjob,
11156+
request: {
11157+
jsonrpc: '2.0',
11158+
method: 'test',
11159+
params: {},
11160+
id: 1,
11161+
},
11162+
});
11163+
11164+
expect(mockTrackEvent).not.toHaveBeenCalled();
11165+
snapController.destroy();
11166+
});
11167+
});
1106411168
});

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

Lines changed: 54 additions & 0 deletions
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/test-utils/controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ export const getSnapControllerOptions = (
593593
Promise.resolve(TEST_SECRET_RECOVERY_PHRASE_SEED_BYTES),
594594
clientCryptography: {},
595595
encryptor: getSnapControllerEncryptor(),
596+
trackEvent: jest.fn(),
596597
...opts,
597598
} as SnapControllerConstructorParams;
598599

@@ -626,6 +627,7 @@ export const getSnapControllerWithEESOptions = ({
626627
Promise.resolve(TEST_SECRET_RECOVERY_PHRASE_SEED_BYTES),
627628
encryptor: getSnapControllerEncryptor(),
628629
fetchFunction: jest.fn(),
630+
trackEvent: jest.fn(),
629631
...options,
630632
} as SnapControllerConstructorParams & {
631633
rootMessenger: ReturnType<typeof getControllerMessenger>;

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

Lines changed: 122 additions & 1 deletion
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,8 @@ import {
2020
getSnapFiles,
2121
permissionsDiff,
2222
setDiff,
23+
throttleTracking,
24+
TRACKABLE_HANDLERS,
2325
} from './utils';
2426
import { SnapEndowments } from '../../snaps-rpc-methods/src/endowments';
2527

@@ -221,3 +223,122 @@ describe('debouncePersistState', () => {
221223
expect(fn).toHaveBeenNthCalledWith(4, MOCK_LOCAL_SNAP_ID, {}, false);
222224
});
223225
});
226+
227+
describe('TRACKABLE_HANDLERS', () => {
228+
it('should contain the expected handler types', () => {
229+
expect(TRACKABLE_HANDLERS).toStrictEqual([
230+
HandlerType.OnHomePage,
231+
HandlerType.OnInstall,
232+
HandlerType.OnNameLookup,
233+
HandlerType.OnRpcRequest,
234+
HandlerType.OnSignature,
235+
HandlerType.OnTransaction,
236+
HandlerType.OnUpdate,
237+
]);
238+
});
239+
240+
it('should be a readonly array', () => {
241+
expect(Object.isFrozen(TRACKABLE_HANDLERS)).toBe(true);
242+
});
243+
244+
it('should contain unique values', () => {
245+
const uniqueValues = new Set(TRACKABLE_HANDLERS);
246+
expect(uniqueValues.size).toBe(TRACKABLE_HANDLERS.length);
247+
});
248+
});
249+
250+
describe('throttleTracking', () => {
251+
beforeAll(() => {
252+
jest.useFakeTimers();
253+
});
254+
255+
afterAll(() => {
256+
jest.useRealTimers();
257+
});
258+
259+
it('throttles tracking calls based on unique combinations of snapId, handler, and origin', async () => {
260+
const fn = jest.fn();
261+
const throttled = throttleTracking(fn, 1000);
262+
263+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
264+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
265+
await throttled(MOCK_SNAP_ID, HandlerType.OnRpcRequest, true, 'origin1');
266+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin2');
267+
268+
expect(fn).toHaveBeenCalledTimes(3);
269+
expect(fn).toHaveBeenNthCalledWith(
270+
1,
271+
MOCK_SNAP_ID,
272+
HandlerType.OnHomePage,
273+
true,
274+
'origin1',
275+
);
276+
expect(fn).toHaveBeenNthCalledWith(
277+
2,
278+
MOCK_SNAP_ID,
279+
HandlerType.OnRpcRequest,
280+
true,
281+
'origin1',
282+
);
283+
expect(fn).toHaveBeenNthCalledWith(
284+
3,
285+
MOCK_SNAP_ID,
286+
HandlerType.OnHomePage,
287+
true,
288+
'origin2',
289+
);
290+
291+
jest.advanceTimersByTime(500);
292+
293+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
294+
await throttled(MOCK_SNAP_ID, HandlerType.OnRpcRequest, true, 'origin1');
295+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin2');
296+
297+
expect(fn).toHaveBeenCalledTimes(3);
298+
299+
jest.advanceTimersByTime(600);
300+
301+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
302+
await throttled(MOCK_SNAP_ID, HandlerType.OnRpcRequest, true, 'origin1');
303+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin2');
304+
305+
expect(fn).toHaveBeenCalledTimes(6);
306+
expect(fn).toHaveBeenNthCalledWith(
307+
4,
308+
MOCK_SNAP_ID,
309+
HandlerType.OnHomePage,
310+
true,
311+
'origin1',
312+
);
313+
expect(fn).toHaveBeenNthCalledWith(
314+
5,
315+
MOCK_SNAP_ID,
316+
HandlerType.OnRpcRequest,
317+
true,
318+
'origin1',
319+
);
320+
expect(fn).toHaveBeenNthCalledWith(
321+
6,
322+
MOCK_SNAP_ID,
323+
HandlerType.OnHomePage,
324+
true,
325+
'origin2',
326+
);
327+
});
328+
329+
it('uses default timeout of 60000ms when no timeout is specified', async () => {
330+
const fn = jest.fn();
331+
const throttled = throttleTracking(fn);
332+
333+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
334+
expect(fn).toHaveBeenCalledTimes(1);
335+
336+
jest.advanceTimersByTime(59999);
337+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
338+
expect(fn).toHaveBeenCalledTimes(1);
339+
340+
jest.advanceTimersByTime(2);
341+
await throttled(MOCK_SNAP_ID, HandlerType.OnHomePage, true, 'origin1');
342+
expect(fn).toHaveBeenCalledTimes(2);
343+
});
344+
});

0 commit comments

Comments
 (0)