Skip to content

Commit bd0b0f1

Browse files
gauthierpetetinclaudeitsyoboieltr
authored andcommitted
feat: integrate analytics controller (MetaMask#42885)
## **Description** This PR integrates AnalyticsController in Extension codebase. It replaces this state: - MetaMetricsController's `metaMetricsId` - MetaMetricsController's `participateInMetaMetrics` - MetaMetricsController's `segmentApiCalls` by this state: - AnalyticsController's `analyticsId` - AnalyticsController's `optedIn` - AnalyticsController's `eventQueue` - MetaMetricsController's `completedMetaMetricsOnboarding` Since these changes impact many areas of the codebase, we’ve decided to take a phased approach. This PR migrates the persisted state and updates the UI compatibility selectors, as well as the background `getState()` function, to derive the legacy values (`metaMetricsId`, `participateInMetaMetrics`) from the new state. This lets existing code continue using the legacy values while the source of truth moves to `AnalyticsController.analyticsId`, `AnalyticsController.optedIn`, and `MetaMetricsController.completedMetaMetricsOnboarding`. In future phases, we’ll progressively remove all remaining usages of the legacy values (`metaMetricsId`, `participateInMetaMetrics`) throughout the codebase. The files that deserve more attention during PR review are the following: - app/scripts/controllers/analytics/platform-adapter.ts - app/scripts/controllers/metametrics-controller.ts ```mermaid flowchart TB subgraph Before["Before"] direction LR MMC1["MetaMetricsController"] --> SDK1["Segment SDK"] Early1["Early events"] --> SDK1 end subgraph After["After"] direction LR MMC2["MetaMetricsController"] --> AC["AnalyticsController"] AC --> SDK2["Segment SDK"] Early2["Early events"] --> SDK2 end Before ~~~ After ``` ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: MetaMask/MetaMask-planning#7193 ## **Manual testing steps** Main test case 1. Build two different Extensions, one on this branch and one on main, the goal will be to compare events emitted by both and confirm they're equivalent 2. Go through onboarding with both extension and find your metaMetricsId/analyticsId by doing right click on extension UI, then inspect > Application > Storage > IndexedDB > metamask-backup > MetaMetricsController/AnalyticsController 3. Open Segment, choose the right source (usually `MetaMask Extension [Dev]`, except if you configured SEGMENT_WRITE_KEY env variable with your own Segment project), and filter events with your metaMetricsId/analyticsId 4. Confirm the three types of events emitted by both extension are equivalent: a. track b. identify c. page Edge cases - Anonymous events (see list [here](MetaMask/MetaMask-planning#7230)) - Fully anonymous ones - These events are fully anonymous in the sense that the entire event, including all properties are considered as sensitive and get anonymized - Fully anonymous events are emitted by passing "excludeMetaMetricsId: true" as option (Example [here](https://github.com/MetaMask/metamask-extension/blob/main/app/scripts/background.js#L502)) - An easy one to reproduce is "Phishing Page Displayed": visit one of the pages listed [here](https://phishing-detection.api.cx.metamask.io/v1/stalelist) and the event will be emitted - Partially anonymous ones - These events are partially anonymous in the sense that some properties are considered as sensitive and some are not. As a consequence, 2 events are sent: an anonymous one with all properties, and a non-anonymous one with only properties considered as non-sensitive - Partially anonymous events are emitted by passing "sensitiveProperties" (Example [here](https://github.com/MetaMask/metamask-extension/blob/main/ui/pages/contacts/components/add-contact-form.tsx#L236-L239)) - An easy one to reproduce is "Contact Added": go to Contacts, click "Add contact", and add Vitalik's address as contact (0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045) - Before this PR, both events used to have similar messageId (example: "1780055175321.6592", and "1780055175321.6592-0x000"), while with this PR, messageId have a new uuidv4 format and both events don't have similar messageId (example: "5cc0d85e-3f0e-4374-8811-d36924d06689" and "433ff4c1-948d-4a4e-9470-81e878c7df44") - Anonymous ones with event renaming - Some anonymous events get renamed when being emitted (list of events available [here](https://github.com/MetaMask/metamask-extension/blob/main/app/scripts/controllers/metametrics-controller.ts#L113-L130)): for example “Signature Requested” gets renamed “Signature Requested Anon” - An easy one to reproduce is “Signature Requested” (see Fragments sections below for more detail) - Fragments - Fragments are events used to tie a multi-step flow together, such as “Signature Requested” followed later by “Signature Approved” or “Signature Rejected” - They can be tested by visiting [test-dapp](https://metamask.github.io/test-dapp/), then connect extension, then click "Sign Typed Data V4", then accept or reject signature - The purpose of fragments is for both each event of the multi-step flow to share the same properties - Before this PR, events from a same fragment used to have similar `messageId` (example: "signature-3947127447", and "signature-3947127447-success" or "signature-3947127447-failure"), while with this PR, `messageId` have a new uuidv4 format and events from a same fragment don't have similar `messageId` (example: "9e3a83d0-ce13-47d2-a2f4-08a6d9adc29d" and "d2368a25-01c5-4b3c-af55-7d5f54eb58a6") - Persisted queue - Go to home page - Check that persisted queue is an empty object: right click on extension UI, then inspect > Application > Storage > IndexedDB > metamask-backup > MetaMetricsController/AnalyticsController > segmentApiCalls/eventQueue - Turn internet off - Click on "Swap" - Confirm no event is sent to Segment - Confirm events are added to persisted queue, including "Unified SwapBridge Button Clicked" (click on refresh button in the inspector to see them) - Wait for 5 minutes - Turn internet on - Restart extension - Confirm events are sent to Segment, including "Unified SwapBridge Button Clicked", and there's a 5 minute difference between `timestamp` and `sentAt` - These repro steps are not working on main branch, because persisted queue used to get cleared, regardless of whether the callback represents success or failure, whereas in this PR, it only gets cleared upon success. That change of behavior introduces the risk that if an event is malformed, it can end up being retried after every extension restart. But at the same time it allows us to collect more events, in cases of transient failures (e.g. offline, timeout). This core repo PR got opened to align behavior with what's currently on main: MetaMask/core#8934 - Early events - Right click on extension UI, then inspect > Application > Storage > Extension storage > Local > KeyringController > Right click to delete KeyringController - Restart Extension straight away - You should see "Vault Corruption Restore Wallet Screen Viewed" event in Segment - Opt out event - Start from a fresh onboarding state, go through create/import wallet, on the MetaMetrics onboarding page, uncheck “Participate in MetaMetrics”, continue and finish onboarding. - On Chrome, you should see "Metrics Opt Out" event in Segment - On Firefox, you should not see "Metrics Opt Out" event in Segment - Delete metametrics - Right click on extension UI, then inspect > Network, and paste "regulations" in the search bar - Go to Settings > Privacy > Delete MetaMetrics data and click the button - You should see an API call in the Networks tab, with "subjectIds" array as parameter, and your metaMetricsId/analyticsId included in that array ## **Screenshots/Recordings** NA ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Central refactor of consent, IDs, queuing, and anonymous event handling across background, Segment, and data-deletion paths; messageId and offline-queue behavior differ from main and can affect metrics parity or stuck retries. > > **Overview** > This PR routes MetaMetrics **track / identify / page** through **`AnalyticsController`** and a new **Segment `platform-adapter`**, instead of **`MetaMetricsController`** calling Segment directly. > > **State & consent:** Persisted **`metaMetricsId`**, **`participateInMetaMetrics`**, and **`segmentApiCalls`** are removed from **`MetaMetricsController`** in favor of **`AnalyticsController`** (`analyticsId`, `optedIn`, `eventQueue`) plus **`completedMetaMetricsOnboarding`**. Opt-in/out now calls **`AnalyticsController:optIn` / `optOut`**; IDs come from **`getMetaMetricsId()`** → analytics state. **Sentry** snapshots add **`AnalyticsController`** and drop the old MMC consent fields. > > **Behavior changes:** Anonymous / sensitive events use a **`properties.anonymous`** marker; the adapter swaps to the shared anonymous ID and renames select signature/transaction events. Direct Segment logic (payload building, **`segmentApiCalls`** MV3 replay, deterministic **`messageId`**, **`generateMetaMetricsId`**) is removed from MMC. **Early** and **opt-out** paths use **`custom-segment-tracking`** reading **`AnalyticsController`** from backup state. **RPC middleware**, **data deletion**, and **background** helpers read consent/ID via **`controller.getState()`** / **`analyticsController.state`**. > > **Tests:** Large **`metametrics-controller`** test updates; new **`platform-adapter`** unit tests. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5d73b11. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com>
1 parent 37d9a60 commit bd0b0f1

117 files changed

Lines changed: 3124 additions & 2165 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/scripts/background.js

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1276,7 +1276,7 @@ export async function loadStateFromPersistence(backup) {
12761276
* @param {number} [frameId] - The frame ID from chrome.runtime.MessageSender (0 = top-level, >0 = iframe)
12771277
*/
12781278
function emitDappViewedMetricEvent(origin, mainFrameOrigin, frameId) {
1279-
const { metaMetricsId } = controller.metaMetricsController.state;
1279+
const { metaMetricsId } = controller.getState();
12801280
if (!shouldEmitDappViewedEvent(metaMetricsId)) {
12811281
return;
12821282
}
@@ -1370,11 +1370,10 @@ function trackDappView(remotePort) {
13701370
* @param {string} environmentType - The environment type where the app is opening
13711371
*/
13721372
function emitAppOpenedMetricEvent(environmentType) {
1373-
const { metaMetricsId, participateInMetaMetrics } =
1374-
controller.metaMetricsController.state;
1373+
const { participateInMetaMetrics } = controller.getState();
13751374

13761375
// Skip if user hasn't opted into metrics
1377-
if (metaMetricsId === null && !participateInMetaMetrics) {
1376+
if (!participateInMetaMetrics) {
13781377
return;
13791378
}
13801379

@@ -2157,20 +2156,16 @@ const addAppInstalledEvent = async (installAttributionPromise) => {
21572156
properties: eventProperties,
21582157
};
21592158

2160-
const { participateInMetaMetrics, metaMetricsId } =
2161-
controller.metaMetricsController.state;
2159+
const { participateInMetaMetrics, metaMetricsId } = controller.getState();
21622160

21632161
if (participateInMetaMetrics === false) {
21642162
// We can skip tracking completely if they've already explicitly opted out
21652163
return;
21662164
}
21672165

2168-
// Track immediately only once consent is active and the controller has a
2169-
// persisted MetaMetrics ID. Otherwise keep the event buffered for the opt-in
2170-
// flush path so it is not dropped.
2171-
// No need to call getMetaMetricsId() first: setParticipateInMetaMetrics()
2172-
// generates and persists the ID before setting participation to true, and this
2173-
// install handler should not create a metrics ID outside that consent path.
2166+
// Track immediately only once consent is active and the compatibility
2167+
// MetaMetrics ID is available. Otherwise keep the event buffered for the
2168+
// opt-in flush path so it is not dropped.
21742169
if (participateInMetaMetrics === true && metaMetricsId) {
21752170
controller.metaMetricsController.trackEvent(appInstalledEvent);
21762171
} else {

app/scripts/constants/sentry-state.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ export const SENTRY_BACKGROUND_STATE: SentryBackgroundControllerMasks = {
4141
unconnectedAccountAlertShownOrigins: false,
4242
web3ShimUsageOrigins: false,
4343
},
44+
AnalyticsController: {
45+
analyticsId: true,
46+
eventQueue: false,
47+
optedIn: true,
48+
},
4449
AnnouncementController: {
4550
announcements: false,
4651
},
@@ -194,12 +199,10 @@ export const SENTRY_BACKGROUND_STATE: SentryBackgroundControllerMasks = {
194199
isUpdatingMetamaskNotificationsAccount: false,
195200
},
196201
MetaMetricsController: {
202+
completedMetaMetricsOnboarding: true,
197203
eventsBeforeMetricsOptIn: false,
198204
tracesBeforeMetricsOptIn: false,
199205
fragments: false,
200-
metaMetricsId: true,
201-
participateInMetaMetrics: true,
202-
segmentApiCalls: false,
203206
traits: false,
204207
dataCollectionForMarketing: false,
205208
marketingCampaignCookieId: true,
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { segment as sharedExtensionSegment } from '../../lib/segment';
2+
import {
3+
METAMETRICS_ANONYMOUS_ID,
4+
MetaMetricsEventName,
5+
} from '../../../../shared/constants/metametrics';
6+
import {
7+
AnonymousTransactionMetaMetricsEvent,
8+
TransactionMetaMetricsEvent,
9+
} from '../../../../shared/constants/transaction';
10+
import {
11+
ANONYMOUS_EVENT_PROPERTY,
12+
createPlatformAdapter,
13+
} from './platform-adapter';
14+
15+
const ANALYTICS_ID = '0xabc123';
16+
17+
type SegmentSpy = {
18+
track: jest.SpiedFunction<typeof sharedExtensionSegment.track>;
19+
identify: jest.SpiedFunction<typeof sharedExtensionSegment.identify>;
20+
page: jest.SpiedFunction<typeof sharedExtensionSegment.page>;
21+
};
22+
23+
function createSegmentSpy(): SegmentSpy {
24+
return {
25+
track: jest.spyOn(sharedExtensionSegment, 'track').mockImplementation(),
26+
identify: jest
27+
.spyOn(sharedExtensionSegment, 'identify')
28+
.mockImplementation(),
29+
page: jest.spyOn(sharedExtensionSegment, 'page').mockImplementation(),
30+
};
31+
}
32+
33+
function buildAdapter() {
34+
const segment = createSegmentSpy();
35+
const adapter = createPlatformAdapter();
36+
// After PR MetaMask/core#8543 lands, AnalyticsController guarantees
37+
// onSetupCompleted runs before track/identify/view; replicate that here.
38+
adapter.onSetupCompleted(ANALYTICS_ID);
39+
return { adapter, segment };
40+
}
41+
42+
afterEach(() => {
43+
jest.restoreAllMocks();
44+
});
45+
46+
describe('createPlatformAdapter', () => {
47+
describe('skipUUIDv4Check', () => {
48+
it('is set to true so non-UUIDv4 extension analyticsIds are accepted', () => {
49+
const adapter = createPlatformAdapter();
50+
expect(adapter.skipUUIDv4Check).toBe(true);
51+
});
52+
});
53+
54+
describe('track', () => {
55+
it('calls segment.track with the analyticsId as userId and the event name', () => {
56+
const { adapter, segment } = buildAdapter();
57+
adapter.track('Wallet Opened');
58+
expect(segment.track).toHaveBeenCalledWith(
59+
{ userId: ANALYTICS_ID, event: 'Wallet Opened' },
60+
undefined,
61+
);
62+
});
63+
64+
it('forwards properties when provided', () => {
65+
const { adapter, segment } = buildAdapter();
66+
adapter.track('Wallet Opened', { chainId: '0x1' });
67+
expect(segment.track).toHaveBeenCalledWith(
68+
{
69+
userId: ANALYTICS_ID,
70+
event: 'Wallet Opened',
71+
properties: { chainId: '0x1' },
72+
},
73+
undefined,
74+
);
75+
});
76+
77+
it('forwards context and delivery options', () => {
78+
const { adapter, segment } = buildAdapter();
79+
const callback = jest.fn();
80+
const timestamp = new Date('2026-01-01T00:00:00Z');
81+
adapter.track(
82+
'Wallet Opened',
83+
{ foo: 'bar' },
84+
{ app: { name: 'MetaMask Extension', version: '1.0.0' } },
85+
{
86+
messageId: 'msg-1',
87+
timestamp,
88+
callback,
89+
},
90+
);
91+
expect(segment.track).toHaveBeenCalledWith(
92+
{
93+
userId: ANALYTICS_ID,
94+
event: 'Wallet Opened',
95+
properties: { foo: 'bar' },
96+
context: { app: { name: 'MetaMask Extension', version: '1.0.0' } },
97+
messageId: 'msg-1',
98+
timestamp,
99+
},
100+
callback,
101+
);
102+
});
103+
104+
it('downgrades anonymous-marked track payloads to the shared anonymous ID', () => {
105+
const { adapter, segment } = buildAdapter();
106+
const properties = {
107+
foo: 'bar',
108+
[ANONYMOUS_EVENT_PROPERTY]: true,
109+
};
110+
111+
adapter.track('Wallet Opened', properties);
112+
113+
expect(segment.track).toHaveBeenCalledWith(
114+
{
115+
anonymousId: METAMETRICS_ANONYMOUS_ID,
116+
event: 'Wallet Opened',
117+
properties: { foo: 'bar' },
118+
},
119+
undefined,
120+
);
121+
expect(properties).toStrictEqual({
122+
foo: 'bar',
123+
[ANONYMOUS_EVENT_PROPERTY]: true,
124+
});
125+
});
126+
127+
it('overrides anonymous signature event names', () => {
128+
const { adapter, segment } = buildAdapter();
129+
130+
adapter.track(MetaMetricsEventName.SignatureRequested, {
131+
[ANONYMOUS_EVENT_PROPERTY]: true,
132+
});
133+
134+
expect(segment.track).toHaveBeenCalledWith(
135+
{
136+
anonymousId: METAMETRICS_ANONYMOUS_ID,
137+
event: MetaMetricsEventName.SignatureRequestedAnon,
138+
properties: {},
139+
},
140+
undefined,
141+
);
142+
});
143+
144+
it('overrides anonymous transaction event names', () => {
145+
const { adapter, segment } = buildAdapter();
146+
147+
adapter.track(TransactionMetaMetricsEvent.submitted, {
148+
[ANONYMOUS_EVENT_PROPERTY]: true,
149+
});
150+
151+
expect(segment.track).toHaveBeenCalledWith(
152+
{
153+
anonymousId: METAMETRICS_ANONYMOUS_ID,
154+
event: AnonymousTransactionMetaMetricsEvent.submitted,
155+
properties: {},
156+
},
157+
undefined,
158+
);
159+
});
160+
});
161+
162+
describe('identify', () => {
163+
it('forwards the userId argument from AnalyticsController and traits', () => {
164+
const { adapter, segment } = buildAdapter();
165+
adapter.identify('user-1', { plan: 'pro' });
166+
expect(segment.identify).toHaveBeenCalledWith(
167+
{ userId: 'user-1', traits: { plan: 'pro' } },
168+
undefined,
169+
);
170+
});
171+
172+
it('omits traits when not provided', () => {
173+
const { adapter, segment } = buildAdapter();
174+
adapter.identify('user-1');
175+
expect(segment.identify).toHaveBeenCalledWith(
176+
{ userId: 'user-1' },
177+
undefined,
178+
);
179+
});
180+
181+
it('forwards context and delivery options', () => {
182+
const { adapter, segment } = buildAdapter();
183+
const callback = jest.fn();
184+
const timestamp = new Date('2026-01-01T00:00:00Z');
185+
adapter.identify(
186+
'user-1',
187+
{ plan: 'pro' },
188+
{ app: { name: 'MetaMask Extension' } },
189+
{
190+
messageId: 'id-1',
191+
timestamp,
192+
callback,
193+
},
194+
);
195+
expect(segment.identify).toHaveBeenCalledWith(
196+
{
197+
userId: 'user-1',
198+
traits: { plan: 'pro' },
199+
context: { app: { name: 'MetaMask Extension' } },
200+
messageId: 'id-1',
201+
timestamp,
202+
},
203+
callback,
204+
);
205+
});
206+
});
207+
208+
describe('view', () => {
209+
it('forwards as a Segment page() call with the analyticsId', () => {
210+
const { adapter, segment } = buildAdapter();
211+
adapter.view('Home');
212+
expect(segment.page).toHaveBeenCalledWith(
213+
{ userId: ANALYTICS_ID, name: 'Home' },
214+
undefined,
215+
);
216+
});
217+
218+
it('forwards properties, context, and delivery options', () => {
219+
const { adapter, segment } = buildAdapter();
220+
const callback = jest.fn();
221+
const timestamp = new Date('2026-01-01T00:00:00Z');
222+
adapter.view(
223+
'Home',
224+
{ section: 'tokens' },
225+
{ app: { name: 'MetaMask Extension' } },
226+
{
227+
messageId: 'page-1',
228+
timestamp,
229+
callback,
230+
},
231+
);
232+
expect(segment.page).toHaveBeenCalledWith(
233+
{
234+
userId: ANALYTICS_ID,
235+
name: 'Home',
236+
properties: { section: 'tokens' },
237+
context: { app: { name: 'MetaMask Extension' } },
238+
messageId: 'page-1',
239+
timestamp,
240+
},
241+
callback,
242+
);
243+
});
244+
});
245+
});

0 commit comments

Comments
 (0)