Skip to content

Commit 0ffe5f4

Browse files
authored
feat: allow disabling analytics (#303)
* feat: allow disabling analytics * docs: update analytics changelogs * fix: singlenton issue with setupAnalytics method * chore: update readmes * change setupAnalytics to public instance method and update singleton merge behavior * fix: skip evm wallet analytics when disabled * fix: tolerate legacy multichain singleton analytics setup * docs: clarify analytics enabled changelog entry * fix: address analytics lint failures
1 parent 234844b commit 0ffe5f4

27 files changed

Lines changed: 772 additions & 153 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,16 +169,16 @@ Host pages integrating MetaMask Connect need to allow a few origins in their [Co
169169
### Required
170170

171171
```
172-
connect-src wss://mm-sdk-relay.api.cx.metamask.io https://mm-sdk-analytics.api.cx.metamask.io;
172+
connect-src wss://mm-sdk-relay.api.cx.metamask.io;
173173
img-src data:;
174174
```
175175

176176
- `wss://mm-sdk-relay.api.cx.metamask.io` — the WebSocket URL of the MetaMask relay used for remote connections (mobile, no-extension, etc.). Unavoidable — the relay cannot be proxied or deferred from within the library, and remote connections will fail without it.
177-
- `https://mm-sdk-analytics.api.cx.metamask.io` — the telemetry endpoint used by `@metamask/analytics`. Required for the connection lifecycle events the SDK emits by default. You can override the host via the `METAMASK_ANALYTICS_ENDPOINT` env var at build time, but some analytics endpoint must be reachable.
178177
- `img-src data:` — the install/QR-code modal in `@metamask/multichain-ui` embeds the MetaMask fox SVG as a `data:` URI inside the generated QR code. Without this, the QR code will fail to render entirely.
179178

180179
### Also consider
181180

181+
- **`https://mm-sdk-analytics.api.cx.metamask.io`** — telemetry endpoint used by `@metamask/analytics` when analytics are enabled. Analytics are enabled by default; set `analytics.enabled: false` to disable analytics events and omit this endpoint from `connect-src`.
182182
- **`style-src 'unsafe-inline'`**`@metamask/multichain-ui` is built with [Stencil](https://stenciljs.com/), which injects component styles at runtime inside Shadow DOM. Strict CSPs without `'unsafe-inline'` (or an equivalent nonce/hash strategy) may break modal styling.
183183
- **RPC endpoints you pass to `api.infuraProjectId` / `api.readonlyRPCMap` / `supportedNetworks`** — e.g. `https://*.infura.io`, your own node provider, or a public RPC. These are supplied by your dApp, so add whatever `connect-src` entries match the endpoints you configure.
184184
- **`https://metamask.app.link` and `metamask://`** — used for mobile deeplinks / universal links. These are top-level navigations and are not normally subject to `connect-src`, but strict CSPs that use `navigate-to` or `form-action` may need to allow them.

packages/analytics/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `analytics.disable()` to stop event collection and clear queued analytics events. ([#303](https://github.com/MetaMask/connect-monorepo/pull/303))
13+
1014
## [0.5.0]
1115

1216
### Added

packages/analytics/src/analytics.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,56 @@ t.describe('Analytics Integration', () => {
103103
scope.done();
104104
});
105105

106+
t.it('should stop tracking after disable is called', async () => {
107+
let captured: EventV2[] = [];
108+
scope = nock('http://127.0.0.4')
109+
.post('/v2/events', (body) => {
110+
captured = body;
111+
return true;
112+
})
113+
.optionally()
114+
.reply(
115+
200,
116+
{ status: 'success' },
117+
{ 'Content-Type': 'application/json' },
118+
);
119+
120+
analytics = new Analytics('http://127.0.0.4');
121+
analytics.enable();
122+
analytics.disable();
123+
analytics.track('mmconnect_initialized', eventProperties);
124+
125+
await new Promise((resolve) => setTimeout(resolve, 300));
126+
127+
t.expect(captured).toEqual([]);
128+
scope.done();
129+
});
130+
131+
t.it('should clear queued events when disable is called', async () => {
132+
let captured: EventV2[] = [];
133+
scope = nock('http://127.0.0.5')
134+
.post('/v2/events', (body) => {
135+
captured = body;
136+
return true;
137+
})
138+
.optionally()
139+
.reply(
140+
200,
141+
{ status: 'success' },
142+
{ 'Content-Type': 'application/json' },
143+
);
144+
145+
analytics = new Analytics('http://127.0.0.5');
146+
analytics.enable();
147+
analytics.track('mmconnect_initialized', eventProperties);
148+
analytics.disable();
149+
150+
await new Promise((resolve) => setTimeout(resolve, 300));
151+
152+
t.expect(captured).toEqual([]);
153+
scope.done();
154+
});
155+
106156
t.it('should merge multiple integration_types global updates', async () => {
107157
let captured: EventV2[] = [];
108158
scope = nock('http://127.0.0.3')

packages/analytics/src/analytics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ class Analytics {
3434
this.enabled = true;
3535
}
3636

37+
/**
38+
* Disables analytics and drops queued events.
39+
*/
40+
public disable(): void {
41+
this.enabled = false;
42+
this.sender.clear();
43+
}
44+
3745
public setGlobalProperty<K extends keyof MMConnectProperties>(
3846
key: K,
3947
value: MMConnectProperties[K],

packages/analytics/src/sender.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ t.describe('Sender', () => {
4040
t.expect(sendFn).toHaveBeenCalledWith(['event3']);
4141
});
4242

43+
t.it(
44+
'should clear queued events and cancel the scheduled flush',
45+
async () => {
46+
sender.enqueue('event1');
47+
sender.clear();
48+
49+
await t.vi.advanceTimersByTimeAsync(50);
50+
51+
t.expect(sendFn).not.toHaveBeenCalled();
52+
},
53+
);
54+
4355
t.it(
4456
'should handle failure (with exponential backoff) and reset base timeout after successful send',
4557
async () => {

packages/analytics/src/sender.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ class Sender<T> {
3939
this.schedule();
4040
}
4141

42+
/**
43+
* Clears queued items and cancels the scheduled flush.
44+
*/
45+
public clear(): void {
46+
this.batch = [];
47+
this.currentTimeoutMs = this.baseTimeoutMs;
48+
if (this.timeoutId) {
49+
clearTimeout(this.timeoutId);
50+
this.timeoutId = null;
51+
}
52+
}
53+
4254
private schedule(): void {
4355
// If the batch is not full and there is no scheduled flush, schedule a flush
4456
if (this.batch.length > 0 && !this.timeoutId) {

packages/connect-evm/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add an `analytics.enabled` option to `createEVMClient()`. Consumers can set it to `false` to disable dapp-side analytics events and wallet correlation metadata. ([#303](https://github.com/MetaMask/connect-monorepo/pull/303))
13+
1014
## [1.3.1]
1115

1216
### Fixed

packages/connect-evm/README.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -165,21 +165,23 @@ Factory function to create a new MetaMask Connect EVM instance.
165165

166166
#### Parameters
167167

168-
| Option | Type | Required | Description |
169-
| -------------------------- | --------------------------------------------- | -------- | ---------------------------------------------------- |
170-
| `dapp.name` | `string` | Yes | Name of your dApp |
171-
| `api.supportedNetworks` | `Record<Hex, string>` | Yes | Map of hex chain IDs to RPC URLs |
172-
| `dapp.url` | `string` | No | URL of your dApp |
173-
| `dapp.iconUrl` | `string` | No | Icon URL for your dApp |
174-
| `ui.headless` | `boolean` | No | Run without UI (for custom QR implementations) |
175-
| `ui.preferExtension` | `boolean` | No | Prefer browser extension over mobile (default: true) |
176-
| `ui.showInstallModal` | `boolean` | No | Show installation modal for desktop |
177-
| `mobile.preferredOpenLink` | `(deeplink: string, target?: string) => void` | No | Custom deeplink handler |
178-
| `mobile.useDeeplink` | `boolean` | No | Use `metamask://` instead of universal links |
179-
| `transport.extensionId` | `string` | No | Custom extension ID |
180-
| `transport.onNotification` | `(notification: unknown) => void` | No | Notification handler |
181-
| `eventHandlers` | `Partial<EventHandlers>` | No | Event handlers for provider events |
182-
| `debug` | `boolean` | No | Enable debug logging |
168+
| Option | Type | Required | Description |
169+
| --------------------------- | --------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
170+
| `dapp.name` | `string` | Yes | Name of your dApp |
171+
| `api.supportedNetworks` | `Record<Hex, string>` | Yes | Map of hex chain IDs to RPC URLs |
172+
| `dapp.url` | `string` | No | URL of your dApp |
173+
| `dapp.iconUrl` | `string` | No | Icon URL for your dApp |
174+
| `ui.headless` | `boolean` | No | Run without UI (for custom QR implementations) |
175+
| `ui.preferExtension` | `boolean` | No | Prefer browser extension over mobile (default: true) |
176+
| `ui.showInstallModal` | `boolean` | No | Show installation modal for desktop |
177+
| `mobile.preferredOpenLink` | `(deeplink: string, target?: string) => void` | No | Custom deeplink handler |
178+
| `mobile.useDeeplink` | `boolean` | No | Use `metamask://` instead of universal links |
179+
| `analytics.enabled` | `boolean` | No | Enables dapp-side analytics. Defaults to `true`; set to `false` to disable analytics events and wallet correlation metadata. |
180+
| `analytics.integrationType` | `string` | No | Integration type for analytics |
181+
| `transport.extensionId` | `string` | No | Custom extension ID |
182+
| `transport.onNotification` | `(notification: unknown) => void` | No | Notification handler |
183+
| `eventHandlers` | `Partial<EventHandlers>` | No | Event handlers for provider events |
184+
| `debug` | `boolean` | No | Enable debug logging |
183185

184186
#### Returns
185187

packages/connect-evm/src/connect.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type MockCore = MultichainCore & {
99
emit: (event: string, ...args: unknown[]) => void;
1010
_status: ConnectEvmStatus;
1111
storage: MultichainCore['storage'] & {
12+
getAnonId: Mock<() => Promise<string>>;
1213
adapter: {
1314
get: Mock<(key: string) => Promise<string | null>>;
1415
set: Mock<(key: string, value: string) => Promise<void>>;
@@ -93,6 +94,7 @@ function createMockCore(): MockCore {
9394
onNotification,
9495
},
9596
storage: {
97+
getAnonId: vi.fn().mockResolvedValue('anon-id'),
9698
adapter: {
9799
get: storageGet,
98100
set: storageSet,
@@ -868,6 +870,31 @@ describe('MetamaskConnectEVM', () => {
868870
expect(calls).toEqual(['wallet_switchEthereumChain']);
869871
expect(calls).not.toContain('wallet_addEthereumChain');
870872
});
873+
874+
it('does not build wallet action analytics properties when analytics are disabled', async () => {
875+
(mockCore as unknown as { options: MultichainCore['options'] }).options =
876+
{
877+
dapp: {
878+
name: 'Test DApp',
879+
url: 'https://test.example',
880+
},
881+
analytics: { enabled: false, integrationType: 'direct' },
882+
versions: {},
883+
} as MultichainCore['options'];
884+
mockCore.transport.sendEip1193Message.mockResolvedValue({
885+
result: null,
886+
id: 1,
887+
jsonrpc: '2.0' as const,
888+
});
889+
890+
await client.switchChain({ chainId: '0x89' });
891+
892+
expect(mockCore.storage.getAnonId).not.toHaveBeenCalled();
893+
expect(mockCore.transport.sendEip1193Message).toHaveBeenCalledWith({
894+
method: 'wallet_switchEthereumChain',
895+
params: [{ chainId: '0x89' }],
896+
});
897+
});
871898
});
872899

873900
describe('#requestInterceptor', () => {

packages/connect-evm/src/connect.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ declare const __PACKAGE_VERSION__: string | undefined;
4848
const DEFAULT_CHAIN_ID = '0x1';
4949
const CHAIN_STORE_KEY = 'cache_eth_chainId';
5050

51+
/**
52+
* Checks whether dapp-side analytics are enabled for the multichain core.
53+
*
54+
* @param options - Current multichain options.
55+
* @returns Whether analytics events should be collected and sent.
56+
*/
57+
function isAnalyticsEnabled(options: MultichainOptions | undefined): boolean {
58+
return options?.analytics?.enabled !== false;
59+
}
60+
5161
/** The options for the connect method */
5262
type ConnectOptions = {
5363
/** The account to connect to */
@@ -228,6 +238,10 @@ export class MetamaskConnectEVM {
228238
params: unknown[],
229239
): Promise<void> {
230240
const coreOptions = this.#getCoreOptions();
241+
if (!isAnalyticsEnabled(coreOptions)) {
242+
return;
243+
}
244+
231245
try {
232246
const invokeOptions = this.#createInvokeOptions(method, scope, params);
233247
const props = await getWalletActionAnalyticsProperties(
@@ -255,6 +269,10 @@ export class MetamaskConnectEVM {
255269
params: unknown[],
256270
): Promise<void> {
257271
const coreOptions = this.#getCoreOptions();
272+
if (!isAnalyticsEnabled(coreOptions)) {
273+
return;
274+
}
275+
258276
try {
259277
const invokeOptions = this.#createInvokeOptions(method, scope, params);
260278
const props = await getWalletActionAnalyticsProperties(
@@ -284,6 +302,10 @@ export class MetamaskConnectEVM {
284302
error: unknown,
285303
): Promise<void> {
286304
const coreOptions = this.#getCoreOptions();
305+
if (!isAnalyticsEnabled(coreOptions)) {
306+
return;
307+
}
308+
287309
try {
288310
const invokeOptions = this.#createInvokeOptions(method, scope, params);
289311
const props = await getWalletActionAnalyticsProperties(
@@ -1008,6 +1030,7 @@ export class MetamaskConnectEVM {
10081030
* @param options.dapp - Dapp identification and branding settings
10091031
* @param options.api - API configuration including read-only RPC map
10101032
* @param options.api.supportedNetworks - A map of hex chain IDs to RPC URLs for read-only requests
1033+
* @param [options.analytics.enabled] - Whether to enable dapp-side analytics (defaults to true)
10111034
* @param [options.analytics.integrationType] - Integration type for analytics
10121035
* @param [options.ui] - UI configuration options
10131036
* @param [options.ui.headless] - Whether to run without UI
@@ -1071,6 +1094,7 @@ export async function createEVMClient(
10711094
supportedNetworks: supportedNetworksCaipChainId,
10721095
},
10731096
analytics: {
1097+
...(options.analytics ?? {}),
10741098
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
10751099
integrationType: options.analytics?.integrationType || 'direct',
10761100
},

0 commit comments

Comments
 (0)