From 2b95db2949afe11bb5227ed738206210cfa98253 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Fri, 22 May 2026 17:11:30 +0200 Subject: [PATCH 01/35] feat: integrate analytics controller --- app/scripts/background.js | 19 +- app/scripts/constants/sentry-state.ts | 6 +- .../analytics/platform-adapter.test.ts | 245 +++ .../controllers/analytics/platform-adapter.ts | 149 ++ ...ametrics-controller-method-action-types.ts | 6 - .../metametrics-controller.test.ts | 1350 +++++++---------- .../controllers/metametrics-controller.ts | 1052 +++++-------- .../metametrics-data-deletion.test.ts | 7 +- .../metametrics-data-deletion.ts | 12 +- .../lib/createRPCMethodTrackingMiddleware.js | 10 +- .../createRPCMethodTrackingMiddleware.test.js | 39 +- .../track-critical-error.test.ts | 35 +- .../critical-error/track-critical-error.ts | 2 +- .../handlers/request-accounts.test.ts | 2 +- .../handlers/request-accounts.ts | 4 +- .../segment/__tests__/segment-shim.test.ts | 130 +- ...est.ts => custom-segment-tracking.test.ts} | 83 +- .../lib/segment/custom-segment-tracking.ts | 221 +++ .../lib/segment/early-segment-tracking.ts | 162 -- app/scripts/lib/segment/index.ts | 118 +- app/scripts/lib/sentry-get-state.test.ts | 103 +- app/scripts/lib/sentry-get-state.ts | 84 +- app/scripts/lib/sentry-make-transport.test.ts | 44 +- app/scripts/lib/setup-initial-state-hooks.js | 2 +- .../track-vault-corruption.test.ts | 28 +- .../track-vault-corruption.ts | 2 +- .../analytics-controller-init.ts | 42 + .../messenger-client-init/controller-list.ts | 3 + .../analytics-controller-messenger.ts | 33 + .../messenger-client-init/messengers/index.ts | 6 + .../metametrics-controller-messenger.ts | 6 + ...rics-data-deletion-controller-messenger.ts | 2 +- .../metametrics-controller-init.test.ts | 1 - .../metametrics-controller-init.ts | 8 +- .../profile-metrics-controller-init.ts | 3 +- .../seedless-onboarding/oauth-service-init.ts | 7 +- app/scripts/metamask-controller.js | 27 +- app/scripts/migrations/212.test.ts | 102 ++ app/scripts/migrations/212.ts | 72 + app/scripts/migrations/index.js | 1 + app/scripts/services/oauth/oauth-service.ts | 2 +- app/scripts/services/oauth/types.ts | 2 +- attribution.txt | 11 + lavamoat/browserify/beta/policy.json | 8 + lavamoat/browserify/experimental/policy.json | 8 + lavamoat/browserify/flask/policy.json | 8 + lavamoat/browserify/main/policy.json | 8 + lavamoat/webpack/mv2/beta/policy.json | 8 + lavamoat/webpack/mv2/experimental/policy.json | 8 + lavamoat/webpack/mv2/flask/policy.json | 8 + lavamoat/webpack/mv2/main/policy.json | 8 + package.json | 1 + shared/constants/metametrics.ts | 42 - shared/lib/generate-metametrics-id.ts | 16 + shared/lib/stores/persistence-manager.ts | 1 + shared/types/background.ts | 8 +- test/e2e/fixtures/default-fixture.json | 10 +- test/e2e/fixtures/fixture-builder-v2.ts | 45 +- test/e2e/fixtures/onboarding-fixture.json | 10 +- ...rs-after-init-opt-in-background-state.json | 1 - .../errors-after-init-opt-in-ui-state.json | 1 - ...s-before-init-opt-in-background-state.json | 1 - .../errors-before-init-opt-in-ui-state.json | 3 +- test/e2e/tests/settings/state-logs.json | 1 - .../data/onboarding-completion-route.json | 15 - test/jest/console-baseline-unit.json | 3 +- ui/components/app/toast-master/selectors.ts | 11 +- ui/contexts/metametrics.test.tsx | 17 +- ui/contexts/metametrics.tsx | 10 +- .../creation-successful.tsx | 3 - ui/selectors/metametrics.js | 11 +- ui/store/actions.ts | 22 +- yarn.lock | 14 + 73 files changed, 2487 insertions(+), 2066 deletions(-) create mode 100644 app/scripts/controllers/analytics/platform-adapter.test.ts create mode 100644 app/scripts/controllers/analytics/platform-adapter.ts rename app/scripts/lib/segment/{early-segment-tracking.test.ts => custom-segment-tracking.test.ts} (62%) create mode 100644 app/scripts/lib/segment/custom-segment-tracking.ts delete mode 100644 app/scripts/lib/segment/early-segment-tracking.ts create mode 100644 app/scripts/messenger-client-init/analytics-controller-init.ts create mode 100644 app/scripts/messenger-client-init/messengers/analytics-controller-messenger.ts create mode 100644 app/scripts/migrations/212.test.ts create mode 100644 app/scripts/migrations/212.ts create mode 100644 shared/lib/generate-metametrics-id.ts diff --git a/app/scripts/background.js b/app/scripts/background.js index 64a3dbe35843..01214b98e31a 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -1296,7 +1296,7 @@ export async function loadStateFromPersistence(backup) { * @param {number} [frameId] - The frame ID from chrome.runtime.MessageSender (0 = top-level, >0 = iframe) */ function emitDappViewedMetricEvent(origin, mainFrameOrigin, frameId) { - const { metaMetricsId } = controller.metaMetricsController.state; + const { metaMetricsId } = controller.getState(); if (!shouldEmitDappViewedEvent(metaMetricsId)) { return; } @@ -1390,11 +1390,10 @@ function trackDappView(remotePort) { * @param {string} environmentType - The environment type where the app is opening */ function emitAppOpenedMetricEvent(environmentType) { - const { metaMetricsId, participateInMetaMetrics } = - controller.metaMetricsController.state; + const { participateInMetaMetrics } = controller.getState(); // Skip if user hasn't opted into metrics - if (metaMetricsId === null && !participateInMetaMetrics) { + if (!participateInMetaMetrics) { return; } @@ -2183,20 +2182,16 @@ const addAppInstalledEvent = async (installAttributionPromise) => { properties: eventProperties, }; - const { participateInMetaMetrics, metaMetricsId } = - controller.metaMetricsController.state; + const { participateInMetaMetrics, metaMetricsId } = controller.getState(); if (participateInMetaMetrics === false) { // We can skip tracking completely if they've already explicitly opted out return; } - // Track immediately only once consent is active and the controller has a - // persisted MetaMetrics ID. Otherwise keep the event buffered for the opt-in - // flush path so it is not dropped. - // No need to call getMetaMetricsId() first: setParticipateInMetaMetrics() - // generates and persists the ID before setting participation to true, and this - // install handler should not create a metrics ID outside that consent path. + // Track immediately only once consent is active and the compatibility + // MetaMetrics ID is available. Otherwise keep the event buffered for the + // opt-in flush path so it is not dropped. if (participateInMetaMetrics === true && metaMetricsId) { controller.metaMetricsController.trackEvent(appInstalledEvent); } else { diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 3491f4897d5b..eb8a438cfe34 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -41,6 +41,10 @@ export const SENTRY_BACKGROUND_STATE: SentryBackgroundControllerMasks = { unconnectedAccountAlertShownOrigins: false, web3ShimUsageOrigins: false, }, + AnalyticsController: { + analyticsId: true, + optedIn: true, + }, AnnouncementController: { announcements: false, }, @@ -190,12 +194,12 @@ export const SENTRY_BACKGROUND_STATE: SentryBackgroundControllerMasks = { isUpdatingMetamaskNotificationsAccount: false, }, MetaMetricsController: { + completedMetaMetricsOnboarding: true, eventsBeforeMetricsOptIn: false, tracesBeforeMetricsOptIn: false, fragments: false, metaMetricsId: true, participateInMetaMetrics: true, - segmentApiCalls: false, traits: false, dataCollectionForMarketing: false, marketingCampaignCookieId: true, diff --git a/app/scripts/controllers/analytics/platform-adapter.test.ts b/app/scripts/controllers/analytics/platform-adapter.test.ts new file mode 100644 index 000000000000..db736cb447e1 --- /dev/null +++ b/app/scripts/controllers/analytics/platform-adapter.test.ts @@ -0,0 +1,245 @@ +import { segment as sharedExtensionSegment } from '../../lib/segment'; +import { + METAMETRICS_ANONYMOUS_ID, + MetaMetricsEventName, +} from '../../../../shared/constants/metametrics'; +import { + AnonymousTransactionMetaMetricsEvent, + TransactionMetaMetricsEvent, +} from '../../../../shared/constants/transaction'; +import { + ANONYMOUS_EVENT_PROPERTY, + createPlatformAdapter, +} from './platform-adapter'; + +const ANALYTICS_ID = '0xabc123'; + +type SegmentSpy = { + track: jest.SpiedFunction; + identify: jest.SpiedFunction; + page: jest.SpiedFunction; +}; + +function createSegmentSpy(): SegmentSpy { + return { + track: jest.spyOn(sharedExtensionSegment, 'track').mockImplementation(), + identify: jest + .spyOn(sharedExtensionSegment, 'identify') + .mockImplementation(), + page: jest.spyOn(sharedExtensionSegment, 'page').mockImplementation(), + }; +} + +function buildAdapter() { + const segment = createSegmentSpy(); + const adapter = createPlatformAdapter(); + // After PR MetaMask/core#8543 lands, AnalyticsController guarantees + // onSetupCompleted runs before track/identify/view; replicate that here. + adapter.onSetupCompleted(ANALYTICS_ID); + return { adapter, segment }; +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('createPlatformAdapter', () => { + describe('skipUUIDv4Check', () => { + it('is set to true so non-UUIDv4 extension analyticsIds are accepted', () => { + const adapter = createPlatformAdapter(); + expect(adapter.skipUUIDv4Check).toBe(true); + }); + }); + + describe('track', () => { + it('calls segment.track with the analyticsId as userId and the event name', () => { + const { adapter, segment } = buildAdapter(); + adapter.track('Wallet Opened'); + expect(segment.track).toHaveBeenCalledWith( + { userId: ANALYTICS_ID, event: 'Wallet Opened' }, + undefined, + ); + }); + + it('forwards properties when provided', () => { + const { adapter, segment } = buildAdapter(); + adapter.track('Wallet Opened', { chainId: '0x1' }); + expect(segment.track).toHaveBeenCalledWith( + { + userId: ANALYTICS_ID, + event: 'Wallet Opened', + properties: { chainId: '0x1' }, + }, + undefined, + ); + }); + + it('forwards context and delivery options', () => { + const { adapter, segment } = buildAdapter(); + const callback = jest.fn(); + const timestamp = new Date('2026-01-01T00:00:00Z'); + adapter.track( + 'Wallet Opened', + { foo: 'bar' }, + { app: { name: 'MetaMask Extension', version: '1.0.0' } }, + { + messageId: 'msg-1', + timestamp, + callback, + }, + ); + expect(segment.track).toHaveBeenCalledWith( + { + userId: ANALYTICS_ID, + event: 'Wallet Opened', + properties: { foo: 'bar' }, + context: { app: { name: 'MetaMask Extension', version: '1.0.0' } }, + messageId: 'msg-1', + timestamp, + }, + callback, + ); + }); + + it('downgrades anonymous-marked track payloads to the shared anonymous ID', () => { + const { adapter, segment } = buildAdapter(); + const properties = { + foo: 'bar', + [ANONYMOUS_EVENT_PROPERTY]: true, + }; + + adapter.track('Wallet Opened', properties); + + expect(segment.track).toHaveBeenCalledWith( + { + anonymousId: METAMETRICS_ANONYMOUS_ID, + event: 'Wallet Opened', + properties: { foo: 'bar' }, + }, + undefined, + ); + expect(properties).toStrictEqual({ + foo: 'bar', + [ANONYMOUS_EVENT_PROPERTY]: true, + }); + }); + + it('overrides anonymous signature event names', () => { + const { adapter, segment } = buildAdapter(); + + adapter.track(MetaMetricsEventName.SignatureRequested, { + [ANONYMOUS_EVENT_PROPERTY]: true, + }); + + expect(segment.track).toHaveBeenCalledWith( + { + anonymousId: METAMETRICS_ANONYMOUS_ID, + event: MetaMetricsEventName.SignatureRequestedAnon, + properties: {}, + }, + undefined, + ); + }); + + it('overrides anonymous transaction event names', () => { + const { adapter, segment } = buildAdapter(); + + adapter.track(TransactionMetaMetricsEvent.submitted, { + [ANONYMOUS_EVENT_PROPERTY]: true, + }); + + expect(segment.track).toHaveBeenCalledWith( + { + anonymousId: METAMETRICS_ANONYMOUS_ID, + event: AnonymousTransactionMetaMetricsEvent.submitted, + properties: {}, + }, + undefined, + ); + }); + }); + + describe('identify', () => { + it('forwards the userId argument from AnalyticsController and traits', () => { + const { adapter, segment } = buildAdapter(); + adapter.identify('user-1', { plan: 'pro' }); + expect(segment.identify).toHaveBeenCalledWith( + { userId: 'user-1', traits: { plan: 'pro' } }, + undefined, + ); + }); + + it('omits traits when not provided', () => { + const { adapter, segment } = buildAdapter(); + adapter.identify('user-1'); + expect(segment.identify).toHaveBeenCalledWith( + { userId: 'user-1' }, + undefined, + ); + }); + + it('forwards context and delivery options', () => { + const { adapter, segment } = buildAdapter(); + const callback = jest.fn(); + const timestamp = new Date('2026-01-01T00:00:00Z'); + adapter.identify( + 'user-1', + { plan: 'pro' }, + { app: { name: 'MetaMask Extension' } }, + { + messageId: 'id-1', + timestamp, + callback, + }, + ); + expect(segment.identify).toHaveBeenCalledWith( + { + userId: 'user-1', + traits: { plan: 'pro' }, + context: { app: { name: 'MetaMask Extension' } }, + messageId: 'id-1', + timestamp, + }, + callback, + ); + }); + }); + + describe('view', () => { + it('forwards as a Segment page() call with the analyticsId', () => { + const { adapter, segment } = buildAdapter(); + adapter.view('Home'); + expect(segment.page).toHaveBeenCalledWith( + { userId: ANALYTICS_ID, name: 'Home' }, + undefined, + ); + }); + + it('forwards properties, context, and delivery options', () => { + const { adapter, segment } = buildAdapter(); + const callback = jest.fn(); + const timestamp = new Date('2026-01-01T00:00:00Z'); + adapter.view( + 'Home', + { section: 'tokens' }, + { app: { name: 'MetaMask Extension' } }, + { + messageId: 'page-1', + timestamp, + callback, + }, + ); + expect(segment.page).toHaveBeenCalledWith( + { + userId: ANALYTICS_ID, + name: 'Home', + properties: { section: 'tokens' }, + context: { app: { name: 'MetaMask Extension' } }, + messageId: 'page-1', + timestamp, + }, + callback, + ); + }); + }); +}); diff --git a/app/scripts/controllers/analytics/platform-adapter.ts b/app/scripts/controllers/analytics/platform-adapter.ts new file mode 100644 index 000000000000..818578b6390f --- /dev/null +++ b/app/scripts/controllers/analytics/platform-adapter.ts @@ -0,0 +1,149 @@ +import type { + AnalyticsContext, + AnalyticsDeliveryOptions, + AnalyticsEventProperties, + AnalyticsPlatformAdapter, + AnalyticsUserTraits, +} from '@metamask/analytics-controller'; +import { + METAMETRICS_ANONYMOUS_ID, + MetaMetricsEventName, +} from '../../../../shared/constants/metametrics'; +import { + AnonymousTransactionMetaMetricsEvent, + TransactionMetaMetricsEvent, +} from '../../../../shared/constants/transaction'; +import { + segment as extensionSegmentSingleton, + type SegmentClient, +} from '../../lib/segment'; + +export const ANONYMOUS_EVENT_PROPERTY = 'anonymous' as const; + +const anonymousEventNameOverrides = { + [TransactionMetaMetricsEvent.added]: + AnonymousTransactionMetaMetricsEvent.added, + [TransactionMetaMetricsEvent.approved]: + AnonymousTransactionMetaMetricsEvent.approved, + [TransactionMetaMetricsEvent.finalized]: + AnonymousTransactionMetaMetricsEvent.finalized, + [TransactionMetaMetricsEvent.rejected]: + AnonymousTransactionMetaMetricsEvent.rejected, + [TransactionMetaMetricsEvent.submitted]: + AnonymousTransactionMetaMetricsEvent.submitted, + [MetaMetricsEventName.SignatureRequested]: + MetaMetricsEventName.SignatureRequestedAnon, + [MetaMetricsEventName.SignatureApproved]: + MetaMetricsEventName.SignatureApprovedAnon, + [MetaMetricsEventName.SignatureRejected]: + MetaMetricsEventName.SignatureRejectedAnon, +} as const; + +function getSegmentClient(): SegmentClient { + return extensionSegmentSingleton; +} + +type BasePayload = { + context?: AnalyticsContext; + messageId?: AnalyticsDeliveryOptions['messageId']; + timestamp?: AnalyticsDeliveryOptions['timestamp']; +} & ({ userId: string } | { anonymousId: string }); + +function buildBasePayload( + identity: { userId: string } | { anonymousId: string }, + context?: AnalyticsContext, + options?: AnalyticsDeliveryOptions, +): BasePayload { + return { + ...identity, + ...(context ? { context } : {}), + ...(options?.messageId ? { messageId: options.messageId } : {}), + ...(options?.timestamp ? { timestamp: options.timestamp } : {}), + }; +} + +function getAnonymousEventName(eventName: string): string { + return ( + anonymousEventNameOverrides[ + eventName as keyof typeof anonymousEventNameOverrides + ] ?? eventName + ); +} + +/** + * Platform adapter for the AnalyticsController. + * + * - Build full Segment payloads from adapter inputs. Track payloads marked + * with `properties.anonymous` are downgraded to the shared anonymous ID before + * being sent to Segment. + * + * Sets `skipUUIDv4Check: true`: extension `analyticsId` values are + * non-UUIDv4 hex strings (PR MetaMask/core#8543). + */ +export function createPlatformAdapter(): AnalyticsPlatformAdapter { + let cachedAnalyticsId: string; + const client = getSegmentClient(); + + return { + skipUUIDv4Check: true, + + track( + eventName: string, + properties?: AnalyticsEventProperties, + context?: AnalyticsContext, + options?: AnalyticsDeliveryOptions, + ): void { + const isAnonymousEvent = + properties?.[ANONYMOUS_EVENT_PROPERTY] === true; + let payloadProperties = properties; + if (isAnonymousEvent) { + payloadProperties = { ...properties }; + delete payloadProperties[ANONYMOUS_EVENT_PROPERTY]; + } + + const payload = { + ...buildBasePayload( + isAnonymousEvent + ? { anonymousId: METAMETRICS_ANONYMOUS_ID } + : { userId: cachedAnalyticsId }, + context, + options, + ), + event: isAnonymousEvent ? getAnonymousEventName(eventName) : eventName, + ...(payloadProperties ? { properties: payloadProperties } : {}), + }; + client.track(payload, options?.callback); + }, + + identify( + userId: string, + traits?: AnalyticsUserTraits, + context?: AnalyticsContext, + options?: AnalyticsDeliveryOptions, + ): void { + const payload = { + ...buildBasePayload({ userId }, context, options), + ...(traits ? { traits } : {}), + }; + client.identify(payload, options?.callback); + }, + + view( + name: string, + properties?: AnalyticsEventProperties, + context?: AnalyticsContext, + options?: AnalyticsDeliveryOptions, + ): void { + const payload = { + ...buildBasePayload({ userId: cachedAnalyticsId }, context, options), + name, + ...(properties ? { properties } : {}), + }; + client.page(payload, options?.callback); + }, + + onSetupCompleted(analyticsId: string): void { + cachedAnalyticsId = analyticsId; + }, + }; +} diff --git a/app/scripts/controllers/metametrics-controller-method-action-types.ts b/app/scripts/controllers/metametrics-controller-method-action-types.ts index 49b79101dad7..5ecb4505135f 100644 --- a/app/scripts/controllers/metametrics-controller-method-action-types.ts +++ b/app/scripts/controllers/metametrics-controller-method-action-types.ts @@ -10,11 +10,6 @@ export type MetaMetricsControllerFinalizeAbandonedFragmentsAction = { handler: MetaMetricsController['finalizeAbandonedFragments']; }; -export type MetaMetricsControllerGenerateMetaMetricsIdAction = { - type: `MetaMetricsController:generateMetaMetricsId`; - handler: MetaMetricsController['generateMetaMetricsId']; -}; - /** * Create an event fragment in state and returns the event fragment object. * @@ -214,7 +209,6 @@ export type MetaMetricsControllerGetMetaMetricsIdAction = { */ export type MetaMetricsControllerMethodActions = | MetaMetricsControllerFinalizeAbandonedFragmentsAction - | MetaMetricsControllerGenerateMetaMetricsIdAction | MetaMetricsControllerCreateEventFragmentAction | MetaMetricsControllerGetEventFragmentByIdAction | MetaMetricsControllerProcessAbandonedFragmentAction diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index 27c69b177c6b..e2724a74e390 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -3,8 +3,13 @@ import type { NetworkClientId, NetworkState, } from '@metamask/network-controller'; -import { NameEntry, NameType } from '@metamask/name-controller'; +import type { + AnalyticsContext, + AnalyticsControllerState, + AnalyticsUserTraits, +} from '@metamask/analytics-controller'; import { AddressBookEntry } from '@metamask/address-book-controller'; +import { NameEntry, NameType } from '@metamask/name-controller'; import { Nft, Token, @@ -36,11 +41,10 @@ import { OS, PLATFORM_CHROME, } from '../../../shared/constants/app'; -import type { SegmentClient } from '../lib/segment'; -import { createSegmentMock } from '../lib/segment'; +import { createSegmentMock, segment } from '../lib/segment'; import { - METAMETRICS_ANONYMOUS_ID, METAMETRICS_BACKGROUND_PAGE_OBJECT, + MetaMetricsEventName, MetaMetricsUserTrait, MetaMetricsUserTraits, } from '../../../shared/constants/metametrics'; @@ -61,11 +65,14 @@ import { createMockInternalAccounts, } from '../../../test/data/mock-accounts'; import type { Preferences } from '../../../shared/types/preferences'; +import { ANONYMOUS_EVENT_PROPERTY } from './analytics/platform-adapter'; import { MetaMetricsController, AllowedActions, AllowedEvents, MetaMetricsControllerOptions, + type MetaMaskState, + type MetaMetricsControllerState, } from './metametrics-controller'; import { getDefaultPreferencesControllerState, @@ -81,9 +88,13 @@ const segmentMock = createSegmentMock(2); const VERSION = '0.0.1-test'; const DEFAULT_CHAIN_ID = '0x1338'; const LOCALE = 'en_US'; -const TEST_META_METRICS_ID = '0xabc'; +const TEST_ANALYTICS_ID = '00000000-0000-4000-8000-000000000001'; const TEST_GA_COOKIE_ID = '123456.123455'; -const DUMMY_ACTION_ID = 'DUMMY_ACTION_ID'; + +const MOCK_ANALYTICS_CONTROLLER_OPTED_IN: AnalyticsControllerState = { + optedIn: true, + analyticsId: TEST_ANALYTICS_ID, +}; const MOCK_EXTENSION_ID = 'testid'; const MOCK_EXTENSION = { @@ -209,11 +220,9 @@ describe('MetaMetricsController', function () { await withController(({ controller }) => { expect(controller.version).toStrictEqual(VERSION); expect(controller.chainId).toStrictEqual(DEFAULT_CHAIN_ID); - expect(controller.state.participateInMetaMetrics).toStrictEqual(true); - expect(controller.state.metaMetricsId).toStrictEqual( - TEST_META_METRICS_ID, - ); + expect(controller.state.completedMetaMetricsOnboarding).toBe(true); expect(controller.state.marketingCampaignCookieId).toStrictEqual(null); + expect(controller.getMetaMetricsId()).toStrictEqual(TEST_ANALYTICS_ID); expect(controller.locale).toStrictEqual(LOCALE.replace('_', '-')); expect(controller.state.fragments).toStrictEqual({ testid: SAMPLE_PERSISTED_EVENT, @@ -222,14 +231,12 @@ describe('MetaMetricsController', function () { expect(spy).toHaveBeenCalledWith( { event: 'sample non-persisted event failure', - userId: TEST_META_METRICS_ID, + userId: TEST_ANALYTICS_ID, context: DEFAULT_TEST_CONTEXT, properties: { ...DEFAULT_EVENT_PROPERTIES, test: true, }, - messageId: 'sample-non-persisted-event-failure', - timestamp: new Date(), }, spy.mock.calls[0][1], ); @@ -308,47 +315,29 @@ describe('MetaMetricsController', function () { }); it('should track the initial event if provided', async function () { - await withController( - { - options: { - state: { - participateInMetaMetrics: true, - }, - }, - }, - ({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - const mockInitialEventName = 'Test Initial Event'; + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + const mockInitialEventName = 'Test Initial Event'; - controller.createEventFragment({ - ...SAMPLE_PERSISTED_EVENT_NO_ID, - initialEvent: mockInitialEventName, - }); + controller.createEventFragment({ + ...SAMPLE_PERSISTED_EVENT_NO_ID, + initialEvent: mockInitialEventName, + }); - expect(spy).toHaveBeenCalledTimes(1); - }, - ); + expect(spy).toHaveBeenCalledTimes(1); + }); }); it('should not call track if no initialEvent was provided', async function () { - await withController( - { - options: { - state: { - participateInMetaMetrics: true, - }, - }, - }, - ({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); - controller.createEventFragment({ - ...SAMPLE_PERSISTED_EVENT_NO_ID, - }); + controller.createEventFragment({ + ...SAMPLE_PERSISTED_EVENT_NO_ID, + }); - expect(spy).toHaveBeenCalledTimes(0); - }, - ); + expect(spy).toHaveBeenCalledTimes(0); + }); }); describe('when intialEvent is "Transaction Submitted" and a fragment exists before createEventFragment is called', function () { @@ -466,118 +455,73 @@ describe('MetaMetricsController', function () { }); }); - describe('generateMetaMetricsId', function () { - it('should generate an 0x prefixed hex string', async function () { - await withController(({ controller }) => { - expect( - controller.generateMetaMetricsId().startsWith('0x'), - ).toStrictEqual(true); - }); - }); - }); - describe('getMetaMetricsId', function () { - it('should generate or return the metametrics id', async function () { - await withController( - { - options: { - state: { - participateInMetaMetrics: true, - metaMetricsId: null, - }, - }, - }, - ({ controller }) => { - // Starts off being empty. - expect(controller.state.metaMetricsId).toStrictEqual(null); + it('returns the analytics metametrics id and keeps it stable across calls', async function () { + await withController(({ controller, controllerMessenger }) => { + const { analyticsId: initialAnalyticsId } = controllerMessenger.call( + 'AnalyticsController:getState', + ); + expect(initialAnalyticsId).toStrictEqual(TEST_ANALYTICS_ID); - // Create a new metametrics id. - const clientMetaMetricsId = controller.getMetaMetricsId(); - expect(clientMetaMetricsId.startsWith('0x')).toStrictEqual(true); + const clientMetaMetricsId = controller.getMetaMetricsId(); + expect(clientMetaMetricsId).toStrictEqual(TEST_ANALYTICS_ID); + expect(clientMetaMetricsId).toMatch( + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/iu, + ); - // Return same metametrics id. - const sameMetaMetricsId = controller.getMetaMetricsId(); - expect(clientMetaMetricsId).toStrictEqual(sameMetaMetricsId); - }, - ); + const sameMetaMetricsId = controller.getMetaMetricsId(); + expect(clientMetaMetricsId).toStrictEqual(sameMetaMetricsId); + }); }); }); describe('identify', function () { it('should call segment.identify for valid traits if user is participating in metametrics', async function () { const spy = jest.spyOn(segmentMock, 'identify'); - await withController( - { - options: { - state: { - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, - }, - }, - }, - ({ controller }) => { - controller.identify({ - ...MOCK_TRAITS, - ...MOCK_INVALID_TRAITS, - }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - userId: TEST_META_METRICS_ID, - traits: MOCK_TRAITS, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }, - ); + await withController(({ controller }) => { + controller.identify({ + ...MOCK_TRAITS, + ...MOCK_INVALID_TRAITS, + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + userId: TEST_ANALYTICS_ID, + traits: MOCK_TRAITS, + }), + undefined, + ); + }); }); it('should transform date type traits into ISO-8601 timestamp strings', async function () { const spy = jest.spyOn(segmentMock, 'identify'); - await withController( - { - options: { - state: { - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, - }, - }, - }, - ({ controller }) => { - controller.identify({ - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - test_date: new Date().toISOString(), - } as MetaMetricsUserTraits); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - userId: TEST_META_METRICS_ID, - traits: { - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - test_date: new Date().toISOString(), - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), + await withController(({ controller }) => { + controller.identify({ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + test_date: new Date().toISOString(), + } as MetaMetricsUserTraits); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + userId: TEST_ANALYTICS_ID, + traits: { + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + test_date: new Date().toISOString(), }, - spy.mock.calls[0][1], - ); - }, - ); + }), + undefined, + ); + }); }); it('should not call segment.identify if user is not participating in metametrics', async function () { const spy = jest.spyOn(segmentMock, 'identify'); await withController( { - options: { - state: { - participateInMetaMetrics: false, - }, - }, + analyticsControllerState: { optedIn: false }, }, ({ controller }) => { controller.identify(MOCK_TRAITS); @@ -588,20 +532,10 @@ describe('MetaMetricsController', function () { it('should not call segment.identify if there are no valid traits to identify', async function () { const spy = jest.spyOn(segmentMock, 'identify'); - await withController( - { - options: { - state: { - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, - }, - }, - }, - ({ controller }) => { - controller.identify(MOCK_INVALID_TRAITS); - expect(spy).toHaveBeenCalledTimes(0); - }, - ); + await withController(({ controller }) => { + controller.identify(MOCK_INVALID_TRAITS); + expect(spy).toHaveBeenCalledTimes(0); + }); }); }); @@ -610,46 +544,32 @@ describe('MetaMetricsController', function () { await withController( { options: { - state: { - participateInMetaMetrics: null, - metaMetricsId: null, - }, + state: { completedMetaMetricsOnboarding: false }, }, + analyticsControllerState: { optedIn: false }, }, - async ({ controller }) => { - expect(controller.state.participateInMetaMetrics).toStrictEqual(null); - await controller.setParticipateInMetaMetrics(true); - expect(controller.state.participateInMetaMetrics).toStrictEqual(true); - await controller.setParticipateInMetaMetrics(false); - expect(controller.state.participateInMetaMetrics).toStrictEqual( + async ({ controller, controllerMessenger }) => { + expect(controller.state.completedMetaMetricsOnboarding).toBe( false, ); - }, - ); - }); - it('should generate and update the metaMetricsId when set to true', async function () { - await withController( - { - options: { - state: { - participateInMetaMetrics: null, - metaMetricsId: null, - }, - }, - }, - async ({ controller }) => { - expect(controller.state.metaMetricsId).toStrictEqual(null); await controller.setParticipateInMetaMetrics(true); - expect(typeof controller.state.metaMetricsId).toStrictEqual('string'); + expect(controller.state.completedMetaMetricsOnboarding).toBe( + true, + ); + expect( + controllerMessenger.call('AnalyticsController:getState').optedIn, + ).toBe(true); + await controller.setParticipateInMetaMetrics(false); + expect( + controllerMessenger.call('AnalyticsController:getState').optedIn, + ).toBe(false); }, ); }); it('should not nullify the metaMetricsId when set to false', async function () { await withController(async ({ controller }) => { await controller.setParticipateInMetaMetrics(false); - expect(controller.state.metaMetricsId).toStrictEqual( - TEST_META_METRICS_ID, - ); + expect(controller.getMetaMetricsId()).toStrictEqual(TEST_ANALYTICS_ID); }); }); it('should nullify the marketingCampaignCookieId when participateInMetaMetrics is toggled off', async function () { @@ -657,8 +577,6 @@ describe('MetaMetricsController', function () { { options: { state: { - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, dataCollectionForMarketing: true, marketingCampaignCookieId: TEST_GA_COOKIE_ID, }, @@ -681,10 +599,10 @@ describe('MetaMetricsController', function () { it('updates the profile when install attribution traits arrive after opt-in', async function () { await withController( { + analyticsControllerState: { optedIn: false }, options: { state: { - participateInMetaMetrics: null, - metaMetricsId: TEST_META_METRICS_ID, + completedMetaMetricsOnboarding: false, dataCollectionForMarketing: false, traits: {}, }, @@ -718,6 +636,7 @@ describe('MetaMetricsController', function () { ethereumAddress: {}, }, participateInMetaMetrics: true, + metaMetricsId: TEST_ANALYTICS_ID, currentCurrency: 'usd', dataCollectionForMarketing: false, preferences: { @@ -761,11 +680,7 @@ describe('MetaMetricsController', function () { const spy = jest.spyOn(segmentMock, 'track'); await withController( { - options: { - state: { - participateInMetaMetrics: false, - }, - }, + analyticsControllerState: { optedIn: false }, }, ({ controller }) => { controller.trackEvent({ @@ -782,34 +697,28 @@ describe('MetaMetricsController', function () { ); }); - it('should track an event if user has not opted in, but isOptIn is true', async function () { + it('tracks Metrics Opt Out when user is opted out on non-Firefox browsers', async function () { await withController( { - options: { - state: { - participateInMetaMetrics: true, - }, - }, + analyticsControllerState: { optedIn: false }, }, ({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent( - { - event: 'Fake Event', - category: 'Unit Test', - properties: { - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: '1', - }, + const spy = jest.spyOn(segment, 'track'); + const flushSpy = jest.spyOn(segment, 'flush'); + controller.trackEvent({ + event: MetaMetricsEventName.MetricsOptOut, + category: 'Unit Test', + properties: { + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: '1', }, - { isOptIn: true }, - ); + }); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith( { - event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, + event: MetaMetricsEventName.MetricsOptOut, + userId: TEST_ANALYTICS_ID, context: DEFAULT_TEST_CONTEXT, properties: { ...DEFAULT_EVENT_PROPERTIES, @@ -817,55 +726,51 @@ describe('MetaMetricsController', function () { // eslint-disable-next-line @typescript-eslint/naming-convention chain_id: '1', }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), }, - spy.mock.calls[0][1], ); + expect(flushSpy).toHaveBeenCalledTimes(1); }, ); }); - it('should track an event during optin and allow for metaMetricsId override', async function () { + it('does not track Metrics Opt Out when user is opted out on Firefox', async function () { + jest + .spyOn(window.navigator, 'userAgent', 'get') + .mockReturnValue('Mozilla/5.0 Firefox/126.0'); await withController( { - options: { - state: { - participateInMetaMetrics: true, - }, - }, + analyticsControllerState: { optedIn: false }, + }, + ({ controller }) => { + const spy = jest.spyOn(segment, 'track'); + controller.trackEvent({ + event: MetaMetricsEventName.MetricsOptOut, + category: 'Unit Test', + }); + expect(spy).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not track normal events when user is opted out', async function () { + await withController( + { + analyticsControllerState: { optedIn: false }, }, ({ controller }) => { const spy = jest.spyOn(segmentMock, 'track'); controller.trackEvent( - { - event: 'Fake Event', - category: 'Unit Test', - properties: { - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: '1', - }, - }, - { isOptIn: true, metaMetricsId: 'TESTID' }, - ); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: 'TESTID', - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: '1', - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), + { + event: 'Fake Event', + category: 'Unit Test', + properties: { + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: '1', }, - spy.mock.calls[0][1], - ); + }, + ); + expect(spy).not.toHaveBeenCalled(); }, ); }); @@ -889,7 +794,7 @@ describe('MetaMetricsController', function () { expect(spy).toHaveBeenCalledWith( { event: 'Fake Event', - userId: TEST_META_METRICS_ID, + userId: TEST_ANALYTICS_ID, context: DEFAULT_TEST_CONTEXT, properties: { ...DEFAULT_EVENT_PROPERTIES, @@ -900,8 +805,6 @@ describe('MetaMetricsController', function () { // eslint-disable-next-line @typescript-eslint/naming-convention chain_id: '1', }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), }, spy.mock.calls[0][1], ); @@ -931,19 +834,19 @@ describe('MetaMetricsController', function () { chain_id: '1', }, context: DEFAULT_TEST_CONTEXT, - userId: TEST_META_METRICS_ID, - messageId: Utils.generateRandomId(), - timestamp: new Date(), + userId: TEST_ANALYTICS_ID, }, spy.mock.calls[0][1], ); }); }); - it('should use custom timestamp when provided in event payload', async function () { + it('uses current time for latest analytics event state', async function () { await withController(({ controller }) => { const spy = jest.spyOn(segmentMock, 'track'); + const currentTimestamp = new Date('2024-02-01T00:00:00.000Z').getTime(); const customTimestamp = '2024-01-15T00:00:00.000Z'; + jest.setSystemTime(currentTimestamp); controller.trackEvent({ event: 'Fake Event', category: 'Unit Test', @@ -965,26 +868,13 @@ describe('MetaMetricsController', function () { chain_id: '1', }, context: DEFAULT_TEST_CONTEXT, - userId: TEST_META_METRICS_ID, - messageId: Utils.generateRandomId(), - timestamp: new Date(customTimestamp), + userId: TEST_ANALYTICS_ID, }, spy.mock.calls[0][1], ); - }); - }); - - it('should immediately flush queue if flushImmediately set to true', async function () { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'flush'); - controller.trackEvent( - { - event: 'Fake Event', - category: 'Unit Test', - }, - { flushImmediately: true }, + expect(controller.state.latestNonAnonymousEventTimestamp).toBe( + currentTimestamp, ); - expect(spy).not.toThrow(); }); }); @@ -1025,7 +915,7 @@ describe('MetaMetricsController', function () { ); }); - it('should track sensitiveProperties in a separate, anonymous event', async function () { + it('tracks sensitiveProperties in a separate event marked for anonymization', async function () { await withController(({ controller }) => { const spy = jest.spyOn(segmentMock, 'track'); controller.trackEvent({ @@ -1034,30 +924,32 @@ describe('MetaMetricsController', function () { sensitiveProperties: { foo: 'bar' }, }); expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { + + expect(spy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, + userId: TEST_ANALYTICS_ID, context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], + properties: expect.objectContaining(DEFAULT_EVENT_PROPERTIES), + }), + undefined, ); - expect(spy).toHaveBeenCalledWith( - { + expect(spy.mock.calls[0][0].properties).not.toHaveProperty('foo'); + + expect(spy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'Fake Event', - userId: TEST_META_METRICS_ID, + userId: TEST_ANALYTICS_ID, context: DEFAULT_TEST_CONTEXT, - properties: DEFAULT_EVENT_PROPERTIES, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[1][1], + properties: expect.objectContaining({ + foo: 'bar', + ...DEFAULT_EVENT_PROPERTIES, + [ANONYMOUS_EVENT_PROPERTY]: true, + }), + }), + undefined, ); }); }); @@ -1095,7 +987,7 @@ describe('MetaMetricsController', function () { ], }), }), - expect.anything(), + undefined, ); }, ); @@ -1143,7 +1035,7 @@ describe('MetaMetricsController', function () { ], }), }), - expect.anything(), + undefined, ); }, ); @@ -1209,7 +1101,7 @@ describe('MetaMetricsController', function () { ], }), }), - expect.anything(), + undefined, ); }, ); @@ -1247,7 +1139,7 @@ describe('MetaMetricsController', function () { test_prop: 'value', }), }), - expect.anything(), + undefined, ); expect(spy.mock.calls[0][0].properties).not.toHaveProperty( 'active_ab_tests', @@ -1312,12 +1204,12 @@ describe('MetaMetricsController', function () { test_prop: 'value', }), }), - expect.anything(), + undefined, ); }); }); - it('normalizes existing active_ab_tests on anonymous sensitive-property events', async function () { + it('normalizes active_ab_tests before splitting sensitive events', async function () { const getManifestFlagsSpy = jest .spyOn(ManifestFlags, 'getManifestFlags') .mockReturnValue({}); @@ -1355,25 +1247,28 @@ describe('MetaMetricsController', function () { properties: expect.objectContaining({ // eslint-disable-next-line @typescript-eslint/naming-convention active_ab_tests: [normalizedAssignment], - sensitive: 'value', }), }), - expect.anything(), + undefined, ); + expect(spy.mock.calls[0][0].properties).not.toHaveProperty('sensitive'); + expect(spy).toHaveBeenNthCalledWith( 2, expect.objectContaining({ properties: expect.objectContaining({ // eslint-disable-next-line @typescript-eslint/naming-convention active_ab_tests: [normalizedAssignment], + sensitive: 'value', + [ANONYMOUS_EVENT_PROPERTY]: true, }), }), - expect.anything(), + undefined, ); }); }); - it('preserves sensitiveProperties and only enriches the identified event', async function () { + it('enriches mapped events before splitting sensitive events', async function () { AB_TEST_ANALYTICS_MAPPINGS.push({ flagKey: TEST_BADGE_FLAG_KEY, validVariants: ['control', 'withBadge'], @@ -1408,13 +1303,16 @@ describe('MetaMetricsController', function () { properties: expect.objectContaining({ // eslint-disable-next-line @typescript-eslint/naming-convention button_type: 'card', - sensitive: 'value', + // eslint-disable-next-line @typescript-eslint/naming-convention + active_ab_tests: [ + createActiveABTestAssignment(TEST_BADGE_FLAG_KEY, 'control'), + ], }), }), - expect.anything(), + undefined, ); expect(spy.mock.calls[0][0].properties).not.toHaveProperty( - 'active_ab_tests', + 'sensitive', ); expect(spy).toHaveBeenNthCalledWith( 2, @@ -1422,13 +1320,15 @@ describe('MetaMetricsController', function () { properties: expect.objectContaining({ // eslint-disable-next-line @typescript-eslint/naming-convention button_type: 'card', + sensitive: 'value', // eslint-disable-next-line @typescript-eslint/naming-convention active_ab_tests: [ createActiveABTestAssignment(TEST_BADGE_FLAG_KEY, 'control'), ], + [ANONYMOUS_EVENT_PROPERTY]: true, }), }), - expect.anything(), + undefined, ); }, ); @@ -1472,479 +1372,162 @@ describe('MetaMetricsController', function () { ], }), }), - expect.anything(), - ); - }, - ); - }); - }); - - describe('Change Signature XXX anonymous event names', function () { - // @ts-expect-error This function is missing from the Mocha type definitions - it.each([ - ['Signature Requested', 'Signature Requested Anon'], - ['Signature Rejected', 'Signature Rejected Anon'], - ['Signature Approved', 'Signature Approved Anon'], - ])( - 'should change "%s" anonymous event names to "%s"', - async (eventType: string, anonEventType: string) => { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: eventType, - category: 'Unit Test', - properties: DEFAULT_EVENT_PROPERTIES, - sensitiveProperties: { foo: 'bar' }, - }); - - expect(spy).toHaveBeenCalledTimes(2); - - expect(spy.mock.calls[0][0]).toMatchObject({ - event: anonEventType, - properties: { foo: 'bar', ...DEFAULT_EVENT_PROPERTIES }, - }); - - expect(spy.mock.calls[1][0]).toMatchObject({ - event: eventType, - properties: { ...DEFAULT_EVENT_PROPERTIES }, - }); - }); - }, - ); - }); - - describe('Change Transaction XXX anonymous event namnes', function () { - it('should change "Transaction Added" anonymous event names to "Transaction Added Anon"', async function () { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: 'Transaction Added', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: `Transaction Added Anon`, - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - }); - - it('should change "Transaction Submitted" anonymous event names to "Transaction Added Anon"', async function () { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: 'Transaction Submitted', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: `Transaction Submitted Anon`, - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - }); - - it('should change "Transaction Finalized" anonymous event names to "Transaction Added Anon"', async function () { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: 'Transaction Finalized', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: `Transaction Finalized Anon`, - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - }); - }); - - describe('trackPage', function () { - it('should track a page view', async function () { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'page'); - controller.trackPage({ - name: 'home', - environmentType: ENVIRONMENT_TYPE_BACKGROUND, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - name: 'home', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - params: undefined, - ...DEFAULT_PAGE_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - }); - - it('should not track a page view if user is not participating in metametrics', async function () { - await withController( - { - options: { - state: { - participateInMetaMetrics: false, - }, - }, - }, - ({ controller }) => { - const spy = jest.spyOn(segmentMock, 'page'); - controller.trackPage({ - name: 'home', - environmentType: ENVIRONMENT_TYPE_BACKGROUND, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - }); - expect(spy).toHaveBeenCalledTimes(0); - }, - ); - }); - - it('should track a page view if isOptInPath is true and user not yet opted in', async function () { - await withController( - { - currentLocale: LOCALE, - options: { - state: { - participateInMetaMetrics: true, - }, - }, - }, - ({ controller }) => { - const spy = jest.spyOn(segmentMock, 'page'); - controller.trackPage( - { - name: 'home', - environmentType: ENVIRONMENT_TYPE_BACKGROUND, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - }, - { isOptInPath: true }, - ); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - name: 'home', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_PAGE_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }, - ); - }); - - it('multiple trackPage call with same actionId should result in same messageId being sent to segment', async function () { - await withController( - { - currentLocale: LOCALE, - options: { - state: { - participateInMetaMetrics: true, - }, - }, - }, - ({ controller }) => { - const spy = jest.spyOn(segmentMock, 'page'); - controller.trackPage( - { - name: 'home', - actionId: DUMMY_ACTION_ID, - environmentType: ENVIRONMENT_TYPE_BACKGROUND, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - }, - { isOptInPath: true }, - ); - controller.trackPage( - { - name: 'home', - actionId: DUMMY_ACTION_ID, - environmentType: ENVIRONMENT_TYPE_BACKGROUND, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - }, - { isOptInPath: true }, - ); - - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - name: 'home', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: DEFAULT_PAGE_PROPERTIES, - messageId: DUMMY_ACTION_ID, - timestamp: new Date(), - }, - spy.mock.calls[0][1], + undefined, ); }, ); }); - }); - - describe('deterministic messageId', function () { - it('should use the actionId as messageId when provided', async function () { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: 'Fake Event', - category: 'Unit Test', - properties: { - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: 'bar', - }, - actionId: '0x001', - }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: 'bar', - }, - messageId: '0x001', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - }); - - it('should append 0x000 to the actionId of anonymized event when tracking sensitiveProperties', async function () { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: 'Fake Event', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - actionId: '0x001', - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: '0x001-0x000', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: DEFAULT_EVENT_PROPERTIES, - messageId: '0x001', - timestamp: new Date(), - }, - spy.mock.calls[1][1], - ); - }); - }); + }); - it('should use the uniqueIdentifier as messageId when provided', async function () { + describe('Sensitive transaction and signature events', function () { + it('keeps the original event name and marks anonymous-only tracks', async function () { await withController(({ controller }) => { const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: 'Fake Event', - category: 'Unit Test', - properties: { - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: 'bar', + const currentTimestamp = new Date('2024-02-01T00:00:00.000Z').getTime(); + jest.setSystemTime(currentTimestamp); + controller.trackEvent( + { + event: 'Signature Requested', + category: 'Unit Test', + properties: DEFAULT_EVENT_PROPERTIES, }, - uniqueIdentifier: 'transaction-submitted-0000', - }); + { excludeMetaMetricsId: true }, + ); + expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { + expect.objectContaining({ + event: 'Signature Requested', + properties: expect.objectContaining({ ...DEFAULT_EVENT_PROPERTIES, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: 'bar', - }, - messageId: 'transaction-submitted-0000', - timestamp: new Date(), - }, - spy.mock.calls[0][1], + [ANONYMOUS_EVENT_PROPERTY]: true, + }), + }), + undefined, + ); + expect(controller.state.latestNonAnonymousEventTimestamp).toBe( + currentTimestamp, ); }); }); - it('should append 0x000 to the uniqueIdentifier of anonymized event when tracking sensitiveProperties', async function () { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: 'Fake Event', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - uniqueIdentifier: 'transaction-submitted-0000', - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { + // @ts-expect-error This function is missing from the Mocha type definitions + it.each([ + 'Signature Requested', + 'Signature Rejected', + 'Signature Approved', + ])( + 'keeps the original event name before the platform adapter handles anonymous tracks for "%s"', + async (eventType: string) => { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: eventType, + category: 'Unit Test', + properties: DEFAULT_EVENT_PROPERTIES, + sensitiveProperties: { foo: 'bar' }, + }); + + expect(spy).toHaveBeenCalledTimes(2); + + expect(spy.mock.calls[0][0]).toMatchObject({ + event: eventType, + properties: expect.objectContaining({ ...DEFAULT_EVENT_PROPERTIES }), + }); + expect(spy.mock.calls[0][0].properties).not.toHaveProperty('foo'); + + expect(spy.mock.calls[1][0]).toMatchObject({ + event: eventType, + properties: expect.objectContaining({ foo: 'bar', ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: 'transaction-submitted-0000-0x000', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { + [ANONYMOUS_EVENT_PROPERTY]: true, + }), + }); + }); + }, + ); + }); + + describe('Sensitive transaction lifecycle events', function () { + // @ts-expect-error This function is missing from the Mocha type definitions + it.each([ + 'Transaction Added', + 'Transaction Submitted', + 'Transaction Finalized', + ])( + 'keeps the original event name before the platform adapter handles anonymous tracks for "%s"', + async (eventType: string) => { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: eventType, + category: 'Unit Test', + sensitiveProperties: { foo: 'bar' }, + }); + expect(spy).toHaveBeenCalledTimes(2); + + expect(spy.mock.calls[0][0]).toMatchObject({ + event: eventType, + properties: expect.objectContaining(DEFAULT_EVENT_PROPERTIES), + }); + expect(spy.mock.calls[0][0].properties).not.toHaveProperty('foo'); + + expect(spy.mock.calls[1][0]).toMatchObject({ + event: eventType, + properties: expect.objectContaining({ + foo: 'bar', ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: 'transaction-submitted-0000', - timestamp: new Date(), - }, - spy.mock.calls[1][1], - ); - }); - }); + [ANONYMOUS_EVENT_PROPERTY]: true, + }), + }); + }); + }, + ); + }); - it('should combine the uniqueIdentifier and actionId as messageId when both provided', async function () { + describe('trackPage', function () { + it('should track a page view', async function () { await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: 'Fake Event', - category: 'Unit Test', - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - properties: { chain_id: 'bar' }, - actionId: '0x001', - uniqueIdentifier: 'transaction-submitted-0000', + const spy = jest.spyOn(segmentMock, 'page'); + controller.trackPage({ + name: 'home', + environmentType: ENVIRONMENT_TYPE_BACKGROUND, + page: METAMETRICS_BACKGROUND_PAGE_OBJECT, }); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith( { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, + name: 'home', + userId: TEST_ANALYTICS_ID, context: DEFAULT_TEST_CONTEXT, properties: { - ...DEFAULT_EVENT_PROPERTIES, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: 'bar', + params: undefined, + ...DEFAULT_PAGE_PROPERTIES, }, - messageId: 'transaction-submitted-0000-0x001', - timestamp: new Date(), }, spy.mock.calls[0][1], ); }); }); - it('should append 0x000 to the combined uniqueIdentifier and actionId of anonymized event when tracking sensitiveProperties', async function () { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: 'Fake Event', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - actionId: '0x001', - uniqueIdentifier: 'transaction-submitted-0000', - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: 'transaction-submitted-0000-0x001-0x000', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: 'transaction-submitted-0000-0x001', - timestamp: new Date(), - }, - spy.mock.calls[1][1], - ); - }); + it('should not track a page view if user is not participating in metametrics', async function () { + await withController( + { + analyticsControllerState: { optedIn: false }, + }, + ({ controller }) => { + const spy = jest.spyOn(segmentMock, 'page'); + controller.trackPage({ + name: 'home', + environmentType: ENVIRONMENT_TYPE_BACKGROUND, + page: METAMETRICS_BACKGROUND_PAGE_OBJECT, + }); + expect(spy).toHaveBeenCalledTimes(0); + }, + ); }); + }); function buildStateWithAccounts( @@ -1971,6 +1554,7 @@ describe('MetaMetricsController', function () { currentCurrency: 'usd', securityAlertsEnabled: false, participateInMetaMetrics: true, + metaMetricsId: null, dataCollectionForMarketing: false, preferences: { privacyMode: false, @@ -2161,6 +1745,7 @@ describe('MetaMetricsController', function () { }, }, participateInMetaMetrics: true, + metaMetricsId: TEST_ANALYTICS_ID, currentCurrency: 'usd', dataCollectionForMarketing: false, preferences: { @@ -2258,6 +1843,7 @@ describe('MetaMetricsController', function () { useTokenDetection: true, allNfts: {}, participateInMetaMetrics: true, + metaMetricsId: TEST_ANALYTICS_ID, dataCollectionForMarketing: false, preferences: { privacyMode: false, @@ -2312,6 +1898,7 @@ describe('MetaMetricsController', function () { useTokenDetection: true, allNfts: {}, participateInMetaMetrics: true, + metaMetricsId: TEST_ANALYTICS_ID, dataCollectionForMarketing: false, preferences: { privacyMode: false, @@ -2379,6 +1966,7 @@ describe('MetaMetricsController', function () { useTokenDetection: true, allNfts: {}, participateInMetaMetrics: true, + metaMetricsId: TEST_ANALYTICS_ID, dataCollectionForMarketing: false, preferences: { privacyMode: true, @@ -2399,7 +1987,7 @@ describe('MetaMetricsController', function () { keyrings: [], firstTimeFlowType: FirstTimeFlowType.create, multichainNetworkConfigurationsByChainId: {}, - }); + } as MetaMaskState); const updatedTraits = controller._buildUserTraitsObject({ addressBook: { @@ -2445,6 +2033,7 @@ describe('MetaMetricsController', function () { currentCurrency: 'usd', allNfts: {}, participateInMetaMetrics: true, + metaMetricsId: TEST_ANALYTICS_ID, dataCollectionForMarketing: false, preferences: { privacyMode: true, @@ -2475,7 +2064,7 @@ describe('MetaMetricsController', function () { keyrings: [], firstTimeFlowType: FirstTimeFlowType.import, multichainNetworkConfigurationsByChainId: {}, - }); + } as MetaMaskState); expect(updatedTraits).toStrictEqual({ [MetaMetricsUserTrait.AddressBookEntries]: 4, @@ -2528,6 +2117,7 @@ describe('MetaMetricsController', function () { useTokenDetection: true, allNfts: {}, participateInMetaMetrics: true, + metaMetricsId: TEST_ANALYTICS_ID, dataCollectionForMarketing: false, preferences: { privacyMode: true, @@ -2594,6 +2184,7 @@ describe('MetaMetricsController', function () { useTokenDetection: true, allNfts: {}, participateInMetaMetrics: true, + metaMetricsId: TEST_ANALYTICS_ID, dataCollectionForMarketing: false, preferences: { privacyMode: true, @@ -2757,48 +2348,12 @@ describe('MetaMetricsController', function () { }); }); - describe('submitting segmentApiCalls to segment SDK', function () { - it('should add event to store when submitting to SDK', async function () { - await withController(({ controller }) => { - controller.trackPage({}, { isOptInPath: true }); - const { segmentApiCalls } = controller.state; - expect(Object.keys(segmentApiCalls).length > 0).toStrictEqual(true); - }); - }); - - it('should remove event from store when callback is invoked', async function () { - const segmentInstance = createSegmentMock(2); - const stubFn = ( - _message: Parameters[0], - callback?: Parameters[1], - ): void => { - callback?.(); - }; - jest.spyOn(segmentInstance, 'track').mockImplementation(stubFn); - jest.spyOn(segmentInstance, 'page').mockImplementation(stubFn); - - await withController( - { - options: { - segment: segmentInstance, - }, - }, - ({ controller }) => { - controller.trackPage({}, { isOptInPath: true }); - const { segmentApiCalls } = controller.state; - expect(Object.keys(segmentApiCalls).length === 0).toStrictEqual(true); - }, - ); - }); - }); describe('setMarketingCampaignCookieId', function () { it('should update marketingCampaignCookieId in the context when cookieId is available', async function () { await withController( { options: { state: { - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, dataCollectionForMarketing: true, }, }, @@ -2809,23 +2364,20 @@ describe('MetaMetricsController', function () { TEST_GA_COOKIE_ID, ); const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent( - { - event: 'Fake Event', - category: 'Unit Test', - properties: { - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: '1', - }, + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + properties: { + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: '1', }, - { isOptIn: true }, - ); + }); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith( { event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, + userId: TEST_ANALYTICS_ID, context: { ...DEFAULT_TEST_CONTEXT, marketingCampaignCookieId: TEST_GA_COOKIE_ID, @@ -2836,8 +2388,6 @@ describe('MetaMetricsController', function () { // eslint-disable-next-line @typescript-eslint/naming-convention chain_id: '1', }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), }, spy.mock.calls[0][1], ); @@ -2851,7 +2401,6 @@ describe('MetaMetricsController', function () { { options: { state: { - metaMetricsId: TEST_META_METRICS_ID, dataCollectionForMarketing: true, marketingCampaignCookieId: TEST_GA_COOKIE_ID, }, @@ -3115,7 +2664,9 @@ describe('MetaMetricsController', function () { await withController( // Set `fragments` to an empty object to override complex default `fragments` mock state // that also updates the `latestNonAnonymousEventTimestamp` timestamp. - { options: { state: { fragments: {} } } }, + { + options: { state: { fragments: {} } }, + }, ({ controller }) => { expect( deriveStateFromMetadata( @@ -3125,10 +2676,9 @@ describe('MetaMetricsController', function () { ), ).toMatchInlineSnapshot(` { + "completedMetaMetricsOnboarding": true, "latestNonAnonymousEventTimestamp": 0, "marketingCampaignCookieId": null, - "metaMetricsId": "0xabc", - "participateInMetaMetrics": true, } `); }, @@ -3139,7 +2689,9 @@ describe('MetaMetricsController', function () { await withController( // Set `fragments` to an empty object to override complex default `fragments` mock state // that also updates the `latestNonAnonymousEventTimestamp` timestamp. - { options: { state: { fragments: {} } } }, + { + options: { state: { fragments: {} } }, + }, ({ controller }) => { expect( deriveStateFromMetadata( @@ -3149,14 +2701,12 @@ describe('MetaMetricsController', function () { ), ).toMatchInlineSnapshot(` { + "completedMetaMetricsOnboarding": true, "dataCollectionForMarketing": null, "eventsBeforeMetricsOptIn": [], "fragments": {}, "latestNonAnonymousEventTimestamp": 0, "marketingCampaignCookieId": null, - "metaMetricsId": "0xabc", - "participateInMetaMetrics": true, - "segmentApiCalls": {}, "tracesBeforeMetricsOptIn": [], "traits": {}, } @@ -3169,7 +2719,9 @@ describe('MetaMetricsController', function () { await withController( // Set `fragments` to an empty object to override complex default `fragments` mock state // that also updates the `latestNonAnonymousEventTimestamp` timestamp. - { options: { state: { fragments: {} } } }, + { + options: { state: { fragments: {} } }, + }, ({ controller }) => { expect( deriveStateFromMetadata( @@ -3179,14 +2731,12 @@ describe('MetaMetricsController', function () { ), ).toMatchInlineSnapshot(` { + "completedMetaMetricsOnboarding": true, "dataCollectionForMarketing": null, "eventsBeforeMetricsOptIn": [], "fragments": {}, "latestNonAnonymousEventTimestamp": 0, "marketingCampaignCookieId": null, - "metaMetricsId": "0xabc", - "participateInMetaMetrics": true, - "segmentApiCalls": {}, "tracesBeforeMetricsOptIn": [], "traits": {}, } @@ -3199,7 +2749,9 @@ describe('MetaMetricsController', function () { await withController( // Set `fragments` to an empty object to override complex default `fragments` mock state // that also updates the `latestNonAnonymousEventTimestamp` timestamp. - { options: { state: { fragments: {} } } }, + { + options: { state: { fragments: {} } }, + }, ({ controller }) => { expect( deriveStateFromMetadata( @@ -3209,11 +2761,10 @@ describe('MetaMetricsController', function () { ), ).toMatchInlineSnapshot(` { + "completedMetaMetricsOnboarding": true, "dataCollectionForMarketing": null, "fragments": {}, "latestNonAnonymousEventTimestamp": 0, - "metaMetricsId": "0xabc", - "participateInMetaMetrics": true, } `); }, @@ -3224,9 +2775,21 @@ describe('MetaMetricsController', function () { type RootMessenger = Messenger; +type MetaMetricsControllerTestState = Partial; + +type AnalyticsTrackingEventPayload = { + readonly name: string; + properties: Record; + sensitiveProperties: Record; + readonly hasProperties: boolean; +}; + type WithControllerOptions = { currentLocale?: string; - options?: Partial; + analyticsControllerState?: Partial; + options?: Partial> & { + state?: MetaMetricsControllerTestState; + }; remoteFeatureFlags?: Record; seedlessOnboardingState?: Partial; mockNetworkClientConfigurationsByNetworkClientId?: Record< @@ -3239,10 +2802,17 @@ type WithControllerOptions = { type WithControllerCallback = ({ controller, + controllerMessenger, triggerPreferencesControllerStateChange, triggerNetworkDidChange, }: { controller: MetaMetricsController; + controllerMessenger: Messenger< + 'MetaMetricsController', + AllowedActions, + AllowedEvents, + RootMessenger + >; triggerPreferencesControllerStateChange: ( state: PreferencesControllerState, ) => void; @@ -3264,6 +2834,7 @@ async function withController( const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; const { options = {}, + analyticsControllerState, currentLocale = LOCALE, remoteFeatureFlags = {}, seedlessOnboardingState = {}, @@ -3273,6 +2844,24 @@ async function withController( }, }, } = rest; + + const mmcState = merge( + {}, + { + completedMetaMetricsOnboarding: true, + marketingCampaignCookieId: null, + fragments: { + testid: SAMPLE_PERSISTED_EVENT, + testid2: SAMPLE_NON_PERSISTED_EVENT, + }, + }, + options.state ?? {}, + ) as MetaMetricsControllerState; + + if (options.state && Object.hasOwn(options.state, 'fragments')) { + mmcState.fragments = options.state.fragments ?? {}; + } + const messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -3315,6 +2904,122 @@ async function withController( jest.fn().mockReturnValue(seedlessOnboardingState), ); + const mockAnalyticsControllerState: AnalyticsControllerState = { + ...MOCK_ANALYTICS_CONTROLLER_OPTED_IN, + ...(analyticsControllerState ?? {}), + }; + + messenger.registerActionHandler('AnalyticsController:getState', () => ({ + ...mockAnalyticsControllerState, + })); + + messenger.registerActionHandler('AnalyticsController:optIn', () => { + mockAnalyticsControllerState.optedIn = true; + }); + + messenger.registerActionHandler('AnalyticsController:optOut', () => { + mockAnalyticsControllerState.optedIn = false; + }); + + // Emulate the analytics platform adapter: every Segment payload is built + // here and passed straight to `segmentMock`, preserving the existing + // spy-based assertions in tests. + messenger.registerActionHandler( + 'AnalyticsController:identify', + (( + traits?: AnalyticsUserTraits, + context?: AnalyticsContext, + ) => { + if (!traits) { + return; + } + const payload: Record = { + userId: mockAnalyticsControllerState.analyticsId, + traits, + }; + if (context) { + payload.context = context; + } + segmentMock.identify(payload as never, undefined); + }) as never, + ); + + messenger.registerActionHandler( + 'AnalyticsController:trackEvent', + (( + event: AnalyticsTrackingEventPayload, + context?: AnalyticsContext, + ) => { + if (!mockAnalyticsControllerState.optedIn) { + return; + } + + const buildPayload = (properties?: Record) => { + const payload: Record = { + userId: mockAnalyticsControllerState.analyticsId, + event: event.name, + }; + if (properties !== undefined) { + payload.properties = properties; + } + if (context) { + payload.context = context; + } + return payload; + }; + + if (!event.hasProperties) { + segmentMock.track(buildPayload() as never, undefined); + return; + } + + const hasSensitiveProperties = + Object.keys(event.sensitiveProperties ?? {}).length > 0; + + if (!hasSensitiveProperties) { + segmentMock.track( + buildPayload(event.properties) as never, + undefined, + ); + return; + } + + segmentMock.track(buildPayload(event.properties) as never, undefined); + segmentMock.track( + buildPayload({ + ...event.properties, + ...event.sensitiveProperties, + anonymous: true, + }) as never, + undefined, + ); + }) as never, + ); + + messenger.registerActionHandler( + 'AnalyticsController:trackView', + (( + name: string, + properties?: Record, + context?: AnalyticsContext, + ) => { + if (!mockAnalyticsControllerState.optedIn) { + return; + } + const payload: Record = { + userId: mockAnalyticsControllerState.analyticsId, + name, + }; + if (properties) { + payload.properties = properties; + } + if (context) { + payload.context = context; + } + segmentMock.page(payload as never, undefined); + }) as never, + ); + const metaMetricsControllerMessenger = new Messenger< 'MetaMetricsController', AllowedActions, @@ -3327,6 +3032,12 @@ async function withController( messenger.delegate({ messenger: metaMetricsControllerMessenger, actions: [ + 'AnalyticsController:getState', + 'AnalyticsController:identify', + 'AnalyticsController:optIn', + 'AnalyticsController:optOut', + 'AnalyticsController:trackEvent', + 'AnalyticsController:trackView', 'PreferencesController:getState', 'NetworkController:getState', 'NetworkController:getNetworkClientById', @@ -3341,31 +3052,22 @@ async function withController( return fn({ controller: new MetaMetricsController({ - segment: segmentMock, messenger: metaMetricsControllerMessenger, version: '0.0.1', environment: 'test', extension: MOCK_EXTENSION, ...options, - state: { - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, - marketingCampaignCookieId: null, - fragments: { - testid: SAMPLE_PERSISTED_EVENT, - testid2: SAMPLE_NON_PERSISTED_EVENT, - }, - ...options.state, - }, + state: mmcState, }), + controllerMessenger: metaMetricsControllerMessenger, triggerPreferencesControllerStateChange: (state) => messenger.publish('PreferencesController:stateChange', state, []), triggerNetworkDidChange: (state) => messenger.publish('NetworkController:networkDidChange', state), }); } finally { - // flush the queues manually after each test - segmentMock.flush(); + // clear the queues manually after each test + segmentMock.queue.length = 0; jest.useRealTimers(); jest.restoreAllMocks(); } diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index d07bda327602..e5339703f544 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -8,15 +8,17 @@ import { size, sum, } from 'lodash'; -import { keccak256 } from 'ethereum-cryptography/keccak'; import { v4 as uuidv4 } from 'uuid'; import { NameType } from '@metamask/name-controller'; -import { - bytesToHex, - getErrorMessage, - isErrorWithMessage, - isErrorWithStack, -} from '@metamask/utils'; +import { getErrorMessage } from '@metamask/utils'; +import type { + AnalyticsControllerActions, + AnalyticsControllerState, + AnalyticsContext, + AnalyticsEventProperties, + AnalyticsTrackingEvent, + AnalyticsUserTraits, +} from '@metamask/analytics-controller'; import type { NetworkClientId, NetworkControllerGetNetworkClientByIdAction, @@ -44,7 +46,6 @@ import { PLATFORM_FIREFOX, } from '../../../shared/constants/app'; import { - METAMETRICS_ANONYMOUS_ID, METAMETRICS_BACKGROUND_PAGE_OBJECT, MetaMetricsEventAccountType, MetaMetricsEventCategory, @@ -59,7 +60,6 @@ import type { MetaMetricsEventPayload, MetaMetricsEventOptions, MetaMetricsPagePayload, - MetaMetricsPageOptions, MetaMetricsPageObject, MetaMetricsReferrerObject, } from '../../../shared/constants/metametrics'; @@ -68,19 +68,13 @@ import { isManifestV3 } from '../../../shared/lib/mv3.utils'; import { METAMETRICS_FINALIZE_EVENT_FRAGMENT_ALARM } from '../../../shared/constants/alarms'; import { checkAlarmExists, - generateRandomId, getDeviceType, getInstallType, getOs, getPlatform, - isValidDate, } from '../lib/util'; -import { - AnonymousTransactionMetaMetricsEvent, - TransactionMetaMetricsEvent, -} from '../../../shared/constants/transaction'; +import { TransactionMetaMetricsEvent } from '../../../shared/constants/transaction'; import { FirstTimeFlowType } from '../../../shared/constants/onboarding'; -import type { SegmentClient } from '../lib/segment'; import { trace, endTrace, @@ -99,36 +93,19 @@ import { } from '../../../shared/lib/ab-testing/ab-test-analytics'; import { getTokensControllerAllTokens } from '../../../shared/lib/selectors/assets-migration'; import { isMain } from '../../../shared/lib/build-types'; +import { trackSegmentEventWhileOptedOut } from '../lib/segment/custom-segment-tracking'; import type { PreferencesControllerGetStateAction, PreferencesControllerStateChangeEvent, } from './preferences-controller'; import { MetaMetricsControllerMethodActions } from './metametrics-controller-method-action-types'; +import { ANONYMOUS_EVENT_PROPERTY } from './analytics/platform-adapter'; // Unique name for the controller const controllerName = 'MetaMetricsController'; const EXTENSION_UNINSTALL_URL = 'https://metamask.io/uninstalled'; -export const overrideAnonymousEventNames = { - [TransactionMetaMetricsEvent.added]: - AnonymousTransactionMetaMetricsEvent.added, - [TransactionMetaMetricsEvent.approved]: - AnonymousTransactionMetaMetricsEvent.approved, - [TransactionMetaMetricsEvent.finalized]: - AnonymousTransactionMetaMetricsEvent.finalized, - [TransactionMetaMetricsEvent.rejected]: - AnonymousTransactionMetaMetricsEvent.rejected, - [TransactionMetaMetricsEvent.submitted]: - AnonymousTransactionMetaMetricsEvent.submitted, - [MetaMetricsEventName.SignatureRequested]: - MetaMetricsEventName.SignatureRequestedAnon, - [MetaMetricsEventName.SignatureApproved]: - MetaMetricsEventName.SignatureApprovedAnon, - [MetaMetricsEventName.SignatureRejected]: - MetaMetricsEventName.SignatureRejectedAnon, -} as const; - const defaultCaptureException = (err: unknown) => { // throw error on clean stack so its captured by platform integrations (eg sentry) // but does not interrupt the call stack @@ -137,40 +114,10 @@ const defaultCaptureException = (err: unknown) => { }); }; -// The function is used to build a unique messageId for segment messages -// It uses actionId and uniqueIdentifier from event if present -const buildUniqueMessageId = (args: { - uniqueIdentifier?: string; - actionId?: string; - isDuplicateAnonymizedEvent?: boolean; -}): string => { - const messageIdParts = []; - if (args.uniqueIdentifier) { - messageIdParts.push(args.uniqueIdentifier); - } - if (args.actionId) { - messageIdParts.push(args.actionId); - } - if (messageIdParts.length && args.isDuplicateAnonymizedEvent) { - messageIdParts.push('0x000'); - } - if (messageIdParts.length) { - return messageIdParts.join('-'); - } - return generateRandomId(); -}; - const exceptionsToFilter: Record = { [`You must pass either an "anonymousId" or a "userId".`]: true, }; -/** - * The type of a Segment event to create. - * - * Must correspond to the name of a method in {@link Analytics}. - */ -type SegmentEventType = 'identify' | 'track' | 'page'; - /** * Represents a buffered trace that is stored before user consent. * Simplified for JSON serialization - doesn't include callback functions. @@ -189,7 +136,6 @@ export type MetaMaskState = Pick< | 'allNfts' | 'allTokens' | 'theme' - | 'participateInMetaMetrics' | 'dataCollectionForMarketing' | 'useNftDetection' | 'openSeaEnabled' @@ -212,6 +158,14 @@ export type MetaMaskState = Pick< | 'showNativeTokenAsMainBalance' | 'tokenSortConfig' >; +} & { + // TODO: Remove `participateInMetaMetrics` / `metaMetricsId` here once the codebase and + // `FlattenedBackgroundStateProxy` use `completedMetaMetricsOnboarding`, `optedIn`, and + // `analyticsId` as the source of truth (and `MetamaskController.getState()` stops injecting the legacy + // fields). Update `_buildUserTraitsObject` and any other `MetaMaskState` consumers accordingly. + /** Populated by `MetamaskController.getState()` from analytics + metrics prompt completion. */ + participateInMetaMetrics: boolean | null; + metaMetricsId: string | null; }; /** @@ -222,13 +176,7 @@ export type MetaMaskState = Pick< * the `anonymous` flag. */ const controllerMetadata: StateMetadata = { - metaMetricsId: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: true, - usedInUi: true, - }, - participateInMetaMetrics: { + completedMetaMetricsOnboarding: { includeInStateLogs: true, persist: true, includeInDebugSnapshot: true, @@ -276,32 +224,33 @@ const controllerMetadata: StateMetadata = { includeInDebugSnapshot: true, usedInUi: false, }, - segmentApiCalls: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, }; /** * The state that MetaMetricsController stores. * - * @property metaMetricsId - The user's metaMetricsId that will be attached to all non-anonymized event payloads - * @property participateInMetaMetrics - The user's preference for participating in the MetaMetrics analytics program. - * This setting controls whether or not events are tracked - * @property latestNonAnonymousEventTimestamp - The timestamp at which last non anonymous event is tracked. + * @property completedMetaMetricsOnboarding - Whether the user has completed the metrics participation prompt (onboarding/settings). + * @property latestNonAnonymousEventTimestamp - The timestamp at which the latest analytics event submission was attempted. * @property fragments - Object keyed by UUID with stored fragments as values. * @property eventsBeforeMetricsOptIn - Array of queued events added before a user opts into metrics. * @property tracesBeforeMetricsOptIn - Array of queued traces added before a user opts into metrics. * @property traits - Traits that are not derived from other state keys. * @property dataCollectionForMarketing - Flag to determine if data collection for marketing is enabled. * @property marketingCampaignCookieId - The marketing campaign cookie id. - * @property segmentApiCalls - Object keyed by messageId with segment event type and payload as values. */ +type SegmentTrackPayload = Omit & { + properties: AnalyticsEventProperties; + sensitiveProperties?: Record; +}; + +type SegmentPagePayload = { + name: string; + properties: AnalyticsEventProperties; + context: AnalyticsContext; +}; + export type MetaMetricsControllerState = { - metaMetricsId: string | null; - participateInMetaMetrics: boolean | null; + completedMetaMetricsOnboarding: boolean; latestNonAnonymousEventTimestamp: number; fragments: Record; eventsBeforeMetricsOptIn: MetaMetricsEventPayload[]; @@ -309,13 +258,6 @@ export type MetaMetricsControllerState = { traits: MetaMetricsUserTraits; dataCollectionForMarketing: boolean | null; marketingCampaignCookieId: string | null; - segmentApiCalls: Record< - string, - { - eventType: SegmentEventType; - payload: SegmentEventPayload; - } - >; }; /** @@ -351,7 +293,8 @@ export type AllowedActions = | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction | RemoteFeatureFlagControllerGetStateAction - | SeedlessOnboardingControllerGetStateAction; + | SeedlessOnboardingControllerGetStateAction + | AnalyticsControllerActions; /** * Events that this controller is allowed to subscribe. @@ -374,7 +317,6 @@ type CaptureException = typeof captureException | ((err: unknown) => void); export type MetaMetricsControllerOptions = { state?: Partial; messenger: MetaMetricsControllerMessenger; - segment: SegmentClient; version: string; environment: string; extension: Browser; @@ -386,8 +328,7 @@ export type MetaMetricsControllerOptions = { */ export const getDefaultMetaMetricsControllerState = (): MetaMetricsControllerState => ({ - participateInMetaMetrics: null, - metaMetricsId: null, + completedMetaMetricsOnboarding: false, dataCollectionForMarketing: null, marketingCampaignCookieId: null, latestNonAnonymousEventTimestamp: 0, @@ -395,7 +336,6 @@ export const getDefaultMetaMetricsControllerState = tracesBeforeMetricsOptIn: [], traits: {}, fragments: {}, - segmentApiCalls: {}, }); const MESSENGER_EXPOSED_METHODS = [ @@ -409,7 +349,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'deleteEventFragment', 'finalizeAbandonedFragments', 'finalizeEventFragment', - 'generateMetaMetricsId', 'getEventFragmentById', 'getMetaMetricsId', 'handleMetaMaskStateUpdate', @@ -446,14 +385,14 @@ export class MetaMetricsController extends BaseController< #environment: MetaMetricsControllerOptions['environment']; - #segment: MetaMetricsControllerOptions['segment']; + #analyticsGetState(): AnalyticsControllerState { + return this.messenger.call('AnalyticsController:getState'); + } /** * @param options * @param options.state - Initial controller state. * @param options.messenger - Messenger used to communicate with BaseV2 controller. - * @param options.segment - an instance of analytics for tracking - * events that conform to the new MetaMetrics tracking plan. * @param options.version - The version of the extension * @param options.environment - The environment the extension is running in * @param options.extension - webextension-polyfill @@ -462,7 +401,6 @@ export class MetaMetricsController extends BaseController< constructor({ state = {}, messenger, - segment, version, environment, extension, @@ -516,7 +454,6 @@ export class MetaMetricsController extends BaseController< this.chainId = this.#getCurrentChainId(selectedNetworkClientId); }, ); - this.#segment = segment; // Track abandoned fragments that weren't properly cleaned up. // Abandoned fragments are those that were stored in persistent memory @@ -528,21 +465,6 @@ export class MetaMetricsController extends BaseController< this.processAbandonedFragment(fragment); }); - // Code below submits any pending segmentApiCalls to Segment if/when the controller is re-instantiated - if (isManifestV3) { - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31880 - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - Object.values(state.segmentApiCalls || {}).forEach( - ({ eventType, payload }) => { - try { - this.#submitSegmentAPICall(eventType, payload); - } catch (error) { - this.#captureException(error); - } - }, - ); - } - // Close out event fragments that were created but not progressed. An // interval is used to routinely check if a fragment has not been updated // within the fragment's timeout window. When creating a new event fragment @@ -610,17 +532,6 @@ export class MetaMetricsController extends BaseController< }); } - generateMetaMetricsId(): string { - return bytesToHex( - keccak256( - Buffer.from( - String(Date.now()) + - String(Math.round(Math.random() * Number.MAX_SAFE_INTEGER)), - ), - ), - ); - } - /** * Create an event fragment in state and returns the event fragment object. * @@ -674,17 +585,13 @@ export class MetaMetricsController extends BaseController< } : {}; - const mergeEventFragment = merge as ( - ...sources: unknown[] - ) => MetaMetricsEventFragment; - + const mergedFragment = merge( + {}, + additionalFragmentProps, + fragment, + ) as MetaMetricsEventFragment; this.update((state) => { - const metaMetricsState = state as unknown as MetaMetricsControllerState; - metaMetricsState.fragments[id] = mergeEventFragment( - {}, - additionalFragmentProps, - fragment, - ); + Object.assign(state.fragments, { [id]: mergedFragment }); }); if (fragment.initialEvent) { @@ -699,8 +606,6 @@ export class MetaMetricsController extends BaseController< value: fragment.value, currency: fragment.currency, environmentType: fragment.environmentType, - actionId: options.actionId, - uniqueIdentifier: options.uniqueIdentifier, }); } @@ -753,34 +658,32 @@ export class MetaMetricsController extends BaseController< const createIfNotFound = !fragment && id.includes('transaction-submitted-'); if (createIfNotFound) { + const newFragment: MetaMetricsEventFragment = { + canDeleteIfAbandoned: true, + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.finalized, + id, + ...payload, + lastUpdated: Date.now(), + }; this.update((state) => { - const metaMetricsState = state as unknown as MetaMetricsControllerState; - metaMetricsState.fragments[id] = { - canDeleteIfAbandoned: true, - category: MetaMetricsEventCategory.Transactions, - successEvent: TransactionMetaMetricsEvent.finalized, - id, - ...payload, - lastUpdated: Date.now(), - } as MetaMetricsEventFragment; + Object.assign(state.fragments, { [id]: newFragment }); }); return; } else if (!fragment) { throw new Error(`Event fragment with id ${id} does not exist.`); } - const mergeEventFragment = merge as ( - ...sources: unknown[] - ) => MetaMetricsEventFragment; + const updatedFragment = merge( + {} as MetaMetricsEventFragment, + fragment, + { + ...payload, + lastUpdated: Date.now(), + }, + ) as MetaMetricsEventFragment; this.update((state) => { - const metaMetricsState = state as unknown as MetaMetricsControllerState; - metaMetricsState.fragments[id] = mergeEventFragment( - metaMetricsState.fragments[id], - { - ...payload, - lastUpdated: Date.now(), - }, - ); + Object.assign(state.fragments, { [id]: updatedFragment }); }); } @@ -838,44 +741,12 @@ export class MetaMetricsController extends BaseController< value: fragment.value, currency: fragment.currency, environmentType: fragment.environmentType, - actionId: fragment.actionId, - // We append success or failure to the unique-identifier so that the - // messageId can still be idempotent, but so that it differs from the - // initial event fired. The initial event was preventing new events from - // making it to mixpanel because they were using the same unique ID as - // the events processed in other parts of the fragment lifecycle. - uniqueIdentifier: fragment.uniqueIdentifier - ? `${fragment.uniqueIdentifier}-${abandoned ? 'failure' : 'success'}` - : undefined, }); this.update((state) => { delete state.fragments[id]; }); } - /** - * Calls this._identify with validated metaMetricsId and user traits if user is participating - * in the MetaMetrics analytics program - * - * @param userTraits - */ - identify(userTraits: Partial): void { - const { metaMetricsId, participateInMetaMetrics } = this.state; - if (!participateInMetaMetrics || !metaMetricsId || !userTraits) { - return; - } - if (typeof userTraits !== 'object') { - console.warn( - `MetaMetricsController#identify: userTraits parameter must be an object. Received type: ${typeof userTraits}`, - ); - return; - } - - const allValidTraits = this.#buildValidTraits(userTraits); - - this.#identify(allValidTraits); - } - // It sets an uninstall URL ("Sorry to see you go!" page), // which is opened if a user uninstalls the extension. // This method should only be called after the user has made a decision about MetaMetrics participation. @@ -916,15 +787,17 @@ export class MetaMetricsController extends BaseController< async setParticipateInMetaMetrics( participateInMetaMetrics: boolean | null, ): Promise { - const { metaMetricsId: existingMetaMetricsId } = this.state; + const analyticsId = this.getMetaMetricsId(); - // regardless of the Opt In/Out status, we want to generate metaMetricsId if it doesn't exist - // this is to assign the id to the `Metrics Opt Out` event (in which participateInMetaMetrics is null/false) - const metaMetricsId = existingMetaMetricsId ?? this.generateMetaMetricsId(); + if (participateInMetaMetrics === true) { + this.messenger.call('AnalyticsController:optIn'); + } else { + this.messenger.call('AnalyticsController:optOut'); + } this.update((state) => { - state.participateInMetaMetrics = participateInMetaMetrics; - state.metaMetricsId = metaMetricsId; + state.completedMetaMetricsOnboarding = + participateInMetaMetrics !== null; }); if (participateInMetaMetrics) { @@ -946,19 +819,19 @@ export class MetaMetricsController extends BaseController< if ( isMain() && this.#environment !== ENVIRONMENT.DEVELOPMENT && - metaMetricsId !== null && participateInMetaMetrics !== null ) { - this.updateExtensionUninstallUrl(participateInMetaMetrics, metaMetricsId); + this.updateExtensionUninstallUrl( + participateInMetaMetrics === true, + analyticsId, + ); } - return metaMetricsId; + return analyticsId; } - setDataCollectionForMarketing( - dataCollectionForMarketing: boolean, - ): MetaMetricsControllerState['metaMetricsId'] { - const { metaMetricsId } = this.state; + setDataCollectionForMarketing(dataCollectionForMarketing: boolean): string { + const { analyticsId } = this.#analyticsGetState(); this.update((state) => { state.dataCollectionForMarketing = dataCollectionForMarketing; @@ -968,7 +841,7 @@ export class MetaMetricsController extends BaseController< this.setMarketingCampaignCookieId(null); } - return metaMetricsId; + return analyticsId; } setMarketingCampaignCookieId(marketingCampaignCookieId: string | null): void { @@ -978,187 +851,113 @@ export class MetaMetricsController extends BaseController< } /** - * track a page view with Segment + * submits a metametrics event, not waiting for it to complete or allowing its error to bubble up * - * @param payload - details of the page viewed. - * @param options - options for handling the page view. + * @param payload - details of the event + * @param options - options for handling/routing the event */ - trackPage( - payload: MetaMetricsPagePayload, - options?: MetaMetricsPageOptions, + trackEvent( + payload: MetaMetricsEventPayload, + options?: MetaMetricsEventOptions, ): void { + // validation is not caught and handled + this.#validateTrackEventPayload(payload); + try { - if (this.state.participateInMetaMetrics === false) { + if (!this.#canSubmitAnalytics(payload.event)) { return; } - if ( - this.state.participateInMetaMetrics === null && - !options?.isOptInPath - ) { + const eventPayload = this.#buildTrackEventPayload(payload); + + if (payload.event === MetaMetricsEventName.MetricsOptOut) { + this.#trackMetricsOptOutEvent(eventPayload); return; } - const { name, params, environmentType, page, referrer, actionId } = - payload; - const { metaMetricsId } = this.state; - const idTrait = metaMetricsId ? 'userId' : 'anonymousId'; - const idValue = metaMetricsId ?? METAMETRICS_ANONYMOUS_ID; - this.#submitSegmentAPICall('page', { - messageId: buildUniqueMessageId({ actionId }), - [idTrait]: idValue, - name, - properties: { - params, - locale: this.locale, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: this.chainId, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - environment_type: environmentType, - }, - context: this.#buildContext(referrer, page), - }); + this.#applyAnonymousEventOptions(eventPayload, options); + this.#applyLegacyEventOptions(eventPayload, options); + + this.#updateLatestAnalyticsEventTimestamp(); + const sensitiveProperties = eventPayload.sensitiveProperties ?? {}; + + this.messenger.call( + 'AnalyticsController:trackEvent', + { + name: eventPayload.event, + properties: eventPayload.properties, + sensitiveProperties, + saveDataRecording: false, // Legacy property that is ignored by the analytics controller and will be removed from the type in the future. + hasProperties: + Object.keys(eventPayload.properties).length > 0 || + Object.keys(sensitiveProperties).length > 0, + } satisfies AnalyticsTrackingEvent, + eventPayload.context as AnalyticsContext | undefined, + ); } catch (err) { this.#captureException(err); } } /** - * submits a metametrics event, not waiting for it to complete or allowing its error to bubble up - * - * @param payload - details of the event - * @param options - options for handling/routing the event - */ - trackEvent( - payload: MetaMetricsEventPayload, - options?: MetaMetricsEventOptions, - ): void { - // validation is not caught and handled - this.#validatePayload(payload); - this.#submitEvent(payload, options).catch((err) => { - this.#captureException(err); - }); - } - - /** - * submits (or queues for submission) a metametrics event, performing necessary payload manipulation and - * routing the event to the appropriate segment source. Will split events - * with sensitiveProperties into two events, tracking the sensitiveProperties - * with the anonymousId only. + * Identifies the user with valid user traits if they are participating in + * the MetaMetrics analytics program. * - * @param payload - details of the event - * @param options - options for handling/routing the event + * @param userTraits */ - async #submitEvent( - payload: MetaMetricsEventPayload, - options?: MetaMetricsEventOptions, - ): Promise { - const { useExternalServices } = this.messenger.call( - 'PreferencesController:getState', - ); - const isBasicFunctionalityDisabled = !useExternalServices; - if (isBasicFunctionalityDisabled) { - // If basic functionality is disabled, we block all events - return; - } + identify(userTraits: Partial): void { + const identifyPayload = this.#validateIdentifyPayload(userTraits); - const { participateInMetaMetrics, metaMetricsId } = this.state; - if (!participateInMetaMetrics && !options?.isOptIn) { + if (!identifyPayload) { return; } - const isMetricsOptOutEvent = - payload.event === MetaMetricsEventName.MetricsOptOut; - if (isMetricsOptOutEvent && options?.isOptIn) { - // For the `Metrics Opt Out` event, we want to track it with the user's `metaMetricsId` - options.metaMetricsId = metaMetricsId ?? undefined; - } - - let identifiedPayload = payload; - - const hasABTestAnalyticsMapping = hasABTestAnalyticsMappingForEvent( - payload.event, - ); - const hasActiveABTests = payload.properties?.active_ab_tests !== undefined; - - let normalizedPayload = payload; - - if (hasActiveABTests) { - try { - normalizedPayload = enrichWithABTests(payload, null, []); - } catch { - normalizedPayload = payload; + try { + if (!this.#canSubmitAnalytics()) { + return; } - } - identifiedPayload = normalizedPayload; + this.#updateLatestAnalyticsEventTimestamp(); - if (hasABTestAnalyticsMapping) { - try { - identifiedPayload = enrichWithABTests( - normalizedPayload, - this.#getRemoteFeatureFlags(), - ); - } catch { - identifiedPayload = normalizedPayload; - } + this.messenger.call( + 'AnalyticsController:identify', + identifyPayload, + undefined, + ); + } catch (err) { + this.#captureException(err); } + } - // We might track multiple events if sensitiveProperties is included, this array will hold - // the promises returned from this._track. - const events = []; - - if (payload.sensitiveProperties) { - // sensitiveProperties will only be tracked using the anonymousId property and generic id - // If the event options already specify to exclude the metaMetricsId we throw an error as - // a signal to the developer that the event was implemented incorrectly - if (options?.excludeMetaMetricsId === true) { - throw new Error( - 'sensitiveProperties was specified in an event payload that also set the excludeMetaMetricsId flag', - ); + /** + * Track a page view through AnalyticsController. + * + * @param payload - details of the page viewed. + */ + trackPage(payload: MetaMetricsPagePayload): void { + this.#validateTrackPagePayload(payload); + + try { + if (!this.#canSubmitAnalytics()) { + return; } - // change anonymous event names - const anonymousEventName = - // @ts-expect-error This property may not exist. We check for it below. - overrideAnonymousEventNames[`${payload.event}`]; - const anonymousPayload = { - ...normalizedPayload, - event: anonymousEventName ?? payload.event, - }; + const pagePayload = this.#buildTrackPagePayload(payload); - const combinedProperties = merge( - { ...anonymousPayload.sensitiveProperties }, - { ...anonymousPayload.properties }, - ); + this.#updateLatestAnalyticsEventTimestamp(); - events.push( - this.#track( - this.#buildEventPayload({ - ...anonymousPayload, - properties: combinedProperties, - isDuplicateAnonymizedEvent: true, - }), - { ...options, excludeMetaMetricsId: true }, - ), + this.messenger.call( + 'AnalyticsController:trackView', + pagePayload.name, + pagePayload.properties, + pagePayload.context, ); + } catch (err) { + this.#captureException(err); } - - events.push( - this.#track(this.#buildEventPayload(identifiedPayload), options), - ); - - await Promise.all(events); } - /** - * validates a metametrics event - * - * @param payload - details of the event - */ - #validatePayload(payload: MetaMetricsEventPayload): void { + #validateTrackEventPayload(payload: MetaMetricsEventPayload): void { // event and category are required fields for all payloads if (!payload.event) { throw new Error( @@ -1177,6 +976,140 @@ export class MetaMetricsController extends BaseController< } } + #validateTrackPagePayload(payload: MetaMetricsPagePayload): void { + if (!payload || typeof payload !== 'object') { + throw new Error( + `MetaMetricsController#trackPage: payload parameter must be an object. Received type: ${typeof payload}`, + ); + } + } + + #validateIdentifyPayload( + userTraits: Partial, + ): AnalyticsUserTraits | undefined { + if (!userTraits) { + return undefined; + } + if (typeof userTraits !== 'object') { + console.warn( + `MetaMetricsController#identify: userTraits parameter must be an object. Received type: ${typeof userTraits}`, + ); + return undefined; + } + + const validTraits: Record = {}; + + for (const [key, value] of Object.entries(userTraits)) { + if (this.#isValidTraitDate(value)) { + validTraits[key] = value.toISOString(); + } else if (this.#isValidTrait(value)) { + (validTraits as Record)[key] = value; + } else { + console.warn( + `MetaMetricsController: "${key}" value is not a valid trait type`, + ); + } + } + + if (Object.keys(validTraits).length === 0) { + return undefined; + } + + return validTraits as AnalyticsUserTraits; + } + + /** + * Builds the event payload, processing all fields into a format that can be + * routed through AnalyticsController. + * + * @private + * @param rawPayload - raw payload provided to trackEvent + * @returns formatted analytics track event payload + */ + #buildTrackEventPayload( + rawPayload: MetaMetricsEventPayload, + ): SegmentTrackPayload { + const enrichedPayload = this.#enrichWithABTestAnalytics(rawPayload); + + const { + event, + properties, + revenue, + value, + currency, + category, + page, + referrer, + environmentType = ENVIRONMENT_TYPE_BACKGROUND, + sensitiveProperties, + } = enrichedPayload; + + let chainId; + if ( + properties && + 'chain_id_caip' in properties && + typeof properties.chain_id_caip === 'string' + ) { + chainId = null; + } else if ( + properties && + 'chain_id' in properties && + typeof properties.chain_id === 'string' + ) { + chainId = properties.chain_id; + } else { + chainId = this.chainId; + } + + return { + event, + properties: omitBy( + { + // These values are omitted from properties because they have special meaning + // in the Segment track spec: https://segment.com/docs/connections/spec/track/#properties. + // To avoid accidentally using these inappropriately, + // add them as top level properties on the event payload. We also exclude locale + // to prevent consumers from overwriting this context level property. We track it + // as a property because not all destinations map locale from context. + ...omit(properties, ['revenue', 'locale', 'currency', 'value']), + revenue, + value, + currency, + category, + locale: this.locale, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: chainId, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + environment_type: environmentType, + }, + (propertyValue) => propertyValue === undefined, + ) as AnalyticsEventProperties, + context: this.#buildContext(referrer, page), + sensitiveProperties, + }; + } + + #buildTrackPagePayload(payload: MetaMetricsPagePayload): SegmentPagePayload { + const { name, params, environmentType, page, referrer } = payload; + + return { + name: name ?? '', + properties: pickBy({ + params, + locale: this.locale, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: this.chainId, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + environment_type: environmentType, + }) as AnalyticsEventProperties, + context: this.#buildContext(referrer, page) as AnalyticsContext, + }; + } + handleMetaMaskStateUpdate(newState: MetaMaskState): void { const userTraits = this._buildUserTraitsObject(newState); if (userTraits) { @@ -1203,8 +1136,9 @@ export class MetaMetricsController extends BaseController< // It adds an event into a queue, which is only tracked if a user opts into metrics. addEventBeforeMetricsOptIn(event: MetaMetricsEventPayload): void { this.update((state) => { - const metaMetricsState = state as unknown as MetaMetricsControllerState; - metaMetricsState.eventsBeforeMetricsOptIn.push(event); + const queue = + state.eventsBeforeMetricsOptIn as unknown as MetaMetricsEventPayload[]; + queue.push(event); }); } @@ -1231,8 +1165,9 @@ export class MetaMetricsController extends BaseController< // It adds a trace into a queue, which is only tracked if a user opts into metrics. addTraceBeforeMetricsOptIn(traceData: BufferedTrace): void { this.update((state) => { - const metaMetricsState = state as unknown as MetaMetricsControllerState; - metaMetricsState.tracesBeforeMetricsOptIn.push(traceData); + const queue = + state.tracesBeforeMetricsOptIn as unknown as BufferedTrace[]; + queue.push(traceData); }); } @@ -1247,7 +1182,7 @@ export class MetaMetricsController extends BaseController< request: TraceRequest, fn?: TraceCallback, ): TraceResultType | undefined { - if (this.state.participateInMetaMetrics) { + if (this.#analyticsGetState().optedIn) { return fn ? trace(request, fn) : (trace(request) as TraceResultType); } @@ -1279,7 +1214,7 @@ export class MetaMetricsController extends BaseController< * @param request - The end trace request */ bufferedEndTrace(request: EndTraceRequest): void { - if (this.state.participateInMetaMetrics) { + if (this.#analyticsGetState().optedIn) { endTrace(request); } else { this.addTraceBeforeMetricsOptIn({ @@ -1301,16 +1236,104 @@ export class MetaMetricsController extends BaseController< }); } - // Retrieve (or generate if doesn't exist) the client metametrics id + // Retrieve the client metametrics id from AnalyticsController state getMetaMetricsId(): string { - let { metaMetricsId } = this.state; - if (!metaMetricsId) { - metaMetricsId = this.generateMetaMetricsId(); - this.update((state) => { - state.metaMetricsId = metaMetricsId; - }); + const { analyticsId } = this.#analyticsGetState(); + return analyticsId; + } + + #isBasicFunctionalityEnabled(): boolean { + const { useExternalServices } = this.messenger.call( + 'PreferencesController:getState', + ); + return useExternalServices; + } + + #canSubmitAnalytics(eventName?: string): boolean { + if (!this.#isBasicFunctionalityEnabled()) { + return false; + } + + if (eventName === MetaMetricsEventName.MetricsOptOut) { + return true; + } + + const { analyticsId, optedIn } = this.#analyticsGetState(); + return optedIn && analyticsId.length > 0; + } + + #updateLatestAnalyticsEventTimestamp(): void { + this.update((state) => { + state.latestNonAnonymousEventTimestamp = Date.now(); + }); + } + + #enrichWithABTestAnalytics( + payload: MetaMetricsEventPayload, + ): MetaMetricsEventPayload { + let normalizedPayload = payload; + + if (payload.properties?.active_ab_tests !== undefined) { + try { + normalizedPayload = enrichWithABTests(payload, null, []); + } catch { + normalizedPayload = payload; + } + } + + if (!hasABTestAnalyticsMappingForEvent(payload.event)) { + return normalizedPayload; + } + + try { + return enrichWithABTests( + normalizedPayload, + this.#getRemoteFeatureFlags(), + ); + } catch { + return normalizedPayload; + } + } + + #applyAnonymousEventOptions( + eventPayload: SegmentTrackPayload, + options?: MetaMetricsEventOptions, + ): void { + if ( + eventPayload.sensitiveProperties && + options?.excludeMetaMetricsId === true + ) { + throw new Error( + 'sensitiveProperties was specified in an event payload that also set the excludeMetaMetricsId flag', + ); + } + + let excludeMetaMetricsId = options?.excludeMetaMetricsId ?? false; + // This is carried over from the old implementation, and will likely need + // to be updated to work with the new tracking plan. I think we should use + // a config setting for this instead of trying to match the event name + const isSendFlow = Boolean(eventPayload.event.match(/^send|^confirm/iu)); + // do not filter if excludeMetaMetricsId is explicitly set to false + if (options?.excludeMetaMetricsId !== false && isSendFlow) { + excludeMetaMetricsId = true; + } + + // The platform adapter reads the "anonymous" marker from track `properties` + // and swaps the user id for the shared anonymous id when marked is true. + if (excludeMetaMetricsId) { + (eventPayload.properties as Record)[ + ANONYMOUS_EVENT_PROPERTY + ] = true; + } + } + + #applyLegacyEventOptions( + eventPayload: SegmentTrackPayload, + options?: MetaMetricsEventOptions, + ): void { + if (options?.matomoEvent === true) { + eventPayload.properties.legacy_event = true; } - return metaMetricsId; } /** PRIVATE METHODS */ @@ -1347,75 +1370,6 @@ export class MetaMetricsController extends BaseController< ); } - /** - * Build's the event payload, processing all fields into a format that can be - * fed to Segment's track method - * - * @private - * @param rawPayload - raw payload provided to trackEvent - * @returns formatted event payload for segment - */ - #buildEventPayload( - rawPayload: Omit, - ): SegmentEventPayload { - const { - event, - properties, - revenue, - value, - currency, - category, - page, - referrer, - environmentType = ENVIRONMENT_TYPE_BACKGROUND, - timestamp, - } = rawPayload; - - let chainId; - if ( - properties && - 'chain_id_caip' in properties && - typeof properties.chain_id_caip === 'string' - ) { - chainId = null; - } else if ( - properties && - 'chain_id' in properties && - typeof properties.chain_id === 'string' - ) { - chainId = properties.chain_id; - } else { - chainId = this.chainId; - } - - return { - event, - messageId: buildUniqueMessageId(rawPayload), - properties: { - // These values are omitted from properties because they have special meaning - // in segment. https://segment.com/docs/connections/spec/track/#properties. - // to avoid accidentally using these inappropriately, you must add them as top - // level properties on the event payload. We also exclude locale to prevent consumers - // from overwriting this context level property. We track it as a property - // because not all destinations map locale from context. - ...omit(properties, ['revenue', 'locale', 'currency', 'value']), - revenue, - value, - currency, - category, - locale: this.locale, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: chainId, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - environment_type: environmentType, - }, - context: this.#buildContext(referrer, page), - timestamp, - }; - } - /** * This method generates the MetaMetrics user traits object, omitting any * traits that have not changed since the last invocation of this method. @@ -1579,32 +1533,6 @@ export class MetaMetricsController extends BaseController< } } - /** - * Returns a new object of all valid user traits. For dates, we transform them into ISO-8601 timestamp strings. - * - * @see {@link https://segment.com/docs/connections/spec/common/#timestamps} - * @param userTraits - */ - #buildValidTraits( - userTraits: Partial, - ): MetaMetricsUserTraits { - const validTraits: Record = {}; - - for (const [key, value] of Object.entries(userTraits)) { - if (this.#isValidTraitDate(value)) { - validTraits[key] = value.toISOString(); - } else if (this.#isValidTrait(value)) { - (validTraits as Record)[key] = value; - } else { - console.warn( - `MetaMetricsController: "${key}" value is not a valid trait type`, - ); - } - } - - return validTraits; - } - /** * Returns an array of all of the NFTs the user * possesses across all networks and accounts. @@ -1729,32 +1657,8 @@ export class MetaMetricsController extends BaseController< } /** - * Calls segment.identify with given user traits - * - * @see {@link https://segment.com/docs/connections/sources/catalog/libraries/server/node/#identify} - * @param userTraits - */ - #identify(userTraits: MetaMetricsUserTraits): void { - const { metaMetricsId } = this.state; - - if (!userTraits || Object.keys(userTraits).length === 0) { - console.warn('MetaMetricsController#_identify: No userTraits found'); - return; - } - - try { - this.#submitSegmentAPICall('identify', { - userId: metaMetricsId ?? undefined, - traits: userTraits, - }); - } catch (err) { - this.#captureException(err); - } - } - - /** - * Validates the trait value. Segment accepts any data type. We are adding validation here to - * support data types for our Segment destination(s) e.g. MixPanel + * Validates the trait value so AnalyticsController receives values supported + * by the configured analytics destinations. * * @param value */ @@ -1771,7 +1675,7 @@ export class MetaMetricsController extends BaseController< } /** - * Segment accepts any data type value. We have special logic to validate arrays. + * Validates trait arrays. * * @param value */ @@ -1799,171 +1703,21 @@ export class MetaMetricsController extends BaseController< return Object.prototype.toString.call(value) === '[object Date]'; } - /** - * Perform validation on the payload and update the id type to use before - * sending to Segment. Also examines the options to route and handle the - * event appropriately. - * - * @private - * @param payload - properties to attach to event - * @param options - options for routing and handling the event - */ - #track( - payload: SegmentEventPayload, - options?: MetaMetricsEventOptions, - ): Promise { - const { - isOptIn, - metaMetricsId: metaMetricsIdOverride, - matomoEvent, - flushImmediately, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31880 - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - } = options || {}; - let idType: 'userId' | 'anonymousId' = 'userId'; - let idValue = this.state.metaMetricsId; - let excludeMetaMetricsId = options?.excludeMetaMetricsId ?? false; - // This is carried over from the old implementation, and will likely need - // to be updated to work with the new tracking plan. I think we should use - // a config setting for this instead of trying to match the event name - const isSendFlow = Boolean(payload.event.match(/^send|^confirm/iu)); - // do not filter if excludeMetaMetricsId is explicitly set to false - if (options?.excludeMetaMetricsId !== false && isSendFlow) { - excludeMetaMetricsId = true; - } - // If we are tracking sensitive data we will always use the anonymousId - // property as well as our METAMETRICS_ANONYMOUS_ID. This prevents us from - // associating potentially identifiable information with a specific id. - // During the opt in flow we will track all events, but do so with the - // anonymous id. The one exception to that rule is after the user opts in - // to MetaMetrics. When that happens we receive back the user's new - // MetaMetrics id before it is fully persisted to state. To avoid a race - // condition we explicitly pass the new id to the track method. In that - // case we will track the opt in event to the user's id. In all other cases - // we use the metaMetricsId from state. - if (excludeMetaMetricsId || (isOptIn && !metaMetricsIdOverride)) { - idType = 'anonymousId'; - idValue = METAMETRICS_ANONYMOUS_ID; - } else if (isOptIn && metaMetricsIdOverride) { - idValue = metaMetricsIdOverride; - } - payload[idType] = idValue ?? undefined; - - // If this is an event on the old matomo schema, add a key to the payload - // to designate it as such - if (matomoEvent === true) { - payload.properties.legacy_event = true; - } - - // Promises will only resolve when the event is sent to segment. For any - // event that relies on this promise being fulfilled before performing UI - // updates, or otherwise delaying user interaction, supply the - // 'flushImmediately' flag to the trackEvent method. - return new Promise((resolve, reject) => { - const callback = (err: unknown) => { - if (err) { - const message = isErrorWithMessage(err) ? err.message : ''; - const stack = isErrorWithStack(err) ? err.stack : undefined; - // The error that segment gives us has some manipulation done to it - // that seemingly breaks with lockdown enabled. Creating a new error - // here prevents the system from freezing when the network request to - // segment fails for any reason. - const safeError = new Error(message); - if (stack) { - safeError.stack = stack; - } - return reject(safeError); - } - return resolve(); - }; - - this.#submitSegmentAPICall('track', payload, callback); - if (flushImmediately) { - this.#segment.flush(); - } - }); - } + #trackMetricsOptOutEvent(payload: SegmentTrackPayload): void { + const { analyticsId } = this.#analyticsGetState(); - /** - * Method below submits the request to analytics SDK. - * It will also add event to controller store - * and pass a callback to remove it from store once request is submitted to segment - * Saving segmentApiCalls in controller store in MV3 ensures that events are tracked - * even if service worker terminates before events are submitted to segment. - * - * @param eventType - The type of event to track - * @param payload - The payload to track - * @param callback - The callback to call when the event is submitted - */ - #submitSegmentAPICall( - eventType: SegmentEventType, - payload: Partial, - callback?: (result: unknown) => unknown, - ): void { - const { useExternalServices } = this.messenger.call( - 'PreferencesController:getState', - ); - const isBasicFunctionalityDisabled = !useExternalServices; - if (isBasicFunctionalityDisabled) { - // If basic functionality is disabled, we block all events + if (analyticsId.length === 0 || getPlatform() === PLATFORM_FIREFOX) { return; } - const { - metaMetricsId, - latestNonAnonymousEventTimestamp, - participateInMetaMetrics, - } = this.state; - - const userOptedOut = !participateInMetaMetrics || !metaMetricsId; - const isMetricsOptOutEvent = - payload.event === MetaMetricsEventName.MetricsOptOut; - const isFireFox = getPlatform() === PLATFORM_FIREFOX; - const shouldTrackMetricsOptOutEvent = isMetricsOptOutEvent && !isFireFox; - - // Block events when user opted out. Exception: MetricsOptOut events are still sent on - // non-Firefox browsers to record the opt-out action (Firefox privacy policies prohibit this). - if (userOptedOut && !shouldTrackMetricsOptOutEvent) { - return; - } + this.#updateLatestAnalyticsEventTimestamp(); - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31880 - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const messageId = payload.messageId || generateRandomId(); - let timestamp = new Date(); - if (payload.timestamp) { - const payloadDate = new Date(payload.timestamp); - if (isValidDate(payloadDate)) { - timestamp = payloadDate; - } - } - const modifiedPayload = { - ...payload, - messageId, - timestamp, - }; - this.update((state) => { - state.latestNonAnonymousEventTimestamp = - modifiedPayload.anonymousId === METAMETRICS_ANONYMOUS_ID - ? latestNonAnonymousEventTimestamp - : timestamp.valueOf(); - state.segmentApiCalls[messageId] = { - eventType, - // @ts-expect-error The reason this is needed is that the event property in the payload can be missing, - // whereas the state expects it to be present. It's unclear how best to handle this discrepancy. - payload: { - ...modifiedPayload, - timestamp: modifiedPayload.timestamp.toString(), - }, - }; + trackSegmentEventWhileOptedOut({ + analyticsId, + event: MetaMetricsEventName.MetricsOptOut, + properties: payload.properties as Record | undefined, + context: payload.context as AnalyticsContext | undefined, }); - const modifiedCallback = (result: unknown) => { - this.update((state) => { - delete state.segmentApiCalls[messageId]; - }); - return callback?.(result); - }; - this.#segment[eventType](modifiedPayload, modifiedCallback); } /** diff --git a/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts index 583990cbee16..3ae6c1cba039 100644 --- a/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts +++ b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts @@ -267,8 +267,8 @@ function setupController({ namespace: MOCK_ANY_NAMESPACE, }); messenger.registerActionHandler( - 'MetaMetricsController:getState', - jest.fn().mockReturnValue({ metaMetricsId }), + 'AnalyticsController:getState', + jest.fn().mockReturnValue({ analyticsId: metaMetricsId }), ); const mockCreateDataDeletionRegulationTaskResponse = 'mockRegulateId'; const mockFetchDeletionRegulationStatusResponse = 'UNKNOWN'; @@ -293,11 +293,10 @@ function setupController({ }); messenger.delegate({ messenger: controllerMessenger, - actions: ['MetaMetricsController:getState'], + actions: ['AnalyticsController:getState'], }); const constructorOptions = { dataDeletionService: mockDataDeletionService, - getMetaMetricsId: jest.fn().mockReturnValue('mockMetaMetricsId'), messenger: controllerMessenger, ...options, }; diff --git a/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts index 6b8d439eda92..8b0d51dfeb4d 100644 --- a/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts +++ b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts @@ -6,9 +6,9 @@ import { } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { PublicInterface } from '@metamask/utils'; +import type { AnalyticsControllerGetStateAction } from '@metamask/analytics-controller'; import type { DataDeletionService } from '../../services/data-deletion-service'; import { DeleteRegulationStatus } from '../../../../shared/constants/metametrics'; -import { MetaMetricsControllerGetStateAction } from '../metametrics-controller'; import { MetaMetricsDataDeletionControllerMethodActions } from './metametrics-data-deletion-method-action-types'; // Unique name for the controller @@ -86,7 +86,7 @@ export type MetaMetricsDataDeletionControllerMessengerEvents = /** * Actions that this controller is allowed to call. */ -export type AllowedActions = MetaMetricsControllerGetStateAction; +export type AllowedActions = AnalyticsControllerGetStateAction; /** * Events that this controller is allowed to subscribe. @@ -147,16 +147,14 @@ export class MetaMetricsDataDeletionController extends BaseController< * */ async createMetaMetricsDataDeletionTask(): Promise { - const { metaMetricsId } = this.messenger.call( - 'MetaMetricsController:getState', - ); - if (!metaMetricsId) { + const { analyticsId } = this.messenger.call('AnalyticsController:getState'); + if (!analyticsId) { throw new Error('MetaMetrics ID not found'); } const deleteRegulateId = await this.#dataDeletionService.createDataDeletionRegulationTask( - metaMetricsId, + analyticsId, ); this.update((state) => { state.metaMetricsDataDeletionId = deleteRegulateId ?? null; diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.js index 8caab1cebebd..cb94913124a6 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.js @@ -237,6 +237,7 @@ function isMultichainRequestMethod(method) { * tracked within the globalRateLimitTimeout time window. * @param {AppStateController} [opts.appStateController] * @param {MetaMetricsController} [opts.metaMetricsController] + * @param {AnalyticsController} [opts.analyticsController] * @returns {Function} */ @@ -251,6 +252,7 @@ export default function createRPCMethodTrackingMiddleware({ snapAndHardwareMessenger, appStateController, metaMetricsController, + analyticsController, getHDEntropyIndex, }) { return async function rpcMethodTrackingMiddleware( @@ -303,11 +305,11 @@ export default function createRPCMethodTrackingMiddleware({ globalRateLimitMaxAmount > 0 && globalRateLimitCount >= globalRateLimitMaxAmount; - // Get the participateInMetaMetrics state to determine if we should track + // Get the optedIn state to determine if we should track // anything. This is extra redundancy because this value is checked in - // the metametrics controller's trackEvent method as well. - const userParticipatingInMetaMetrics = - metaMetricsController.state.participateInMetaMetrics === true; + // the analytics controller's trackEvent method as well. + const { optedIn } = analyticsController.state; + const userParticipatingInMetaMetrics = optedIn === true; // Get the event type, each of which has APPROVED, REJECTED and REQUESTED // keys for the various events in the flow. diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js index bfa88d8563d9..e54a8bea0eec 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js @@ -2,7 +2,10 @@ import { errorCodes } from '@metamask/rpc-errors'; import { detectSIWE } from '@metamask/controller-utils'; import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; -import { MetaMetricsController } from '../controllers/metametrics-controller'; +import { + MetaMetricsController, + getDefaultMetaMetricsControllerState, +} from '../controllers/metametrics-controller'; import { MESSAGE_TYPE } from '../../../shared/constants/app'; import { MetaMetricsEventCategory, @@ -33,10 +36,8 @@ jest.mock('./snap-keyring/metrics', () => { const MockSnapKeyringMetrics = jest.mocked(snapKeyringMetrics); const MOCK_ID = '123'; -const expectedUniqueIdentifier = `signature-${MOCK_ID}`; const expectedMetametricsEventUndefinedProps = { - actionId: undefined, currency: undefined, environmentType: undefined, page: undefined, @@ -82,6 +83,23 @@ messenger.registerActionHandler( }), ); +const analyticsControllerState = { + analyticsId: '00000000-0000-4000-8000-000000000001', + optedIn: false, +}; + +messenger.registerActionHandler('AnalyticsController:getState', () => ({ + ...analyticsControllerState, +})); + +messenger.registerActionHandler('AnalyticsController:optIn', () => { + analyticsControllerState.optedIn = true; +}); + +messenger.registerActionHandler('AnalyticsController:optOut', () => { + analyticsControllerState.optedIn = false; +}); + const controllerMessenger = new Messenger({ namespace: 'MetaMetricsController', parent: messenger, @@ -90,6 +108,9 @@ const controllerMessenger = new Messenger({ messenger.delegate({ messenger: controllerMessenger, actions: [ + 'AnalyticsController:getState', + 'AnalyticsController:optIn', + 'AnalyticsController:optOut', 'PreferencesController:getState', 'NetworkController:getState', 'NetworkController:getNetworkClientById', @@ -100,12 +121,16 @@ messenger.delegate({ ], }); +const analyticsController = { + get state() { + return analyticsControllerState; + }, +}; + const metaMetricsController = new MetaMetricsController({ state: { - participateInMetaMetrics: null, - metaMetricsId: '0xabc', + ...getDefaultMetaMetricsControllerState(), fragments: {}, - events: {}, }, messenger: controllerMessenger, segment: createSegmentMock(2), @@ -127,6 +152,7 @@ const createHandler = (opts) => globalRateLimitMaxAmount: 0, appStateController, metaMetricsController, + analyticsController, getHDEntropyIndex: jest.fn(), ...opts, }); @@ -373,7 +399,6 @@ describe('createRPCMethodTrackingMiddleware', () => { top_level_origin: null, }, referrer: { url: 'some.dapp' }, - uniqueIdentifier: expectedUniqueIdentifier, }); }); diff --git a/app/scripts/lib/critical-error/track-critical-error.test.ts b/app/scripts/lib/critical-error/track-critical-error.test.ts index 0f0d27442619..373027e0833c 100644 --- a/app/scripts/lib/critical-error/track-critical-error.test.ts +++ b/app/scripts/lib/critical-error/track-critical-error.test.ts @@ -25,9 +25,12 @@ describe('trackCriticalErrorEvent', () => { const backup: Backup = { KeyringController: { vault: 'encrypted-vault-data' }, AppMetadataController: {}, + AnalyticsController: { + optedIn: true, + analyticsId: 'test-metrics-id-123', + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'test-metrics-id-123', + completedMetaMetricsOnboarding: true, }, }; @@ -59,9 +62,12 @@ describe('trackCriticalErrorEvent', () => { const backup: Backup = { KeyringController: { vault: 'encrypted-vault-data' }, AppMetadataController: {}, + AnalyticsController: { + optedIn: true, + analyticsId: 'test-metrics-id-456', + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'test-metrics-id-456', + completedMetaMetricsOnboarding: true, }, }; @@ -96,29 +102,22 @@ describe('trackCriticalErrorEvent', () => { // @ts-expect-error This is missing from the Mocha type definitions it.each([ ['backup is null', null], - ['MetaMetricsController is missing', { KeyringController: {} }], + ['AnalyticsController is missing', { KeyringController: {} }], [ - 'participateInMetaMetrics is false', + 'optedIn is false', { - MetaMetricsController: { - participateInMetaMetrics: false, - metaMetricsId: 'id', + AnalyticsController: { + optedIn: false, + analyticsId: 'id', }, }, ], [ - 'participateInMetaMetrics is null', + 'analyticsId is missing', { - MetaMetricsController: { - participateInMetaMetrics: null, - metaMetricsId: 'id', - }, + AnalyticsController: { optedIn: true }, }, ], - [ - 'metaMetricsId is missing', - { MetaMetricsController: { participateInMetaMetrics: true } }, - ], ])('does not track when %s', (_: string, backup: Backup | null) => { trackCriticalErrorEvent( backup, diff --git a/app/scripts/lib/critical-error/track-critical-error.ts b/app/scripts/lib/critical-error/track-critical-error.ts index e4ce42180418..77704af16338 100644 --- a/app/scripts/lib/critical-error/track-critical-error.ts +++ b/app/scripts/lib/critical-error/track-critical-error.ts @@ -6,7 +6,7 @@ import { MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import type { Backup } from '../../../../shared/lib/stores/persistence-manager'; -import { trackEarlySegmentEvent } from '../segment/early-segment-tracking'; +import { trackEarlySegmentEvent } from '../segment/custom-segment-tracking'; /** * Tracks a critical error event directly to Segment. diff --git a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.test.ts b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.test.ts index c4ba37df724e..b43af8206934 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.test.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.test.ts @@ -31,7 +31,7 @@ const createMockedHandler = () => { const sendMetrics = jest.fn(); const metamaskState = { permissionHistory: {}, - metaMetricsId: 'metaMetricsId', + analyticsId: 'analyticsId', internalAccounts: { accounts: { '0x01': { address: '0x01' }, diff --git a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.ts b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.ts index 6c504b418bde..4cbdddc62ea5 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.ts @@ -31,7 +31,7 @@ export type RequestEthereumAccountsHooks = { sendMetrics: SendMetrics; metamaskState: Pick< FlattenedBackgroundStateProxy, - 'metaMetricsId' | 'permissionHistory' | 'internalAccounts' + 'analyticsId' | 'permissionHistory' | 'internalAccounts' >; getCaip25PermissionFromLegacyPermissionsForOrigin: GetCaip25PermissionFromLegacyPermissionsForOrigin; requestPermissionsForOrigin: RequestPermissionsForOrigin; @@ -142,7 +142,7 @@ async function requestEthereumAccountsImplementation( // first time connection to dapp will lead to no log in the permissionHistory // and if user has connected to dapp before, the dapp origin will be included in the permissionHistory state // we will leverage that to identify `is_first_visit` for metrics - if (shouldEmitDappViewedEvent(metamaskState.metaMetricsId)) { + if (shouldEmitDappViewedEvent(metamaskState.analyticsId)) { const isFirstVisit = !Object.keys(metamaskState.permissionHistory).includes( origin, ); diff --git a/app/scripts/lib/segment/__tests__/segment-shim.test.ts b/app/scripts/lib/segment/__tests__/segment-shim.test.ts index a9bc511388f5..4e48a7dca6b6 100644 --- a/app/scripts/lib/segment/__tests__/segment-shim.test.ts +++ b/app/scripts/lib/segment/__tests__/segment-shim.test.ts @@ -42,7 +42,8 @@ describe('createSegmentMock', () => { it('uses a noop callback when track is called without one', async () => { const client = createSegmentMock(MANUAL_FLUSH_AT); client.track({ event: 'solo' }); - await client.flush(); + + await expect(client.flush()).resolves.toBeUndefined(); expect(client.queue).toHaveLength(0); }); @@ -62,10 +63,14 @@ describe('createSegmentMock', () => { expect(client.queue).toHaveLength(1); }); - it('exposes noop page and identify for spying', () => { + it('exposes noop page and identify (with optional callback) for spying', () => { const client = createSegmentMock(MANUAL_FLUSH_AT); - expect(() => client.page({ name: 'n' })).not.toThrow(); - expect(() => client.identify({ userId: 'u' })).not.toThrow(); + const pageCb = jest.fn(); + const identifyCb = jest.fn(); + expect(() => client.page({ name: 'n' }, pageCb)).not.toThrow(); + expect(() => client.identify({ userId: 'u' }, identifyCb)).not.toThrow(); + expect(pageCb).toHaveBeenCalledTimes(1); + expect(identifyCb).toHaveBeenCalledTimes(1); }); }); @@ -138,7 +143,7 @@ describe('segment module export', () => { }); }); - it('clones track payloads before forwarding so frozen context objects work', async () => { + it('forwards track / identify / page payloads to the Analytics instance', async () => { jest.resetModules(); process.env.SEGMENT_WRITE_KEY = 'key'; process.env.METAMASK_ENVIRONMENT = 'development'; @@ -146,56 +151,17 @@ describe('segment module export', () => { Analytics: analyticsConstructor, })); const { segment: client } = await importSegmentIndexModule(); - const payload = { - event: 'evt', - context: Object.freeze({ app: Object.freeze({ name: 'x' }) }), - }; - client.track(payload); - const [forwarded] = analyticsInstance.track.mock.calls[0] ?? []; - expect(forwarded).toBeDefined(); - expect(forwarded).not.toBe(payload); - if ( - forwarded && - typeof forwarded === 'object' && - 'context' in forwarded && - forwarded.context && - typeof forwarded.context === 'object' - ) { - Reflect.set(forwarded.context, 'library', { name: 'lib' }); - } - expect( - payload.context && - typeof payload.context === 'object' && - 'library' in payload.context, - ).toBe(false); - }); - it('clones identify and page payloads before forwarding', async () => { - jest.resetModules(); - process.env.SEGMENT_WRITE_KEY = 'key'; - process.env.METAMASK_ENVIRONMENT = 'development'; - jest.doMock('@segment/analytics-node', () => ({ - Analytics: analyticsConstructor, - })); - const { segment: client } = await importSegmentIndexModule(); - const identifyPayload = { - userId: 'u', - traits: Object.freeze({ plan: 'free' }), - }; - client.identify(identifyPayload); - const [idArg] = analyticsInstance.identify.mock.calls[0] ?? []; - expect(idArg).not.toBe(identifyPayload); - - const pagePayload = { - name: 'home', - properties: Object.freeze({ path: '/' }), - }; - client.page(pagePayload); - const [pageArg] = analyticsInstance.page.mock.calls[0] ?? []; - expect(pageArg).not.toBe(pagePayload); + client.track({ event: 'e' }); + client.identify({ userId: 'u' }); + client.page({ name: 'n' }); + + expect(analyticsInstance.track).toHaveBeenCalledTimes(1); + expect(analyticsInstance.identify).toHaveBeenCalledTimes(1); + expect(analyticsInstance.page).toHaveBeenCalledTimes(1); }); - it('delegates flush to the Analytics instance', async () => { + it('clones payloads before forwarding them to the Analytics instance', async () => { jest.resetModules(); process.env.SEGMENT_WRITE_KEY = 'key'; process.env.METAMASK_ENVIRONMENT = 'development'; @@ -203,8 +169,52 @@ describe('segment module export', () => { Analytics: analyticsConstructor, })); const { segment: client } = await importSegmentIndexModule(); - await client.flush(); - expect(analyticsInstance.flush).toHaveBeenCalledTimes(1); + + const properties = Object.freeze({ chainId: '0x1' }); + const context = Object.freeze({ + app: Object.freeze({ name: 'MetaMask Extension' }), + }); + const traits = Object.freeze({ plan: 'pro' }); + + client.track( + { + event: 'e', + properties, + context, + }, + ); + client.identify( + { + userId: 'u', + traits, + context, + }, + ); + client.page( + { + name: 'n', + properties, + context, + }, + ); + + const [trackCall] = analyticsInstance.track.mock.calls[0] ?? []; + expect(trackCall.properties).toEqual(properties); + expect(trackCall.properties).not.toBe(properties); + expect(trackCall.context).toEqual(context); + expect(trackCall.context).not.toBe(context); + + const [identifyCall] = analyticsInstance.identify.mock.calls[0] ?? []; + expect(identifyCall.traits).toEqual(traits); + expect(identifyCall.traits).not.toBe(traits); + expect(identifyCall.context).toEqual(context); + expect(identifyCall.context).not.toBe(context); + + const [pageCall] = analyticsInstance.page.mock.calls[0] ?? []; + expect(pageCall.properties).toEqual(properties); + expect(pageCall.properties).not.toBe(properties); + expect(pageCall.context).toEqual(context); + expect(pageCall.context).not.toBe(context); }); it('forwards the optional callback to track, identify, and page', async () => { @@ -234,4 +244,16 @@ describe('segment module export', () => { onPage, ); }); + + it('delegates flush to the Analytics instance', async () => { + jest.resetModules(); + process.env.SEGMENT_WRITE_KEY = 'key'; + process.env.METAMASK_ENVIRONMENT = 'development'; + jest.doMock('@segment/analytics-node', () => ({ + Analytics: analyticsConstructor, + })); + const { segment: client } = await importSegmentIndexModule(); + await client.flush(); + expect(analyticsInstance.flush).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/scripts/lib/segment/early-segment-tracking.test.ts b/app/scripts/lib/segment/custom-segment-tracking.test.ts similarity index 62% rename from app/scripts/lib/segment/early-segment-tracking.test.ts rename to app/scripts/lib/segment/custom-segment-tracking.test.ts index a2306263ccae..f727dae6e590 100644 --- a/app/scripts/lib/segment/early-segment-tracking.test.ts +++ b/app/scripts/lib/segment/custom-segment-tracking.test.ts @@ -4,8 +4,9 @@ import { } from '../../../../shared/constants/metametrics'; import { trackEarlySegmentEvent, + trackSegmentEventWhileOptedOut, type EarlySegmentState, -} from './early-segment-tracking'; +} from './custom-segment-tracking'; import { segment } from '.'; jest.mock('.', () => ({ @@ -25,9 +26,9 @@ describe('trackEarlySegmentEvent', () => { it('tracks event with correct payload and flushes immediately when user has opted in', () => { trackEarlySegmentEvent({ state: { - MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'test-metrics-id-123', + AnalyticsController: { + optedIn: true, + analyticsId: 'test-metrics-id-123', }, }, event: MetaMetricsEventName.StateMigrationSucceeded, @@ -59,9 +60,9 @@ describe('trackEarlySegmentEvent', () => { it('merges custom context with the default app context', () => { trackEarlySegmentEvent({ state: { - MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'test-metrics-id-456', + AnalyticsController: { + optedIn: true, + analyticsId: 'test-metrics-id-456', }, }, event: MetaMetricsEventName.StateMigrationSucceeded, @@ -94,28 +95,19 @@ describe('trackEarlySegmentEvent', () => { // @ts-expect-error This is missing from the Mocha type definitions it.each([ ['state is null', null], - ['MetaMetricsController is missing', { KeyringController: {} }], + ['AnalyticsController is missing', { KeyringController: {} }], [ - 'participateInMetaMetrics is false', + 'optedIn is false', { - MetaMetricsController: { - participateInMetaMetrics: false, - metaMetricsId: 'id', + AnalyticsController: { + optedIn: false, + analyticsId: 'id', }, }, ], [ - 'participateInMetaMetrics is null', - { - MetaMetricsController: { - participateInMetaMetrics: null, - metaMetricsId: 'id', - }, - }, - ], - [ - 'metaMetricsId is missing', - { MetaMetricsController: { participateInMetaMetrics: true } }, + 'analyticsId is missing', + { AnalyticsController: { optedIn: true } }, ], ])('does not track when %s', (_: string, state: EarlySegmentState | null) => { trackEarlySegmentEvent({ @@ -128,3 +120,48 @@ describe('trackEarlySegmentEvent', () => { expect(mockSegment.flush).not.toHaveBeenCalled(); }); }); + +describe('trackSegmentEventWhileOptedOut', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('tracks event directly to Segment and flushes immediately', () => { + trackSegmentEventWhileOptedOut({ + analyticsId: 'test-metrics-id-789', + event: MetaMetricsEventName.MetricsOptOut, + properties: { + category: MetaMetricsEventCategory.Onboarding, + }, + context: { + page: { + path: '/onboarding', + }, + }, + }); + + expect(mockSegment.track).toHaveBeenCalledWith({ + userId: 'test-metrics-id-789', + event: MetaMetricsEventName.MetricsOptOut, + properties: { + category: MetaMetricsEventCategory.Onboarding, + }, + context: { + page: { + path: '/onboarding', + }, + }, + }); + expect(mockSegment.flush).toHaveBeenCalledTimes(1); + }); + + it('does not track when analyticsId is empty', () => { + trackSegmentEventWhileOptedOut({ + analyticsId: '', + event: MetaMetricsEventName.MetricsOptOut, + }); + + expect(mockSegment.track).not.toHaveBeenCalled(); + expect(mockSegment.flush).not.toHaveBeenCalled(); + }); +}); diff --git a/app/scripts/lib/segment/custom-segment-tracking.ts b/app/scripts/lib/segment/custom-segment-tracking.ts new file mode 100644 index 000000000000..bdb03b76336c --- /dev/null +++ b/app/scripts/lib/segment/custom-segment-tracking.ts @@ -0,0 +1,221 @@ +// Custom Segment Tracking +// +// This module provides standalone helpers for direct Segment tracking flows +// that happen outside AnalyticsController. + +import { isObject, type Json } from '@metamask/utils'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, + type MetaMetricsContext, +} from '../../../../shared/constants/metametrics'; +import { segment } from '.'; + +/** + * Partial state object that may contain AnalyticsController state. + */ +export type EarlySegmentState = Record & { + /** + * AnalyticsController state. + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + AnalyticsController?: unknown; +}; + +/** + * Extracts analytics consent and ID from a partial state object. Returns the + * `analyticsId` if the user has opted in, otherwise `null`. + * + * @param state - Partial state object that may contain AnalyticsController + * state. + * @returns The analyticsId if the user has opted in, otherwise null. + */ +function getMetaMetricsFromState( + state: EarlySegmentState | null, +): string | null { + if (!state?.AnalyticsController) { + return null; + } + + const analyticsState = state.AnalyticsController; + + // Validate it's an object (defensive check in case state shape changes) + if (!isObject(analyticsState)) { + console.error( + 'AnalyticsController is not an object in state:', + typeof analyticsState, + ); + return null; + } + + // User hasn't opted in to analytics + const { optedIn, analyticsId } = analyticsState; + if (optedIn !== true) { + return null; + } + + // Validate analyticsId is a string. + // If it's missing (undefined/null), that's expected - user hasn't set up analytics. + // Only log an error if it exists but has an unexpected type. + if (typeof analyticsId !== 'string') { + if (analyticsId !== undefined && analyticsId !== null) { + console.error( + 'analyticsId has unexpected type in state:', + typeof analyticsId, + ); + } + return null; + } + + return analyticsId; +} + +/** + * Arguments for tracking an early Segment event. + */ +type EarlySegmentEventArgs = { + /** + * Partial state object that may contain AnalyticsController state. + */ + state: EarlySegmentState | null; + /** + * The MetaMetrics event name to track. + */ + event: MetaMetricsEventName; + /** + * The MetaMetrics event category. + */ + category: MetaMetricsEventCategory; + /** + * Additional properties to include with the event. + */ + properties?: Record; + /** + * Additional context to include with the event. + */ + context?: Partial; +}; + +/** + * Arguments for directly tracking a Segment event while analytics is opted out. + */ +type SegmentEventWhileOptedOutArgs = { + /** + * The analytics ID to attach as Segment userId. + */ + analyticsId: string; + /** + * The MetaMetrics event name to track. + */ + event: MetaMetricsEventName; + /** + * Additional properties to include with the event. + */ + properties?: Record; + /** + * Additional context to include with the event. + */ + context?: Partial; +}; + +/** + * Tracks an event directly to Segment using analytics consent and ID from + * a partial state object. + * + * @param segmentEventArgs - The arguments for tracking the event. + * @param segmentEventArgs.state - Partial state object that may contain AnalyticsController state. + * @param segmentEventArgs.event - The MetaMetrics event name to track. + * @param segmentEventArgs.category - The MetaMetrics event category. + * @param segmentEventArgs.properties - Additional properties to include with the event. + * @param segmentEventArgs.context - Additional context to include with the event. + */ +export function trackEarlySegmentEvent({ + state, + event, + category, + properties, + context, +}: EarlySegmentEventArgs): void { + const analyticsId = getMetaMetricsFromState(state); + + // Don't track if user hasn't opted in to analytics + if (!analyticsId) { + return; + } + + const baseContext = { + app: { + name: 'MetaMask Extension', + version: process.env.METAMASK_VERSION, + }, + }; + + const mergedContext = context + ? { + ...baseContext, + ...context, + app: { + ...baseContext.app, + ...context.app, + }, + } + : baseContext; + + try { + segment.track({ + userId: analyticsId, + event, + properties: { + ...(properties ?? {}), + category, + }, + context: mergedContext, + }); + + // Flush immediately to ensure the event is sent before the page might reload + segment.flush(); + } catch (error) { + // Log but don't propagate analytics errors to ensure they never break the + // flow. This matches MetaMetricsController's behavior. + console.error('Failed to track early Segment event:', error); + } +} + +/** + * Tracks an event directly to Segment while AnalyticsController is opted out. + * This is intentionally narrow and should only be used for events that are + * allowed to bypass analytics opt-out handling, such as Metrics Opt Out. + * + * @param segmentEventArgs - The arguments for tracking the event. + * @param segmentEventArgs.analyticsId - The analytics ID to attach as Segment userId. + * @param segmentEventArgs.event - The MetaMetrics event name to track. + * @param segmentEventArgs.properties - Additional properties to include with the event. + * @param segmentEventArgs.context - Additional context to include with the event. + */ +export function trackSegmentEventWhileOptedOut({ + analyticsId, + event, + properties, + context, +}: SegmentEventWhileOptedOutArgs): void { + if (!analyticsId) { + return; + } + + try { + segment.track({ + userId: analyticsId, + event, + properties, + context, + }); + + // Flush immediately so the opt-out event is not left in the in-memory SDK queue. + segment.flush(); + } catch (error) { + // Log but don't propagate analytics errors to ensure they never break the + // flow. This matches MetaMetricsController's behavior. + console.error('Failed to track Segment event while opted out:', error); + } +} diff --git a/app/scripts/lib/segment/early-segment-tracking.ts b/app/scripts/lib/segment/early-segment-tracking.ts deleted file mode 100644 index 73e938b23925..000000000000 --- a/app/scripts/lib/segment/early-segment-tracking.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Early Segment Tracking -// -// This module provides a standalone way to track Segment events before -// MetaMetricsController is initialized. It extracts the MetaMetrics consent -// and ID from a partial state object and sends events directly to Segment. - -import { isObject, type Json } from '@metamask/utils'; -import { - MetaMetricsEventCategory, - MetaMetricsEventName, - type MetaMetricsContext, -} from '../../../../shared/constants/metametrics'; -import { segment } from '.'; - -/** - * Partial state object that may contain MetaMetricsController state. - */ -export type EarlySegmentState = Record & { - /** - * MetaMetricsController state. - */ - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - MetaMetricsController?: unknown; -}; - -/** - * Extracts MetaMetrics consent and ID from a partial state object. Returns the - * `metaMetricsId` if the user has opted in, otherwise `null`. - * - * @param state - Partial state object that may contain MetaMetricsController - * state. - * @returns The metaMetricsId if the user has opted in, otherwise null. - */ -function getMetaMetricsFromState( - state: EarlySegmentState | null, -): string | null { - if (!state?.MetaMetricsController) { - return null; - } - - const metaMetricsState = state.MetaMetricsController; - - // Validate it's an object (defensive check in case state shape changes) - if (!isObject(metaMetricsState)) { - console.error( - 'MetaMetricsController is not an object in state:', - typeof metaMetricsState, - ); - return null; - } - - // User hasn't opted in to MetaMetrics - const { participateInMetaMetrics, metaMetricsId } = metaMetricsState; - if (participateInMetaMetrics !== true) { - return null; - } - - // Validate metaMetricsId is a string. - // If it's missing (undefined/null), that's expected - user hasn't set up MetaMetrics. - // Only log an error if it exists but has an unexpected type. - if (typeof metaMetricsId !== 'string') { - if (metaMetricsId !== undefined && metaMetricsId !== null) { - console.error( - 'metaMetricsId has unexpected type in state:', - typeof metaMetricsId, - ); - } - return null; - } - - return metaMetricsId; -} - -/** - * Arguments for tracking an early Segment event. - */ -type EarlySegmentEventArgs = { - /** - * Partial state object that may contain MetaMetricsController state. - */ - state: EarlySegmentState | null; - /** - * The MetaMetrics event name to track. - */ - event: MetaMetricsEventName; - /** - * The MetaMetrics event category. - */ - category: MetaMetricsEventCategory; - /** - * Additional properties to include with the event. - */ - properties?: Record; - /** - * Additional context to include with the event. - */ - context?: Partial; -}; - -/** - * Tracks an event directly to Segment using MetaMetrics consent and ID from - * a partial state object. - * - * @param segmentEventArgs - The arguments for tracking the event. - * @param segmentEventArgs.state - Partial state object that may contain MetaMetricsController state. - * @param segmentEventArgs.event - The MetaMetrics event name to track. - * @param segmentEventArgs.category - The MetaMetrics event category. - * @param segmentEventArgs.properties - Additional properties to include with the event. - * @param segmentEventArgs.context - Additional context to include with the event. - */ -export function trackEarlySegmentEvent({ - state, - event, - category, - properties, - context, -}: EarlySegmentEventArgs): void { - const metaMetricsId = getMetaMetricsFromState(state); - - // Don't track if user hasn't opted in to MetaMetrics - if (!metaMetricsId) { - return; - } - - const baseContext = { - app: { - name: 'MetaMask Extension', - version: process.env.METAMASK_VERSION, - }, - }; - - const mergedContext = context - ? { - ...baseContext, - ...context, - app: { - ...baseContext.app, - ...context.app, - }, - } - : baseContext; - - try { - segment.track({ - userId: metaMetricsId, - event, - properties: { - ...(properties ?? {}), - category, - }, - context: mergedContext, - }); - - // Flush immediately to ensure the event is sent before the page might reload - segment.flush(); - } catch (error) { - // Log but don't propagate analytics errors to ensure they never break the - // flow. This matches MetaMetricsController's behavior. - console.error('Failed to track early Segment event:', error); - } -} diff --git a/app/scripts/lib/segment/index.ts b/app/scripts/lib/segment/index.ts index 8e2d8e354e1d..f58c31d3665e 100644 --- a/app/scripts/lib/segment/index.ts +++ b/app/scripts/lib/segment/index.ts @@ -7,7 +7,7 @@ const SEGMENT_WRITE_KEY = process.env.SEGMENT_WRITE_KEY ?? null; const SEGMENT_HOST = process.env.SEGMENT_HOST || undefined; // flushAt controls how many events are sent to segment at once. Segment will -// hold onto a queue of events until it hits this number, then it sends them as +// hold onto a queue of events until it hits this number, then sends them as // a batch. This setting defaults to 15 in `@segment/analytics-node`, but in // development we likely want to see events in real time for debugging, so this // is set to 1 to disable the queueing mechanism. @@ -26,20 +26,15 @@ const SEGMENT_FLUSH_INTERVAL = SECOND * 5; /** * Callback invoked after an event has been flushed to Segment. The first * argument is an error (if any). The second argument is the Segment SDK - * `Context` object; consumers in this codebase only inspect the error, so the + * `Context` object. Consumers in this codebase only inspect the error, so the * type is intentionally loose. */ export type SegmentCallback = (err?: unknown, ctx?: unknown) => void; /** - * Payload accepted by the segment client. Extension call sites pass partial - * payloads (e.g. `#identify` only sets `userId` + `traits`, early-init events - * omit `locale`/`chain_id`), so all fields are optional and `properties` / - * `context` / `traits` are typed loosely; validation is left to Segment. - * - * The `timestamp` field accepts both `string` (as persisted in - * `MetaMetricsController.state.segmentApiCalls` across service-worker - * restarts) and `Date` (as produced when the event is first enqueued). + * Loose payload shape used by `custom-segment-tracking` and tests. The real + * `Analytics` SDK enforces stricter per-method types. We keep this lenient + * because early-init traffic and the mock both forward partial payloads. */ export type SegmentTrackPayload = { userId?: string; @@ -55,10 +50,9 @@ export type SegmentTrackPayload = { }; /** - * Thin interface exposed to the rest of the extension. This is the only - * surface `MetaMetricsController` and `early-segment-tracking` interact with, - * so swapping the underlying implementation (real SDK vs. mock) is confined - * to this module. + * Safe Segment client exposed to the rest of the extension. The real + * implementation clones payloads before forwarding to `@segment/analytics-node` + * because the SDK mutates payloads during normalization. */ export type SegmentClient = { track: (payload: SegmentTrackPayload, callback?: SegmentCallback) => void; @@ -68,18 +62,14 @@ export type SegmentClient = { }; /** - * Create a segment client backed by the official `@segment/analytics-node` - * SDK. The SDK uses `fetch` under the hood, so it is compatible with the MV3 - * service worker runtime (unlike the legacy `analytics-node` which relied on - * `axios`/`XMLHttpRequest`). + * Constructs a `@segment/analytics-node` Analytics instance. * * @param writeKey - The Segment project write key. * @param host - Segment API host override (e.g. local mock). - * @param flushAt - Queue batch size; `undefined` uses the SDK default. + * @param flushAt - Queue batch size. `undefined` uses the SDK default. * @param flushInterval - Periodic flush interval in milliseconds. - * @returns A Segment client that forwards to the official Segment SDK. */ -function createSegmentClient( +export function createSegmentClient( writeKey: string, host: string | undefined, flushAt: number | undefined, @@ -92,38 +82,22 @@ function createSegmentClient( flushInterval, }); - /** - * `MetaMetricsController.#submitSegmentAPICall` writes the same payload into - * `state.segmentApiCalls` (via `this.update`) before calling this client. - * BaseController uses immer with auto-freeze, so nested fields like - * `context`, `properties`, and `traits` that are shared by reference become - * non-extensible / frozen in place. - * - * `@segment/analytics-node`'s Segment.io plugin runs `normalizeEvent`, which - * uses `dset` to add `context.library` on the existing `context` object. That - * throws `TypeError: Cannot add property library, object is not extensible`. - * The error can be swallowed inside the SDK's plugin pipeline, so callbacks - * may still report success but no HTTP request is sent. - * - * `cloneDeep` on each SDK call gives a plain mutable copy so normalization - * can run. - */ return { track(payload, callback) { analytics.track( - cloneDeep(payload) as Parameters[0], + cloneDeep(payload) as Parameters[0], callback, ); }, identify(payload, callback) { analytics.identify( - cloneDeep(payload) as Parameters[0], + cloneDeep(payload) as Parameters[0], callback, ); }, page(payload, callback) { analytics.page( - cloneDeep(payload) as Parameters[0], + cloneDeep(payload) as Parameters[0], callback, ); }, @@ -133,57 +107,63 @@ function createSegmentClient( }; } -/** - * Mock segment client queue item: the payload and its completion callback. - */ type MockQueueItem = [SegmentTrackPayload, SegmentCallback]; /** - * Segment mock used in test environments. It preserves the shape of the real - * client, exposes an internal `queue`, and lets tests drive flushes - * synchronously via `flush()`. Used both by unit tests and by the app when - * `SEGMENT_WRITE_KEY` is not provided (e.g. test builds). + * Segment mock used in test environments and in builds without + * `SEGMENT_WRITE_KEY`. Exposes an inspectable `queue` and lets tests drive + * flushes synchronously via `flush()`. * * @param flushAt - Number of events to queue before auto-flushing. When * undefined, events are only flushed when `flush()` is called explicitly. - * @returns A Segment client with an inspectable queue. */ export const createSegmentMock = ( flushAt: number | undefined = SEGMENT_FLUSH_AT, ): SegmentClient & { queue: MockQueueItem[] } => { - const segmentMock: SegmentClient & { queue: MockQueueItem[] } = { - queue: [], + const noopCallback: SegmentCallback = () => undefined; - flush() { - segmentMock.queue.forEach(([, callback]) => { - callback(); - }); - segmentMock.queue = []; - return Promise.resolve(); - }, + const flushQueue = (queue: MockQueueItem[]) => { + queue.forEach(([, callback]) => { + callback(); + }); + queue.length = 0; + }; - track(payload, callback = () => undefined) { - segmentMock.queue.push([payload, callback]); + const segmentMock = { + queue: [] as MockQueueItem[], + track(payload: SegmentTrackPayload, callback: SegmentCallback = noopCallback) { + segmentMock.queue.push([payload, callback]); if (flushAt !== undefined && segmentMock.queue.length >= flushAt) { - segmentMock.flush(); + flushQueue(segmentMock.queue); } }, - // These methods are either unused in tests or do not await their callback, - // so a NOOP implementation is sufficient. Tests still `spyOn` them to - // assert invocation. - page() { - // noop + flush() { + flushQueue(segmentMock.queue); + return Promise.resolve(); + }, + + page(_payload?: SegmentTrackPayload, callback?: SegmentCallback): void { + callback?.(); }, - identify() { - // noop + + identify(_payload?: SegmentTrackPayload, callback?: SegmentCallback): void { + callback?.(); }, }; - return segmentMock; + return segmentMock as unknown as SegmentClient & { queue: MockQueueItem[] }; }; +/** + * Shared Segment client used across the extension background. Real builds use + * the `@segment/analytics-node` SDK; test builds and CI without + * `SEGMENT_WRITE_KEY` fall back to createSegmentMock. + * + * Payload cloning lives in this module so every direct Segment caller receives + * the same protection from SDK payload mutation. + */ export const segment: SegmentClient = SEGMENT_WRITE_KEY ? createSegmentClient( SEGMENT_WRITE_KEY, @@ -191,4 +171,4 @@ export const segment: SegmentClient = SEGMENT_WRITE_KEY SEGMENT_FLUSH_AT, SEGMENT_FLUSH_INTERVAL, ) - : createSegmentMock(SEGMENT_FLUSH_AT); + : (createSegmentMock(SEGMENT_FLUSH_AT) as unknown as SegmentClient); diff --git a/app/scripts/lib/sentry-get-state.test.ts b/app/scripts/lib/sentry-get-state.test.ts index 242fdeab3bda..82dfddf26dba 100644 --- a/app/scripts/lib/sentry-get-state.test.ts +++ b/app/scripts/lib/sentry-get-state.test.ts @@ -73,9 +73,12 @@ describe('sentry-get-state', () => { getSentryState: () => emptySentrySnapshot(), getPersistedState: async () => ({ data: { + AnalyticsController: { + analyticsId: 'id-123', + optedIn: true, + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'id-123', + completedMetaMetricsOnboarding: true, }, }, }), @@ -94,7 +97,13 @@ describe('sentry-get-state', () => { getSentryState: () => emptySentrySnapshot(), getPersistedState: async () => ({ data: { - MetaMetricsController: { participateInMetaMetrics: false }, + AnalyticsController: { + analyticsId: 'id-123', + optedIn: false, + }, + MetaMetricsController: { + completedMetaMetricsOnboarding: true, + }, }, }), getBackupState: async () => ({}), @@ -102,7 +111,7 @@ describe('sentry-get-state', () => { await expect(getMetaMetricsState()).resolves.toStrictEqual({ participateInMetaMetrics: false, - metaMetricsId: undefined, + metaMetricsId: 'id-123', }); }); @@ -115,7 +124,7 @@ describe('sentry-get-state', () => { }; await expect(getMetaMetricsState()).resolves.toStrictEqual({ - participateInMetaMetrics: false, + participateInMetaMetrics: null, metaMetricsId: undefined, }); @@ -127,7 +136,7 @@ describe('sentry-get-state', () => { }; await expect(getMetaMetricsState()).resolves.toStrictEqual({ - participateInMetaMetrics: false, + participateInMetaMetrics: null, metaMetricsId: undefined, }); }); @@ -140,9 +149,12 @@ describe('sentry-get-state', () => { throw new Error('persisted unavailable'); }, getBackupState: async () => ({ + AnalyticsController: { + analyticsId: 'backup-id', + optedIn: true, + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'backup-id', + completedMetaMetricsOnboarding: true, }, }), }; @@ -161,13 +173,19 @@ describe('sentry-get-state', () => { throw new Error('persisted unavailable'); }, getBackupState: async () => ({ - MetaMetricsController: { participateInMetaMetrics: false }, + AnalyticsController: { + analyticsId: 'backup-id', + optedIn: false, + }, + MetaMetricsController: { + completedMetaMetricsOnboarding: true, + }, }), }; await expect(getMetaMetricsState()).resolves.toStrictEqual({ participateInMetaMetrics: false, - metaMetricsId: undefined, + metaMetricsId: 'backup-id', }); }); @@ -182,7 +200,7 @@ describe('sentry-get-state', () => { }; await expect(getMetaMetricsState()).resolves.toStrictEqual({ - participateInMetaMetrics: false, + participateInMetaMetrics: null, metaMetricsId: undefined, }); @@ -196,7 +214,7 @@ describe('sentry-get-state', () => { }; await expect(getMetaMetricsState()).resolves.toStrictEqual({ - participateInMetaMetrics: false, + participateInMetaMetrics: null, metaMetricsId: undefined, }); }); @@ -210,9 +228,12 @@ describe('sentry-get-state', () => { it('delegates to persisted state when persistedState is present', () => { const persistedState = { data: { + AnalyticsController: { + analyticsId: 'persisted-id', + optedIn: true, + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'persisted-id', + completedMetaMetricsOnboarding: true, }, }, }; @@ -240,13 +261,32 @@ describe('sentry-get-state', () => { }); }); - it('returns state from appState.state.MetaMetricsController when state has no metamask', () => { + it('returns null participation from appState.state.metamask before opt-in selection', () => { expect( getMetaMetricsStateFromAppState({ state: { + metamask: { + participateInMetaMetrics: null, + metaMetricsId: 'metamask-id', + }, + }, + }), + ).toStrictEqual({ + participateInMetaMetrics: null, + metaMetricsId: 'metamask-id', + }); + }); + + it('returns state from controller state when state has no metamask', () => { + expect( + getMetaMetricsStateFromAppState({ + state: { + AnalyticsController: { + analyticsId: 'controller-id', + optedIn: true, + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'controller-id', + completedMetaMetricsOnboarding: true, }, }, }), @@ -260,12 +300,37 @@ describe('sentry-get-state', () => { expect( getMetaMetricsStateFromAppState({ state: { - MetaMetricsController: { participateInMetaMetrics: false }, + AnalyticsController: { + analyticsId: 'controller-id', + optedIn: false, + }, + MetaMetricsController: { + completedMetaMetricsOnboarding: true, + }, }, }), ).toStrictEqual({ participateInMetaMetrics: false, - metaMetricsId: undefined, + metaMetricsId: 'controller-id', + }); + }); + + it('returns participateInMetaMetrics null when onboarding is incomplete', () => { + expect( + getMetaMetricsStateFromAppState({ + state: { + AnalyticsController: { + analyticsId: 'controller-id', + optedIn: true, + }, + MetaMetricsController: { + completedMetaMetricsOnboarding: false, + }, + }, + }), + ).toStrictEqual({ + participateInMetaMetrics: null, + metaMetricsId: 'controller-id', }); }); }); diff --git a/app/scripts/lib/sentry-get-state.ts b/app/scripts/lib/sentry-get-state.ts index 36bbf6c4fb52..e1d1f7a8f007 100644 --- a/app/scripts/lib/sentry-get-state.ts +++ b/app/scripts/lib/sentry-get-state.ts @@ -4,8 +4,17 @@ import type { Backup } from '../../../shared/lib/stores/persistence-manager'; type SentryAppStateSnapshot = Record; +type AnalyticsState = { + analyticsId?: unknown; + optedIn?: unknown; +}; + +type MetaMetricsState = { + completedMetaMetricsOnboarding?: unknown; +}; + export type MetaMetricsParticipation = { - participateInMetaMetrics: boolean; + participateInMetaMetrics: boolean | null; metaMetricsId?: string; } | null; @@ -65,26 +74,15 @@ export function getMetaMetricsStateFromAppState( const state = appState.state as Record; if ('metamask' in state && state.metamask !== undefined) { const metamask = state.metamask as { - participateInMetaMetrics?: boolean; + participateInMetaMetrics?: boolean | null; metaMetricsId?: string; }; - const enabled = Boolean(metamask.participateInMetaMetrics); return { - participateInMetaMetrics: enabled, - metaMetricsId: enabled ? metamask.metaMetricsId : undefined, + participateInMetaMetrics: metamask.participateInMetaMetrics ?? null, + metaMetricsId: metamask.metaMetricsId, }; } - const controller = state.MetaMetricsController as - | { - participateInMetaMetrics?: boolean; - metaMetricsId?: string; - } - | undefined; - const enabled = Boolean(controller?.participateInMetaMetrics); - return { - participateInMetaMetrics: enabled, - metaMetricsId: enabled ? controller?.metaMetricsId : undefined, - }; + return getMetaMetricsStateFromControllerState(state); } return null; } @@ -99,17 +97,7 @@ function getMetaMetricsStateFromPersistedState( const data = ( persistedState as { data?: Record } | undefined )?.data; - const controller = data?.MetaMetricsController as - | { - participateInMetaMetrics?: boolean; - metaMetricsId?: string; - } - | undefined; - const enabled = Boolean(controller?.participateInMetaMetrics); - return { - participateInMetaMetrics: enabled, - metaMetricsId: enabled ? controller?.metaMetricsId : undefined, - }; + return getMetaMetricsStateFromControllerState(data); } /** @@ -119,15 +107,39 @@ function getMetaMetricsStateFromPersistedState( function getMetaMetricsStateFromBackupState( backupState: Backup | null, ): Exclude { - const controller = backupState?.MetaMetricsController as - | { - participateInMetaMetrics?: boolean; - metaMetricsId?: string; - } - | undefined; - const enabled = Boolean(controller?.participateInMetaMetrics); + return getMetaMetricsStateFromControllerState(backupState); +} + +function getMetaMetricsStateFromControllerState( + state: unknown, +): Exclude { + const controllerState = isRecord(state) ? state : {}; + const analyticsController = getControllerState( + controllerState.AnalyticsController, + ); + const metaMetricsController = getControllerState( + controllerState.MetaMetricsController, + ); + + const { analyticsId } = analyticsController ?? {}; + const participateInMetaMetrics = + analyticsController && + metaMetricsController?.completedMetaMetricsOnboarding === true + ? analyticsController.optedIn === true + : null; + return { - participateInMetaMetrics: enabled, - metaMetricsId: enabled ? controller?.metaMetricsId : undefined, + participateInMetaMetrics, + metaMetricsId: typeof analyticsId === 'string' ? analyticsId : undefined, }; } + +function getControllerState( + state: unknown, +): TControllerState | undefined { + return isRecord(state) ? (state as TControllerState) : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object'; +} diff --git a/app/scripts/lib/sentry-make-transport.test.ts b/app/scripts/lib/sentry-make-transport.test.ts index f8faf16fcb3f..e8a5425984e0 100644 --- a/app/scripts/lib/sentry-make-transport.test.ts +++ b/app/scripts/lib/sentry-make-transport.test.ts @@ -108,7 +108,13 @@ describe('sentry-make-transport', () => { getSentryState: () => emptySentrySnapshot(), getPersistedState: async () => ({ data: { - MetaMetricsController: { participateInMetaMetrics: false }, + AnalyticsController: { + analyticsId: 'transport-test-id', + optedIn: false, + }, + MetaMetricsController: { + completedMetaMetricsOnboarding: true, + }, }, }), getBackupState: async () => ({}), @@ -141,9 +147,12 @@ describe('sentry-make-transport', () => { getSentryState: () => emptySentrySnapshot(), getPersistedState: async () => ({ data: { + AnalyticsController: { + analyticsId: 'transport-test-id', + optedIn: true, + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'transport-test-id', + completedMetaMetricsOnboarding: true, }, }, }), @@ -175,9 +184,12 @@ describe('sentry-make-transport', () => { getSentryState: () => ({ ...emptySentrySnapshot(), state: { + AnalyticsController: { + analyticsId: 'app-state-id', + optedIn: true, + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'app-state-id', + completedMetaMetricsOnboarding: true, }, }, }), @@ -212,9 +224,12 @@ describe('sentry-make-transport', () => { throw new Error('persisted unavailable'); }, getBackupState: async () => ({ + AnalyticsController: { + analyticsId: 'backup-id', + optedIn: true, + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'backup-id', + completedMetaMetricsOnboarding: true, }, }), }; @@ -286,7 +301,13 @@ describe('sentry-make-transport', () => { getSentryState: () => emptySentrySnapshot(), getPersistedState: async () => ({ data: { - MetaMetricsController: { participateInMetaMetrics: false }, + AnalyticsController: { + analyticsId: 'init-session-test-id', + optedIn: false, + }, + MetaMetricsController: { + completedMetaMetricsOnboarding: true, + }, }, }), getBackupState: async () => ({}), @@ -320,9 +341,12 @@ describe('sentry-make-transport', () => { getSentryState: () => emptySentrySnapshot(), getPersistedState: async () => ({ data: { + AnalyticsController: { + analyticsId: 'init-session-test-id', + optedIn: true, + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'init-session-test-id', + completedMetaMetricsOnboarding: true, }, }, }), diff --git a/app/scripts/lib/setup-initial-state-hooks.js b/app/scripts/lib/setup-initial-state-hooks.js index ebb814fb5ae4..d8b2dfc1cdea 100644 --- a/app/scripts/lib/setup-initial-state-hooks.js +++ b/app/scripts/lib/setup-initial-state-hooks.js @@ -12,7 +12,7 @@ import { } from '../../../shared/constants/metametrics'; import { PersistenceManager } from '../../../shared/lib/stores/persistence-manager'; import { trackVaultCorruptionEvent } from './state-corruption/track-vault-corruption'; -import { trackEarlySegmentEvent } from './segment/early-segment-tracking'; +import { trackEarlySegmentEvent } from './segment/custom-segment-tracking'; const platform = new ExtensionPlatform(); diff --git a/app/scripts/lib/state-corruption/track-vault-corruption.test.ts b/app/scripts/lib/state-corruption/track-vault-corruption.test.ts index 27782acb1346..207ce24a7a62 100644 --- a/app/scripts/lib/state-corruption/track-vault-corruption.test.ts +++ b/app/scripts/lib/state-corruption/track-vault-corruption.test.ts @@ -25,9 +25,12 @@ describe('trackVaultCorruptionEvent', () => { const backup: Backup = { KeyringController: { vault: 'encrypted-vault-data' }, AppMetadataController: {}, + AnalyticsController: { + optedIn: true, + analyticsId: 'test-metrics-id-123', + }, MetaMetricsController: { - participateInMetaMetrics: true, - metaMetricsId: 'test-metrics-id-123', + completedMetaMetricsOnboarding: true, }, }; @@ -58,29 +61,22 @@ describe('trackVaultCorruptionEvent', () => { // @ts-expect-error This is missing from the Mocha type definitions it.each([ ['backup is null', null], - ['MetaMetricsController is missing', { KeyringController: {} }], + ['AnalyticsController is missing', { KeyringController: {} }], [ - 'participateInMetaMetrics is false', + 'optedIn is false', { - MetaMetricsController: { - participateInMetaMetrics: false, - metaMetricsId: 'id', + AnalyticsController: { + optedIn: false, + analyticsId: 'id', }, }, ], [ - 'participateInMetaMetrics is null', + 'analyticsId is missing', { - MetaMetricsController: { - participateInMetaMetrics: null, - metaMetricsId: 'id', - }, + AnalyticsController: { optedIn: true }, }, ], - [ - 'metaMetricsId is missing', - { MetaMetricsController: { participateInMetaMetrics: true } }, - ], ])('does not track when %s', (_: string, backup: Backup | null) => { trackVaultCorruptionEvent( backup, diff --git a/app/scripts/lib/state-corruption/track-vault-corruption.ts b/app/scripts/lib/state-corruption/track-vault-corruption.ts index 693df6039ecb..b331686f3f71 100644 --- a/app/scripts/lib/state-corruption/track-vault-corruption.ts +++ b/app/scripts/lib/state-corruption/track-vault-corruption.ts @@ -6,7 +6,7 @@ import { MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import type { Backup } from '../../../../shared/lib/stores/persistence-manager'; -import { trackEarlySegmentEvent } from '../segment/early-segment-tracking'; +import { trackEarlySegmentEvent } from '../segment/custom-segment-tracking'; /** * Tracks a vault corruption event directly to Segment. diff --git a/app/scripts/messenger-client-init/analytics-controller-init.ts b/app/scripts/messenger-client-init/analytics-controller-init.ts new file mode 100644 index 000000000000..f3e12dced1f0 --- /dev/null +++ b/app/scripts/messenger-client-init/analytics-controller-init.ts @@ -0,0 +1,42 @@ +import { + AnalyticsController, + type AnalyticsControllerMessenger, + type AnalyticsControllerState, +} from '@metamask/analytics-controller'; +import { generateMetaMetricsId } from '../../../shared/lib/generate-metametrics-id'; +import { createPlatformAdapter } from '../controllers/analytics/platform-adapter'; +import { MessengerClientInitFunction } from './types'; + +/** + * Initialize the analytics controller. + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger to use for the controller. + * @param request.persistedState - The persisted state to use for the + * controller. + * @returns The initialized controller. + */ +export const AnalyticsControllerInit: MessengerClientInitFunction< + AnalyticsController, + AnalyticsControllerMessenger +> = ({ controllerMessenger, persistedState }) => { + const persisted = { + ...persistedState.AnalyticsController, + }; + + persisted.analyticsId = + (typeof persisted.analyticsId === 'string' ? persisted.analyticsId : '') || + generateMetaMetricsId(); + persisted.optedIn = persisted.optedIn === true; + + const controller = new AnalyticsController({ + messenger: controllerMessenger, + platformAdapter: createPlatformAdapter(), + state: persisted as AnalyticsControllerState, + isAnonymousEventsFeatureEnabled: true, + isEventQueuePersistenceEnabled: true, + }); + controller.init(); + + return { messengerClient: controller }; +}; diff --git a/app/scripts/messenger-client-init/controller-list.ts b/app/scripts/messenger-client-init/controller-list.ts index a52d6712b437..a79aeaed8fbf 100644 --- a/app/scripts/messenger-client-init/controller-list.ts +++ b/app/scripts/messenger-client-init/controller-list.ts @@ -96,6 +96,7 @@ import { } from '@metamask/geolocation-controller'; import { PerpsController } from '@metamask/perps-controller'; import { PasskeyController } from '@metamask/passkey-controller'; +import { AnalyticsController } from '@metamask/analytics-controller'; import { OnboardingController } from '../controllers/onboarding'; import { PreferencesController } from '../controllers/preferences-controller'; import { InstitutionalSnapController } from '../controllers/institutional-snap/InstitutionalSnapController'; @@ -127,6 +128,7 @@ export type MessengerClient = | AccountsController | AddressBookController | AlertController + | AnalyticsController | AnnouncementController | AppMetadataController | ApprovalController @@ -238,6 +240,7 @@ export type MessengerClientFlatState = AccountOrderController['state'] & AppMetadataController['state'] & ApprovalController['state'] & AppStateController['state'] & + AnalyticsController['state'] & AssetsController['state'] & AuthenticationController['state'] & BridgeController['state'] & diff --git a/app/scripts/messenger-client-init/messengers/analytics-controller-messenger.ts b/app/scripts/messenger-client-init/messengers/analytics-controller-messenger.ts new file mode 100644 index 000000000000..e295d23a4b6c --- /dev/null +++ b/app/scripts/messenger-client-init/messengers/analytics-controller-messenger.ts @@ -0,0 +1,33 @@ +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { AnalyticsControllerMessenger } from '@metamask/analytics-controller'; +import type { RootMessenger } from '../../lib/messenger'; + +/** + * Create a messenger restricted to the allowed actions and events of the + * analytics controller. + * + * @param messenger - The base messenger used to create the restricted + * messenger. + */ +export function getAnalyticsControllerMessenger( + messenger: RootMessenger< + MessengerActions, + MessengerEvents + >, +) { + const analyticsControllerMessenger: AnalyticsControllerMessenger = + new Messenger({ + namespace: 'AnalyticsController', + parent: messenger, + }); + messenger.delegate({ + messenger: analyticsControllerMessenger, + actions: [], + events: [], + }); + return analyticsControllerMessenger; +} diff --git a/app/scripts/messenger-client-init/messengers/index.ts b/app/scripts/messenger-client-init/messengers/index.ts index 296fcc3edb48..1fa613266813 100644 --- a/app/scripts/messenger-client-init/messengers/index.ts +++ b/app/scripts/messenger-client-init/messengers/index.ts @@ -1,4 +1,5 @@ import { noop } from 'lodash'; +import { getAnalyticsControllerMessenger } from './analytics-controller-messenger'; import { getPPOMControllerMessenger, getPPOMControllerInitMessenger, @@ -280,6 +281,7 @@ export { } from './keyring-controller-messenger'; export type { LoggingControllerMessenger } from './logging-controller-messenger'; export { getLoggingControllerMessenger } from './logging-controller-messenger'; +export { getAnalyticsControllerMessenger } from './analytics-controller-messenger'; export { getMetaMetricsControllerMessenger } from './metametrics-controller-messenger'; export { getMetaMetricsDataDeletionControllerMessenger } from './metametrics-data-deletion-controller-messenger'; export type { NetworkControllerInitMessenger } from './network-controller-messenger'; @@ -425,6 +427,10 @@ export const MESSENGER_FACTORIES = { getMessenger: getAppStateControllerMessenger, getInitMessenger: noop, }, + AnalyticsController: { + getMessenger: getAnalyticsControllerMessenger, + getInitMessenger: noop, + }, AssetsController: { getMessenger: getAssetsControllerMessenger, getInitMessenger: getAssetsControllerInitMessenger, diff --git a/app/scripts/messenger-client-init/messengers/metametrics-controller-messenger.ts b/app/scripts/messenger-client-init/messengers/metametrics-controller-messenger.ts index 401980eb077a..4630dabbdc69 100644 --- a/app/scripts/messenger-client-init/messengers/metametrics-controller-messenger.ts +++ b/app/scripts/messenger-client-init/messengers/metametrics-controller-messenger.ts @@ -20,6 +20,12 @@ export function getMetaMetricsControllerMessenger( messenger.delegate({ messenger: metaMetricsControllerMessenger, actions: [ + 'AnalyticsController:getState', + 'AnalyticsController:identify', + 'AnalyticsController:optIn', + 'AnalyticsController:optOut', + 'AnalyticsController:trackEvent', + 'AnalyticsController:trackView', 'NetworkController:getNetworkClientById', 'NetworkController:getState', 'PreferencesController:getState', diff --git a/app/scripts/messenger-client-init/messengers/metametrics-data-deletion-controller-messenger.ts b/app/scripts/messenger-client-init/messengers/metametrics-data-deletion-controller-messenger.ts index c8486464e2e5..c485a1f1ef9d 100644 --- a/app/scripts/messenger-client-init/messengers/metametrics-data-deletion-controller-messenger.ts +++ b/app/scripts/messenger-client-init/messengers/metametrics-data-deletion-controller-messenger.ts @@ -26,7 +26,7 @@ export function getMetaMetricsDataDeletionControllerMessenger( }); messenger.delegate({ messenger: metaMetricsDataDeletionControllerMessenger, - actions: ['MetaMetricsController:getState'], + actions: ['AnalyticsController:getState'], }); return metaMetricsDataDeletionControllerMessenger; } diff --git a/app/scripts/messenger-client-init/metametrics-controller-init.test.ts b/app/scripts/messenger-client-init/metametrics-controller-init.test.ts index 86ae0d36cdb8..027152d5b965 100644 --- a/app/scripts/messenger-client-init/metametrics-controller-init.test.ts +++ b/app/scripts/messenger-client-init/metametrics-controller-init.test.ts @@ -40,7 +40,6 @@ describe('MetaMetricsControllerInit', () => { captureException: expect.any(Function), environment: 'test', extension: expect.any(Object), - segment: expect.any(Object), version: 'MOCK_VERSION', }); }); diff --git a/app/scripts/messenger-client-init/metametrics-controller-init.ts b/app/scripts/messenger-client-init/metametrics-controller-init.ts index 41b0a0705e1f..752793ede4da 100644 --- a/app/scripts/messenger-client-init/metametrics-controller-init.ts +++ b/app/scripts/messenger-client-init/metametrics-controller-init.ts @@ -2,17 +2,20 @@ import { MetaMetricsController, MetaMetricsControllerMessenger, } from '../controllers/metametrics-controller'; -import { segment } from '../lib/segment'; import { captureException } from '../../../shared/lib/sentry'; import { MessengerClientInitFunction } from './types'; /** * Initialize the MetaMetrics controller. * + * Tracking is delegated to {@link AnalyticsController} via the messenger. + * The Segment SDK is owned by the analytics platform adapter, not by this + * controller. + * * @param request - The request object. * @param request.controllerMessenger - The messenger to use for the controller. * @param request.persistedState - The persisted state of the extension. - * @param request.extension + * @param request.extension - The webextension polyfill instance. * @returns The initialized controller. */ export const MetaMetricsControllerInit: MessengerClientInitFunction< @@ -24,7 +27,6 @@ export const MetaMetricsControllerInit: MessengerClientInitFunction< messenger: controllerMessenger, version: process.env.METAMASK_VERSION as string, environment: process.env.METAMASK_ENVIRONMENT as string, - segment, extension, captureException, }); diff --git a/app/scripts/messenger-client-init/profile-metrics-controller-init.ts b/app/scripts/messenger-client-init/profile-metrics-controller-init.ts index 14f5e00ee5da..7c344e014ad9 100644 --- a/app/scripts/messenger-client-init/profile-metrics-controller-init.ts +++ b/app/scripts/messenger-client-init/profile-metrics-controller-init.ts @@ -23,10 +23,11 @@ export const ProfileMetricsControllerInit: MessengerClientInitFunction< ProfileMetricsControllerMessenger > = ({ controllerMessenger, persistedState, getMessengerClient }) => { const metaMetricsController = getMessengerClient('MetaMetricsController'); + const analyticsController = getMessengerClient('AnalyticsController'); const appStateController = getMessengerClient('AppStateController'); const assertUserOptedIn = () => appStateController.state.pna25Acknowledged === true && - metaMetricsController.state.participateInMetaMetrics === true; + analyticsController.state.optedIn === true; const messengerClient = new ProfileMetricsController({ messenger: controllerMessenger, diff --git a/app/scripts/messenger-client-init/seedless-onboarding/oauth-service-init.ts b/app/scripts/messenger-client-init/seedless-onboarding/oauth-service-init.ts index a220f0b4d7f5..aedd21d2b4dd 100644 --- a/app/scripts/messenger-client-init/seedless-onboarding/oauth-service-init.ts +++ b/app/scripts/messenger-client-init/seedless-onboarding/oauth-service-init.ts @@ -1,3 +1,4 @@ +import { AnalyticsController } from '@metamask/analytics-controller'; import { MessengerClientInitFunction } from '../types'; import { OAuthService } from '../../services/oauth/oauth-service'; import { webAuthenticatorFactory } from '../../services/oauth/web-authenticator-factory'; @@ -13,6 +14,9 @@ export const OAuthServiceInit: MessengerClientInitFunction< const metaMetricsController = getMessengerClient( 'MetaMetricsController', ) as MetaMetricsController; + const analyticsController = getMessengerClient( + 'AnalyticsController', + ) as AnalyticsController; const messengerClient = new OAuthService({ messenger: controllerMessenger, @@ -37,8 +41,7 @@ export const OAuthServiceInit: MessengerClientInitFunction< metaMetricsController, ), - getParticipateInMetaMetrics: () => - metaMetricsController.state.participateInMetaMetrics, + getParticipateInMetaMetrics: () => analyticsController.state.optedIn, }); return { diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index d9502ac682bb..de6b2eabdff5 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -402,6 +402,7 @@ import { ShieldControllerInit } from './messenger-client-init/shield/shield-cont import { GatorPermissionsControllerInit } from './messenger-client-init/gator-permissions/gator-permissions-controller-init'; import { forwardRequestToSnap } from './lib/forwardRequestToSnap'; +import { AnalyticsControllerInit } from './messenger-client-init/analytics-controller-init'; import { MetaMetricsControllerInit } from './messenger-client-init/metametrics-controller-init'; import { TokenListControllerInit } from './messenger-client-init/token-list-controller-init'; import { TokenDetectionControllerInit } from './messenger-client-init/token-detection-controller-init'; @@ -644,6 +645,7 @@ export default class MetamaskController extends EventEmitter { PasskeyController: PasskeyControllerInit, RemoteFeatureFlagController: RemoteFeatureFlagControllerInit, NetworkController: NetworkControllerInit, + AnalyticsController: AnalyticsControllerInit, MetaMetricsController: MetaMetricsControllerInit, DataDeletionService: DataDeletionServiceInit, MetaMetricsDataDeletionController: MetaMetricsDataDeletionControllerInit, @@ -769,6 +771,7 @@ export default class MetamaskController extends EventEmitter { messengerClientsByName.SubjectMetadataController; this.appStateController = messengerClientsByName.AppStateController; this.networkController = messengerClientsByName.NetworkController; + this.analyticsController = messengerClientsByName.AnalyticsController; this.metaMetricsController = messengerClientsByName.MetaMetricsController; this.dataDeletionService = messengerClientsByName.DataDeletionService; this.metaMetricsDataDeletionController = @@ -1379,6 +1382,7 @@ export default class MetamaskController extends EventEmitter { AppMetadataController: this.appMetadataController, KeyringController: this.keyringController, PreferencesController: this.preferencesController, + AnalyticsController: this.analyticsController, MetaMetricsController: this.metaMetricsController, MetaMetricsDataDeletionController: this.metaMetricsDataDeletionController, AddressBookController: this.addressBookController, @@ -1439,6 +1443,7 @@ export default class MetamaskController extends EventEmitter { NetworkController: this.networkController, KeyringController: this.keyringController, PreferencesController: this.preferencesController, + AnalyticsController: this.analyticsController, MetaMetricsController: this.metaMetricsController, MetaMetricsDataDeletionController: this.metaMetricsDataDeletionController, @@ -2665,10 +2670,17 @@ export default class MetamaskController extends EventEmitter { const { vault } = this.keyringController.state; const isInitialized = Boolean(vault); const flatState = this.memStore.getFlatState(); + const { completedMetaMetricsOnboarding } = + this.metaMetricsController.state; + const { optedIn, analyticsId } = this.analyticsController.state; + const participateInMetaMetrics = + completedMetaMetricsOnboarding === true ? optedIn : null; return { isInitialized, ...sanitizeUIState(flatState), + participateInMetaMetrics, + metaMetricsId: analyticsId, }; } @@ -7061,9 +7073,9 @@ export default class MetamaskController extends EventEmitter { setUpCookieHandlerCommunication({ connectionStream }) { const { metaMetricsId, - dataCollectionForMarketing, participateInMetaMetrics, - } = this.metaMetricsController.state; + dataCollectionForMarketing, + } = this.getState(); if ( metaMetricsId && @@ -7783,6 +7795,7 @@ export default class MetamaskController extends EventEmitter { snapAndHardwareMessenger, appStateController: this.appStateController, metaMetricsController: this.metaMetricsController, + analyticsController: this.analyticsController, }), ); @@ -8061,6 +8074,7 @@ export default class MetamaskController extends EventEmitter { snapAndHardwareMessenger, appStateController: this.appStateController, metaMetricsController: this.metaMetricsController, + analyticsController: this.analyticsController, }), ); @@ -8606,9 +8620,12 @@ export default class MetamaskController extends EventEmitter { upsertTransactionUIMetricsFragment: this.upsertTransactionUIMetricsFragment.bind(this), // Metametrics Actions - getParticipateInMetrics: () => - this.controllerMessenger.call('MetaMetricsController:getState') - .participateInMetaMetrics, + getParticipateInMetrics: () => { + const { completedMetaMetricsOnboarding } = + this.metaMetricsController.state; + const { optedIn } = this.analyticsController.state; + return completedMetaMetricsOnboarding === true && optedIn === true; + }, trackEvent: this.controllerMessenger.call.bind( this.controllerMessenger, 'MetaMetricsController:trackEvent', diff --git a/app/scripts/migrations/212.test.ts b/app/scripts/migrations/212.test.ts new file mode 100644 index 000000000000..3a37d2bb3c9b --- /dev/null +++ b/app/scripts/migrations/212.test.ts @@ -0,0 +1,102 @@ +import { cloneDeep } from 'lodash'; +import { migrate, version } from './212'; + +const VERSION = version; +const OLD_VERSION = VERSION - 1; + +describe(`migration #${VERSION}`, () => { + it('moves MetaMetrics participation and id to AnalyticsController and MMC prompt flag', async () => { + const metaMetricsId = '0xabc123'; + const oldStorage: { + meta: { version: number }; + data: Record; + } = { + meta: { version: OLD_VERSION }, + data: { + MetaMetricsController: { + participateInMetaMetrics: true, + metaMetricsId, + latestNonAnonymousEventTimestamp: 0, + eventsBeforeMetricsOptIn: [], + tracesBeforeMetricsOptIn: [], + traits: {}, + dataCollectionForMarketing: null, + marketingCampaignCookieId: null, + fragments: {}, + segmentApiCalls: { + queuedEvent: { + eventType: 'track', + payload: { + event: 'Queued Event', + timestamp: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + }, + }; + + const versionedData = cloneDeep(oldStorage); + const changedControllers = new Set(); + + await migrate(versionedData, changedControllers); + + expect(versionedData.meta.version).toBe(VERSION); + const mmc = versionedData.data.MetaMetricsController as Record< + string, + unknown + >; + expect(mmc.metaMetricsId).toBeUndefined(); + expect(mmc.participateInMetaMetrics).toBeUndefined(); + expect(mmc.segmentApiCalls).toBeUndefined(); + expect(mmc.completedMetaMetricsOnboarding).toBe(true); + + const ac = versionedData.data.AnalyticsController as { + analyticsId: string; + optedIn: boolean; + }; + expect(ac.optedIn).toBe(true); + expect(ac.analyticsId).toBe(metaMetricsId); + expect(changedControllers.has('MetaMetricsController')).toBe(true); + expect(changedControllers.has('AnalyticsController')).toBe(true); + }); + + it('sets completedMetaMetricsOnboarding false when participateInMetaMetrics was null', async () => { + const oldStorage: { + meta: { version: number }; + data: Record; + } = { + meta: { version: OLD_VERSION }, + data: { + MetaMetricsController: { + participateInMetaMetrics: null, + metaMetricsId: null, + latestNonAnonymousEventTimestamp: 0, + eventsBeforeMetricsOptIn: [], + tracesBeforeMetricsOptIn: [], + traits: {}, + dataCollectionForMarketing: null, + marketingCampaignCookieId: null, + fragments: {}, + }, + }, + }; + + const versionedData = cloneDeep(oldStorage); + const changedControllers = new Set(); + + await migrate(versionedData, changedControllers); + + const mmc = versionedData.data.MetaMetricsController as Record< + string, + unknown + >; + expect(mmc.completedMetaMetricsOnboarding).toBe(false); + const ac = versionedData.data.AnalyticsController as { + analyticsId: string; + optedIn: boolean; + }; + expect(ac.optedIn).toBe(false); + expect(ac.analyticsId).toBe(''); + }); +}); diff --git a/app/scripts/migrations/212.ts b/app/scripts/migrations/212.ts new file mode 100644 index 000000000000..962ea41cbb61 --- /dev/null +++ b/app/scripts/migrations/212.ts @@ -0,0 +1,72 @@ +import { hasProperty, isObject } from '@metamask/utils'; +import type { Migrate } from './types'; + +export const version = 212; + +/** + * Introduces `AnalyticsController` state (`analyticsId`, `optedIn`) and moves + * participation off `MetaMetricsController.participateInMetaMetrics` onto + * `completedMetaMetricsOnboarding` plus analytics `optedIn`. Legacy + * `segmentApiCalls` entries are discarded because event queue persistence now + * belongs to `AnalyticsController`. + * + * @param versionedData - The versioned data object to migrate. + * @param changedControllers - A set used to record controllers that were modified. + */ +export const migrate = (async (versionedData, changedControllers) => { + versionedData.meta.version = version; + + const data = versionedData.data as Record; + + const metaMetricsController = + hasProperty(data, 'MetaMetricsController') && + isObject(data.MetaMetricsController) + ? (data.MetaMetricsController as Record) + : null; + + const participateInMetaMetrics = + metaMetricsController !== null && + hasProperty(metaMetricsController, 'participateInMetaMetrics') + ? metaMetricsController.participateInMetaMetrics + : null; + + const metaMetricsId = + metaMetricsController !== null && + hasProperty(metaMetricsController, 'metaMetricsId') + ? metaMetricsController.metaMetricsId + : null; + + const analyticsId = + typeof metaMetricsId === 'string' && metaMetricsId.length > 0 + ? metaMetricsId + : ''; + + const optedIn = participateInMetaMetrics === true; + const completedMetaMetricsOnboarding = participateInMetaMetrics !== null; + + if ( + !hasProperty(data, 'AnalyticsController') || + !isObject(data.AnalyticsController) + ) { + data.AnalyticsController = { + analyticsId, + optedIn, + }; + changedControllers.add('AnalyticsController'); + } + + if (metaMetricsController) { + if (hasProperty(metaMetricsController, 'metaMetricsId')) { + delete metaMetricsController.metaMetricsId; + } + if (hasProperty(metaMetricsController, 'participateInMetaMetrics')) { + delete metaMetricsController.participateInMetaMetrics; + } + if (hasProperty(metaMetricsController, 'segmentApiCalls')) { + delete metaMetricsController.segmentApiCalls; + } + metaMetricsController.completedMetaMetricsOnboarding = + completedMetaMetricsOnboarding; + changedControllers.add('MetaMetricsController'); + } +}) satisfies Migrate; diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 1629d00c7921..b47903fbe1d4 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -247,6 +247,7 @@ const migrations = [ require('./209'), require('./210'), require('./211'), + require('./212'), ]; export default migrations; diff --git a/app/scripts/services/oauth/oauth-service.ts b/app/scripts/services/oauth/oauth-service.ts index 289c0e82696e..0ee196a2e866 100644 --- a/app/scripts/services/oauth/oauth-service.ts +++ b/app/scripts/services/oauth/oauth-service.ts @@ -104,7 +104,7 @@ export class OAuthService { payload: MetaMetricsEventPayload, options?: MetaMetricsEventOptions, ): void { - const isMetricsEnabled = Boolean(this.#getParticipateInMetaMetrics()); + const isMetricsEnabled = this.#getParticipateInMetaMetrics(); if (isMetricsEnabled) { this.#trackEvent(payload, options); diff --git a/app/scripts/services/oauth/types.ts b/app/scripts/services/oauth/types.ts index cbcb7afa5e43..cacae50bfb7d 100644 --- a/app/scripts/services/oauth/types.ts +++ b/app/scripts/services/oauth/types.ts @@ -175,7 +175,7 @@ export type OAuthServiceOptions = { /** * Get whether the user has opted into MetaMetrics */ - getParticipateInMetaMetrics: () => boolean | null; + getParticipateInMetaMetrics: () => boolean; }; /** diff --git a/attribution.txt b/attribution.txt index 1aa3f0b67be0..2375d99d630e 100644 --- a/attribution.txt +++ b/attribution.txt @@ -33975,6 +33975,17 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE limitations under the License. +****************************** + +@metamask/analytics-controller +1.1.0 +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. + ****************************** @metamask/profile-metrics-controller diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index c0e03abf229c..03a3aa9dff0c 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -867,6 +867,14 @@ "@metamask/controller-utils": true } }, + "@metamask/analytics-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/utils": true, + "lodash": true, + "uuid": true + } + }, "@metamask/announcement-controller": { "packages": { "@metamask/base-controller": true diff --git a/lavamoat/browserify/experimental/policy.json b/lavamoat/browserify/experimental/policy.json index c0e03abf229c..03a3aa9dff0c 100644 --- a/lavamoat/browserify/experimental/policy.json +++ b/lavamoat/browserify/experimental/policy.json @@ -867,6 +867,14 @@ "@metamask/controller-utils": true } }, + "@metamask/analytics-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/utils": true, + "lodash": true, + "uuid": true + } + }, "@metamask/announcement-controller": { "packages": { "@metamask/base-controller": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index c0e03abf229c..03a3aa9dff0c 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -867,6 +867,14 @@ "@metamask/controller-utils": true } }, + "@metamask/analytics-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/utils": true, + "lodash": true, + "uuid": true + } + }, "@metamask/announcement-controller": { "packages": { "@metamask/base-controller": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index c0e03abf229c..03a3aa9dff0c 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -867,6 +867,14 @@ "@metamask/controller-utils": true } }, + "@metamask/analytics-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/utils": true, + "lodash": true, + "uuid": true + } + }, "@metamask/announcement-controller": { "packages": { "@metamask/base-controller": true diff --git a/lavamoat/webpack/mv2/beta/policy.json b/lavamoat/webpack/mv2/beta/policy.json index 5d38af18e612..7f3bfe1c6fed 100644 --- a/lavamoat/webpack/mv2/beta/policy.json +++ b/lavamoat/webpack/mv2/beta/policy.json @@ -802,6 +802,14 @@ "@metamask/controller-utils": true } }, + "@metamask/analytics-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/utils": true, + "lodash": true, + "uuid": true + } + }, "@metamask/announcement-controller": { "packages": { "@metamask/base-controller": true diff --git a/lavamoat/webpack/mv2/experimental/policy.json b/lavamoat/webpack/mv2/experimental/policy.json index 5d38af18e612..7f3bfe1c6fed 100644 --- a/lavamoat/webpack/mv2/experimental/policy.json +++ b/lavamoat/webpack/mv2/experimental/policy.json @@ -802,6 +802,14 @@ "@metamask/controller-utils": true } }, + "@metamask/analytics-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/utils": true, + "lodash": true, + "uuid": true + } + }, "@metamask/announcement-controller": { "packages": { "@metamask/base-controller": true diff --git a/lavamoat/webpack/mv2/flask/policy.json b/lavamoat/webpack/mv2/flask/policy.json index 5d38af18e612..7f3bfe1c6fed 100644 --- a/lavamoat/webpack/mv2/flask/policy.json +++ b/lavamoat/webpack/mv2/flask/policy.json @@ -802,6 +802,14 @@ "@metamask/controller-utils": true } }, + "@metamask/analytics-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/utils": true, + "lodash": true, + "uuid": true + } + }, "@metamask/announcement-controller": { "packages": { "@metamask/base-controller": true diff --git a/lavamoat/webpack/mv2/main/policy.json b/lavamoat/webpack/mv2/main/policy.json index 5d38af18e612..7f3bfe1c6fed 100644 --- a/lavamoat/webpack/mv2/main/policy.json +++ b/lavamoat/webpack/mv2/main/policy.json @@ -802,6 +802,14 @@ "@metamask/controller-utils": true } }, + "@metamask/analytics-controller": { + "packages": { + "@metamask/base-controller": true, + "@metamask/utils": true, + "lodash": true, + "uuid": true + } + }, "@metamask/announcement-controller": { "packages": { "@metamask/base-controller": true diff --git a/package.json b/package.json index 134fa5adc7e0..d75d26fdbda0 100644 --- a/package.json +++ b/package.json @@ -333,6 +333,7 @@ "@metamask/account-watcher": "^4.1.3", "@metamask/accounts-controller": "^38.0.0", "@metamask/address-book-controller": "^7.1.0", + "@metamask/analytics-controller": "^1.1.0", "@metamask/announcement-controller": "^8.0.0", "@metamask/approval-controller": "^9.0.0", "@metamask/assets-controller": "^8.0.1", diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index f9c06222a58c..fc0306c04bab 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -99,10 +99,6 @@ export type MetaMetricsEventPayload = { * The category to associate the event to. */ category?: string; - /** - * The action ID to deduplicate event requests from the UI. - */ - actionId?: string; /** * The type of environment this event occurred in. Defaults to the background * process type. @@ -140,10 +136,6 @@ export type MetaMetricsEventPayload = { * The origin of the dapp that triggered this event. */ referrer?: MetaMetricsReferrerObject; - /* - * The unique identifier for the event. - */ - uniqueIdentifier?: string; /** * Whether the event is a duplicate of an anonymized event. */ @@ -163,27 +155,10 @@ export type UnsanitizedMetaMetricsEventPayload = Omit< }; export type MetaMetricsEventOptions = { - /** - * Whether or not the event happened during the opt-in workflow. - */ - isOptIn?: boolean; - /** - * Whether the segment queue should be flushed after tracking the event. - * Recommended if the result of tracking the event must be known before UI - * transition or update. - */ - flushImmediately?: boolean; /** * Whether to exclude the user's `metaMetricsId` for anonymity. */ excludeMetaMetricsId?: boolean; - /** - * An override for the `metaMetricsId` in the event (no pun intended) one is - * created as a part of an asynchronous workflow, such as awaiting the result - * of the MetaMetrics opt-in function that generates the user's - * `metaMetricsId`. - */ - metaMetricsId?: string; /** * Is this event a holdover from Matomo that needs further migration? When * true, sends the data to a special Segment source that marks the event data @@ -197,10 +172,6 @@ export type MetaMetricsEventOptions = { }; export type MetaMetricsEventFragment = { - /** - * The action ID of transaction metadata object. - */ - actionId?: string; /** * The event name to fire when the fragment is closed in an affirmative action. */ @@ -379,19 +350,6 @@ export type MetaMetricsPagePayload = { * The dapp that triggered the page view. */ referrer?: MetaMetricsReferrerObject; - /** - * The action ID of the page view. - */ - actionId?: string; -}; - -export type MetaMetricsPageOptions = { - /** - * Is the current path one of the pages in the onboarding workflow? (If this - * is true and participateInMetaMetrics is null, then the page view will be - * tracked.) - */ - isOptInPath?: boolean; }; /** diff --git a/shared/lib/generate-metametrics-id.ts b/shared/lib/generate-metametrics-id.ts new file mode 100644 index 000000000000..a9af8070d385 --- /dev/null +++ b/shared/lib/generate-metametrics-id.ts @@ -0,0 +1,16 @@ +import { bytesToHex } from '@metamask/utils'; +import { keccak256 } from 'ethereum-cryptography/keccak'; + +/** + * Generate a new MetaMetrics / analytics client id (keccak256 hash of entropy). + */ +export function generateMetaMetricsId(): string { + return bytesToHex( + keccak256( + Buffer.from( + String(Date.now()) + + String(Math.round(Math.random() * Number.MAX_SAFE_INTEGER)), + ), + ), + ); +} diff --git a/shared/lib/stores/persistence-manager.ts b/shared/lib/stores/persistence-manager.ts index 21cc63e5590b..15f5f4dbdfa8 100644 --- a/shared/lib/stores/persistence-manager.ts +++ b/shared/lib/stores/persistence-manager.ts @@ -22,6 +22,7 @@ export const backedUpStateKeys = [ 'KeyringController', 'AppMetadataController', 'MetaMetricsController', + 'AnalyticsController', ] as const; export type BackedUpStateKey = (typeof backedUpStateKeys)[number]; diff --git a/shared/types/background.ts b/shared/types/background.ts index b153d63ee5ad..17eb9c752bb7 100644 --- a/shared/types/background.ts +++ b/shared/types/background.ts @@ -59,6 +59,7 @@ import type { } from '@metamask/notification-services-controller'; import type { SmartTransactionsControllerState } from '@metamask/smart-transactions-controller'; import type { ConnectivityControllerState } from '@metamask/connectivity-controller'; +import type { AnalyticsControllerState } from '@metamask/analytics-controller'; import type { ClaimsControllerState } from '@metamask/claims-controller'; import type { NetworkOrderControllerState } from '../../app/scripts/controllers/network-order'; @@ -186,10 +187,10 @@ export type ControllerStatePropertiesEnumerated = { eventsBeforeMetricsOptIn: MetaMetricsControllerState['eventsBeforeMetricsOptIn']; tracesBeforeMetricsOptIn: MetaMetricsControllerState['tracesBeforeMetricsOptIn']; fragments: MetaMetricsControllerState['fragments']; - metaMetricsId: MetaMetricsControllerState['metaMetricsId']; - participateInMetaMetrics: MetaMetricsControllerState['participateInMetaMetrics']; + completedMetaMetricsOnboarding: MetaMetricsControllerState['completedMetaMetricsOnboarding']; + optedIn: AnalyticsControllerState['optedIn']; + analyticsId: AnalyticsControllerState['analyticsId']; passkeyRecord: PasskeyControllerState['passkeyRecord']; - segmentApiCalls: MetaMetricsControllerState['segmentApiCalls']; traits: MetaMetricsControllerState['traits']; dataCollectionForMarketing: MetaMetricsControllerState['dataCollectionForMarketing']; marketingCampaignCookieId: MetaMetricsControllerState['marketingCampaignCookieId']; @@ -354,6 +355,7 @@ type ControllerStateTypesMerged = AccountsControllerState & } & KeyringControllerState & LoggingControllerState & MetaMetricsControllerState & + AnalyticsControllerState & MetaMetricsDataDeletionState & MultichainBalancesControllerState & MultichainTransactionsControllerState & diff --git a/test/e2e/fixtures/default-fixture.json b/test/e2e/fixtures/default-fixture.json index d2e4c0f85d3c..9a0aaa965988 100644 --- a/test/e2e/fixtures/default-fixture.json +++ b/test/e2e/fixtures/default-fixture.json @@ -313,15 +313,17 @@ "LoggingController": { "logs": {} }, + "AnalyticsController": { + "analyticsId": "897e4491-7257-4908-ae83-48013df43d80", + "optedIn": false + }, "MetaMetricsController": { "dataCollectionForMarketing": false, "eventsBeforeMetricsOptIn": [], "fragments": {}, "latestNonAnonymousEventTimestamp": 0, "marketingCampaignCookieId": null, - "metaMetricsId": null, - "participateInMetaMetrics": false, - "segmentApiCalls": {}, + "completedMetaMetricsOnboarding": true, "tracesBeforeMetricsOptIn": [], "traits": {} }, @@ -1121,6 +1123,6 @@ }, "meta": { "storageKind": "split", - "version": 211 + "version": 212 } } diff --git a/test/e2e/fixtures/fixture-builder-v2.ts b/test/e2e/fixtures/fixture-builder-v2.ts index 21ee672dfccf..ae61bfa35264 100644 --- a/test/e2e/fixtures/fixture-builder-v2.ts +++ b/test/e2e/fixtures/fixture-builder-v2.ts @@ -123,6 +123,15 @@ type TransactionControllerFixtureInput = Partial< transactions?: TransactionMeta[]; }; +/** + * TODO: Migrate E2E fixtures to patch AnalyticsController directly. + * For now, many tests still pass legacy MetaMetrics keys through this helper. + */ +type MetaMetricsControllerFixturePatch = Partial & { + participateInMetaMetrics?: boolean | null; + metaMetricsId?: string | null; +}; + type StorageServiceNamespaceMap = { [STORAGE_SERVICE_NAMESPACE.SNAP_CONTROLLER]: { key: string; @@ -256,8 +265,40 @@ class FixtureBuilderV2 { return this; } - withMetaMetricsController(data: Partial): this { - merge(this.fixture.data.MetaMetricsController, data); + withMetaMetricsController(data: MetaMetricsControllerFixturePatch): this { + const { + participateInMetaMetrics, + metaMetricsId, + ...metaMetricsControllerPatch + } = data; + + merge(this.fixture.data.MetaMetricsController, metaMetricsControllerPatch); + + if (participateInMetaMetrics !== undefined) { + merge(this.fixture.data.MetaMetricsController, { + completedMetaMetricsOnboarding: participateInMetaMetrics !== null, + }); + } + + if (participateInMetaMetrics !== undefined || metaMetricsId !== undefined) { + const fixtureData = this.fixture.data as Record; + if (!fixtureData.AnalyticsController) { + fixtureData.AnalyticsController = {}; + } + const analyticsController = fixtureData.AnalyticsController as Record< + string, + unknown + >; + const analyticsPatch: Record = {}; + if (typeof metaMetricsId === 'string') { + analyticsPatch.analyticsId = metaMetricsId; + } + if (participateInMetaMetrics !== undefined) { + analyticsPatch.optedIn = participateInMetaMetrics === true; + } + merge(analyticsController, analyticsPatch); + } + return this; } diff --git a/test/e2e/fixtures/onboarding-fixture.json b/test/e2e/fixtures/onboarding-fixture.json index f5e386e575ee..8e4ec0ddfa01 100644 --- a/test/e2e/fixtures/onboarding-fixture.json +++ b/test/e2e/fixtures/onboarding-fixture.json @@ -172,6 +172,10 @@ "LoggingController": { "logs": {} }, + "AnalyticsController": { + "analyticsId": "897e4491-7257-4908-ae83-48013df43d80", + "optedIn": false + }, "MetaMetricsController": { "dataCollectionForMarketing": null, "eventsBeforeMetricsOptIn": [ @@ -184,9 +188,7 @@ "fragments": {}, "latestNonAnonymousEventTimestamp": 0, "marketingCampaignCookieId": null, - "metaMetricsId": null, - "participateInMetaMetrics": null, - "segmentApiCalls": {}, + "completedMetaMetricsOnboarding": false, "tracesBeforeMetricsOptIn": [ { "request": { @@ -2267,6 +2269,6 @@ }, "meta": { "storageKind": "split", - "version": 211 + "version": 212 } } diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index b126eb854b99..a52266ed39c6 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -177,7 +177,6 @@ "marketingCampaignCookieId": null, "metaMetricsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", "participateInMetaMetrics": true, - "segmentApiCalls": "object", "tracesBeforeMetricsOptIn": "object", "traits": "object" }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 43880f29494b..64b63751332c 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -344,7 +344,6 @@ "rewardsSubscriptions": "object", "securityAlertsEnabled": "boolean", "seedPhraseBackedUp": null, - "segmentApiCalls": "object", "selectedAccountGroup": "string", "selectedMultichainNetworkChainId": "string", "selectedNetworkClientId": "string", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index 5c02883117ea..d2277f9f6f05 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -114,7 +114,6 @@ "marketingCampaignCookieId": null, "metaMetricsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", "participateInMetaMetrics": true, - "segmentApiCalls": "object", "tracesBeforeMetricsOptIn": "object", "traits": "object" }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index 47a5fa5ffac3..27f60ffaf6a4 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -120,7 +120,6 @@ "marketingCampaignCookieId": null, "metaMetricsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", "participateInMetaMetrics": true, - "segmentApiCalls": "object", "tracesBeforeMetricsOptIn": "object", "traits": "object" }, @@ -310,5 +309,5 @@ }, "config": "object" }, - "meta": { "storageKind": "split", "version": 211 } + "meta": { "storageKind": "split", "version": 212 } } diff --git a/test/e2e/tests/settings/state-logs.json b/test/e2e/tests/settings/state-logs.json index cb8f8554b282..6badce32211b 100644 --- a/test/e2e/tests/settings/state-logs.json +++ b/test/e2e/tests/settings/state-logs.json @@ -1265,7 +1265,6 @@ "securityAlertsEnabled": "boolean", "seedPhraseBackedUp": "null", "passkeyRecord": "null", - "segmentApiCalls": {}, "selectedAccountGroup": "string", "selectedMultichainNetworkChainId": "string", "selectedPaymentToken": "null", diff --git a/test/integration/data/onboarding-completion-route.json b/test/integration/data/onboarding-completion-route.json index 83ff715504c7..025f52592f65 100644 --- a/test/integration/data/onboarding-completion-route.json +++ b/test/integration/data/onboarding-completion-route.json @@ -293,21 +293,6 @@ "recoveryPhraseReminderLastShown": 1715943225448, "securityAlertsEnabled": true, "seedPhraseBackedUp": true, - "segmentApiCalls": { - "6QHUuopP26ok11pDgrCt": { - "eventType": "identify", - "payload": { - "userId": "0x4d6d78a255217af6411a5bbd39e31b5e46e0e920bdf7e979470f316cbe8c00eb", - "traits": { - "address_book_entries": 1, - "number_of_accounts": 1, - "petname_addresses_count": 1 - }, - "messageId": "6QHUuopP26ok11pDgrCt", - "timestamp": "Fri May 17 2024 12:07:22 GMT+0100 (Western European Summer Time)" - } - } - }, "selectedCurrency": "usd", "selectedMultichainNetworkChainId": "bip122:000000000019d6689c085ae165831e93", "selectedNetworkClientId": "sepolia", diff --git a/test/jest/console-baseline-unit.json b/test/jest/console-baseline-unit.json index 28c40fdd87ba..56378de41707 100644 --- a/test/jest/console-baseline-unit.json +++ b/test/jest/console-baseline-unit.json @@ -1,8 +1,7 @@ { "files": { "app/scripts/controllers/metametrics-controller.test.ts": { - "MetaMask: MetaMetrics invalid trait types": 4, - "warn: MetaMetricsController#_identify: No userTraits found": 1 + "MetaMask: MetaMetrics invalid trait types": 4 }, "app/scripts/controllers/rewards/rewards-data-service.test.ts": { "error: RewardsDataService: Failed to fetch geolocation": 2 diff --git a/ui/components/app/toast-master/selectors.ts b/ui/components/app/toast-master/selectors.ts index 7f7d8e1f06b2..5b046922e849 100644 --- a/ui/components/app/toast-master/selectors.ts +++ b/ui/components/app/toast-master/selectors.ts @@ -15,7 +15,7 @@ type State = { | 'onboardingDate' | 'shieldEndingToastLastClickedOrClosed' | 'shieldPausedToastLastClickedOrClosed' - | 'participateInMetaMetrics' + | 'optedIn' | 'remoteFeatureFlags' | 'pna25Acknowledged' | 'completedOnboarding' @@ -141,7 +141,7 @@ export function selectShowSidePanelMigrationToast( /** * Determines if the PNA25 banner should be shown based on: * - User has completed onboarding (completedOnboarding === true) - * - User has opted into metrics (participateInMetaMetrics === true) + * - User has opted into analytics (`optedIn === true`) * - User hasn't acknowledged the banner yet (pna25Acknowledged === false) * * Regular new users: Go through metametrics page → pna25Acknowledged = true → don't see banner @@ -152,16 +152,15 @@ export function selectShowSidePanelMigrationToast( * @returns Boolean indicating whether to show the banner */ export function selectShowPna25Modal(state: Pick): boolean { - const { completedOnboarding, participateInMetaMetrics, pna25Acknowledged } = - state.metamask || {}; + const { completedOnboarding, optedIn, pna25Acknowledged } = state.metamask || {}; // Only show to users who have completed onboarding if (!completedOnboarding) { return false; // User hasn't completed onboarding yet } - if (participateInMetaMetrics !== true) { - return false; // User hasn't opted into metrics + if (optedIn !== true) { + return false; // User hasn't opted into analytics } if (pna25Acknowledged === true) { diff --git a/ui/contexts/metametrics.test.tsx b/ui/contexts/metametrics.test.tsx index 18b48cc3cbce..bf8a2f8a5ca2 100644 --- a/ui/contexts/metametrics.test.tsx +++ b/ui/contexts/metametrics.test.tsx @@ -21,7 +21,6 @@ jest.mock('../store/actions', () => ({ })); jest.mock('../store/background-connection', () => ({ - generateActionId: jest.fn(() => 'test-action-id'), submitRequestToBackground: jest.fn().mockResolvedValue(undefined), })); @@ -34,7 +33,8 @@ const renderProvider = ({ event: MetaMetricsEventName; state: { metamask: { - participateInMetaMetrics: boolean | null; + completedMetaMetricsOnboarding: boolean; + optedIn: boolean; metaMetricsId: string | null; }; }; @@ -90,7 +90,8 @@ describe('MetaMetricsProvider', () => { event: MetaMetricsEventName.AnalyticsPreferenceSelected, state: { metamask: { - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, metaMetricsId: null, }, }, @@ -101,7 +102,6 @@ describe('MetaMetricsProvider', () => { 'addEventBeforeMetricsOptIn', [ expect.objectContaining({ - actionId: 'test-action-id', category: MetaMetricsEventCategory.Onboarding, event: MetaMetricsEventName.AnalyticsPreferenceSelected, }), @@ -117,7 +117,8 @@ describe('MetaMetricsProvider', () => { event: MetaMetricsEventName.AnalyticsPreferenceSelected, state: { metamask: { - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, metaMetricsId: '0x123', }, }, @@ -141,7 +142,8 @@ describe('MetaMetricsProvider', () => { event: MetaMetricsEventName.MetricsOptOut, state: { metamask: { - participateInMetaMetrics: false, + completedMetaMetricsOnboarding: true, + optedIn: false, metaMetricsId: null, }, }, @@ -165,7 +167,8 @@ describe('MetaMetricsProvider', () => { event: MetaMetricsEventName.AnalyticsPreferenceSelected, state: { metamask: { - participateInMetaMetrics: false, + completedMetaMetricsOnboarding: true, + optedIn: false, metaMetricsId: '0x123', }, }, diff --git a/ui/contexts/metametrics.tsx b/ui/contexts/metametrics.tsx index c1b598109a02..02bca7c8d5d6 100644 --- a/ui/contexts/metametrics.tsx +++ b/ui/contexts/metametrics.tsx @@ -42,10 +42,7 @@ import { getMetaMetricsId, getParticipateInMetaMetrics, } from '../selectors'; -import { - generateActionId, - submitRequestToBackground, -} from '../store/background-connection'; +import { submitRequestToBackground } from '../store/background-connection'; import { trackMetaMetricsEvent, trackMetaMetricsPage } from '../store/actions'; import type { TraceName, @@ -196,7 +193,7 @@ export function MetaMetricsProvider({ children }: MetaMetricsProviderProps) { trackMetaMetricsEvent(fullPayload as MetaMetricsEventPayload, options); } else if (canMaybeTrackLater) { await submitRequestToBackground('addEventBeforeMetricsOptIn', [ - { ...fullPayload, actionId: generateActionId() }, + fullPayload, ]); } }, @@ -283,9 +280,6 @@ export function MetaMetricsProvider({ children }: MetaMetricsProviderProps) { page: context.page, referrer: context.referrer, }, - { - isOptInPath: location.pathname.startsWith('/initialize'), - }, ); } previousMatch.current = match?.pattern?.path; diff --git a/ui/pages/onboarding-flow/creation-successful/creation-successful.tsx b/ui/pages/onboarding-flow/creation-successful/creation-successful.tsx index 0e0abdc0ce10..00ac8b166c5e 100644 --- a/ui/pages/onboarding-flow/creation-successful/creation-successful.tsx +++ b/ui/pages/onboarding-flow/creation-successful/creation-successful.tsx @@ -317,9 +317,6 @@ export default function CreationSuccessful() { account_type: accountType, }, }, - { - isOptIn: !participateInMetaMetrics, // Force the event to be tracked even if participateInMetaMetrics is false - }, ); } diff --git a/ui/selectors/metametrics.js b/ui/selectors/metametrics.js index 57572430f9a7..5df40735508a 100644 --- a/ui/selectors/metametrics.js +++ b/ui/selectors/metametrics.js @@ -6,13 +6,16 @@ const selectFragments = (state) => state.metamask.fragments; export const getDataCollectionForMarketing = (state) => state.metamask.dataCollectionForMarketing; -// return true if user has set their participation preference in MetaMetrics or if they are a social login user +// return true if user has opted into MetaMetrics (prompt completed and opted in) export const getParticipateInMetaMetrics = (state) => - Boolean(state.metamask.participateInMetaMetrics); + Boolean( + state.metamask.completedMetaMetricsOnboarding && + state.metamask.optedIn, + ); -// return true if user has set their participation preference in MetaMetrics or if they are a social login user +// return true once the user has completed the metrics participation prompt (yes or no) export const getIsParticipateInMetaMetricsSet = (state) => - state.metamask.participateInMetaMetrics !== null; + state.metamask.completedMetaMetricsOnboarding === true; export const getPna25Acknowledged = (state) => state.metamask.pna25Acknowledged; diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 9d8990940ae6..d4ee29f2d02a 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -147,7 +147,6 @@ import { MetaMetricsEventOptions, MetaMetricsEventPayload, MetaMetricsPageObject, - MetaMetricsPageOptions, MetaMetricsPagePayload, MetaMetricsReferrerObject, MetaMetricsEventCategory, @@ -6488,19 +6487,13 @@ export function trackMetaMetricsEvent( payload: MetaMetricsEventPayload, options?: MetaMetricsEventOptions, ) { - return submitRequestToBackground('trackMetaMetricsEvent', [ - { ...payload, actionId: generateActionId() }, - options, - ]); + return submitRequestToBackground('trackMetaMetricsEvent', [payload, options]); } export function createEventFragment( options: MetaMetricsEventFragment, ): Promise { - const actionId = generateActionId(); - return submitRequestToBackground('createEventFragment', [ - { ...options, actionId }, - ]); + return submitRequestToBackground('createEventFragment', [options]); } export function upsertTransactionUIMetricsFragment( @@ -6533,16 +6526,9 @@ export function finalizeEventFragment( /** * @param payload - details of the page viewed - * @param options - options for handling the page view */ -export function trackMetaMetricsPage( - payload: MetaMetricsPagePayload, - options: MetaMetricsPageOptions, -) { - return submitRequestToBackground('trackMetaMetricsPage', [ - { ...payload, actionId: generateActionId() }, - options, - ]); +export function trackMetaMetricsPage(payload: MetaMetricsPagePayload) { + return submitRequestToBackground('trackMetaMetricsPage', [payload]); } export function updateMetaMetricsTraits(traits: MetaMetricsUserTraits) { diff --git a/yarn.lock b/yarn.lock index 21a94ccf1c5d..d93b3e8dfa58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5703,6 +5703,19 @@ __metadata: languageName: node linkType: hard +"@metamask/analytics-controller@npm:^1.1.0": + version: 1.1.0 + resolution: "@metamask/analytics-controller@npm:1.1.0" + dependencies: + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/utils": "npm:^11.9.0" + lodash: "npm:^4.17.21" + uuid: "npm:^8.3.2" + checksum: 10/de713c2af1db74f8c8f2e512ea17e4ceebfa3788ff34e1960dcdf11e59ffd8b75d28933900674cfd9106b244c80a78199bc2612420d38cc0ed1def3241c29688 + languageName: node + linkType: hard + "@metamask/announcement-controller@npm:^8.0.0": version: 8.0.0 resolution: "@metamask/announcement-controller@npm:8.0.0" @@ -33441,6 +33454,7 @@ __metadata: "@metamask/account-watcher": "npm:^4.1.3" "@metamask/accounts-controller": "npm:^38.0.0" "@metamask/address-book-controller": "npm:^7.1.0" + "@metamask/analytics-controller": "npm:^1.1.0" "@metamask/announcement-controller": "npm:^8.0.0" "@metamask/api-specs": "npm:^0.13.0" "@metamask/approval-controller": "npm:^9.0.0" From 59ed60c0414de0202cbbe20ea2b360bd6fda693a Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Sat, 23 May 2026 06:35:25 +0200 Subject: [PATCH 02/35] fix: address analytics controller ci failures --- .../controllers/analytics/platform-adapter.ts | 3 +- .../metametrics-controller.test.ts | 197 ++++++++---------- .../controllers/metametrics-controller.ts | 20 +- .../segment/__tests__/segment-shim.test.ts | 36 ++-- .../segment/custom-segment-tracking.test.ts | 5 +- app/scripts/lib/segment/index.ts | 5 +- app/scripts/metamask-controller.js | 3 +- test/e2e/fixtures/default-fixture.json | 7 +- test/e2e/fixtures/fixture-validation.ts | 1 + test/e2e/fixtures/onboarding-fixture.json | 5 +- ...rs-after-init-opt-in-background-state.json | 10 +- .../errors-after-init-opt-in-ui-state.json | 6 +- ...s-before-init-opt-in-background-state.json | 10 +- .../errors-before-init-opt-in-ui-state.json | 10 +- ui/components/app/toast-master/selectors.ts | 3 +- ui/contexts/metametrics.test.tsx | 5 + ui/contexts/metametrics.tsx | 23 +- .../creation-successful.tsx | 20 +- ui/selectors/metametrics.js | 9 +- 19 files changed, 182 insertions(+), 196 deletions(-) diff --git a/app/scripts/controllers/analytics/platform-adapter.ts b/app/scripts/controllers/analytics/platform-adapter.ts index 818578b6390f..26145d461db8 100644 --- a/app/scripts/controllers/analytics/platform-adapter.ts +++ b/app/scripts/controllers/analytics/platform-adapter.ts @@ -93,8 +93,7 @@ export function createPlatformAdapter(): AnalyticsPlatformAdapter { context?: AnalyticsContext, options?: AnalyticsDeliveryOptions, ): void { - const isAnonymousEvent = - properties?.[ANONYMOUS_EVENT_PROPERTY] === true; + const isAnonymousEvent = properties?.[ANONYMOUS_EVENT_PROPERTY] === true; let payloadProperties = properties; if (isAnonymousEvent) { payloadProperties = { ...properties }; diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index e2724a74e390..87eaa86bc4ad 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -549,13 +549,9 @@ describe('MetaMetricsController', function () { analyticsControllerState: { optedIn: false }, }, async ({ controller, controllerMessenger }) => { - expect(controller.state.completedMetaMetricsOnboarding).toBe( - false, - ); + expect(controller.state.completedMetaMetricsOnboarding).toBe(false); await controller.setParticipateInMetaMetrics(true); - expect(controller.state.completedMetaMetricsOnboarding).toBe( - true, - ); + expect(controller.state.completedMetaMetricsOnboarding).toBe(true); expect( controllerMessenger.call('AnalyticsController:getState').optedIn, ).toBe(true); @@ -715,19 +711,17 @@ describe('MetaMetricsController', function () { }, }); expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: MetaMetricsEventName.MetricsOptOut, - userId: TEST_ANALYTICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: '1', - }, + expect(spy).toHaveBeenCalledWith({ + event: MetaMetricsEventName.MetricsOptOut, + userId: TEST_ANALYTICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: '1', }, - ); + }); expect(flushSpy).toHaveBeenCalledTimes(1); }, ); @@ -759,8 +753,7 @@ describe('MetaMetricsController', function () { }, ({ controller }) => { const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent( - { + controller.trackEvent({ event: 'Fake Event', category: 'Unit Test', properties: { @@ -768,8 +761,7 @@ describe('MetaMetricsController', function () { // eslint-disable-next-line @typescript-eslint/naming-convention chain_id: '1', }, - }, - ); + }); expect(spy).not.toHaveBeenCalled(); }, ); @@ -1432,7 +1424,9 @@ describe('MetaMetricsController', function () { expect(spy.mock.calls[0][0]).toMatchObject({ event: eventType, - properties: expect.objectContaining({ ...DEFAULT_EVENT_PROPERTIES }), + properties: expect.objectContaining({ + ...DEFAULT_EVENT_PROPERTIES, + }), }); expect(spy.mock.calls[0][0].properties).not.toHaveProperty('foo'); @@ -1527,7 +1521,6 @@ describe('MetaMetricsController', function () { }, ); }); - }); function buildStateWithAccounts( @@ -2924,101 +2917,89 @@ async function withController( // Emulate the analytics platform adapter: every Segment payload is built // here and passed straight to `segmentMock`, preserving the existing // spy-based assertions in tests. - messenger.registerActionHandler( - 'AnalyticsController:identify', - (( - traits?: AnalyticsUserTraits, - context?: AnalyticsContext, - ) => { - if (!traits) { - return; - } + messenger.registerActionHandler('AnalyticsController:identify', (( + traits?: AnalyticsUserTraits, + context?: AnalyticsContext, + ) => { + if (!traits) { + return; + } + const payload: Record = { + userId: mockAnalyticsControllerState.analyticsId, + traits, + }; + if (context) { + payload.context = context; + } + segmentMock.identify(payload as never, undefined); + }) as never); + + messenger.registerActionHandler('AnalyticsController:trackEvent', (( + event: AnalyticsTrackingEventPayload, + context?: AnalyticsContext, + ) => { + if (!mockAnalyticsControllerState.optedIn) { + return; + } + + const buildPayload = (properties?: Record) => { const payload: Record = { userId: mockAnalyticsControllerState.analyticsId, - traits, + event: event.name, }; + if (properties !== undefined) { + payload.properties = properties; + } if (context) { payload.context = context; } - segmentMock.identify(payload as never, undefined); - }) as never, - ); - - messenger.registerActionHandler( - 'AnalyticsController:trackEvent', - (( - event: AnalyticsTrackingEventPayload, - context?: AnalyticsContext, - ) => { - if (!mockAnalyticsControllerState.optedIn) { - return; - } - - const buildPayload = (properties?: Record) => { - const payload: Record = { - userId: mockAnalyticsControllerState.analyticsId, - event: event.name, - }; - if (properties !== undefined) { - payload.properties = properties; - } - if (context) { - payload.context = context; - } - return payload; - }; + return payload; + }; - if (!event.hasProperties) { - segmentMock.track(buildPayload() as never, undefined); - return; - } + if (!event.hasProperties) { + segmentMock.track(buildPayload() as never, undefined); + return; + } - const hasSensitiveProperties = - Object.keys(event.sensitiveProperties ?? {}).length > 0; - - if (!hasSensitiveProperties) { - segmentMock.track( - buildPayload(event.properties) as never, - undefined, - ); - return; - } + const hasSensitiveProperties = + Object.keys(event.sensitiveProperties ?? {}).length > 0; + if (!hasSensitiveProperties) { segmentMock.track(buildPayload(event.properties) as never, undefined); - segmentMock.track( - buildPayload({ - ...event.properties, - ...event.sensitiveProperties, - anonymous: true, - }) as never, - undefined, - ); - }) as never, - ); + return; + } - messenger.registerActionHandler( - 'AnalyticsController:trackView', - (( - name: string, - properties?: Record, - context?: AnalyticsContext, - ) => { - if (!mockAnalyticsControllerState.optedIn) { - return; - } - const payload: Record = { - userId: mockAnalyticsControllerState.analyticsId, - name, - }; - if (properties) { - payload.properties = properties; - } - if (context) { - payload.context = context; - } - segmentMock.page(payload as never, undefined); - }) as never, - ); + segmentMock.track(buildPayload(event.properties) as never, undefined); + segmentMock.track( + buildPayload({ + ...event.properties, + ...event.sensitiveProperties, + anonymous: true, + }) as never, + undefined, + ); + }) as never); + + messenger.registerActionHandler('AnalyticsController:trackView', (( + name: string, + properties?: Record, + context?: AnalyticsContext, + ) => { + if (!mockAnalyticsControllerState.optedIn) { + return; + } + const payload: Record = { + userId: mockAnalyticsControllerState.analyticsId, + name, + }; + if (properties) { + payload.properties = properties; + } + if (context) { + payload.context = context; + } + segmentMock.page(payload as never, undefined); + }) as never); const metaMetricsControllerMessenger = new Messenger< 'MetaMetricsController', diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index e5339703f544..0177126623a1 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -238,7 +238,10 @@ const controllerMetadata: StateMetadata = { * @property dataCollectionForMarketing - Flag to determine if data collection for marketing is enabled. * @property marketingCampaignCookieId - The marketing campaign cookie id. */ -type SegmentTrackPayload = Omit & { +type SegmentTrackPayload = Omit< + SegmentEventPayload, + 'properties' | 'timestamp' +> & { properties: AnalyticsEventProperties; sensitiveProperties?: Record; }; @@ -674,14 +677,10 @@ export class MetaMetricsController extends BaseController< throw new Error(`Event fragment with id ${id} does not exist.`); } - const updatedFragment = merge( - {} as MetaMetricsEventFragment, - fragment, - { - ...payload, - lastUpdated: Date.now(), - }, - ) as MetaMetricsEventFragment; + const updatedFragment = merge({} as MetaMetricsEventFragment, fragment, { + ...payload, + lastUpdated: Date.now(), + }) as MetaMetricsEventFragment; this.update((state) => { Object.assign(state.fragments, { [id]: updatedFragment }); }); @@ -796,8 +795,7 @@ export class MetaMetricsController extends BaseController< } this.update((state) => { - state.completedMetaMetricsOnboarding = - participateInMetaMetrics !== null; + state.completedMetaMetricsOnboarding = participateInMetaMetrics !== null; }); if (participateInMetaMetrics) { diff --git a/app/scripts/lib/segment/__tests__/segment-shim.test.ts b/app/scripts/lib/segment/__tests__/segment-shim.test.ts index 4e48a7dca6b6..57d2911c17ae 100644 --- a/app/scripts/lib/segment/__tests__/segment-shim.test.ts +++ b/app/scripts/lib/segment/__tests__/segment-shim.test.ts @@ -176,27 +176,21 @@ describe('segment module export', () => { }); const traits = Object.freeze({ plan: 'pro' }); - client.track( - { - event: 'e', - properties, - context, - }, - ); - client.identify( - { - userId: 'u', - traits, - context, - }, - ); - client.page( - { - name: 'n', - properties, - context, - }, - ); + client.track({ + event: 'e', + properties, + context, + }); + client.identify({ + userId: 'u', + traits, + context, + }); + client.page({ + name: 'n', + properties, + context, + }); const [trackCall] = analyticsInstance.track.mock.calls[0] ?? []; expect(trackCall.properties).toEqual(properties); diff --git a/app/scripts/lib/segment/custom-segment-tracking.test.ts b/app/scripts/lib/segment/custom-segment-tracking.test.ts index f727dae6e590..0ab92a977313 100644 --- a/app/scripts/lib/segment/custom-segment-tracking.test.ts +++ b/app/scripts/lib/segment/custom-segment-tracking.test.ts @@ -105,10 +105,7 @@ describe('trackEarlySegmentEvent', () => { }, }, ], - [ - 'analyticsId is missing', - { AnalyticsController: { optedIn: true } }, - ], + ['analyticsId is missing', { AnalyticsController: { optedIn: true } }], ])('does not track when %s', (_: string, state: EarlySegmentState | null) => { trackEarlySegmentEvent({ state, diff --git a/app/scripts/lib/segment/index.ts b/app/scripts/lib/segment/index.ts index f58c31d3665e..dd959d66d9b6 100644 --- a/app/scripts/lib/segment/index.ts +++ b/app/scripts/lib/segment/index.ts @@ -132,7 +132,10 @@ export const createSegmentMock = ( const segmentMock = { queue: [] as MockQueueItem[], - track(payload: SegmentTrackPayload, callback: SegmentCallback = noopCallback) { + track( + payload: SegmentTrackPayload, + callback: SegmentCallback = noopCallback, + ) { segmentMock.queue.push([payload, callback]); if (flushAt !== undefined && segmentMock.queue.length >= flushAt) { flushQueue(segmentMock.queue); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index de6b2eabdff5..5ad33b40e52b 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2670,8 +2670,7 @@ export default class MetamaskController extends EventEmitter { const { vault } = this.keyringController.state; const isInitialized = Boolean(vault); const flatState = this.memStore.getFlatState(); - const { completedMetaMetricsOnboarding } = - this.metaMetricsController.state; + const { completedMetaMetricsOnboarding } = this.metaMetricsController.state; const { optedIn, analyticsId } = this.analyticsController.state; const participateInMetaMetrics = completedMetaMetricsOnboarding === true ? optedIn : null; diff --git a/test/e2e/fixtures/default-fixture.json b/test/e2e/fixtures/default-fixture.json index 9a0aaa965988..1444e0cf4290 100644 --- a/test/e2e/fixtures/default-fixture.json +++ b/test/e2e/fixtures/default-fixture.json @@ -174,10 +174,10 @@ "announcements": {} }, "AppMetadataController": { - "currentMigrationVersion": 211, + "currentMigrationVersion": 212, "firstTimeInfo": {}, "previousAppVersion": "", - "previousMigrationVersion": 0 + "previousMigrationVersion": 211 }, "AppStateController": { "browserEnvironment": { @@ -315,7 +315,8 @@ }, "AnalyticsController": { "analyticsId": "897e4491-7257-4908-ae83-48013df43d80", - "optedIn": false + "eventQueue": {}, + "optedIn": true }, "MetaMetricsController": { "dataCollectionForMarketing": false, diff --git a/test/e2e/fixtures/fixture-validation.ts b/test/e2e/fixtures/fixture-validation.ts index 220a1b6ead70..fa4f222aa638 100644 --- a/test/e2e/fixtures/fixture-validation.ts +++ b/test/e2e/fixtures/fixture-validation.ts @@ -95,6 +95,7 @@ const getFixtureIgnoredKeys = (): string[] => [ // Version that changes on every release 'data.AppMetadataController.currentAppVersion', // Random ids + 'data.AnalyticsController.analyticsId', 'data.MultichainBalancesController', 'data.MultichainBalancesController.balances', 'data.MultichainTransactionsController.nonEvmTransactions', diff --git a/test/e2e/fixtures/onboarding-fixture.json b/test/e2e/fixtures/onboarding-fixture.json index 8e4ec0ddfa01..2c4acb255150 100644 --- a/test/e2e/fixtures/onboarding-fixture.json +++ b/test/e2e/fixtures/onboarding-fixture.json @@ -35,13 +35,13 @@ "announcements": {} }, "AppMetadataController": { - "currentMigrationVersion": 211, + "currentMigrationVersion": 212, "firstTimeInfo": { "date": 0, "version": "13.17.0" }, "previousAppVersion": "", - "previousMigrationVersion": 0 + "previousMigrationVersion": 211 }, "AppStateController": { "browserEnvironment": { @@ -174,6 +174,7 @@ }, "AnalyticsController": { "analyticsId": "897e4491-7257-4908-ae83-48013df43d80", + "eventQueue": {}, "optedIn": false }, "MetaMetricsController": { diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index a52266ed39c6..1c38c1dbec5e 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -26,13 +26,18 @@ "unconnectedAccountAlertShownOrigins": "object", "web3ShimUsageOrigins": "object" }, + "AnalyticsController": { + "analyticsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", + "eventQueue": "object", + "optedIn": true + }, "AnnouncementController": { "announcements": "object" }, "AppMetadataController": { "currentAppVersion": "string", "currentMigrationVersion": "number", "firstTimeInfo": "object", "previousAppVersion": "", - "previousMigrationVersion": 0 + "previousMigrationVersion": 211 }, "AppStateController": { "activeQrCodeScanRequest": null, @@ -171,12 +176,11 @@ }, "LoggingController": { "logs": "object" }, "MetaMetricsController": { + "completedMetaMetricsOnboarding": true, "dataCollectionForMarketing": "boolean", "eventsBeforeMetricsOptIn": "object", "fragments": "object", "marketingCampaignCookieId": null, - "metaMetricsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", - "participateInMetaMetrics": true, "tracesBeforeMetricsOptIn": "object", "traits": "object" }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 64b63751332c..05e0aefb41d7 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -93,6 +93,7 @@ "allNftContracts": "object", "allNfts": "object", "allTokens": {}, + "analyticsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", "announcements": "object", "approvalFlows": "object", "assetExchangeRates": "object", @@ -105,6 +106,7 @@ "canTrackWalletFundsObtained": false, "claims": "object", "claimsConfigurations": "object", + "completedMetaMetricsOnboarding": true, "completedOnboarding": true, "connectedStatusPopoverHasBeenShown": true, "connectivityStatus": "online", @@ -159,6 +161,7 @@ "ensResolutionsByAddress": "object", "error": null, "estimatedGasFeeTimeBounds": {}, + "eventQueue": "object", "events": "object", "eventsBeforeMetricsOptIn": "object", "fcmToken": "string", @@ -271,6 +274,7 @@ "onboardingDate": null, "onboardingTabs": "object", "openSeaEnabled": true, + "optedIn": true, "orderedNetworkList": "object", "orderedTransactionHistory": "object", "outdatedBrowserWarningLastShown": "object", @@ -313,7 +317,7 @@ "useSidePanelAsDefault": "boolean" }, "previousAppVersion": "", - "previousMigrationVersion": 0, + "previousMigrationVersion": 211, "productTour": "accountIcon", "quoteFetchError": null, "quoteRequest": "object", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index d2277f9f6f05..8810280b8a6f 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -25,12 +25,17 @@ "unconnectedAccountAlertShownOrigins": "object", "web3ShimUsageOrigins": "object" }, + "AnalyticsController": { + "analyticsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", + "eventQueue": "object", + "optedIn": true + }, "AnnouncementController": { "announcements": "object" }, "AppMetadataController": { "currentMigrationVersion": "number", "firstTimeInfo": "object", "previousAppVersion": "", - "previousMigrationVersion": 0 + "previousMigrationVersion": 211 }, "AppStateController": { "browserEnvironment": { "browser": "string" }, @@ -108,12 +113,11 @@ "KeyringController": { "vault": "string" }, "LoggingController": { "logs": "object" }, "MetaMetricsController": { + "completedMetaMetricsOnboarding": true, "dataCollectionForMarketing": "boolean", "eventsBeforeMetricsOptIn": "object", "fragments": "object", "marketingCampaignCookieId": null, - "metaMetricsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", - "participateInMetaMetrics": true, "tracesBeforeMetricsOptIn": "object", "traits": "object" }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index 27f60ffaf6a4..758177a5e63a 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -25,13 +25,18 @@ "unconnectedAccountAlertShownOrigins": "object", "web3ShimUsageOrigins": "object" }, + "AnalyticsController": { + "analyticsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", + "eventQueue": "object", + "optedIn": true + }, "AnnouncementController": { "announcements": "object" }, "AppMetadataController": { "currentAppVersion": "string", "currentMigrationVersion": "number", "firstTimeInfo": "object", "previousAppVersion": "", - "previousMigrationVersion": 0 + "previousMigrationVersion": 211 }, "AppStateController": { "browserEnvironment": { "browser": "string", "os": "string" }, @@ -114,12 +119,11 @@ "KeyringController": { "vault": "string" }, "LoggingController": { "logs": "object" }, "MetaMetricsController": { + "completedMetaMetricsOnboarding": true, "dataCollectionForMarketing": "boolean", "eventsBeforeMetricsOptIn": "object", "fragments": "object", "marketingCampaignCookieId": null, - "metaMetricsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", - "participateInMetaMetrics": true, "tracesBeforeMetricsOptIn": "object", "traits": "object" }, diff --git a/ui/components/app/toast-master/selectors.ts b/ui/components/app/toast-master/selectors.ts index 5b046922e849..0751170f5b97 100644 --- a/ui/components/app/toast-master/selectors.ts +++ b/ui/components/app/toast-master/selectors.ts @@ -152,7 +152,8 @@ export function selectShowSidePanelMigrationToast( * @returns Boolean indicating whether to show the banner */ export function selectShowPna25Modal(state: Pick): boolean { - const { completedOnboarding, optedIn, pna25Acknowledged } = state.metamask || {}; + const { completedOnboarding, optedIn, pna25Acknowledged } = + state.metamask || {}; // Only show to users who have completed onboarding if (!completedOnboarding) { diff --git a/ui/contexts/metametrics.test.tsx b/ui/contexts/metametrics.test.tsx index bf8a2f8a5ca2..1daa14e78447 100644 --- a/ui/contexts/metametrics.test.tsx +++ b/ui/contexts/metametrics.test.tsx @@ -35,6 +35,7 @@ const renderProvider = ({ metamask: { completedMetaMetricsOnboarding: boolean; optedIn: boolean; + participateInMetaMetrics: boolean | null; metaMetricsId: string | null; }; }; @@ -92,6 +93,7 @@ describe('MetaMetricsProvider', () => { metamask: { completedMetaMetricsOnboarding: true, optedIn: true, + participateInMetaMetrics: true, metaMetricsId: null, }, }, @@ -119,6 +121,7 @@ describe('MetaMetricsProvider', () => { metamask: { completedMetaMetricsOnboarding: true, optedIn: true, + participateInMetaMetrics: true, metaMetricsId: '0x123', }, }, @@ -144,6 +147,7 @@ describe('MetaMetricsProvider', () => { metamask: { completedMetaMetricsOnboarding: true, optedIn: false, + participateInMetaMetrics: false, metaMetricsId: null, }, }, @@ -169,6 +173,7 @@ describe('MetaMetricsProvider', () => { metamask: { completedMetaMetricsOnboarding: true, optedIn: false, + participateInMetaMetrics: false, metaMetricsId: '0x123', }, }, diff --git a/ui/contexts/metametrics.tsx b/ui/contexts/metametrics.tsx index 02bca7c8d5d6..f8bb8c1909a9 100644 --- a/ui/contexts/metametrics.tsx +++ b/ui/contexts/metametrics.tsx @@ -267,20 +267,15 @@ export function MetaMetricsProvider({ children }: MetaMetricsProviderProps) { const { pattern, params } = match; const { path } = pattern; const name = PATH_NAME_MAP.get(path as AppRoutes['path']); - trackMetaMetricsPage( - { - name, - // We do not want to send addresses or accounts in any events - // Some routes include these as params. - params: omit(params, ['account', 'address']) as Record< - string, - string - >, - environmentType: environmentType as EnvironmentType, - page: context.page, - referrer: context.referrer, - }, - ); + trackMetaMetricsPage({ + name, + // We do not want to send addresses or accounts in any events + // Some routes include these as params. + params: omit(params, ['account', 'address']) as Record, + environmentType: environmentType as EnvironmentType, + page: context.page, + referrer: context.referrer, + }); } previousMatch.current = match?.pattern?.path; }, [ diff --git a/ui/pages/onboarding-flow/creation-successful/creation-successful.tsx b/ui/pages/onboarding-flow/creation-successful/creation-successful.tsx index 00ac8b166c5e..d6fa27bf7714 100644 --- a/ui/pages/onboarding-flow/creation-successful/creation-successful.tsx +++ b/ui/pages/onboarding-flow/creation-successful/creation-successful.tsx @@ -306,18 +306,16 @@ export default function CreationSuccessful() { ? `${baseAccountType}_${socialLoginType}` : baseAccountType; - trackEvent( - { - category: MetaMetricsEventCategory.Onboarding, - event: participateInMetaMetrics - ? MetaMetricsEventName.MetricsOptIn - : MetaMetricsEventName.MetricsOptOut, - properties: { - // eslint-disable-next-line @typescript-eslint/naming-convention - account_type: accountType, - }, + trackEvent({ + category: MetaMetricsEventCategory.Onboarding, + event: participateInMetaMetrics + ? MetaMetricsEventName.MetricsOptIn + : MetaMetricsEventName.MetricsOptOut, + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + account_type: accountType, }, - ); + }); } // Side Panel - only if feature flag is enabled diff --git a/ui/selectors/metametrics.js b/ui/selectors/metametrics.js index 5df40735508a..794bf4489d14 100644 --- a/ui/selectors/metametrics.js +++ b/ui/selectors/metametrics.js @@ -6,16 +6,13 @@ const selectFragments = (state) => state.metamask.fragments; export const getDataCollectionForMarketing = (state) => state.metamask.dataCollectionForMarketing; -// return true if user has opted into MetaMetrics (prompt completed and opted in) +// return true if user has opted into MetaMetrics export const getParticipateInMetaMetrics = (state) => - Boolean( - state.metamask.completedMetaMetricsOnboarding && - state.metamask.optedIn, - ); + state.metamask.participateInMetaMetrics === true; // return true once the user has completed the metrics participation prompt (yes or no) export const getIsParticipateInMetaMetricsSet = (state) => - state.metamask.completedMetaMetricsOnboarding === true; + state.metamask.participateInMetaMetrics !== null; export const getPna25Acknowledged = (state) => state.metamask.pna25Acknowledged; From 2e575404367ec83fd96c72bdd5281243269b4ed6 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Sat, 23 May 2026 10:00:40 +0200 Subject: [PATCH 03/35] test: update metametrics analytics state expectations --- ...ametrics-controller-method-action-types.ts | 47 +++++++++---------- app/scripts/metamask-controller.test.js | 7 ++- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/app/scripts/controllers/metametrics-controller-method-action-types.ts b/app/scripts/controllers/metametrics-controller-method-action-types.ts index 5ecb4505135f..caa10f8b762d 100644 --- a/app/scripts/controllers/metametrics-controller-method-action-types.ts +++ b/app/scripts/controllers/metametrics-controller-method-action-types.ts @@ -78,17 +78,6 @@ export type MetaMetricsControllerFinalizeEventFragmentAction = { handler: MetaMetricsController['finalizeEventFragment']; }; -/** - * Calls this._identify with validated metaMetricsId and user traits if user is participating - * in the MetaMetrics analytics program - * - * @param userTraits - */ -export type MetaMetricsControllerIdentifyAction = { - type: `MetaMetricsController:identify`; - handler: MetaMetricsController['identify']; -}; - export type MetaMetricsControllerUpdateExtensionUninstallUrlAction = { type: `MetaMetricsController:updateExtensionUninstallUrl`; handler: MetaMetricsController['updateExtensionUninstallUrl']; @@ -115,17 +104,6 @@ export type MetaMetricsControllerSetMarketingCampaignCookieIdAction = { handler: MetaMetricsController['setMarketingCampaignCookieId']; }; -/** - * track a page view with Segment - * - * @param payload - details of the page viewed. - * @param options - options for handling the page view. - */ -export type MetaMetricsControllerTrackPageAction = { - type: `MetaMetricsController:trackPage`; - handler: MetaMetricsController['trackPage']; -}; - /** * submits a metametrics event, not waiting for it to complete or allowing its error to bubble up * @@ -137,6 +115,27 @@ export type MetaMetricsControllerTrackEventAction = { handler: MetaMetricsController['trackEvent']; }; +/** + * Identifies the user with valid user traits if they are participating in + * the MetaMetrics analytics program. + * + * @param userTraits + */ +export type MetaMetricsControllerIdentifyAction = { + type: `MetaMetricsController:identify`; + handler: MetaMetricsController['identify']; +}; + +/** + * Track a page view through AnalyticsController. + * + * @param payload - details of the page viewed. + */ +export type MetaMetricsControllerTrackPageAction = { + type: `MetaMetricsController:trackPage`; + handler: MetaMetricsController['trackPage']; +}; + export type MetaMetricsControllerHandleMetaMaskStateUpdateAction = { type: `MetaMetricsController:handleMetaMaskStateUpdate`; handler: MetaMetricsController['handleMetaMaskStateUpdate']; @@ -215,13 +214,13 @@ export type MetaMetricsControllerMethodActions = | MetaMetricsControllerUpdateEventFragmentAction | MetaMetricsControllerDeleteEventFragmentAction | MetaMetricsControllerFinalizeEventFragmentAction - | MetaMetricsControllerIdentifyAction | MetaMetricsControllerUpdateExtensionUninstallUrlAction | MetaMetricsControllerSetParticipateInMetaMetricsAction | MetaMetricsControllerSetDataCollectionForMarketingAction | MetaMetricsControllerSetMarketingCampaignCookieIdAction - | MetaMetricsControllerTrackPageAction | MetaMetricsControllerTrackEventAction + | MetaMetricsControllerIdentifyAction + | MetaMetricsControllerTrackPageAction | MetaMetricsControllerHandleMetaMaskStateUpdateAction | MetaMetricsControllerTrackEventsAfterMetricsOptInAction | MetaMetricsControllerClearEventsAfterMetricsOptInAction diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 333ca2b5732d..f53b4dab23f3 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -2876,9 +2876,12 @@ describe('MetaMaskController', () => { encryptor: mockEncryptor, initState: { ...cloneDeep(firstTimeState), + AnalyticsController: { + analyticsId: 'MOCK_METRICS_ID', + optedIn: true, + }, MetaMetricsController: { - metaMetricsId: 'MOCK_METRICS_ID', - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, dataCollectionForMarketing: true, }, }, From d1ce5609508db57aabd215ccd4032719cb9017de Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 26 May 2026 06:45:27 +0200 Subject: [PATCH 04/35] test: align analytics default fixture state --- test/e2e/fixtures/default-fixture.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/fixtures/default-fixture.json b/test/e2e/fixtures/default-fixture.json index 1444e0cf4290..d4036934898e 100644 --- a/test/e2e/fixtures/default-fixture.json +++ b/test/e2e/fixtures/default-fixture.json @@ -314,9 +314,9 @@ "logs": {} }, "AnalyticsController": { - "analyticsId": "897e4491-7257-4908-ae83-48013df43d80", + "analyticsId": null, "eventQueue": {}, - "optedIn": true + "optedIn": false }, "MetaMetricsController": { "dataCollectionForMarketing": false, From aa615907fd971786c3ce36aba296882fd2e7d95a Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 26 May 2026 10:21:29 +0200 Subject: [PATCH 05/35] test: align sentry analytics state mask --- app/scripts/constants/sentry-state.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index eb8a438cfe34..2c4802357994 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -43,6 +43,7 @@ export const SENTRY_BACKGROUND_STATE: SentryBackgroundControllerMasks = { }, AnalyticsController: { analyticsId: true, + eventQueue: false, optedIn: true, }, AnnouncementController: { @@ -198,8 +199,6 @@ export const SENTRY_BACKGROUND_STATE: SentryBackgroundControllerMasks = { eventsBeforeMetricsOptIn: false, tracesBeforeMetricsOptIn: false, fragments: false, - metaMetricsId: true, - participateInMetaMetrics: true, traits: false, dataCollectionForMarketing: false, marketingCampaignCookieId: true, From a3a583d27ced3598ccef416abfc489fe186bcf3b Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 26 May 2026 22:17:10 +0200 Subject: [PATCH 06/35] fix: derive metametrics state from analytics controller --- app/scripts/lib/sentry-get-state.test.ts | 10 ++-- app/scripts/lib/sentry-get-state.ts | 12 ++-- test/e2e/tests/metrics/errors.spec.ts | 12 ++-- .../metrics/metametrics-persistence.spec.ts | 6 +- ui/contexts/metametrics.test.tsx | 23 +++----- ui/ducks/metamask/metamask.js | 3 +- ui/ducks/metamask/metamask.test.js | 56 +++++++++++++++++++ ui/selectors/metametrics.js | 12 ++-- ui/selectors/metametrics.test.ts | 48 ++++++++++++++++ ui/selectors/selectors.js | 4 +- 10 files changed, 148 insertions(+), 38 deletions(-) create mode 100644 ui/selectors/metametrics.test.ts diff --git a/app/scripts/lib/sentry-get-state.test.ts b/app/scripts/lib/sentry-get-state.test.ts index 82dfddf26dba..aaf0e31038c2 100644 --- a/app/scripts/lib/sentry-get-state.test.ts +++ b/app/scripts/lib/sentry-get-state.test.ts @@ -250,8 +250,9 @@ describe('sentry-get-state', () => { getMetaMetricsStateFromAppState({ state: { metamask: { - participateInMetaMetrics: true, - metaMetricsId: 'metamask-id', + analyticsId: 'metamask-id', + completedMetaMetricsOnboarding: true, + optedIn: true, }, }, }), @@ -266,8 +267,9 @@ describe('sentry-get-state', () => { getMetaMetricsStateFromAppState({ state: { metamask: { - participateInMetaMetrics: null, - metaMetricsId: 'metamask-id', + analyticsId: 'metamask-id', + completedMetaMetricsOnboarding: false, + optedIn: true, }, }, }), diff --git a/app/scripts/lib/sentry-get-state.ts b/app/scripts/lib/sentry-get-state.ts index e1d1f7a8f007..3577af84816d 100644 --- a/app/scripts/lib/sentry-get-state.ts +++ b/app/scripts/lib/sentry-get-state.ts @@ -73,13 +73,13 @@ export function getMetaMetricsStateFromAppState( if (appState.state) { const state = appState.state as Record; if ('metamask' in state && state.metamask !== undefined) { - const metamask = state.metamask as { - participateInMetaMetrics?: boolean | null; - metaMetricsId?: string; - }; + const metamask = state.metamask as AnalyticsState & MetaMetricsState; + const { analyticsId, completedMetaMetricsOnboarding, optedIn } = metamask; return { - participateInMetaMetrics: metamask.participateInMetaMetrics ?? null, - metaMetricsId: metamask.metaMetricsId, + participateInMetaMetrics: + completedMetaMetricsOnboarding === true ? optedIn === true : null, + metaMetricsId: + typeof analyticsId === 'string' ? analyticsId : undefined, }; } return getMetaMetricsStateFromControllerState(state); diff --git a/test/e2e/tests/metrics/errors.spec.ts b/test/e2e/tests/metrics/errors.spec.ts index 4751166f9f20..bd24536d1d70 100644 --- a/test/e2e/tests/metrics/errors.spec.ts +++ b/test/e2e/tests/metrics/errors.spec.ts @@ -1130,13 +1130,15 @@ describe('Sentry errors', function () { const mockJsonBody = JSON.parse(mockTextBody[2]); const { level, extra } = mockJsonBody; const [{ type, value }] = mockJsonBody.exception.values; - const { participateInMetaMetrics } = + const { optedIn } = extra.appState.state.AnalyticsController; + const { completedMetaMetricsOnboarding } = extra.appState.state.MetaMetricsController; // Verify request assert.equal(type, 'TestError'); assert.equal(value, 'Test Error'); assert.equal(level, 'error'); - assert.equal(participateInMetaMetrics, true); + assert.equal(optedIn, true); + assert.equal(completedMetaMetricsOnboarding, true); }, ); }); @@ -1313,12 +1315,14 @@ describe('Sentry errors', function () { const mockJsonBody = JSON.parse(mockTextBody[2]); const { level, extra } = mockJsonBody; const [{ type, value }] = mockJsonBody.exception.values; - const { participateInMetaMetrics } = extra.appState.state.metamask; + const { optedIn, completedMetaMetricsOnboarding } = + extra.appState.state.metamask; // Verify request assert.equal(type, 'TestError'); assert.equal(value, 'Test Error'); assert.equal(level, 'error'); - assert.equal(participateInMetaMetrics, true); + assert.equal(optedIn, true); + assert.equal(completedMetaMetricsOnboarding, true); }, ); }); diff --git a/test/e2e/tests/metrics/metametrics-persistence.spec.ts b/test/e2e/tests/metrics/metametrics-persistence.spec.ts index 0ac2b15c4a31..515330263ea8 100644 --- a/test/e2e/tests/metrics/metametrics-persistence.spec.ts +++ b/test/e2e/tests/metrics/metametrics-persistence.spec.ts @@ -24,7 +24,7 @@ describe('MetaMetrics ID persistence', function () { let uiState = await getCleanAppState(driver); - assert.equal(uiState.metamask.metaMetricsId, MOCK_META_METRICS_ID); + assert.equal(uiState.metamask.analyticsId, MOCK_META_METRICS_ID); // goes to the privacy settings screen and toggle off participate in metaMetrics await new HomePage(driver).headerNavbar.openSettingsPage(); @@ -43,7 +43,7 @@ describe('MetaMetrics ID persistence', function () { uiState = await getCleanAppState(driver); assert.equal( - uiState.metamask.metaMetricsId, + uiState.metamask.analyticsId, MOCK_META_METRICS_ID, 'Metametrics ID should be preserved when toggling off metametrics collection', ); @@ -57,7 +57,7 @@ describe('MetaMetrics ID persistence', function () { uiState = await getCleanAppState(driver); assert.equal( - uiState.metamask.metaMetricsId, + uiState.metamask.analyticsId, MOCK_META_METRICS_ID, 'Metametrics ID should be preserved when toggling on metametrics collection', ); diff --git a/ui/contexts/metametrics.test.tsx b/ui/contexts/metametrics.test.tsx index 1daa14e78447..6fc41a1558be 100644 --- a/ui/contexts/metametrics.test.tsx +++ b/ui/contexts/metametrics.test.tsx @@ -33,10 +33,9 @@ const renderProvider = ({ event: MetaMetricsEventName; state: { metamask: { + analyticsId: string | null; completedMetaMetricsOnboarding: boolean; optedIn: boolean; - participateInMetaMetrics: boolean | null; - metaMetricsId: string | null; }; }; }) => { @@ -50,7 +49,7 @@ const renderProvider = ({ category: MetaMetricsEventCategory.Onboarding, event, }); - }, [event, trackEvent]); + }, [trackEvent]); return null; }; @@ -86,15 +85,14 @@ describe('MetaMetricsProvider', () => { jest.clearAllMocks(); }); - it('buffers events when participation is enabled but metaMetricsId is missing', async () => { + it('buffers events when participation is enabled but analyticsId is missing', async () => { renderProvider({ event: MetaMetricsEventName.AnalyticsPreferenceSelected, state: { metamask: { + analyticsId: null, completedMetaMetricsOnboarding: true, optedIn: true, - participateInMetaMetrics: true, - metaMetricsId: null, }, }, }); @@ -114,15 +112,14 @@ describe('MetaMetricsProvider', () => { expect(mockedTrackMetaMetricsEvent).not.toHaveBeenCalled(); }); - it('tracks events immediately when participation is enabled and metaMetricsId exists', async () => { + it('tracks events immediately when participation is enabled and analyticsId exists', async () => { renderProvider({ event: MetaMetricsEventName.AnalyticsPreferenceSelected, state: { metamask: { + analyticsId: '0x123', completedMetaMetricsOnboarding: true, optedIn: true, - participateInMetaMetrics: true, - metaMetricsId: '0x123', }, }, }); @@ -140,15 +137,14 @@ describe('MetaMetricsProvider', () => { expect(mockedSubmitRequestToBackground).not.toHaveBeenCalled(); }); - it('tracks metrics opt out immediately without a metaMetricsId', async () => { + it('tracks metrics opt out immediately without an analyticsId', async () => { renderProvider({ event: MetaMetricsEventName.MetricsOptOut, state: { metamask: { + analyticsId: null, completedMetaMetricsOnboarding: true, optedIn: false, - participateInMetaMetrics: false, - metaMetricsId: null, }, }, }); @@ -171,10 +167,9 @@ describe('MetaMetricsProvider', () => { event: MetaMetricsEventName.AnalyticsPreferenceSelected, state: { metamask: { + analyticsId: '0x123', completedMetaMetricsOnboarding: true, optedIn: false, - participateInMetaMetrics: false, - metaMetricsId: '0x123', }, }, }); diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index b35ae33f92fc..0dbbb9cab5ab 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -135,7 +135,8 @@ export default function reduceMetamask(state = initialState, action) { case actionConstants.SET_PARTICIPATE_IN_METAMETRICS: return { ...metamaskState, - participateInMetaMetrics: action.value, + completedMetaMetricsOnboarding: action.value !== null, + optedIn: action.value === true, }; case actionConstants.SET_DATA_COLLECTION_FOR_MARKETING: diff --git a/ui/ducks/metamask/metamask.test.js b/ui/ducks/metamask/metamask.test.js index 942d83b81a4e..5ff4d35d2eb0 100644 --- a/ui/ducks/metamask/metamask.test.js +++ b/ui/ducks/metamask/metamask.test.js @@ -210,6 +210,62 @@ describe('MetaMask Reducers', () => { expect(lockMetaMask.isUnlocked).toStrictEqual(false); }); + it('updates metrics participation state', () => { + expect( + reduceMetamask( + { + analyticsId: 'old-analytics-id', + completedMetaMetricsOnboarding: true, + optedIn: true, + }, + { + type: actionConstants.SET_PARTICIPATE_IN_METAMETRICS, + value: null, + }, + ), + ).toMatchObject({ + analyticsId: 'old-analytics-id', + completedMetaMetricsOnboarding: false, + optedIn: false, + }); + + expect( + reduceMetamask( + { + analyticsId: null, + completedMetaMetricsOnboarding: false, + optedIn: false, + }, + { + type: actionConstants.SET_PARTICIPATE_IN_METAMETRICS, + value: true, + }, + ), + ).toMatchObject({ + analyticsId: null, + completedMetaMetricsOnboarding: true, + optedIn: true, + }); + + expect( + reduceMetamask( + { + analyticsId: 'old-analytics-id', + completedMetaMetricsOnboarding: false, + optedIn: true, + }, + { + type: actionConstants.SET_PARTICIPATE_IN_METAMETRICS, + value: false, + }, + ), + ).toMatchObject({ + analyticsId: 'old-analytics-id', + completedMetaMetricsOnboarding: true, + optedIn: false, + }); + }); + it('sets account label', () => { const state = reduceMetamask(mockState.metamask, { type: actionConstants.SET_ACCOUNT_LABEL, diff --git a/ui/selectors/metametrics.js b/ui/selectors/metametrics.js index 794bf4489d14..3bb17886f389 100644 --- a/ui/selectors/metametrics.js +++ b/ui/selectors/metametrics.js @@ -6,13 +6,17 @@ const selectFragments = (state) => state.metamask.fragments; export const getDataCollectionForMarketing = (state) => state.metamask.dataCollectionForMarketing; -// return true if user has opted into MetaMetrics -export const getParticipateInMetaMetrics = (state) => - state.metamask.participateInMetaMetrics === true; +// return the user's MetaMetrics participation preference +export const getParticipateInMetaMetrics = (state) => { + if (state.metamask.completedMetaMetricsOnboarding !== true) { + return null; + } + return state.metamask.optedIn === true; +}; // return true once the user has completed the metrics participation prompt (yes or no) export const getIsParticipateInMetaMetricsSet = (state) => - state.metamask.participateInMetaMetrics !== null; + state.metamask.completedMetaMetricsOnboarding === true; export const getPna25Acknowledged = (state) => state.metamask.pna25Acknowledged; diff --git a/ui/selectors/metametrics.test.ts b/ui/selectors/metametrics.test.ts new file mode 100644 index 000000000000..c7e6bfc6a03e --- /dev/null +++ b/ui/selectors/metametrics.test.ts @@ -0,0 +1,48 @@ +import { + getIsParticipateInMetaMetricsSet, + getParticipateInMetaMetrics, +} from './metametrics'; +import { getMetaMetricsId } from './selectors'; + +describe('MetaMetrics selectors', () => { + const state = (metamask: Record) => ({ metamask }); + + it('derives metrics participation from onboarding completion and AnalyticsController opt-in state', () => { + expect( + getParticipateInMetaMetrics( + state({ completedMetaMetricsOnboarding: true, optedIn: true }), + ), + ).toBe(true); + expect( + getParticipateInMetaMetrics( + state({ completedMetaMetricsOnboarding: true, optedIn: false }), + ), + ).toBe(false); + expect( + getParticipateInMetaMetrics( + state({ completedMetaMetricsOnboarding: false, optedIn: true }), + ), + ).toBeNull(); + }); + + it('derives whether metrics participation has been set from onboarding completion', () => { + expect( + getIsParticipateInMetaMetricsSet( + state({ completedMetaMetricsOnboarding: true }), + ), + ).toBe(true); + expect( + getIsParticipateInMetaMetricsSet( + state({ completedMetaMetricsOnboarding: false }), + ), + ).toBe(false); + }); + + it('returns the AnalyticsController analytics ID', () => { + expect( + getMetaMetricsId( + state({ analyticsId: 'analytics-id', metaMetricsId: 'legacy-id' }), + ), + ).toBe('analytics-id'); + }); +}); diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 97ed8d5a1a07..dcb46eb8948b 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -366,8 +366,8 @@ export function getNetworkIdentifier(state) { } export function getMetaMetricsId(state) { - const { metaMetricsId } = state.metamask; - return metaMetricsId; + const { analyticsId } = state.metamask; + return analyticsId; } export function isCurrentProviderCustom(state) { From 55deafbaee7bf29dfdae09f5ac5f82e7db8395fb Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 27 May 2026 05:51:41 +0200 Subject: [PATCH 07/35] fix: normalize metametrics tri-state usage --- .../assets/defi-list/cells/defi-empty-state.tsx | 4 ++-- .../app/wallet-overview/coin-overview.tsx | 4 ++-- .../funding-method-modal/funding-method-modal.tsx | 4 ++-- .../global-menu-drawer/useGlobalMenuSections.tsx | 4 ++-- .../multichain/menu-items/discover-menu-item.tsx | 4 ++-- .../multichain/token-list-item/stakeable-link.tsx | 4 ++-- ui/hooks/ramps/useRamps/useRamps.test.tsx | 2 +- ui/hooks/ramps/useRamps/useRamps.ts | 2 +- ui/pages/asset/components/asset-page.tsx | 4 ++-- .../onboarding-flow/metametrics/metametrics.tsx | 4 +++- .../setup-passkey/setup-passkey.test.tsx | 6 ++++-- .../privacy-tab/data-collection-item.test.tsx | 7 ++++--- .../privacy-tab/metametrics-item.test.tsx | 15 ++++++++------- .../settings/privacy-tab/metametrics-item.tsx | 2 +- 14 files changed, 36 insertions(+), 30 deletions(-) diff --git a/ui/components/app/assets/defi-list/cells/defi-empty-state.tsx b/ui/components/app/assets/defi-list/cells/defi-empty-state.tsx index 4c1ef0ebad8f..b7a548c6867f 100644 --- a/ui/components/app/assets/defi-list/cells/defi-empty-state.tsx +++ b/ui/components/app/assets/defi-list/cells/defi-empty-state.tsx @@ -30,8 +30,8 @@ export const DeFiEmptyStateMessage: FC = () => { 'explore/tokens', 'ext_defi_empty_state_button', metaMetricsId, - isMetaMetricsEnabled, - isMarketingEnabled, + isMetaMetricsEnabled === true, + isMarketingEnabled === true, ); global.platform.openTab({ url }); trackEvent({ diff --git a/ui/components/app/wallet-overview/coin-overview.tsx b/ui/components/app/wallet-overview/coin-overview.tsx index ddefb5fc1584..63b2817e7fe5 100644 --- a/ui/components/app/wallet-overview/coin-overview.tsx +++ b/ui/components/app/wallet-overview/coin-overview.tsx @@ -219,8 +219,8 @@ export const CoinOverview = ({ 'explore/tokens', 'ext_portfolio_button', metaMetricsId, - isMetaMetricsEnabled, - isMarketingEnabled, + isMetaMetricsEnabled === true, + isMarketingEnabled === true, ); global.platform.openTab({ url }); trackEvent({ diff --git a/ui/components/multichain/funding-method-modal/funding-method-modal.tsx b/ui/components/multichain/funding-method-modal/funding-method-modal.tsx index 185f899930eb..58e8cb874451 100644 --- a/ui/components/multichain/funding-method-modal/funding-method-modal.tsx +++ b/ui/components/multichain/funding-method-modal/funding-method-modal.tsx @@ -81,8 +81,8 @@ export const FundingMethodModal: React.FC = ({ 'transfer', 'ext_funding_method_modal', metaMetricsId, - isMetaMetricsEnabled, - isMarketingEnabled, + isMetaMetricsEnabled === true, + isMarketingEnabled === true, accountAddress, 'transfer', ); diff --git a/ui/components/multichain/global-menu-drawer/useGlobalMenuSections.tsx b/ui/components/multichain/global-menu-drawer/useGlobalMenuSections.tsx index 5c2376aa7088..2fc45110c108 100644 --- a/ui/components/multichain/global-menu-drawer/useGlobalMenuSections.tsx +++ b/ui/components/multichain/global-menu-drawer/useGlobalMenuSections.tsx @@ -282,8 +282,8 @@ export function useGlobalMenuSections( 'explore/tokens', 'ext_portfolio_button', metaMetricsId, - isMetaMetricsEnabled, - isMarketingEnabled, + isMetaMetricsEnabled === true, + isMarketingEnabled === true, ); global.platform.openTab({ url }); trackEvent({ diff --git a/ui/components/multichain/menu-items/discover-menu-item.tsx b/ui/components/multichain/menu-items/discover-menu-item.tsx index 03466d46df74..9c073a406401 100644 --- a/ui/components/multichain/menu-items/discover-menu-item.tsx +++ b/ui/components/multichain/menu-items/discover-menu-item.tsx @@ -34,8 +34,8 @@ export const DiscoverMenuItem = ({ 'explore/tokens', 'ext_portfolio_button', metaMetricsId, - isMetaMetricsEnabled, - isMarketingEnabled, + isMetaMetricsEnabled === true, + isMarketingEnabled === true, ); global.platform.openTab({ url }); trackEvent({ diff --git a/ui/components/multichain/token-list-item/stakeable-link.tsx b/ui/components/multichain/token-list-item/stakeable-link.tsx index 5c278f122091..394f67e8976f 100644 --- a/ui/components/multichain/token-list-item/stakeable-link.tsx +++ b/ui/components/multichain/token-list-item/stakeable-link.tsx @@ -50,8 +50,8 @@ export const StakeableLink = ({ chainId, symbol }: StakeableLinkProps) => { 'stake', 'ext_stake_button', metaMetricsId, - isMetaMetricsEnabled, - isMarketingEnabled, + isMetaMetricsEnabled === true, + isMarketingEnabled === true, ); global.platform.openTab({ url }); trackEvent({ diff --git a/ui/hooks/ramps/useRamps/useRamps.test.tsx b/ui/hooks/ramps/useRamps/useRamps.test.tsx index e28d61937d31..e71c6299655a 100644 --- a/ui/hooks/ramps/useRamps/useRamps.test.tsx +++ b/ui/hooks/ramps/useRamps/useRamps.test.tsx @@ -17,7 +17,7 @@ const mockedMetametricsId = '0xtestMetaMetricsId'; let mockStoreState = { metamask: { ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), - metaMetricsId: mockedMetametricsId, + analyticsId: mockedMetametricsId, }, }; diff --git a/ui/hooks/ramps/useRamps/useRamps.ts b/ui/hooks/ramps/useRamps/useRamps.ts index aae84ff13f17..f32ed7113d21 100644 --- a/ui/hooks/ramps/useRamps/useRamps.ts +++ b/ui/hooks/ramps/useRamps/useRamps.ts @@ -50,7 +50,7 @@ const useRamps = ( if (metaMetricsId) { params.set('metametricsId', metaMetricsId); } - params.set('metricsEnabled', String(isMetaMetricsEnabled)); + params.set('metricsEnabled', String(isMetaMetricsEnabled === true)); if (isMarketingEnabled) { params.set('marketingEnabled', String(isMarketingEnabled)); } diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index 5921a1717c6a..dce1455e54d3 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -203,8 +203,8 @@ const AssetPage = ({ '', 'asset_page', metaMetricsId, - isMetaMetricsEnabled, - isMarketingEnabled, + isMetaMetricsEnabled === true, + isMarketingEnabled === true, selectedAccount.address, 'spending-caps', ), diff --git a/ui/pages/onboarding-flow/metametrics/metametrics.tsx b/ui/pages/onboarding-flow/metametrics/metametrics.tsx index 73305694b425..9d2792ba2385 100644 --- a/ui/pages/onboarding-flow/metametrics/metametrics.tsx +++ b/ui/pages/onboarding-flow/metametrics/metametrics.tsx @@ -145,7 +145,9 @@ export default function OnboardingMetametrics() { useEffect(() => { if (participateInMetaMetricsSet) { - setIsParticipateInMetaMetricsChecked(participateInMetaMetrics); + setIsParticipateInMetaMetricsChecked( + participateInMetaMetrics === true, + ); } if (dataCollectionForMarketing) { setIsDataCollectionForMarketingChecked(dataCollectionForMarketing); diff --git a/ui/pages/onboarding-flow/setup-passkey/setup-passkey.test.tsx b/ui/pages/onboarding-flow/setup-passkey/setup-passkey.test.tsx index fbd2f62ebdd8..9eb8885851b0 100644 --- a/ui/pages/onboarding-flow/setup-passkey/setup-passkey.test.tsx +++ b/ui/pages/onboarding-flow/setup-passkey/setup-passkey.test.tsx @@ -121,7 +121,8 @@ const buildMockStore = ( configureStore({ metamask: { firstTimeFlowType, - participateInMetaMetrics: null, + completedMetaMetricsOnboarding: false, + optedIn: false, ...metamaskOverrides, }, }); @@ -283,7 +284,8 @@ describe('SetupPasskey', () => { .spyOn(BrowserRuntimeUtils, 'getBrowserName') .mockReturnValue('chrome'); const mockStore = buildMockStore(FirstTimeFlowType.import, { - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, }); const { getByText } = renderSetupPasskey(mockStore); diff --git a/ui/pages/settings/privacy-tab/data-collection-item.test.tsx b/ui/pages/settings/privacy-tab/data-collection-item.test.tsx index 70b6b6772143..8fc938ec2da4 100644 --- a/ui/pages/settings/privacy-tab/data-collection-item.test.tsx +++ b/ui/pages/settings/privacy-tab/data-collection-item.test.tsx @@ -43,7 +43,8 @@ const createMockStore = (overrides = {}) => metamask: { ...mockState.metamask, useExternalServices: true, - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, dataCollectionForMarketing: false, ...overrides, }, @@ -118,8 +119,8 @@ describe('DataCollectionToggleItem', () => { expect(toggle.closest('.toggle-button--disabled')).toBeInTheDocument(); }); - it('is disabled when participateInMetaMetrics is false', () => { - const mockStore = createMockStore({ participateInMetaMetrics: false }); + it('is disabled when metrics participation is false', () => { + const mockStore = createMockStore({ optedIn: false }); renderWithProvider(, mockStore); const toggle = screen.getByRole('checkbox'); diff --git a/ui/pages/settings/privacy-tab/metametrics-item.test.tsx b/ui/pages/settings/privacy-tab/metametrics-item.test.tsx index 56503e1a3e83..1becbf4f0887 100644 --- a/ui/pages/settings/privacy-tab/metametrics-item.test.tsx +++ b/ui/pages/settings/privacy-tab/metametrics-item.test.tsx @@ -42,7 +42,8 @@ const createMockStore = (overrides = {}) => metamask: { ...mockState.metamask, useExternalServices: true, - participateInMetaMetrics: false, + completedMetaMetricsOnboarding: true, + optedIn: false, dataCollectionForMarketing: false, ...overrides, }, @@ -73,7 +74,7 @@ describe('MetametricsToggleItem', () => { }); it('renders toggle in enabled state', () => { - const mockStore = createMockStore({ participateInMetaMetrics: true }); + const mockStore = createMockStore({ optedIn: true }); renderWithProvider(, mockStore); expect( @@ -82,7 +83,7 @@ describe('MetametricsToggleItem', () => { }); it('renders toggle in disabled state', () => { - const mockStore = createMockStore({ participateInMetaMetrics: false }); + const mockStore = createMockStore({ optedIn: false }); renderWithProvider(, mockStore); expect( @@ -91,7 +92,7 @@ describe('MetametricsToggleItem', () => { }); it('calls enableMetametrics when toggled on', async () => { - const mockStore = createMockStore({ participateInMetaMetrics: false }); + const mockStore = createMockStore({ optedIn: false }); renderWithProvider(, mockStore); fireEvent.click(screen.getByTestId('participate-in-meta-metrics-input')); @@ -102,7 +103,7 @@ describe('MetametricsToggleItem', () => { }); it('calls disableMetametrics when toggled off', async () => { - const mockStore = createMockStore({ participateInMetaMetrics: true }); + const mockStore = createMockStore({ optedIn: true }); renderWithProvider(, mockStore); fireEvent.click(screen.getByTestId('participate-in-meta-metrics-input')); @@ -114,7 +115,7 @@ describe('MetametricsToggleItem', () => { it('disables data collection for marketing when turning off metametrics', async () => { const mockStore = createMockStore({ - participateInMetaMetrics: true, + optedIn: true, dataCollectionForMarketing: true, }); renderWithProvider(, mockStore); @@ -136,7 +137,7 @@ describe('MetametricsToggleItem', () => { it('fires TurnOffMetaMetrics event when toggled off', async () => { const mockTrackEvent = jest.fn(); - const mockStore = createMockStore({ participateInMetaMetrics: true }); + const mockStore = createMockStore({ optedIn: true }); renderWithProvider( , diff --git a/ui/pages/settings/privacy-tab/metametrics-item.tsx b/ui/pages/settings/privacy-tab/metametrics-item.tsx index eaaf8d832414..222f3e73b513 100644 --- a/ui/pages/settings/privacy-tab/metametrics-item.tsx +++ b/ui/pages/settings/privacy-tab/metametrics-item.tsx @@ -95,7 +95,7 @@ export const MetametricsToggleItem = () => { Date: Wed, 27 May 2026 06:16:56 +0200 Subject: [PATCH 08/35] test: update metametrics integration state fixtures --- test/data/mock-send-state.json | 3 ++- test/data/mock-state.json | 3 ++- test/integration/confirmations/signatures/permit.test.tsx | 5 +++-- .../confirmations/signatures/personalSign.test.tsx | 5 +++-- .../transactions/contract-deployment.test.tsx | 5 +++-- .../transactions/contract-interaction.test.tsx | 5 +++-- test/integration/data/integration-init-state.json | 3 ++- test/integration/data/onboarding-completion-route.json | 5 +++-- test/integration/defi/defi-positions.test.tsx | 5 +++-- test/integration/nfts/nfts.test.tsx | 3 ++- .../notifications&auth/data/notification-state.ts | 2 +- .../notifications&auth/notifications-activation.test.tsx | 6 ++++-- .../notifications&auth/notifications-list.test.tsx | 3 ++- .../notifications&auth/notifications-toggle.test.tsx | 8 +++++--- ui/pages/home/home.component.js | 2 +- ui/pages/home/home.container.js | 4 ++-- 16 files changed, 41 insertions(+), 26 deletions(-) diff --git a/test/data/mock-send-state.json b/test/data/mock-send-state.json index 8f7d1afd0727..0cc5970f4937 100644 --- a/test/data/mock-send-state.json +++ b/test/data/mock-send-state.json @@ -70,7 +70,8 @@ "ipfsGateway": "", "dismissSeedBackUpReminder": false, "usePhishDetect": true, - "participateInMetaMetrics": false, + "completedMetaMetricsOnboarding": true, + "optedIn": false, "gasEstimateType": "fee-market", "gasFeeEstimates": { "low": { diff --git a/test/data/mock-state.json b/test/data/mock-state.json index f0ec802945ae..f45247f586ae 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -65,7 +65,8 @@ "dismissSeedBackUpReminder": false, "usePhishDetect": true, "useMultiAccountBalanceChecker": false, - "participateInMetaMetrics": false, + "completedMetaMetricsOnboarding": true, + "optedIn": false, "passkeyRecord": null, "gasEstimateType": "fee-market", "pendingExtensionVersion": null, diff --git a/test/integration/confirmations/signatures/permit.test.tsx b/test/integration/confirmations/signatures/permit.test.tsx index 5ef6c1465fda..2ade39c91b16 100644 --- a/test/integration/confirmations/signatures/permit.test.tsx +++ b/test/integration/confirmations/signatures/permit.test.tsx @@ -58,8 +58,9 @@ describe('Permit Confirmation', () => { await integrationTestRender({ preloadedState: { ...mockedMetaMaskState, - participateInMetaMetrics: true, - metaMetricsId: 'test-metametrics-id', + analyticsId: 'test-metametrics-id', + completedMetaMetricsOnboarding: true, + optedIn: true, dataCollectionForMarketing: false, }, backgroundConnection: backgroundConnectionMocked, diff --git a/test/integration/confirmations/signatures/personalSign.test.tsx b/test/integration/confirmations/signatures/personalSign.test.tsx index ad1966c752ef..b72f564e0725 100644 --- a/test/integration/confirmations/signatures/personalSign.test.tsx +++ b/test/integration/confirmations/signatures/personalSign.test.tsx @@ -83,8 +83,9 @@ describe('PersonalSign Confirmation', () => { await integrationTestRender({ preloadedState: { ...mockedMetaMaskState, - participateInMetaMetrics: true, - metaMetricsId: 'test-metametrics-id', + analyticsId: 'test-metametrics-id', + completedMetaMetricsOnboarding: true, + optedIn: true, dataCollectionForMarketing: false, }, backgroundConnection: backgroundConnectionMocked, diff --git a/test/integration/confirmations/transactions/contract-deployment.test.tsx b/test/integration/confirmations/transactions/contract-deployment.test.tsx index 32b033d60231..73149b192b09 100644 --- a/test/integration/confirmations/transactions/contract-deployment.test.tsx +++ b/test/integration/confirmations/transactions/contract-deployment.test.tsx @@ -47,9 +47,10 @@ const getMetaMaskStateWithUnapprovedContractDeployment = ({ }) => { return { ...mockMetaMaskState, - participateInMetaMetrics: true, + analyticsId: 'test-metametrics-id', + completedMetaMetricsOnboarding: true, + optedIn: true, dataCollectionForMarketing: false, - metaMetricsId: 'test-metametrics-id', preferences: { ...mockMetaMaskState.preferences, showConfirmationAdvancedDetails, diff --git a/test/integration/confirmations/transactions/contract-interaction.test.tsx b/test/integration/confirmations/transactions/contract-interaction.test.tsx index 62f625062db7..e7cbad7a855e 100644 --- a/test/integration/confirmations/transactions/contract-interaction.test.tsx +++ b/test/integration/confirmations/transactions/contract-interaction.test.tsx @@ -178,9 +178,10 @@ describe('Contract Interaction Confirmation', () => { await integrationTestRender({ preloadedState: { ...mockedMetaMaskState, - participateInMetaMetrics: true, + analyticsId: 'test-metametrics-id', + completedMetaMetricsOnboarding: true, + optedIn: true, dataCollectionForMarketing: false, - metaMetricsId: 'test-metametrics-id', }, backgroundConnection: backgroundConnectionMocked, }); diff --git a/test/integration/data/integration-init-state.json b/test/integration/data/integration-init-state.json index 199911ca8cef..5a02bd24e54c 100644 --- a/test/integration/data/integration-init-state.json +++ b/test/integration/data/integration-init-state.json @@ -349,6 +349,7 @@ "os": "mac", "browser": "chrome" }, + "completedMetaMetricsOnboarding": true, "completedOnboarding": true, "confirmationExchangeRates": {}, "connectedStatusPopoverHasBeenShown": true, @@ -823,8 +824,8 @@ } }, "openSeaEnabled": false, + "optedIn": false, "orderedNetworkList": [], - "participateInMetaMetrics": false, "pendingApprovalCount": 0, "pendingApprovals": {}, "permissionHistory": { diff --git a/test/integration/data/onboarding-completion-route.json b/test/integration/data/onboarding-completion-route.json index 025f52592f65..9fe12173472c 100644 --- a/test/integration/data/onboarding-completion-route.json +++ b/test/integration/data/onboarding-completion-route.json @@ -52,6 +52,7 @@ "allNftContracts": {}, "allNfts": {}, "allTokens": {}, + "analyticsId": "0x4d6d78a255217af6411a5bbd39e31b5e46e0e920bdf7e979470f316cbe8c00eb", "announcements": { "8": { "id": 8, "date": "2021-11-01", "isShown": false }, "20": { "id": 20, "date": null, "isShown": false }, @@ -91,6 +92,7 @@ "approvalFlows": [], "assetsMetadata": {}, "browserEnvironment": { "os": "mac", "browser": "firefox" }, + "completedMetaMetricsOnboarding": true, "completedOnboarding": false, "confirmationExchangeRates": {}, "connectedStatusPopoverHasBeenShown": true, @@ -185,7 +187,6 @@ "lastUpdated": null, "ledgerTransportType": "u2f", "logs": {}, - "metaMetricsId": "0x4d6d78a255217af6411a5bbd39e31b5e46e0e920bdf7e979470f316cbe8c00eb", "methodData": {}, "multichainNetworkConfigurationsByChainId": { "bip122:000000000019d6689c085ae165831e93": { @@ -253,9 +254,9 @@ "notifications": {}, "onboardingTabs": {}, "openSeaEnabled": false, + "optedIn": true, "orderedNetworkList": [], "outdatedBrowserWarningLastShown": 1715943225448, - "participateInMetaMetrics": true, "pendingApprovalCount": 0, "pendingApprovals": {}, "pendingTokens": {}, diff --git a/test/integration/defi/defi-positions.test.tsx b/test/integration/defi/defi-positions.test.tsx index d200af2b0107..a450d198d0f5 100644 --- a/test/integration/defi/defi-positions.test.tsx +++ b/test/integration/defi/defi-positions.test.tsx @@ -58,9 +58,10 @@ const accountName = getSelectedAccountGroupName(mockMetaMaskState); const withMetamaskConnectedToMainnet = { ...mockMetaMaskState, - participateInMetaMetrics: true, + analyticsId: 'test-metametrics-id', + completedMetaMetricsOnboarding: true, + optedIn: true, dataCollectionForMarketing: false, - metaMetricsId: 'test-metametrics-id', selectedNetworkClientId: 'testNetworkConfigurationId', preferences: { ...mockMetaMaskState.preferences, diff --git a/test/integration/nfts/nfts.test.tsx b/test/integration/nfts/nfts.test.tsx index 1a74f8c2be82..c05163d5e8de 100644 --- a/test/integration/nfts/nfts.test.tsx +++ b/test/integration/nfts/nfts.test.tsx @@ -50,6 +50,8 @@ describe('NFTs list', () => { const withMetamaskConnectedToMainnet = { ...mockMetaMaskState, + completedMetaMetricsOnboarding: true, + optedIn: true, selectedNetworkClientId: 'testNetworkConfigurationId', enabledNetworkMap: { eip155: { @@ -59,7 +61,6 @@ describe('NFTs list', () => { '0xaa36a7': true, }, }, - participateInMetaMetrics: true, dataCollectionForMarketing: false, }; diff --git a/test/integration/notifications&auth/data/notification-state.ts b/test/integration/notifications&auth/data/notification-state.ts index c4a156f32ebc..225a415f10b9 100644 --- a/test/integration/notifications&auth/data/notification-state.ts +++ b/test/integration/notifications&auth/data/notification-state.ts @@ -40,7 +40,7 @@ export const getMockedNotificationsState = () => { isBackupAndSyncUpdateLoading: false, isContactSyncingEnabled: true, isContactSyncingInProgress: false, - metaMetricsId: 'test-metametrics-id', + analyticsId: 'test-metametrics-id', isMetamaskNotificationsFeatureSeen: true, isNotificationServicesEnabled: true, isFeatureAnnouncementsEnabled: true, diff --git a/test/integration/notifications&auth/notifications-activation.test.tsx b/test/integration/notifications&auth/notifications-activation.test.tsx index 060ad20dafbd..3a1983087bbb 100644 --- a/test/integration/notifications&auth/notifications-activation.test.tsx +++ b/test/integration/notifications&auth/notifications-activation.test.tsx @@ -93,7 +93,8 @@ describe('Notifications Activation', () => { isNotificationServicesEnabled: false, isFeatureAnnouncementsEnabled: false, isMetamaskNotificationsFeatureSeen: false, - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, dataCollectionForMarketing: false, }, backgroundConnection: backgroundConnectionMocked, @@ -139,7 +140,8 @@ describe('Notifications Activation', () => { isNotificationServicesEnabled: false, isFeatureAnnouncementsEnabled: false, isMetamaskNotificationsFeatureSeen: false, - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, dataCollectionForMarketing: false, }, backgroundConnection: backgroundConnectionMocked, diff --git a/test/integration/notifications&auth/notifications-list.test.tsx b/test/integration/notifications&auth/notifications-list.test.tsx index e22417031837..ac9b7b365c9b 100644 --- a/test/integration/notifications&auth/notifications-list.test.tsx +++ b/test/integration/notifications&auth/notifications-list.test.tsx @@ -92,7 +92,8 @@ describe('Notifications List', () => { await integrationTestRender({ preloadedState: { ...mockedState, - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, dataCollectionForMarketing: false, }, backgroundConnection: backgroundConnectionMocked, diff --git a/test/integration/notifications&auth/notifications-toggle.test.tsx b/test/integration/notifications&auth/notifications-toggle.test.tsx index a8a6ebf37beb..5e0f78e983a5 100644 --- a/test/integration/notifications&auth/notifications-toggle.test.tsx +++ b/test/integration/notifications&auth/notifications-toggle.test.tsx @@ -93,9 +93,10 @@ describe('Notifications Toggle', () => { await integrationTestRender({ preloadedState: { ...mockedState, - participateInMetaMetrics: true, + analyticsId: 'test-metametrics-id', + completedMetaMetricsOnboarding: true, + optedIn: true, dataCollectionForMarketing: false, - metaMetricsId: 'test-metametrics-id', }, backgroundConnection: backgroundConnectionMocked, }); @@ -160,7 +161,8 @@ describe('Notifications Toggle', () => { isFeatureAnnouncementsEnabled: false, isMetamaskNotificationsFeatureSeen: true, dataCollectionForMarketing: false, - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, }, backgroundConnection: backgroundConnectionMocked, }); diff --git a/ui/pages/home/home.component.js b/ui/pages/home/home.component.js index c013c65e7b46..dab5a74cca4a 100644 --- a/ui/pages/home/home.component.js +++ b/ui/pages/home/home.component.js @@ -113,7 +113,7 @@ export default class Home extends PureComponent { showUpdateModal: PropTypes.bool.isRequired, newNetworkAddedConfigurationId: PropTypes.string, totalUnapprovedCount: PropTypes.number.isRequired, - participateInMetaMetrics: PropTypes.bool.isRequired, + participateInMetaMetrics: PropTypes.bool, setDataCollectionForMarketing: PropTypes.func.isRequired, dataCollectionForMarketing: PropTypes.bool, location: PropTypes.object, diff --git a/ui/pages/home/home.container.js b/ui/pages/home/home.container.js index 27bb4a4efd8c..2ed63b016d5f 100644 --- a/ui/pages/home/home.container.js +++ b/ui/pages/home/home.container.js @@ -27,6 +27,7 @@ import { getPendingShieldCohort, getPendingRedirectRoute, getLastVisitedPerpsRoute, + getParticipateInMetaMetrics, } from '../../selectors'; import { getInfuraBlocked } from '../../../shared/lib/selectors/networks'; import { getSelectedInternalAccount } from '../../../shared/lib/selectors/accounts'; @@ -91,7 +92,6 @@ const mapStateToProps = (state) => { seedPhraseBackedUp, connectedStatusPopoverHasBeenShown, dataCollectionForMarketing, - participateInMetaMetrics, firstTimeFlowType, completedOnboarding, forgottenPassword, @@ -133,7 +133,7 @@ const mapStateToProps = (state) => { dataCollectionForMarketing, selectedAddress, totalUnapprovedCount, - participateInMetaMetrics, + participateInMetaMetrics: getParticipateInMetaMetrics(state), hasApprovalFlows: getApprovalFlows(state)?.length > 0, connectedStatusPopoverHasBeenShown, firstTimeFlowType, From 05878817920f39d016cb23f1624b5dff85da6f59 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 27 May 2026 08:12:32 +0200 Subject: [PATCH 09/35] test: update metametrics state fixtures --- .../metametrics-toggle/metametrics-toggle.test.tsx | 12 ++++++++---- ui/components/ui/survey-toast/survey-toast.test.tsx | 5 +++-- .../create-password/create-password.test.tsx | 8 +++++--- ui/pages/onboarding-flow/metametrics/metametrics.tsx | 4 +--- .../onboarding-flow-switch.test.tsx | 1 + 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/ui/components/app/metametrics-toggle/metametrics-toggle.test.tsx b/ui/components/app/metametrics-toggle/metametrics-toggle.test.tsx index b33d5edc3f89..b7a5084a378e 100644 --- a/ui/components/app/metametrics-toggle/metametrics-toggle.test.tsx +++ b/ui/components/app/metametrics-toggle/metametrics-toggle.test.tsx @@ -11,14 +11,16 @@ const disableMetametricsMock = jest.fn(() => Promise.resolve()); type StateOverrides = { isSignedIn?: boolean; useExternalServices?: boolean; - participateInMetaMetrics?: boolean; + completedMetaMetricsOnboarding?: boolean; + optedIn?: boolean; isBackupAndSyncEnabled?: boolean; }; const initialState: StateOverrides = { isSignedIn: true, useExternalServices: true, - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, isBackupAndSyncEnabled: true, }; @@ -94,7 +96,8 @@ describe('MetametricsToggle', () => { it('calls enableMetametrics when toggle is turned on', () => { const { metaMetricsToggleButton } = arrangeMocks({ useExternalServices: true, - participateInMetaMetrics: false, + completedMetaMetricsOnboarding: true, + optedIn: false, }); fireEvent.click(metaMetricsToggleButton); @@ -104,7 +107,8 @@ describe('MetametricsToggle', () => { it('calls disableMetametrics when toggle is turned off', () => { const { metaMetricsToggleButton } = arrangeMocks({ useExternalServices: true, - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, }); fireEvent.click(metaMetricsToggleButton); diff --git a/ui/components/ui/survey-toast/survey-toast.test.tsx b/ui/components/ui/survey-toast/survey-toast.test.tsx index 3dbe892851d3..85d861f8e8f9 100644 --- a/ui/components/ui/survey-toast/survey-toast.test.tsx +++ b/ui/components/ui/survey-toast/survey-toast.test.tsx @@ -50,8 +50,9 @@ const createStore = (options = { metametricsEnabled: true }) => metamask: { lastViewedUserSurvey: 2, useExternalServices: true, - participateInMetaMetrics: options.metametricsEnabled, - metaMetricsId: '0x123', + completedMetaMetricsOnboarding: true, + optedIn: options.metametricsEnabled, + analyticsId: '0x123', internalAccounts: { selectedAccount: '0x123', accounts: { '0x123': { address: '0x123' } }, diff --git a/ui/pages/onboarding-flow/create-password/create-password.test.tsx b/ui/pages/onboarding-flow/create-password/create-password.test.tsx index 6fe76f777bce..afa9fc994d3a 100644 --- a/ui/pages/onboarding-flow/create-password/create-password.test.tsx +++ b/ui/pages/onboarding-flow/create-password/create-password.test.tsx @@ -42,7 +42,7 @@ describe('Onboarding Create Password', () => { accounts: {}, selectedAccount: '', }, - metaMetricsId: '0x00000000', + analyticsId: '0x00000000', }, }; @@ -619,7 +619,8 @@ describe('Onboarding Create Password', () => { ...mockState, metamask: { ...mockState.metamask, - participateInMetaMetrics: true, + completedMetaMetricsOnboarding: true, + optedIn: true, }, }; const mockStore = configureMockStore([thunk])(state); @@ -639,7 +640,8 @@ describe('Onboarding Create Password', () => { ...mockState, metamask: { ...mockState.metamask, - participateInMetaMetrics: false, + completedMetaMetricsOnboarding: true, + optedIn: false, }, }; const mockStore = configureMockStore()(state); diff --git a/ui/pages/onboarding-flow/metametrics/metametrics.tsx b/ui/pages/onboarding-flow/metametrics/metametrics.tsx index 9d2792ba2385..e0d34c88ffe1 100644 --- a/ui/pages/onboarding-flow/metametrics/metametrics.tsx +++ b/ui/pages/onboarding-flow/metametrics/metametrics.tsx @@ -145,9 +145,7 @@ export default function OnboardingMetametrics() { useEffect(() => { if (participateInMetaMetricsSet) { - setIsParticipateInMetaMetricsChecked( - participateInMetaMetrics === true, - ); + setIsParticipateInMetaMetricsChecked(participateInMetaMetrics === true); } if (dataCollectionForMarketing) { setIsDataCollectionForMarketingChecked(dataCollectionForMarketing); diff --git a/ui/pages/onboarding-flow/onboarding-flow-switch/onboarding-flow-switch.test.tsx b/ui/pages/onboarding-flow/onboarding-flow-switch/onboarding-flow-switch.test.tsx index 9ff9c75d0df6..5c4f1785b124 100644 --- a/ui/pages/onboarding-flow/onboarding-flow-switch/onboarding-flow-switch.test.tsx +++ b/ui/pages/onboarding-flow/onboarding-flow-switch/onboarding-flow-switch.test.tsx @@ -61,6 +61,7 @@ describe('Onboaring Flow Switch Component', () => { const mockState = { metamask: { seedPhraseBackedUp: false, + completedMetaMetricsOnboarding: true, }, }; From 8c57f6d20f8a60ea372320c30624aeb7309becf9 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 27 May 2026 11:37:43 +0200 Subject: [PATCH 10/35] test: align onboarding metrics fixture --- test/e2e/fixtures/onboarding-fixture.json | 2 +- test/e2e/tests/metrics/wallet-imported.spec.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/test/e2e/fixtures/onboarding-fixture.json b/test/e2e/fixtures/onboarding-fixture.json index 2c4acb255150..0ead0d489b1c 100644 --- a/test/e2e/fixtures/onboarding-fixture.json +++ b/test/e2e/fixtures/onboarding-fixture.json @@ -173,7 +173,7 @@ "logs": {} }, "AnalyticsController": { - "analyticsId": "897e4491-7257-4908-ae83-48013df43d80", + "analyticsId": null, "eventQueue": {}, "optedIn": false }, diff --git a/test/e2e/tests/metrics/wallet-imported.spec.ts b/test/e2e/tests/metrics/wallet-imported.spec.ts index e9e624167f42..86123e8c0159 100644 --- a/test/e2e/tests/metrics/wallet-imported.spec.ts +++ b/test/e2e/tests/metrics/wallet-imported.spec.ts @@ -15,6 +15,7 @@ describe('Wallet Created Events - Imported Account', function () { it('are sent when onboarding user who chooses to opt in metrics', async function () { // We need to distinguish between browsers, because routes differ (MetaMetrics screen) const expectedEvents = [ + MetaMetricsEventName.AppOpened, MetaMetricsEventName.AppInstalled, MetaMetricsEventName.AppInstalled, MetaMetricsEventName.AppInstalled, From 23bfa8173834ccf2ec2c533832af54a4adb01ba9 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 27 May 2026 15:14:15 +0200 Subject: [PATCH 11/35] chore: refresh PR branch From e7e9d5277ee0cab5c79fe1d1bb9cac434f7cd06d Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 27 May 2026 16:51:51 +0200 Subject: [PATCH 12/35] chore: minimize metametrics controller diff --- .../controllers/metametrics-controller.ts | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index 0177126623a1..87c0489ce8ea 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -588,13 +588,17 @@ export class MetaMetricsController extends BaseController< } : {}; - const mergedFragment = merge( - {}, - additionalFragmentProps, - fragment, - ) as MetaMetricsEventFragment; + const mergeEventFragment = merge as ( + ...sources: unknown[] + ) => MetaMetricsEventFragment; + this.update((state) => { - Object.assign(state.fragments, { [id]: mergedFragment }); + const metaMetricsState = state as unknown as MetaMetricsControllerState; + metaMetricsState.fragments[id] = mergeEventFragment( + {}, + additionalFragmentProps, + fragment, + ); }); if (fragment.initialEvent) { @@ -661,28 +665,34 @@ export class MetaMetricsController extends BaseController< const createIfNotFound = !fragment && id.includes('transaction-submitted-'); if (createIfNotFound) { - const newFragment: MetaMetricsEventFragment = { - canDeleteIfAbandoned: true, - category: MetaMetricsEventCategory.Transactions, - successEvent: TransactionMetaMetricsEvent.finalized, - id, - ...payload, - lastUpdated: Date.now(), - }; this.update((state) => { - Object.assign(state.fragments, { [id]: newFragment }); + const metaMetricsState = state as unknown as MetaMetricsControllerState; + metaMetricsState.fragments[id] = { + canDeleteIfAbandoned: true, + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.finalized, + id, + ...payload, + lastUpdated: Date.now(), + } as MetaMetricsEventFragment; }); return; } else if (!fragment) { throw new Error(`Event fragment with id ${id} does not exist.`); } - const updatedFragment = merge({} as MetaMetricsEventFragment, fragment, { - ...payload, - lastUpdated: Date.now(), - }) as MetaMetricsEventFragment; + const mergeEventFragment = merge as ( + ...sources: unknown[] + ) => MetaMetricsEventFragment; this.update((state) => { - Object.assign(state.fragments, { [id]: updatedFragment }); + const metaMetricsState = state as unknown as MetaMetricsControllerState; + metaMetricsState.fragments[id] = mergeEventFragment( + metaMetricsState.fragments[id], + { + ...payload, + lastUpdated: Date.now(), + }, + ); }); } @@ -1134,9 +1144,8 @@ export class MetaMetricsController extends BaseController< // It adds an event into a queue, which is only tracked if a user opts into metrics. addEventBeforeMetricsOptIn(event: MetaMetricsEventPayload): void { this.update((state) => { - const queue = - state.eventsBeforeMetricsOptIn as unknown as MetaMetricsEventPayload[]; - queue.push(event); + const metaMetricsState = state as unknown as MetaMetricsControllerState; + metaMetricsState.eventsBeforeMetricsOptIn.push(event); }); } @@ -1163,9 +1172,8 @@ export class MetaMetricsController extends BaseController< // It adds a trace into a queue, which is only tracked if a user opts into metrics. addTraceBeforeMetricsOptIn(traceData: BufferedTrace): void { this.update((state) => { - const queue = - state.tracesBeforeMetricsOptIn as unknown as BufferedTrace[]; - queue.push(traceData); + const metaMetricsState = state as unknown as MetaMetricsControllerState; + metaMetricsState.tracesBeforeMetricsOptIn.push(traceData); }); } From b773963612e7d544b77d1640fdf4efa4fae2ce84 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 27 May 2026 17:01:00 +0200 Subject: [PATCH 13/35] test: restore wallet import metrics buffering --- test/e2e/tests/metrics/wallet-imported.spec.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test/e2e/tests/metrics/wallet-imported.spec.ts b/test/e2e/tests/metrics/wallet-imported.spec.ts index 86123e8c0159..1777d34b6249 100644 --- a/test/e2e/tests/metrics/wallet-imported.spec.ts +++ b/test/e2e/tests/metrics/wallet-imported.spec.ts @@ -15,7 +15,6 @@ describe('Wallet Created Events - Imported Account', function () { it('are sent when onboarding user who chooses to opt in metrics', async function () { // We need to distinguish between browsers, because routes differ (MetaMetrics screen) const expectedEvents = [ - MetaMetricsEventName.AppOpened, MetaMetricsEventName.AppInstalled, MetaMetricsEventName.AppInstalled, MetaMetricsEventName.AppInstalled, @@ -30,11 +29,7 @@ describe('Wallet Created Events - Imported Account', function () { await withFixtures( { - fixtures: new FixtureBuilderV2({ onboarding: true }) - .withMetaMetricsController({ - participateInMetaMetrics: true, - }) - .build(), + fixtures: new FixtureBuilderV2({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: async (server: Mockttp) => { return await mockSegment(server, expectedEvents); From 6ac3707f26557159f5e2fe69932c8b101c7ed518 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 27 May 2026 19:38:04 +0200 Subject: [PATCH 14/35] test: update analytics fixture state --- test/e2e/fixtures/default-fixture.json | 4 ++-- test/e2e/fixtures/onboarding-fixture.json | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/test/e2e/fixtures/default-fixture.json b/test/e2e/fixtures/default-fixture.json index 12d90cc16e07..490f55c85e62 100644 --- a/test/e2e/fixtures/default-fixture.json +++ b/test/e2e/fixtures/default-fixture.json @@ -137,7 +137,7 @@ "currentMigrationVersion": 212, "firstTimeInfo": {}, "previousAppVersion": "", - "previousMigrationVersion": 211 + "previousMigrationVersion": 0 }, "AppStateController": { "browserEnvironment": { @@ -276,7 +276,7 @@ "AnalyticsController": { "analyticsId": null, "eventQueue": {}, - "optedIn": false + "optedIn": true }, "MetaMetricsController": { "dataCollectionForMarketing": false, diff --git a/test/e2e/fixtures/onboarding-fixture.json b/test/e2e/fixtures/onboarding-fixture.json index 0ead0d489b1c..0ef0ee8e60f8 100644 --- a/test/e2e/fixtures/onboarding-fixture.json +++ b/test/e2e/fixtures/onboarding-fixture.json @@ -41,7 +41,7 @@ "version": "13.17.0" }, "previousAppVersion": "", - "previousMigrationVersion": 211 + "previousMigrationVersion": 0 }, "AppStateController": { "browserEnvironment": { @@ -174,7 +174,6 @@ }, "AnalyticsController": { "analyticsId": null, - "eventQueue": {}, "optedIn": false }, "MetaMetricsController": { From 43f798b39542adfd125dd97a68fd141b79790cfc Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 27 May 2026 20:56:39 +0200 Subject: [PATCH 15/35] test: keep default fixture metrics off --- test/e2e/dist/wallet-fixture-export.spec.ts | 2 +- test/e2e/dist/wallet-fixture-validation.spec.ts | 2 +- test/e2e/fixtures/default-fixture.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/dist/wallet-fixture-export.spec.ts b/test/e2e/dist/wallet-fixture-export.spec.ts index 9350a226b514..3cc444470456 100644 --- a/test/e2e/dist/wallet-fixture-export.spec.ts +++ b/test/e2e/dist/wallet-fixture-export.spec.ts @@ -157,7 +157,7 @@ describe('Wallet State', function () { driver, seedPhrase: E2E_SRP, password: WALLET_PASSWORD, - participateInMetaMetrics: true, + participateInMetaMetrics: false, dataCollectionForMarketing: true, needNavigateToNewPage: false, }); diff --git a/test/e2e/dist/wallet-fixture-validation.spec.ts b/test/e2e/dist/wallet-fixture-validation.spec.ts index cfb84d70b376..9dcd20714364 100644 --- a/test/e2e/dist/wallet-fixture-validation.spec.ts +++ b/test/e2e/dist/wallet-fixture-validation.spec.ts @@ -127,7 +127,7 @@ describe('Wallet State', function () { driver, seedPhrase: E2E_SRP, password: WALLET_PASSWORD, - participateInMetaMetrics: true, + participateInMetaMetrics: false, dataCollectionForMarketing: true, needNavigateToNewPage: false, }); diff --git a/test/e2e/fixtures/default-fixture.json b/test/e2e/fixtures/default-fixture.json index 490f55c85e62..1b96744ea013 100644 --- a/test/e2e/fixtures/default-fixture.json +++ b/test/e2e/fixtures/default-fixture.json @@ -276,7 +276,7 @@ "AnalyticsController": { "analyticsId": null, "eventQueue": {}, - "optedIn": true + "optedIn": false }, "MetaMetricsController": { "dataCollectionForMarketing": false, From 28e2c5a6a6e029f61375f26e8c7e1580c52640fa Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 27 May 2026 22:23:36 +0200 Subject: [PATCH 16/35] test: align analytics fixture snapshots --- test/e2e/fixtures/default-fixture.json | 1 - .../errors-after-init-opt-in-background-state.json | 2 +- .../state-snapshots/errors-after-init-opt-in-ui-state.json | 2 +- .../errors-before-init-opt-in-background-state.json | 2 +- .../state-snapshots/errors-before-init-opt-in-ui-state.json | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/test/e2e/fixtures/default-fixture.json b/test/e2e/fixtures/default-fixture.json index 1b96744ea013..ff49173013b9 100644 --- a/test/e2e/fixtures/default-fixture.json +++ b/test/e2e/fixtures/default-fixture.json @@ -275,7 +275,6 @@ }, "AnalyticsController": { "analyticsId": null, - "eventQueue": {}, "optedIn": false }, "MetaMetricsController": { diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index 1c38c1dbec5e..82554be91930 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -37,7 +37,7 @@ "currentMigrationVersion": "number", "firstTimeInfo": "object", "previousAppVersion": "", - "previousMigrationVersion": 211 + "previousMigrationVersion": 0 }, "AppStateController": { "activeQrCodeScanRequest": null, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 05e0aefb41d7..d14cbbdac6fb 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -317,7 +317,7 @@ "useSidePanelAsDefault": "boolean" }, "previousAppVersion": "", - "previousMigrationVersion": 211, + "previousMigrationVersion": 0, "productTour": "accountIcon", "quoteFetchError": null, "quoteRequest": "object", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index 8810280b8a6f..69a8f2af32f4 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -35,7 +35,7 @@ "currentMigrationVersion": "number", "firstTimeInfo": "object", "previousAppVersion": "", - "previousMigrationVersion": 211 + "previousMigrationVersion": 0 }, "AppStateController": { "browserEnvironment": { "browser": "string" }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index 758177a5e63a..413c616b7764 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -36,7 +36,7 @@ "currentMigrationVersion": "number", "firstTimeInfo": "object", "previousAppVersion": "", - "previousMigrationVersion": 211 + "previousMigrationVersion": 0 }, "AppStateController": { "browserEnvironment": { "browser": "string", "os": "string" }, From f840046ba0e4d915b2f7c001be08335916eb264c Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 06:20:49 +0200 Subject: [PATCH 17/35] test: stabilize analytics e2e expectations --- .../tests/bridge/swap-positive-cases.spec.ts | 31 +++++++++++++++++-- test/e2e/tests/metrics/errors.spec.ts | 2 ++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/test/e2e/tests/bridge/swap-positive-cases.spec.ts b/test/e2e/tests/bridge/swap-positive-cases.spec.ts index c5fa7ccafafe..46cfa2abf615 100644 --- a/test/e2e/tests/bridge/swap-positive-cases.spec.ts +++ b/test/e2e/tests/bridge/swap-positive-cases.spec.ts @@ -16,6 +16,26 @@ import { mockTokensWithSecurityData, } from './bridge-test-utils'; +const getRequestedToCompletedEvents = ( + events: Event[], + expectedEvents: string[], +): Event[] => { + const firstExpectedEvent = expectedEvents[0]; + assert.ok(firstExpectedEvent, 'Expected at least one event'); + const quotesRequestedEventIndex = events.findIndex( + ({ event }) => event === firstExpectedEvent, + ); + assert.notEqual( + quotesRequestedEventIndex, + -1, + `${firstExpectedEvent} event not found`, + ); + return events.slice( + quotesRequestedEventIndex, + quotesRequestedEventIndex + expectedEvents.length, + ); +}; + describe('Swap tests', function (this: Suite) { this.timeout(160000); // This test is very long, so we need an unusually high timeout it('updates recommended swap quote incrementally when SSE events are received', async function () { @@ -58,13 +78,16 @@ describe('Swap tests', function (this: Suite) { (e) => e?.event === 'Transaction Finalized', ); - const requestedToCompletedEvents = unifiedSwapBridgeEvents.slice(6); const expectedEvents = [ 'Unified SwapBridge Quotes Requested', 'Unified SwapBridge Quotes Received', 'Unified SwapBridge Submitted', 'Unified SwapBridge Completed', ]; + const requestedToCompletedEvents = getRequestedToCompletedEvents( + unifiedSwapBridgeEvents, + expectedEvents, + ); requestedToCompletedEvents.forEach((e, idx) => { assert.ok( e.event === expectedEvents[idx], @@ -131,14 +154,16 @@ describe('Swap tests', function (this: Suite) { const events = (await getEventPayloads(driver, mockedEndpoints)).filter( (e) => e?.event?.includes('Unified SwapBridge'), ); - const requestedToCompletedEvents = events.slice(6); - const expectedEvents = [ 'Unified SwapBridge Quotes Requested', 'Unified SwapBridge Quotes Received', 'Unified SwapBridge Submitted', 'Unified SwapBridge Completed', ]; + const requestedToCompletedEvents = getRequestedToCompletedEvents( + events, + expectedEvents, + ); requestedToCompletedEvents.forEach((e, idx) => { assert.ok( e.event === expectedEvents[idx], diff --git a/test/e2e/tests/metrics/errors.spec.ts b/test/e2e/tests/metrics/errors.spec.ts index bd24536d1d70..7cc4e2485fd7 100644 --- a/test/e2e/tests/metrics/errors.spec.ts +++ b/test/e2e/tests/metrics/errors.spec.ts @@ -1483,6 +1483,8 @@ describe('Sentry errors', function () { // Filtered from UI state patches (sensitive auth tokens - see state-utils.ts) rewardsSubscriptionTokens: false, storageWriteErrorType: true, + // AnalyticsController keeps the queue out of UI state. + eventQueue: false, // Optional property on AppStateController; only set after a user // interacts with a Snap install dialog, so absent from initial state. snapsInstallPrivacyWarningShown: true, From 6792038da10b0443785e7239f5fa8f9efdf45edb Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 06:44:57 +0200 Subject: [PATCH 18/35] test: reduce analytics integration diff --- app/scripts/lib/segment/index.ts | 97 ++++++++++++++++---------------- 1 file changed, 48 insertions(+), 49 deletions(-) diff --git a/app/scripts/lib/segment/index.ts b/app/scripts/lib/segment/index.ts index dd959d66d9b6..da2cb47337ab 100644 --- a/app/scripts/lib/segment/index.ts +++ b/app/scripts/lib/segment/index.ts @@ -7,7 +7,7 @@ const SEGMENT_WRITE_KEY = process.env.SEGMENT_WRITE_KEY ?? null; const SEGMENT_HOST = process.env.SEGMENT_HOST || undefined; // flushAt controls how many events are sent to segment at once. Segment will -// hold onto a queue of events until it hits this number, then sends them as +// hold onto a queue of events until it hits this number, then it sends them as // a batch. This setting defaults to 15 in `@segment/analytics-node`, but in // development we likely want to see events in real time for debugging, so this // is set to 1 to disable the queueing mechanism. @@ -26,15 +26,16 @@ const SEGMENT_FLUSH_INTERVAL = SECOND * 5; /** * Callback invoked after an event has been flushed to Segment. The first * argument is an error (if any). The second argument is the Segment SDK - * `Context` object. Consumers in this codebase only inspect the error, so the + * `Context` object; consumers in this codebase only inspect the error, so the * type is intentionally loose. */ export type SegmentCallback = (err?: unknown, ctx?: unknown) => void; /** - * Loose payload shape used by `custom-segment-tracking` and tests. The real - * `Analytics` SDK enforces stricter per-method types. We keep this lenient - * because early-init traffic and the mock both forward partial payloads. + * Payload accepted by the segment client. Extension call sites pass partial + * payloads (e.g. early-init events omit `locale`/`chain_id`), so all fields + * are optional and `properties` / `context` / `traits` are typed loosely; + * validation is left to Segment. */ export type SegmentTrackPayload = { userId?: string; @@ -50,9 +51,10 @@ export type SegmentTrackPayload = { }; /** - * Safe Segment client exposed to the rest of the extension. The real - * implementation clones payloads before forwarding to `@segment/analytics-node` - * because the SDK mutates payloads during normalization. + * Thin interface exposed to the rest of the extension. This is the only + * surface `MetaMetricsController` and `custom-segment-tracking` interact with, + * so swapping the underlying implementation (real SDK vs. mock) is confined + * to this module. */ export type SegmentClient = { track: (payload: SegmentTrackPayload, callback?: SegmentCallback) => void; @@ -62,14 +64,18 @@ export type SegmentClient = { }; /** - * Constructs a `@segment/analytics-node` Analytics instance. + * Create a segment client backed by the official `@segment/analytics-node` + * SDK. The SDK uses `fetch` under the hood, so it is compatible with the MV3 + * service worker runtime (unlike the legacy `analytics-node` which relied on + * `axios`/`XMLHttpRequest`). * * @param writeKey - The Segment project write key. * @param host - Segment API host override (e.g. local mock). - * @param flushAt - Queue batch size. `undefined` uses the SDK default. + * @param flushAt - Queue batch size; `undefined` uses the SDK default. * @param flushInterval - Periodic flush interval in milliseconds. + * @returns A Segment client that forwards to the official Segment SDK. */ -export function createSegmentClient( +function createSegmentClient( writeKey: string, host: string | undefined, flushAt: number | undefined, @@ -82,22 +88,26 @@ export function createSegmentClient( flushInterval, }); + /** + * The Segment SDK mutates payloads during normalization. Clone on each SDK + * call so frozen controller-derived payloads can still be delivered. + */ return { track(payload, callback) { analytics.track( - cloneDeep(payload) as Parameters[0], + cloneDeep(payload) as Parameters[0], callback, ); }, identify(payload, callback) { analytics.identify( - cloneDeep(payload) as Parameters[0], + cloneDeep(payload) as Parameters[0], callback, ); }, page(payload, callback) { analytics.page( - cloneDeep(payload) as Parameters[0], + cloneDeep(payload) as Parameters[0], callback, ); }, @@ -107,66 +117,55 @@ export function createSegmentClient( }; } +/** + * Mock segment client queue item: the payload and its completion callback. + */ type MockQueueItem = [SegmentTrackPayload, SegmentCallback]; /** - * Segment mock used in test environments and in builds without - * `SEGMENT_WRITE_KEY`. Exposes an inspectable `queue` and lets tests drive - * flushes synchronously via `flush()`. + * Segment mock used in test environments. It preserves the shape of the real + * client, exposes an internal `queue`, and lets tests drive flushes + * synchronously via `flush()`. Used both by unit tests and by the app when + * `SEGMENT_WRITE_KEY` is not provided (e.g. test builds). * * @param flushAt - Number of events to queue before auto-flushing. When * undefined, events are only flushed when `flush()` is called explicitly. + * @returns A Segment client with an inspectable queue. */ export const createSegmentMock = ( flushAt: number | undefined = SEGMENT_FLUSH_AT, ): SegmentClient & { queue: MockQueueItem[] } => { - const noopCallback: SegmentCallback = () => undefined; - - const flushQueue = (queue: MockQueueItem[]) => { - queue.forEach(([, callback]) => { - callback(); - }); - queue.length = 0; - }; + const segmentMock: SegmentClient & { queue: MockQueueItem[] } = { + queue: [], - const segmentMock = { - queue: [] as MockQueueItem[], + flush() { + segmentMock.queue.forEach(([, callback]) => { + callback(); + }); + segmentMock.queue = []; + return Promise.resolve(); + }, - track( - payload: SegmentTrackPayload, - callback: SegmentCallback = noopCallback, - ) { + track(payload, callback = () => undefined) { segmentMock.queue.push([payload, callback]); + if (flushAt !== undefined && segmentMock.queue.length >= flushAt) { - flushQueue(segmentMock.queue); + segmentMock.flush(); } }, - flush() { - flushQueue(segmentMock.queue); - return Promise.resolve(); - }, - - page(_payload?: SegmentTrackPayload, callback?: SegmentCallback): void { + page(_payload, callback) { callback?.(); }, - identify(_payload?: SegmentTrackPayload, callback?: SegmentCallback): void { + identify(_payload, callback) { callback?.(); }, }; - return segmentMock as unknown as SegmentClient & { queue: MockQueueItem[] }; + return segmentMock; }; -/** - * Shared Segment client used across the extension background. Real builds use - * the `@segment/analytics-node` SDK; test builds and CI without - * `SEGMENT_WRITE_KEY` fall back to createSegmentMock. - * - * Payload cloning lives in this module so every direct Segment caller receives - * the same protection from SDK payload mutation. - */ export const segment: SegmentClient = SEGMENT_WRITE_KEY ? createSegmentClient( SEGMENT_WRITE_KEY, @@ -174,4 +173,4 @@ export const segment: SegmentClient = SEGMENT_WRITE_KEY SEGMENT_FLUSH_AT, SEGMENT_FLUSH_INTERVAL, ) - : (createSegmentMock(SEGMENT_FLUSH_AT) as unknown as SegmentClient); + : createSegmentMock(SEGMENT_FLUSH_AT); From fd8bddb75fb75636faa686fd44f33d0827eddd25 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 07:07:24 +0200 Subject: [PATCH 19/35] chore: reduce analytics integration diff --- app/scripts/lib/createRPCMethodTrackingMiddleware.test.js | 2 -- app/scripts/services/oauth/oauth-service.ts | 2 +- test/e2e/fixtures/fixture-builder-v2.ts | 4 ---- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js index e54a8bea0eec..9bc19cc3384c 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js @@ -24,7 +24,6 @@ import { orderSignatureMsg, } from '../../../test/data/confirmations/typed_sign'; import { getDefaultPreferencesControllerState } from '../controllers/preferences-controller'; -import { createSegmentMock } from './segment'; import createRPCMethodTrackingMiddleware from './createRPCMethodTrackingMiddleware'; import * as snapKeyringMetrics from './snap-keyring/metrics'; @@ -133,7 +132,6 @@ const metaMetricsController = new MetaMetricsController({ fragments: {}, }, messenger: controllerMessenger, - segment: createSegmentMock(2), version: '0.0.1', environment: 'test', extension: { diff --git a/app/scripts/services/oauth/oauth-service.ts b/app/scripts/services/oauth/oauth-service.ts index ad81ce17b061..079a5510c554 100644 --- a/app/scripts/services/oauth/oauth-service.ts +++ b/app/scripts/services/oauth/oauth-service.ts @@ -111,7 +111,7 @@ export class OAuthService { payload: MetaMetricsEventPayload, options?: MetaMetricsEventOptions, ): void { - const isMetricsEnabled = this.#getParticipateInMetaMetrics(); + const isMetricsEnabled = Boolean(this.#getParticipateInMetaMetrics()); if (isMetricsEnabled) { this.#trackEvent(payload, options); diff --git a/test/e2e/fixtures/fixture-builder-v2.ts b/test/e2e/fixtures/fixture-builder-v2.ts index ae61bfa35264..914454f244fa 100644 --- a/test/e2e/fixtures/fixture-builder-v2.ts +++ b/test/e2e/fixtures/fixture-builder-v2.ts @@ -123,10 +123,6 @@ type TransactionControllerFixtureInput = Partial< transactions?: TransactionMeta[]; }; -/** - * TODO: Migrate E2E fixtures to patch AnalyticsController directly. - * For now, many tests still pass legacy MetaMetrics keys through this helper. - */ type MetaMetricsControllerFixturePatch = Partial & { participateInMetaMetrics?: boolean | null; metaMetricsId?: string | null; From fa96cbac2897cbe3cf7e0134680d74215d09c526 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 07:14:34 +0200 Subject: [PATCH 20/35] refactor: clarify sentry metametrics state derivation --- app/scripts/lib/sentry-get-state.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/app/scripts/lib/sentry-get-state.ts b/app/scripts/lib/sentry-get-state.ts index 3577af84816d..aec678211823 100644 --- a/app/scripts/lib/sentry-get-state.ts +++ b/app/scripts/lib/sentry-get-state.ts @@ -73,14 +73,7 @@ export function getMetaMetricsStateFromAppState( if (appState.state) { const state = appState.state as Record; if ('metamask' in state && state.metamask !== undefined) { - const metamask = state.metamask as AnalyticsState & MetaMetricsState; - const { analyticsId, completedMetaMetricsOnboarding, optedIn } = metamask; - return { - participateInMetaMetrics: - completedMetaMetricsOnboarding === true ? optedIn === true : null, - metaMetricsId: - typeof analyticsId === 'string' ? analyticsId : undefined, - }; + return getMetaMetricsStateFromUIState(state.metamask); } return getMetaMetricsStateFromControllerState(state); } @@ -110,6 +103,19 @@ function getMetaMetricsStateFromBackupState( return getMetaMetricsStateFromControllerState(backupState); } +function getMetaMetricsStateFromUIState( + metamaskState: unknown, +): Exclude { + const { analyticsId, completedMetaMetricsOnboarding, optedIn } = + metamaskState as AnalyticsState & MetaMetricsState; + + return { + participateInMetaMetrics: + completedMetaMetricsOnboarding === true ? optedIn === true : null, + metaMetricsId: typeof analyticsId === 'string' ? analyticsId : undefined, + }; +} + function getMetaMetricsStateFromControllerState( state: unknown, ): Exclude { From a6e2a612b4b8c1b9b332ec2edf7f2c3285a5725e Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 08:28:02 +0200 Subject: [PATCH 21/35] test: complete analytics messenger setup --- app/scripts/lib/createRPCMethodTrackingMiddleware.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js index 9bc19cc3384c..af01082975f9 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js @@ -99,6 +99,10 @@ messenger.registerActionHandler('AnalyticsController:optOut', () => { analyticsControllerState.optedIn = false; }); +messenger.registerActionHandler('AnalyticsController:trackEvent', jest.fn()); +messenger.registerActionHandler('AnalyticsController:identify', jest.fn()); +messenger.registerActionHandler('AnalyticsController:trackView', jest.fn()); + const controllerMessenger = new Messenger({ namespace: 'MetaMetricsController', parent: messenger, @@ -110,6 +114,9 @@ messenger.delegate({ 'AnalyticsController:getState', 'AnalyticsController:optIn', 'AnalyticsController:optOut', + 'AnalyticsController:trackEvent', + 'AnalyticsController:identify', + 'AnalyticsController:trackView', 'PreferencesController:getState', 'NetworkController:getState', 'NetworkController:getNetworkClientById', From 8cbff1cbeb9cafc56496e589b6b500807a4e7259 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 09:03:27 +0200 Subject: [PATCH 22/35] test: loosen opt-out flush assertion --- app/scripts/controllers/metametrics-controller.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index 87eaa86bc4ad..4875dadfcbf8 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -722,7 +722,7 @@ describe('MetaMetricsController', function () { chain_id: '1', }, }); - expect(flushSpy).toHaveBeenCalledTimes(1); + expect(flushSpy).toHaveBeenCalled(); }, ); }); From cc68ab1aa7c602c9cbb089dd357c6cc2945fcbb9 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 10:32:14 +0200 Subject: [PATCH 23/35] test: allow interleaved swap metrics events --- test/e2e/tests/bridge/swap-positive-cases.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/e2e/tests/bridge/swap-positive-cases.spec.ts b/test/e2e/tests/bridge/swap-positive-cases.spec.ts index 46cfa2abf615..f3cb078da1f7 100644 --- a/test/e2e/tests/bridge/swap-positive-cases.spec.ts +++ b/test/e2e/tests/bridge/swap-positive-cases.spec.ts @@ -30,10 +30,19 @@ const getRequestedToCompletedEvents = ( -1, `${firstExpectedEvent} event not found`, ); - return events.slice( - quotesRequestedEventIndex, - quotesRequestedEventIndex + expectedEvents.length, + const expectedEventSet = new Set(expectedEvents); + const requestedToCompletedEvents = events + .slice(quotesRequestedEventIndex) + .filter(({ event }) => event !== undefined && expectedEventSet.has(event)) + .slice(0, expectedEvents.length); + assert.equal( + requestedToCompletedEvents.length, + expectedEvents.length, + `Expected ${expectedEvents.join(', ')} events, but got ${requestedToCompletedEvents + .map(({ event }) => event) + .join(', ')}`, ); + return requestedToCompletedEvents; }; describe('Swap tests', function (this: Suite) { From 94df549d7d7a753286082d4cdf2e898aebff97e0 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 16:28:32 +0200 Subject: [PATCH 24/35] fix: align metrics participation compatibility --- .../controllers/metametrics-controller.ts | 6 +--- .../seedless-onboarding/oauth-service-init.ts | 5 ++- app/scripts/metamask-controller.js | 2 +- ui/components/app/toast-master/selectors.ts | 11 ++++-- .../onboarding-flow-switch.test.tsx | 35 ++++++++++++++++--- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index 960060cef977..0852801b154e 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -159,11 +159,7 @@ export type MetaMaskState = Pick< | 'tokenSortConfig' >; } & { - // TODO: Remove `participateInMetaMetrics` / `metaMetricsId` here once the codebase and - // `FlattenedBackgroundStateProxy` use `completedMetaMetricsOnboarding`, `optedIn`, and - // `analyticsId` as the source of truth (and `MetamaskController.getState()` stops injecting the legacy - // fields). Update `_buildUserTraitsObject` and any other `MetaMaskState` consumers accordingly. - /** Populated by `MetamaskController.getState()` from analytics + metrics prompt completion. */ + /** Legacy fields derived by `MetamaskController.getState()`. */ participateInMetaMetrics: boolean | null; metaMetricsId: string | null; }; diff --git a/app/scripts/messenger-client-init/seedless-onboarding/oauth-service-init.ts b/app/scripts/messenger-client-init/seedless-onboarding/oauth-service-init.ts index 77bccbabd970..6371ddcc308b 100644 --- a/app/scripts/messenger-client-init/seedless-onboarding/oauth-service-init.ts +++ b/app/scripts/messenger-client-init/seedless-onboarding/oauth-service-init.ts @@ -48,7 +48,10 @@ export const OAuthServiceInit: MessengerClientInitFunction< metaMetricsController, ), - getParticipateInMetaMetrics: () => analyticsController.state.optedIn, + getParticipateInMetaMetrics: () => + metaMetricsController.state.completedMetaMetricsOnboarding + ? analyticsController.state.optedIn + : null, }); return { diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index f96c97581862..01bee89c59b3 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -7075,8 +7075,8 @@ export default class MetamaskController extends EventEmitter { setUpCookieHandlerCommunication({ connectionStream }) { const { metaMetricsId, - participateInMetaMetrics, dataCollectionForMarketing, + participateInMetaMetrics, } = this.getState(); if ( diff --git a/ui/components/app/toast-master/selectors.ts b/ui/components/app/toast-master/selectors.ts index 0751170f5b97..c908582688dd 100644 --- a/ui/components/app/toast-master/selectors.ts +++ b/ui/components/app/toast-master/selectors.ts @@ -15,6 +15,7 @@ type State = { | 'onboardingDate' | 'shieldEndingToastLastClickedOrClosed' | 'shieldPausedToastLastClickedOrClosed' + | 'completedMetaMetricsOnboarding' | 'optedIn' | 'remoteFeatureFlags' | 'pna25Acknowledged' @@ -152,15 +153,19 @@ export function selectShowSidePanelMigrationToast( * @returns Boolean indicating whether to show the banner */ export function selectShowPna25Modal(state: Pick): boolean { - const { completedOnboarding, optedIn, pna25Acknowledged } = - state.metamask || {}; + const { + completedOnboarding, + completedMetaMetricsOnboarding, + optedIn, + pna25Acknowledged, + } = state.metamask || {}; // Only show to users who have completed onboarding if (!completedOnboarding) { return false; // User hasn't completed onboarding yet } - if (optedIn !== true) { + if (completedMetaMetricsOnboarding !== true || optedIn !== true) { return false; // User hasn't opted into analytics } diff --git a/ui/pages/onboarding-flow/onboarding-flow-switch/onboarding-flow-switch.test.tsx b/ui/pages/onboarding-flow/onboarding-flow-switch/onboarding-flow-switch.test.tsx index 5c4f1785b124..7c4d83f5f03f 100644 --- a/ui/pages/onboarding-flow/onboarding-flow-switch/onboarding-flow-switch.test.tsx +++ b/ui/pages/onboarding-flow/onboarding-flow-switch/onboarding-flow-switch.test.tsx @@ -5,6 +5,7 @@ import { useLocation } from 'react-router-dom'; import { DEFAULT_ROUTE, ONBOARDING_COMPLETION_ROUTE, + ONBOARDING_METAMETRICS, ONBOARDING_UNLOCK_ROUTE, LOCK_ROUTE, ONBOARDING_WELCOME_ROUTE, @@ -34,7 +35,7 @@ LocationCapture.propTypes = { describe('Onboaring Flow Switch Component', () => { let currentPath: string; - it('should route to default route when completed onboarding', () => { + it('routes to default route when completed onboarding', () => { const mockState = { metamask: { completedOnboarding: true, @@ -57,7 +58,7 @@ describe('Onboaring Flow Switch Component', () => { expect(currentPath).toStrictEqual(DEFAULT_ROUTE); }); - it('should route to completed onboarding route when seed phrase is other than null', () => { + it('routes to completed onboarding route when seed phrase is other than null', () => { const mockState = { metamask: { seedPhraseBackedUp: false, @@ -81,7 +82,31 @@ describe('Onboaring Flow Switch Component', () => { expect(currentPath).toStrictEqual(ONBOARDING_COMPLETION_ROUTE); }); - it('should route to lock when seedPhrase is not backed up and unlocked', () => { + it('routes to metametrics route when seed phrase is other than null and metrics onboarding is incomplete', () => { + const mockState = { + metamask: { + seedPhraseBackedUp: false, + completedMetaMetricsOnboarding: false, + }, + }; + + const mockStore = configureMockStore()(mockState); + renderWithProvider( + <> + + { + currentPath = path; + }} + /> + , + mockStore, + ); + + expect(currentPath).toStrictEqual(ONBOARDING_METAMETRICS); + }); + + it('routes to lock when seedPhrase is not backed up and unlocked', () => { const mockState = { metamask: { seedPhraseBackedUp: null, @@ -105,7 +130,7 @@ describe('Onboaring Flow Switch Component', () => { expect(currentPath).toStrictEqual(LOCK_ROUTE); }); - it('should route to unlock when with appropriate state', () => { + it('routes to unlock when with appropriate state', () => { const mockState = { metamask: { seedPhraseBackedUp: null, @@ -130,7 +155,7 @@ describe('Onboaring Flow Switch Component', () => { expect(currentPath).toStrictEqual(ONBOARDING_UNLOCK_ROUTE); }); - it('should route to welcome route when not initialized', () => { + it('routes to welcome route when not initialized', () => { const mockState = { metamask: { seedPhraseBackedUp: null, From dfb4b76e83b2713fec4858ba642800a3bd43d0bf Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 17:05:13 +0200 Subject: [PATCH 25/35] fix: migrate queued segment calls --- app/scripts/migrations/212.test.ts | 128 +++++++++++++++++++++++++++++ app/scripts/migrations/212.ts | 115 +++++++++++++++++++++++++- 2 files changed, 240 insertions(+), 3 deletions(-) diff --git a/app/scripts/migrations/212.test.ts b/app/scripts/migrations/212.test.ts index 3a37d2bb3c9b..1c55fbaad03b 100644 --- a/app/scripts/migrations/212.test.ts +++ b/app/scripts/migrations/212.test.ts @@ -5,6 +5,14 @@ const VERSION = version; const OLD_VERSION = VERSION - 1; describe(`migration #${VERSION}`, () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-05-28T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('moves MetaMetrics participation and id to AnalyticsController and MMC prompt flag', async () => { const metaMetricsId = '0xabc123'; const oldStorage: { @@ -27,8 +35,19 @@ describe(`migration #${VERSION}`, () => { queuedEvent: { eventType: 'track', payload: { + messageId: 'queuedEvent', event: 'Queued Event', timestamp: '2026-01-01T00:00:00.000Z', + properties: { + category: 'Migration Test', + }, + context: { + page: { + path: '/home', + title: 'Home', + url: 'https://metamask.io/home', + }, + }, }, }, }, @@ -53,14 +72,123 @@ describe(`migration #${VERSION}`, () => { const ac = versionedData.data.AnalyticsController as { analyticsId: string; + eventQueue: Record; optedIn: boolean; }; expect(ac.optedIn).toBe(true); expect(ac.analyticsId).toBe(metaMetricsId); + expect(ac.eventQueue).toStrictEqual({ + queuedEvent: { + type: 'track', + eventName: 'Queued Event', + messageId: 'queuedEvent', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { + category: 'Migration Test', + }, + context: { + page: { + path: '/home', + title: 'Home', + url: 'https://metamask.io/home', + }, + }, + }, + }); expect(changedControllers.has('MetaMetricsController')).toBe(true); expect(changedControllers.has('AnalyticsController')).toBe(true); }); + it('migrates queued page, identify, and anonymous track calls to AnalyticsController eventQueue', async () => { + const oldStorage: { + meta: { version: number }; + data: Record; + } = { + meta: { version: OLD_VERSION }, + data: { + MetaMetricsController: { + participateInMetaMetrics: true, + metaMetricsId: '0xabc123', + segmentApiCalls: { + pageEvent: { + eventType: 'page', + payload: { + name: 'Home', + messageId: 'pageEvent', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { + environment_type: 'background', + }, + }, + }, + identifyEvent: { + eventType: 'identify', + payload: { + userId: '0xabc123', + messageId: 'identifyEvent', + timestamp: '2026-01-02T00:00:00.000Z', + traits: { + account_type: 'Default', + }, + }, + }, + anonymousTrackEvent: { + eventType: 'track', + payload: { + anonymousId: '00000000-0000-0000-0000-000000000000', + event: 'Signature Requested Anon', + messageId: 'anonymousTrackEvent', + timestamp: '2026-01-03T00:00:00.000Z', + properties: { + category: 'Signature', + }, + }, + }, + }, + }, + }, + }; + + const versionedData = cloneDeep(oldStorage); + const changedControllers = new Set(); + + await migrate(versionedData, changedControllers); + + const ac = versionedData.data.AnalyticsController as { + eventQueue: Record; + }; + expect(ac.eventQueue).toStrictEqual({ + pageEvent: { + type: 'view', + name: 'Home', + messageId: 'pageEvent', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { + environment_type: 'background', + }, + }, + identifyEvent: { + type: 'identify', + userId: '0xabc123', + messageId: 'identifyEvent', + timestamp: '2026-01-02T00:00:00.000Z', + traits: { + account_type: 'Default', + }, + }, + anonymousTrackEvent: { + type: 'track', + eventName: 'Signature Requested Anon', + messageId: 'anonymousTrackEvent', + timestamp: '2026-01-03T00:00:00.000Z', + properties: { + anonymous: true, + category: 'Signature', + }, + }, + }); + }); + it('sets completedMetaMetricsOnboarding false when participateInMetaMetrics was null', async () => { const oldStorage: { meta: { version: number }; diff --git a/app/scripts/migrations/212.ts b/app/scripts/migrations/212.ts index 962ea41cbb61..44f2fa43cebf 100644 --- a/app/scripts/migrations/212.ts +++ b/app/scripts/migrations/212.ts @@ -2,13 +2,13 @@ import { hasProperty, isObject } from '@metamask/utils'; import type { Migrate } from './types'; export const version = 212; +const ANONYMOUS_EVENT_PROPERTY = 'anonymous'; /** * Introduces `AnalyticsController` state (`analyticsId`, `optedIn`) and moves * participation off `MetaMetricsController.participateInMetaMetrics` onto * `completedMetaMetricsOnboarding` plus analytics `optedIn`. Legacy - * `segmentApiCalls` entries are discarded because event queue persistence now - * belongs to `AnalyticsController`. + * `segmentApiCalls` entries are moved to `AnalyticsController.eventQueue`. * * @param versionedData - The versioned data object to migrate. * @param changedControllers - A set used to record controllers that were modified. @@ -48,10 +48,17 @@ export const migrate = (async (versionedData, changedControllers) => { !hasProperty(data, 'AnalyticsController') || !isObject(data.AnalyticsController) ) { - data.AnalyticsController = { + const eventQueue = buildAnalyticsEventQueue( + metaMetricsController?.segmentApiCalls, + ); + const analyticsController: Record = { analyticsId, optedIn, }; + if (Object.keys(eventQueue).length > 0) { + analyticsController.eventQueue = eventQueue; + } + data.AnalyticsController = analyticsController; changedControllers.add('AnalyticsController'); } @@ -70,3 +77,105 @@ export const migrate = (async (versionedData, changedControllers) => { changedControllers.add('MetaMetricsController'); } }) satisfies Migrate; + +function buildAnalyticsEventQueue( + segmentApiCalls: unknown, +): Record { + if (!isObject(segmentApiCalls)) { + return {}; + } + + const eventQueue: Record = {}; + for (const [messageId, segmentApiCall] of Object.entries(segmentApiCalls)) { + const queuedEvent = buildAnalyticsQueuedEvent(messageId, segmentApiCall); + if (queuedEvent) { + eventQueue[queuedEvent.messageId] = queuedEvent; + } + } + + return eventQueue; +} + +function buildAnalyticsQueuedEvent( + fallbackMessageId: string, + segmentApiCall: unknown, +): Record | null { + if (!isObject(segmentApiCall)) { + return null; + } + + const { eventType, payload } = segmentApiCall as Record; + if (!isObject(payload)) { + return null; + } + + const messageId = + typeof payload.messageId === 'string' + ? payload.messageId + : fallbackMessageId; + const timestamp = + typeof payload.timestamp === 'string' + ? payload.timestamp + : new Date().toISOString(); + + if (eventType === 'track' && typeof payload.event === 'string') { + return { + type: 'track', + eventName: payload.event, + messageId, + timestamp, + ...getQueuedProperties(payload), + ...getQueuedContext(payload), + }; + } + + if (eventType === 'identify' && typeof payload.userId === 'string') { + return { + type: 'identify', + userId: payload.userId, + messageId, + timestamp, + ...(isObject(payload.traits) ? { traits: payload.traits } : {}), + ...getQueuedContext(payload), + }; + } + + if (eventType === 'page' && typeof payload.name === 'string') { + return { + type: 'view', + name: payload.name, + messageId, + timestamp, + ...getQueuedProperties(payload), + ...getQueuedContext(payload), + }; + } + + return null; +} + +function getQueuedProperties(payload: Record): { + properties?: Record; +} { + const isAnonymous = typeof payload.anonymousId === 'string'; + if (!isObject(payload.properties) && !isAnonymous) { + return {}; + } + + const properties = isObject(payload.properties) + ? { ...(payload.properties as Record) } + : {}; + if (isAnonymous) { + properties[ANONYMOUS_EVENT_PROPERTY] = true; + } + + return { properties }; +} + +function getQueuedContext(payload: Record): { + context?: Record; +} { + return isObject(payload.context) + ? { context: payload.context as Record } + : {}; +} From 0cb4ee172214b9bee7a3b55de77f957a7926582c Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 17:22:32 +0200 Subject: [PATCH 26/35] fix: preserve analytics queue on metrics reset --- .../metametrics-controller.test.ts | 34 +++++++++++++++++++ .../controllers/metametrics-controller.ts | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index 4875dadfcbf8..1f23f10e2f10 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -562,6 +562,39 @@ describe('MetaMetricsController', function () { }, ); }); + it('does not opt out of AnalyticsController when participation is reset to null', async function () { + const eventQueue = { + queuedEvent: { + type: 'track', + eventName: 'Queued Event', + messageId: 'queuedEvent', + timestamp: '2026-05-28T00:00:00.000Z', + }, + }; + + await withController( + { + options: { + state: { completedMetaMetricsOnboarding: true }, + }, + analyticsControllerState: { + optedIn: true, + eventQueue, + }, + }, + async ({ controller, controllerMessenger }) => { + await controller.setParticipateInMetaMetrics(null); + + expect(controller.state.completedMetaMetricsOnboarding).toBe(false); + expect( + controllerMessenger.call('AnalyticsController:getState').optedIn, + ).toBe(true); + expect( + controllerMessenger.call('AnalyticsController:getState').eventQueue, + ).toStrictEqual(eventQueue); + }, + ); + }); it('should not nullify the metaMetricsId when set to false', async function () { await withController(async ({ controller }) => { await controller.setParticipateInMetaMetrics(false); @@ -2912,6 +2945,7 @@ async function withController( messenger.registerActionHandler('AnalyticsController:optOut', () => { mockAnalyticsControllerState.optedIn = false; + mockAnalyticsControllerState.eventQueue = {}; }); // Emulate the analytics platform adapter: every Segment payload is built diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index 0852801b154e..235d649a8717 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -796,7 +796,7 @@ export class MetaMetricsController extends BaseController< if (participateInMetaMetrics === true) { this.messenger.call('AnalyticsController:optIn'); - } else { + } else if (participateInMetaMetrics === false) { this.messenger.call('AnalyticsController:optOut'); } From 37d047e69f43895222adf592c262e8bdf7744f6c Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 17:26:22 +0200 Subject: [PATCH 27/35] fix: preserve non-anonymous metrics timestamp --- .../metametrics-controller.test.ts | 69 ++++++++++++------- .../controllers/metametrics-controller.ts | 9 ++- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index 1f23f10e2f10..9702fd0c6095 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -1405,35 +1405,52 @@ describe('MetaMetricsController', function () { }); describe('Sensitive transaction and signature events', function () { - it('keeps the original event name and marks anonymous-only tracks', async function () { - await withController(({ controller }) => { - const spy = jest.spyOn(segmentMock, 'track'); - const currentTimestamp = new Date('2024-02-01T00:00:00.000Z').getTime(); - jest.setSystemTime(currentTimestamp); - controller.trackEvent( - { - event: 'Signature Requested', - category: 'Unit Test', - properties: DEFAULT_EVENT_PROPERTIES, + it('keeps the original event name, marks anonymous-only tracks, and preserves the latest non-anonymous timestamp', async function () { + const previousTimestamp = new Date('2024-01-01T00:00:00.000Z').getTime(); + + await withController( + { + options: { + state: { + fragments: {}, + latestNonAnonymousEventTimestamp: previousTimestamp, + }, }, - { excludeMetaMetricsId: true }, - ); + }, + ({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + expect(controller.state.latestNonAnonymousEventTimestamp).toBe( + previousTimestamp, + ); + const currentTimestamp = new Date( + '2024-02-01T00:00:00.000Z', + ).getTime(); + jest.setSystemTime(currentTimestamp); + controller.trackEvent( + { + event: 'Signature Requested', + category: 'Unit Test', + properties: DEFAULT_EVENT_PROPERTIES, + }, + { excludeMetaMetricsId: true }, + ); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - event: 'Signature Requested', - properties: expect.objectContaining({ - ...DEFAULT_EVENT_PROPERTIES, - [ANONYMOUS_EVENT_PROPERTY]: true, + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'Signature Requested', + properties: expect.objectContaining({ + ...DEFAULT_EVENT_PROPERTIES, + [ANONYMOUS_EVENT_PROPERTY]: true, + }), }), - }), - undefined, - ); - expect(controller.state.latestNonAnonymousEventTimestamp).toBe( - currentTimestamp, - ); - }); + undefined, + ); + expect(controller.state.latestNonAnonymousEventTimestamp).toBe( + previousTimestamp, + ); + }, + ); }); // @ts-expect-error This function is missing from the Mocha type definitions diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index 235d649a8717..1ae63dd04286 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -882,7 +882,10 @@ export class MetaMetricsController extends BaseController< this.#applyAnonymousEventOptions(eventPayload, options); this.#applyLegacyEventOptions(eventPayload, options); - this.#updateLatestAnalyticsEventTimestamp(); + if (!this.#isAnonymousTrackEvent(eventPayload)) { + this.#updateLatestAnalyticsEventTimestamp(); + } + const sensitiveProperties = eventPayload.sensitiveProperties ?? {}; this.messenger.call( @@ -903,6 +906,10 @@ export class MetaMetricsController extends BaseController< } } + #isAnonymousTrackEvent(eventPayload: SegmentTrackPayload): boolean { + return eventPayload.properties[ANONYMOUS_EVENT_PROPERTY] === true; + } + /** * Identifies the user with valid user traits if they are participating in * the MetaMetrics analytics program. From 8126aeb205af76f5d58d2986eee8add9d45df2af Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 18:11:36 +0200 Subject: [PATCH 28/35] test: fix migration analytics payload lint --- app/scripts/migrations/212.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/scripts/migrations/212.test.ts b/app/scripts/migrations/212.test.ts index 1c55fbaad03b..132b56e02a3a 100644 --- a/app/scripts/migrations/212.test.ts +++ b/app/scripts/migrations/212.test.ts @@ -117,6 +117,7 @@ describe(`migration #${VERSION}`, () => { messageId: 'pageEvent', timestamp: '2026-01-01T00:00:00.000Z', properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention environment_type: 'background', }, }, @@ -128,6 +129,7 @@ describe(`migration #${VERSION}`, () => { messageId: 'identifyEvent', timestamp: '2026-01-02T00:00:00.000Z', traits: { + // eslint-disable-next-line @typescript-eslint/naming-convention account_type: 'Default', }, }, @@ -164,6 +166,7 @@ describe(`migration #${VERSION}`, () => { messageId: 'pageEvent', timestamp: '2026-01-01T00:00:00.000Z', properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention environment_type: 'background', }, }, @@ -173,6 +176,7 @@ describe(`migration #${VERSION}`, () => { messageId: 'identifyEvent', timestamp: '2026-01-02T00:00:00.000Z', traits: { + // eslint-disable-next-line @typescript-eslint/naming-convention account_type: 'Default', }, }, From 975cde07810b430b29567af2e6cc60c80fd7b60e Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 18:15:14 +0200 Subject: [PATCH 29/35] fix: preserve falsy page metrics properties --- .../metametrics-controller.test.ts | 27 +++++++++++++++++++ .../controllers/metametrics-controller.ts | 23 +++++++++------- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index 9702fd0c6095..da95c0b45e21 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -1555,6 +1555,33 @@ describe('MetaMetricsController', function () { }); }); + it('preserves falsy page view properties except undefined', async function () { + await withController( + { + currentLocale: '', + }, + ({ controller }) => { + const spy = jest.spyOn(segmentMock, 'page'); + controller.trackPage({ + name: 'home', + page: METAMETRICS_BACKGROUND_PAGE_OBJECT, + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: DEFAULT_CHAIN_ID, + locale: '', + }, + }), + spy.mock.calls[0][1], + ); + }, + ); + }); + it('should not track a page view if user is not participating in metametrics', async function () { await withController( { diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index 1ae63dd04286..97cb3337dc6b 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -1107,16 +1107,19 @@ export class MetaMetricsController extends BaseController< return { name: name ?? '', - properties: pickBy({ - params, - locale: this.locale, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: this.chainId, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - environment_type: environmentType, - }) as AnalyticsEventProperties, + properties: omitBy( + { + params, + locale: this.locale, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: this.chainId, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + environment_type: environmentType, + }, + (propertyValue) => propertyValue === undefined, + ) as AnalyticsEventProperties, context: this.#buildContext(referrer, page) as AnalyticsContext, }; } From 8cd58f039651cfc6444ffe96634653c9fed3f220 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 28 May 2026 23:06:14 +0200 Subject: [PATCH 30/35] fix: type analytics event queue migration --- app/scripts/migrations/212.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/scripts/migrations/212.ts b/app/scripts/migrations/212.ts index 44f2fa43cebf..45671a9fc47b 100644 --- a/app/scripts/migrations/212.ts +++ b/app/scripts/migrations/212.ts @@ -89,7 +89,7 @@ function buildAnalyticsEventQueue( for (const [messageId, segmentApiCall] of Object.entries(segmentApiCalls)) { const queuedEvent = buildAnalyticsQueuedEvent(messageId, segmentApiCall); if (queuedEvent) { - eventQueue[queuedEvent.messageId] = queuedEvent; + eventQueue[messageId] = queuedEvent; } } From 23796a9b4906d8e55a89099ebe24929b9782f403 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Fri, 29 May 2026 16:49:15 +0200 Subject: [PATCH 31/35] fix: preserve analytics state in vault backup --- shared/lib/stores/persistence-manager.test.ts | 50 +++++++++++++++++++ shared/lib/stores/persistence-manager.ts | 19 +++---- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/shared/lib/stores/persistence-manager.test.ts b/shared/lib/stores/persistence-manager.test.ts index 3c65ad36dda9..a1f5deb1007a 100644 --- a/shared/lib/stores/persistence-manager.test.ts +++ b/shared/lib/stores/persistence-manager.test.ts @@ -293,6 +293,56 @@ describe('PersistenceManager', () => { ); }); }); + + describe('getBackup', () => { + it('returns all backed up controller state', async () => { + await manager.open(); + await manager.reset(); + manager.storageKind = 'data'; + manager.setMetadata({ version: 10 }); + + mockStoreSet.mockResolvedValueOnce(undefined); + + const [result, error] = await manager.set({ + KeyringController: { + vault: 'encrypted-vault', + }, + AppMetadataController: { + currentAppVersion: '13.34.0', + }, + MetaMetricsController: { + completedMetaMetricsOnboarding: true, + }, + AnalyticsController: { + analyticsId: '0xabc123', + optedIn: true, + }, + } as unknown as MetaMaskStateType); + + expect(result).toBe(true); + expect(error).toBeUndefined(); + /* eslint-disable-next-line jest/prefer-strict-equal -- IndexedDB structuredClone can change object prototypes */ + expect(await manager.getBackup()).toEqual({ + KeyringController: { + vault: 'encrypted-vault', + }, + AppMetadataController: { + currentAppVersion: '13.34.0', + }, + MetaMetricsController: { + completedMetaMetricsOnboarding: true, + }, + AnalyticsController: { + analyticsId: '0xabc123', + optedIn: true, + }, + meta: { + version: 10, + }, + }); + }); + }); + describe('persist', () => { it('throws if storageKind is not split', async () => { manager.storageKind = 'data'; diff --git a/shared/lib/stores/persistence-manager.ts b/shared/lib/stores/persistence-manager.ts index 15f5f4dbdfa8..a7e879e3b7d1 100644 --- a/shared/lib/stores/persistence-manager.ts +++ b/shared/lib/stores/persistence-manager.ts @@ -848,18 +848,13 @@ export class PersistenceManager extends EventEmitter if (!backupDb) { return undefined; } - const [ - KeyringController, - AppMetadataController, - MetaMetricsController, - meta, - ] = await backupDb.get([...backedUpStateKeys, `meta`]); - return { - KeyringController, - AppMetadataController, - MetaMetricsController, - meta: meta as MetaData | undefined, - }; + const values = await backupDb.get([...backedUpStateKeys, `meta`]); + const backup: Backup = {}; + backedUpStateKeys.forEach((key, index) => { + backup[key] = values[index]; + }); + backup.meta = values[backedUpStateKeys.length] as MetaData | undefined; + return backup; } /** From 656834fafa205348b8c929d356c859b5d78515ff Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 3 Jun 2026 11:20:47 +0200 Subject: [PATCH 32/35] test: align analytics sentry state snapshots after main merge The analytics-controller rework stopped whitelisting metaMetricsId/ participateInMetaMetrics in the Sentry state mask (they are masked now), and AnalyticsController.eventQueue is absent in the pre-init/migration- error state since it is only populated by migration 212 when there is data to migrate. Update the affected snapshots accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../state-snapshots/errors-after-init-opt-in-ui-state.json | 4 ++-- .../errors-before-init-opt-in-background-state.json | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index c30d51c739cc..2682ae91a878 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -254,7 +254,7 @@ "marketingCampaignCookieId": null, "metaMetricsDataDeletionId": null, "metaMetricsDataDeletionTimestamp": 0, - "metaMetricsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", + "metaMetricsId": "string", "metamaskNotificationsList": "object", "metamaskNotificationsReadList": "object", "methodData": "object", @@ -285,7 +285,7 @@ "orderedTransactionHistory": "object", "outdatedBrowserWarningLastShown": "object", "overrideContentSecurityPolicyHeader": true, - "participateInMetaMetrics": true, + "participateInMetaMetrics": "boolean", "passkeyAutoUnlockSuppressed": "boolean", "passkeyRecord": null, "pendingApprovalCount": "number", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index eed5b6deea65..6dbb1afaee31 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -27,7 +27,6 @@ }, "AnalyticsController": { "analyticsId": "0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420", - "eventQueue": "object", "optedIn": true }, "AnnouncementController": { "announcements": "object" }, From ace2b3a6d2836c92d7e22c77a59647a60515dc21 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 3 Jun 2026 13:43:42 +0200 Subject: [PATCH 33/35] test: add analytics controller keys to state-logs structure map The AnalyticsController surfaces analyticsId and optedIn (both flagged includeInStateLogs), and completedMetaMetricsOnboarding now appears in the flattened metamask state logs. Add them to the expected type map. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/e2e/tests/settings/state-logs.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/e2e/tests/settings/state-logs.json b/test/e2e/tests/settings/state-logs.json index 4e0ae2cb7f15..2e6d9eda02f9 100644 --- a/test/e2e/tests/settings/state-logs.json +++ b/test/e2e/tests/settings/state-logs.json @@ -634,6 +634,7 @@ "supportedNetworks": ["string"], "validSubmissionWindowDays": "number" }, + "completedMetaMetricsOnboarding": "boolean", "completedOnboarding": "boolean", "connectedStatusPopoverHasBeenShown": "boolean", "connectivityStatus": "string", @@ -1087,6 +1088,8 @@ "metaMetricsDataDeletionId": "null", "metaMetricsDataDeletionTimestamp": "number", "metaMetricsId": "string", + "analyticsId": "string", + "optedIn": "boolean", "methodData": {}, "minimumBalanceForRentExemptionInLamports": "string", "multichainNetworkConfigurationsByChainId": { From 482558b7cb50515090ac4b7ceaf5d97f043a49f1 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 3 Jun 2026 14:41:50 +0200 Subject: [PATCH 34/35] fix: block analytics submission when metrics participation is null setParticipateInMetaMetrics(null) clears completedMetaMetricsOnboarding but intentionally leaves AnalyticsController opted in (to preserve the queued events). #canSubmitAnalytics only checked optedIn, so events could still be sent after a participation reset (e.g. onboarding welcome login). Gate submission on completedMetaMetricsOnboarding as well, rather than opting out on null (which would clear the queue). Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../metametrics-controller.test.ts | 28 +++++++++++++++++++ .../controllers/metametrics-controller.ts | 16 ++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index da95c0b45e21..c412cefea507 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -800,6 +800,34 @@ describe('MetaMetricsController', function () { ); }); + it('does not track normal events after participation is reset to null even if AnalyticsController is still opted in', async function () { + await withController( + { + options: { + state: { completedMetaMetricsOnboarding: true }, + }, + analyticsControllerState: { optedIn: true }, + }, + async ({ controller }) => { + // Resetting to null clears the onboarding decision but intentionally + // leaves AnalyticsController opted in (queue is preserved). + await controller.setParticipateInMetaMetrics(null); + + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + properties: { + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: '1', + }, + }); + expect(spy).not.toHaveBeenCalled(); + }, + ); + }); + it('should track a legacy event', async function () { await withController(({ controller }) => { const spy = jest.spyOn(segmentMock, 'track'); diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index 97cb3337dc6b..6267ddd95793 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -1271,7 +1271,21 @@ export class MetaMetricsController extends BaseController< } const { analyticsId, optedIn } = this.#analyticsGetState(); - return optedIn && analyticsId.length > 0; + // Require a completed metrics decision: when participation is reset to + // `null` (e.g. onboarding welcome login), `completedMetaMetricsOnboarding` + // is cleared but AnalyticsController may still be opted in, so `optedIn` + // alone is not sufficient to allow submission. + // + // We intentionally gate on `completedMetaMetricsOnboarding` because `optedIn` + // in not reset to `false` when setParticipateInMetaMetrics(null) is called. + // We don't reset `optedIn` to `false` in that case, because resetting calling + // AnalyticsController:optOut() would clear the queued events which we want to avoid + // when resetting participation to null. + return ( + this.state.completedMetaMetricsOnboarding && + optedIn && + analyticsId.length > 0 + ); } #updateLatestAnalyticsEventTimestamp(): void { From 6b83220436a063e0cb30ec985f2cdd5e9ea8ab52 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 3 Jun 2026 15:03:55 +0200 Subject: [PATCH 35/35] revert: restore opt-out on metrics participation reset Reverts commits 0cb4ee1722 ("fix: preserve analytics queue on metrics reset") and 482558b7cb ("fix: block analytics submission when metrics participation is null"). setParticipateInMetaMetrics(null) once again calls AnalyticsController:optOut (else branch), so optedIn is cleared on reset and #canSubmitAnalytics no longer needs the completedMetaMetricsOnboarding gate. This blocks tracking after a null reset at the cost of clearing the queued events. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../metametrics-controller.test.ts | 62 ------------------- .../controllers/metametrics-controller.ts | 18 +----- 2 files changed, 2 insertions(+), 78 deletions(-) diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index c412cefea507..8ea9426d078d 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -562,39 +562,6 @@ describe('MetaMetricsController', function () { }, ); }); - it('does not opt out of AnalyticsController when participation is reset to null', async function () { - const eventQueue = { - queuedEvent: { - type: 'track', - eventName: 'Queued Event', - messageId: 'queuedEvent', - timestamp: '2026-05-28T00:00:00.000Z', - }, - }; - - await withController( - { - options: { - state: { completedMetaMetricsOnboarding: true }, - }, - analyticsControllerState: { - optedIn: true, - eventQueue, - }, - }, - async ({ controller, controllerMessenger }) => { - await controller.setParticipateInMetaMetrics(null); - - expect(controller.state.completedMetaMetricsOnboarding).toBe(false); - expect( - controllerMessenger.call('AnalyticsController:getState').optedIn, - ).toBe(true); - expect( - controllerMessenger.call('AnalyticsController:getState').eventQueue, - ).toStrictEqual(eventQueue); - }, - ); - }); it('should not nullify the metaMetricsId when set to false', async function () { await withController(async ({ controller }) => { await controller.setParticipateInMetaMetrics(false); @@ -800,34 +767,6 @@ describe('MetaMetricsController', function () { ); }); - it('does not track normal events after participation is reset to null even if AnalyticsController is still opted in', async function () { - await withController( - { - options: { - state: { completedMetaMetricsOnboarding: true }, - }, - analyticsControllerState: { optedIn: true }, - }, - async ({ controller }) => { - // Resetting to null clears the onboarding decision but intentionally - // leaves AnalyticsController opted in (queue is preserved). - await controller.setParticipateInMetaMetrics(null); - - const spy = jest.spyOn(segmentMock, 'track'); - controller.trackEvent({ - event: 'Fake Event', - category: 'Unit Test', - properties: { - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: '1', - }, - }); - expect(spy).not.toHaveBeenCalled(); - }, - ); - }); - it('should track a legacy event', async function () { await withController(({ controller }) => { const spy = jest.spyOn(segmentMock, 'track'); @@ -3017,7 +2956,6 @@ async function withController( messenger.registerActionHandler('AnalyticsController:optOut', () => { mockAnalyticsControllerState.optedIn = false; - mockAnalyticsControllerState.eventQueue = {}; }); // Emulate the analytics platform adapter: every Segment payload is built diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index 6267ddd95793..1f2c8b9e70a8 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -796,7 +796,7 @@ export class MetaMetricsController extends BaseController< if (participateInMetaMetrics === true) { this.messenger.call('AnalyticsController:optIn'); - } else if (participateInMetaMetrics === false) { + } else { this.messenger.call('AnalyticsController:optOut'); } @@ -1271,21 +1271,7 @@ export class MetaMetricsController extends BaseController< } const { analyticsId, optedIn } = this.#analyticsGetState(); - // Require a completed metrics decision: when participation is reset to - // `null` (e.g. onboarding welcome login), `completedMetaMetricsOnboarding` - // is cleared but AnalyticsController may still be opted in, so `optedIn` - // alone is not sufficient to allow submission. - // - // We intentionally gate on `completedMetaMetricsOnboarding` because `optedIn` - // in not reset to `false` when setParticipateInMetaMetrics(null) is called. - // We don't reset `optedIn` to `false` in that case, because resetting calling - // AnalyticsController:optOut() would clear the queued events which we want to avoid - // when resetting participation to null. - return ( - this.state.completedMetaMetricsOnboarding && - optedIn && - analyticsId.length > 0 - ); + return optedIn && analyticsId.length > 0; } #updateLatestAnalyticsEventTimestamp(): void {