Skip to content

Commit d3e1a16

Browse files
authored
feat: implement MM Connect Multichain Analytics (#46)
* feat: add connection analytics to connect-multichain * feat: add request handling event tracking * chore: update CHANGELOG.md * feat: add wallet action event tracking for legacy EVM methods not passing through wallet_invokeMethod * refactor: clean up event tracking logic * chore: update CHANGELOG.md * chore: update CHANGELOG.md * refactor: minor removal of unreferenced methods * refactor: update schema.ts properly after updating api specs and generating from metamask-sdk-analytics-api repo * chore: update CHANGELOG.md * chore: fix CHANGELOG.md * chore: fix CHANGELOG.md * test: misnamed property * fix: change requestRouter to make sure readonly calls also call analytics
1 parent 0f9bc7c commit d3e1a16

18 files changed

Lines changed: 685 additions & 70 deletions

File tree

packages/analytics/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added `v2/events/` endpoint to `schema.ts` ([#46](https://github.com/MetaMask/connect-monorepo/pull/46))
13+
14+
### Changed
15+
16+
- Updated `analytics.ts` to point towards `v2/events` endpoint and update payload accordingly ([#46](https://github.com/MetaMask/connect-monorepo/pull/46))
17+
1018
## [0.1.1]
1119

1220
### Added

packages/analytics/src/analytics.test.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import * as t from 'vitest';
66
import Analytics from './analytics';
77
import type * as schema from './schema';
88

9+
type EventV2 = schema.components['schemas']['EventV2'];
10+
type MMConnectPayload = schema.components['schemas']['MMConnectPayload'];
11+
type MMConnectProperties = schema.components['schemas']['MMConnectProperties'];
12+
913
t.describe('Analytics Integration', () => {
1014
let analytics: Analytics;
1115
let scope: nock.Scope;
1216

13-
const event: schema.components['schemas']['SdkInitializedEvent'] = {
14-
name: 'sdk_initialized',
15-
sdk_version: '1.0.0',
17+
const eventProperties: MMConnectProperties = {
18+
mmconnect_version: '1.0.0',
1619
dapp_id: 'aave.com',
1720
anon_id: 'bbbc1727-8b85-433a-a26a-e9df70ddc81c',
1821
platform: 'web-desktop',
@@ -25,9 +28,9 @@ t.describe('Analytics Integration', () => {
2528
});
2629

2730
t.it('should do nothing when disabled', async () => {
28-
let captured: Event[] = [];
31+
let captured: EventV2[] = [];
2932
scope = nock('http://127.0.0.1')
30-
.post('/v1/events', (body) => {
33+
.post('/v2/events', (body) => {
3134
captured = body; // Capture the request body directly
3235
return true; // Accept any body to proceed with the intercept
3336
})
@@ -39,7 +42,7 @@ t.describe('Analytics Integration', () => {
3942
);
4043

4144
analytics = new Analytics('http://127.0.0.1');
42-
analytics.track(event.name, { ...event });
45+
analytics.track('mmconnect_initialized', eventProperties);
4346

4447
// Wait for the Sender to flush the event (baseIntervalMs = 200ms + buffer)
4548
await new Promise((resolve) => setTimeout(resolve, 300));
@@ -51,9 +54,9 @@ t.describe('Analytics Integration', () => {
5154
});
5255

5356
t.it('should track an event when enabled', async () => {
54-
let captured: Event[] = [];
57+
let captured: EventV2[] = [];
5558
scope = nock('http://127.0.0.2')
56-
.post('/v1/events', (body) => {
59+
.post('/v2/events', (body) => {
5760
captured = body; // Capture the request body directly
5861
return true; // Accept any body to proceed with the intercept
5962
})
@@ -65,19 +68,33 @@ t.describe('Analytics Integration', () => {
6568

6669
analytics = new Analytics('http://127.0.0.2');
6770
analytics.enable();
68-
analytics.setGlobalProperty('sdk_version', event.sdk_version);
69-
analytics.setGlobalProperty('anon_id', event.anon_id);
70-
analytics.setGlobalProperty('platform', event.platform);
71-
analytics.setGlobalProperty('integration_type', event.integration_type);
72-
analytics.track(event.name, { dapp_id: 'some-non-global-property' });
71+
analytics.setGlobalProperty(
72+
'mmconnect_version',
73+
eventProperties.mmconnect_version,
74+
);
75+
analytics.setGlobalProperty('anon_id', eventProperties.anon_id);
76+
analytics.setGlobalProperty('platform', eventProperties.platform);
77+
analytics.setGlobalProperty(
78+
'integration_type',
79+
eventProperties.integration_type,
80+
);
81+
analytics.track('mmconnect_initialized', {
82+
dapp_id: 'dapp_id',
83+
});
7384

7485
// Wait for the Sender to flush the event (baseIntervalMs = 200ms + buffer)
7586
await new Promise((resolve) => setTimeout(resolve, 300));
7687

7788
// Verify the captured payload
78-
t.expect(captured).toEqual([
79-
{ ...event, dapp_id: 'some-non-global-property' },
80-
]);
89+
const expectedEvent: MMConnectPayload = {
90+
namespace: 'metamask/connect',
91+
event_name: 'mmconnect_initialized',
92+
properties: {
93+
...eventProperties,
94+
dapp_id: 'dapp_id',
95+
},
96+
};
97+
t.expect(captured).toEqual([expectedEvent]);
8198

8299
scope.done();
83100
});

packages/analytics/src/analytics.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,23 @@ import createClient from 'openapi-fetch';
55
import type * as schema from './schema';
66
import Sender from './sender';
77

8-
type Event = schema.components['schemas']['Event'];
8+
type Event = schema.components['schemas']['EventV2'];
9+
type MMConnectPayload = schema.components['schemas']['MMConnectPayload'];
10+
type MMConnectProperties = schema.components['schemas']['MMConnectProperties'];
11+
type MMConnectEventName = MMConnectPayload['event_name'];
912

1013
class Analytics {
1114
private enabled = false;
1215

1316
private readonly sender: Sender<Event>;
1417

15-
private properties: Record<string, string> = {};
18+
private properties: Partial<MMConnectProperties> = {};
1619

1720
constructor(baseUrl: string) {
1821
const client = createClient<schema.paths>({ baseUrl });
1922

2023
const sendFn = async (batch: Event[]): Promise<void> => {
21-
const res = await client.POST('/v1/events', { body: batch });
24+
const res = await client.POST('/v2/events', { body: batch });
2225
if (res.response.status !== 200) {
2326
throw new Error(res.error);
2427
}
@@ -31,20 +34,26 @@ class Analytics {
3134
this.enabled = true;
3235
}
3336

34-
public setGlobalProperty(key: string, value: string): void {
37+
public setGlobalProperty<K extends keyof MMConnectProperties>(
38+
key: K,
39+
value: MMConnectProperties[K],
40+
): void {
3541
this.properties[key] = value;
3642
}
3743

38-
public track<T extends Event>(name: T['name'], properties: Partial<T>): void {
44+
public track(
45+
eventName: MMConnectEventName,
46+
properties: Partial<MMConnectProperties>,
47+
): void {
3948
if (!this.enabled) {
4049
return;
4150
}
4251

43-
const event = {
44-
name,
45-
...this.properties,
46-
...properties,
47-
} as T;
52+
const event: MMConnectPayload = {
53+
namespace: 'metamask/connect',
54+
event_name: eventName,
55+
properties: { ...properties, ...this.properties } as MMConnectProperties,
56+
};
4857

4958
this.sender.enqueue(event);
5059
}

packages/analytics/src/schema.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,55 @@ export interface paths {
5353
patch?: never;
5454
trace?: never;
5555
};
56+
"/v2/events": {
57+
parameters: {
58+
query?: never;
59+
header?: never;
60+
path?: never;
61+
cookie?: never;
62+
};
63+
get?: never;
64+
put?: never;
65+
/**
66+
* Track V2 namespaced events
67+
* @description Endpoint to submit namespaced analytics events for the MetaMask SDK (version 2).
68+
*/
69+
post: {
70+
parameters: {
71+
query?: never;
72+
header?: never;
73+
path?: never;
74+
cookie?: never;
75+
};
76+
requestBody: {
77+
content: {
78+
"application/json": components["schemas"]["EventV2"][];
79+
};
80+
};
81+
responses: {
82+
/** @description Events tracked successfully */
83+
200: {
84+
headers: {
85+
[name: string]: unknown;
86+
};
87+
content: {
88+
"application/json": {
89+
/**
90+
* @description Indicates the success of the event tracking.
91+
* @example success
92+
*/
93+
status?: string;
94+
};
95+
};
96+
};
97+
};
98+
};
99+
delete?: never;
100+
options?: never;
101+
head?: never;
102+
patch?: never;
103+
trace?: never;
104+
};
56105
}
57106
export type webhooks = Record<string, never>;
58107
export interface components {
@@ -421,6 +470,52 @@ export interface components {
421470
*/
422471
platform: "extension" | "mobile";
423472
};
473+
EventV2: components["schemas"]["MMConnectPayload"] | components["schemas"]["MobileSDKConnectV2Payload"];
474+
MMConnectPayload: {
475+
/**
476+
* @description discriminator enum property added by openapi-typescript
477+
* @enum {string}
478+
*/
479+
namespace: "metamask/connect";
480+
/** @enum {string} */
481+
event_name: "mmconnect_initialized" | "mmconnect_connection_initiated" | "mmconnect_connection_established" | "mmconnect_connection_rejected" | "mmconnect_connection_failed" | "mmconnect_wallet_action_requested" | "mmconnect_wallet_action_succeeded" | "mmconnect_wallet_action_failed" | "mmconnect_wallet_action_rejected";
482+
properties: components["schemas"]["MMConnectProperties"];
483+
};
484+
MobileSDKConnectV2Payload: {
485+
/**
486+
* @description discriminator enum property added by openapi-typescript
487+
* @enum {string}
488+
*/
489+
namespace: "mobile/sdk-connect-v2";
490+
/** @enum {string} */
491+
event_name: "wallet_connection_request_received" | "wallet_connection_request_failed" | "wallet_connection_user_approved" | "wallet_connection_user_rejected" | "wallet_action_received" | "wallet_action_user_approved" | "wallet_action_user_rejected";
492+
properties: components["schemas"]["MobileSDKConnectV2Properties"];
493+
};
494+
MMConnectProperties: {
495+
mmconnect_version: string;
496+
dapp_id: string;
497+
/** Format: uuid */
498+
anon_id: string;
499+
/** @enum {string} */
500+
platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native";
501+
integration_type: string;
502+
/** @enum {string} */
503+
transport_type?: "browser" | "mwp" | "unknown";
504+
method?: string;
505+
caip_chain_id?: string;
506+
/** @description Array of CAIP-2 chain IDs that the dApp has configured */
507+
dapp_configured_chains?: string[];
508+
/** @description Array of CAIP-2 chain IDs that the dApp has requested */
509+
dapp_requested_chains?: string[];
510+
/** @description Array of CAIP-2 chain IDs that the user has permissioned */
511+
user_permissioned_chains?: string[];
512+
};
513+
MobileSDKConnectV2Properties: {
514+
/** Format: uuid */
515+
anon_id: string;
516+
/** @enum {string} */
517+
platform: "mobile";
518+
};
424519
};
425520
responses: never;
426521
parameters: never;

packages/connect-evm/CHANGELOG.md

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

1010
### Added
1111

12+
- Added analytics tracking of request handling ([#46](https://github.com/MetaMask/connect-monorepo/pull/46))
1213
- Initial release ([#21](https://github.com/MetaMask/connect-monorepo/pull/21))
1314

1415
### Fixed

packages/connect-evm/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"test:watch": "vitest watch"
4949
},
5050
"dependencies": {
51+
"@metamask/analytics": "workspace:^",
5152
"@metamask/chain-agnostic-permission": "^1.2.2",
5253
"@metamask/connect-multichain": "workspace:^",
5354
"@metamask/utils": "^11.8.1"

0 commit comments

Comments
 (0)