Skip to content

Commit 700f85a

Browse files
authored
feat: add METRICS_OPT_OUT analytics event for metrics opt-out flows (#25890)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** * add METRICS_OPT_OUT analytics event for metrics opt-out flows. * Jira: https://consensyssoftware.atlassian.net/browse/TO-524 * Introduce `METRICS_OPT_OUT` analytics event. * Add it when: - The user unchecks **Basic usage** on the OptinMetrics onboarding screen. - The user turns off **Participate in MetaMetrics** in Security & Privacy (Settings) - The user turns off **Participate in MetaMetrics** in Onboarding Default Settings (Manage Default Settings). - Include `location` and `updated_after_onboarding` on every opt-out event. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Metrics Opt Out analytics tracking Scenario: opt-out event fired when unchecking basic usage during onboarding Given user is on the OptinMetrics screen during onboarding And "Basic usage data collection" is checked When user unchecks "Basic usage data collection" Then METRICS_OPT_OUT event is fired with location="onboarding_metametrics" and updated_after_onboarding=false Scenario: opt-out event fired when turning off Participate in MetaMetrics in Settings Given user is in the app And user navigates to Settings → Security & Privacy And "Participate in MetaMetrics" toggle is ON When user turns off the "Participate in MetaMetrics" toggle And user confirms the opt-out alert Then METRICS_OPT_OUT event is fired with location="settings" and updated_after_onboarding=true ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <img width="686" height="75" alt="Screenshot 2026-02-12 at 12 38 03 PM" src="https://github.com/user-attachments/assets/499db8a0-7380-4e10-b912-10b81382dfdd" /> <img width="684" height="71" alt="Screenshot 2026-02-12 at 12 38 56 PM" src="https://github.com/user-attachments/assets/4ef956e6-e22e-4f02-b27e-220f01fe6144" /> <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Adds non-critical analytics tracking and test coverage; main risk is slightly altered event emission timing/volume when toggling metrics off. > > **Overview** > Adds a new analytics event, `MetaMetricsEvents.METRICS_OPT_OUT`, and emits it whenever users opt out of metrics during onboarding (`OptinMetrics` unchecking Basic usage) or later via the Security & Privacy MetaMetrics toggle. > > Also threads an `analyticsLocation` prop through `MetaMetricsAndDataCollectionSection` (used by onboarding default settings) so opt-out/opt-in events carry the correct `location`, and updates tests to assert the new event payloads and marketing auto-uncheck behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 21f9ec5. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a59f1a1 commit 700f85a

6 files changed

Lines changed: 209 additions & 9 deletions

File tree

app/components/UI/OptinMetrics/index.test.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { strings } from '../../../../locales/i18n';
55
import { MetaMetricsOptInSelectorsIDs } from './MetaMetricsOptIn.testIds';
66
import { Platform } from 'react-native';
77
import Device from '../../../util/device';
8+
import { MetaMetricsEvents } from '../../../core/Analytics';
89

910
const { InteractionManager } = jest.requireActual('react-native');
1011

@@ -275,6 +276,94 @@ describe('OptinMetrics', () => {
275276
expect(mockAnalytics.optOut).toHaveBeenCalled();
276277
});
277278
});
279+
280+
it('tracks METRICS_OPT_OUT when user navigates out with basic usage unchecked', async () => {
281+
renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} });
282+
283+
const basicUsageCheckbox = screen.getByText(
284+
strings('privacy_policy.gather_basic_usage_title'),
285+
);
286+
287+
fireEvent.press(basicUsageCheckbox);
288+
289+
fireEvent.press(screen.getByText(strings('privacy_policy.continue')));
290+
291+
await waitFor(() => {
292+
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
293+
expect.objectContaining({
294+
name: MetaMetricsEvents.METRICS_OPT_OUT.category,
295+
properties: expect.objectContaining({
296+
updated_after_onboarding: false,
297+
location: 'onboarding_metametrics',
298+
}),
299+
}),
300+
);
301+
});
302+
});
303+
304+
it('tracks METRICS_OPT_OUT when navigating out via checkbox component uncheck', async () => {
305+
renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} });
306+
307+
const checkboxes = screen.getAllByRole('checkbox');
308+
const basicUsageCheckbox = checkboxes[0];
309+
310+
fireEvent.press(basicUsageCheckbox);
311+
312+
fireEvent.press(screen.getByText(strings('privacy_policy.continue')));
313+
314+
await waitFor(() => {
315+
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
316+
expect.objectContaining({
317+
name: MetaMetricsEvents.METRICS_OPT_OUT.category,
318+
properties: expect.objectContaining({
319+
updated_after_onboarding: false,
320+
location: 'onboarding_metametrics',
321+
}),
322+
}),
323+
);
324+
});
325+
});
326+
327+
it('unchecks marketing when basic usage is unchecked and fires METRICS_OPT_OUT on navigate out', async () => {
328+
renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} });
329+
330+
const marketingCheckbox = screen.getByText(
331+
strings('privacy_policy.checkbox_marketing'),
332+
);
333+
fireEvent.press(marketingCheckbox);
334+
335+
const basicUsageCheckbox = screen.getByText(
336+
strings('privacy_policy.gather_basic_usage_title'),
337+
);
338+
fireEvent.press(basicUsageCheckbox);
339+
340+
fireEvent.press(screen.getByText(strings('privacy_policy.continue')));
341+
342+
await waitFor(() => {
343+
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
344+
expect.objectContaining({
345+
name: MetaMetricsEvents.METRICS_OPT_OUT.category,
346+
properties: expect.objectContaining({
347+
updated_after_onboarding: false,
348+
location: 'onboarding_metametrics',
349+
}),
350+
}),
351+
);
352+
});
353+
354+
fireEvent.press(screen.getByText(strings('privacy_policy.continue')));
355+
356+
await waitFor(() => {
357+
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
358+
expect.objectContaining({
359+
properties: expect.objectContaining({
360+
has_marketing_consent: false,
361+
is_metrics_opted_in: false,
362+
}),
363+
}),
364+
);
365+
});
366+
});
278367
});
279368

280369
describe('Learn more functionality', () => {

app/components/UI/OptinMetrics/index.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,19 @@ const OptinMetrics = () => {
152152

153153
dispatch(setDataCollectionForMarketing(isMarketingChecked));
154154

155-
// Track the analytics preference event first
155+
// Track opt-out event if user opted out of metrics
156+
if (!isBasicUsageChecked) {
157+
metrics.trackEvent(
158+
metrics
159+
.createEventBuilder(MetaMetricsEvents.METRICS_OPT_OUT)
160+
.addProperties({
161+
updated_after_onboarding: false,
162+
location: 'onboarding_metametrics',
163+
})
164+
.build(),
165+
);
166+
}
167+
156168
metrics.trackEvent(
157169
metrics
158170
.createEventBuilder(MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED)
@@ -228,9 +240,8 @@ const OptinMetrics = () => {
228240
);
229241

230242
const handleBasicUsageToggle = useCallback(() => {
231-
setIsBasicUsageChecked((prev) => {
232-
const newValue = !prev;
233-
// If unchecking basic usage, also uncheck marketing
243+
setIsBasicUsageChecked((prevValue) => {
244+
const newValue = !prevValue;
234245
if (!newValue) {
235246
setIsMarketingChecked(false);
236247
}

app/components/Views/OnboardingSuccess/OnboardingSecuritySettings/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ const SecuritySettings = () => {
2929
<NetworkDetailsCheckSettings />
3030
{shouldShowSocialLoginFeatures && (
3131
<>
32-
<MetaMetricsAndDataCollectionSection hideMarketingSection />
32+
<MetaMetricsAndDataCollectionSection
33+
hideMarketingSection
34+
analyticsLocation="onboarding_default_settings"
35+
/>
3336
<DeleteMetaMetricsData metricsOptin={analyticsEnabled} />
3437
</>
3538
)}

app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import React from 'react';
12
import { renderScreen } from '../../../../../../util/test/renderWithProvider';
23

34
import { backgroundState } from '../../../../../../util/test/initial-root-state';
45

56
import MetaMetricsAndDataCollectionSection from './MetaMetricsAndDataCollectionSection';
67
import { SecurityPrivacyViewSelectorsIDs } from '../../SecurityPrivacyView.testIds';
8+
9+
const MetaMetricsScreenWithParams = ({
10+
route,
11+
}: {
12+
route?: { params?: Record<string, unknown> };
13+
}) => <MetaMetricsAndDataCollectionSection {...(route?.params || {})} />;
714
import { fireEvent, waitFor } from '@testing-library/react-native';
815
import { MetaMetricsEvents } from '../../../../../../core/Analytics';
916
import Routes from '../../../../../../constants/navigation/Routes';
@@ -312,7 +319,15 @@ describe('MetaMetricsAndDataCollectionSection', () => {
312319
expect(mockAnalytics.optOut).toHaveBeenCalled();
313320
expect(mockAlert).toHaveBeenCalled();
314321
expect(mockAnalytics.identify).not.toHaveBeenCalled();
315-
expect(mockAnalytics.trackEvent).not.toHaveBeenCalled();
322+
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
323+
expect.objectContaining({
324+
name: MetaMetricsEvents.METRICS_OPT_OUT.category,
325+
properties: expect.objectContaining({
326+
updated_after_onboarding: true,
327+
location: 'settings',
328+
}),
329+
}),
330+
);
316331
});
317332
});
318333

@@ -363,6 +378,64 @@ describe('MetaMetricsAndDataCollectionSection', () => {
363378
});
364379
});
365380

381+
it('tracks ANALYTICS_PREFERENCE_SELECTED with onboarding_default_settings when turned on from onboarding', async () => {
382+
(mockAnalytics.isEnabled as jest.Mock).mockReturnValue(false);
383+
384+
const { findByTestId } = renderScreen(
385+
MetaMetricsScreenWithParams,
386+
{ name: 'MetaMetricsAndDataCollectionSection' },
387+
{ state: initialStateMarketingFalse },
388+
{ analyticsLocation: 'onboarding_default_settings' },
389+
);
390+
391+
const metaMetricsSwitch = await findByTestId(
392+
SecurityPrivacyViewSelectorsIDs.METAMETRICS_SWITCH,
393+
);
394+
395+
fireEvent(metaMetricsSwitch, 'valueChange', true);
396+
397+
await waitFor(() => {
398+
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
399+
expect.objectContaining({
400+
name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category,
401+
properties: expect.objectContaining({
402+
is_metrics_opted_in: true,
403+
updated_after_onboarding: true,
404+
location: 'onboarding_default_settings',
405+
}),
406+
}),
407+
);
408+
});
409+
});
410+
411+
it('tracks METRICS_OPT_OUT with onboarding_default_settings when turned off from onboarding', async () => {
412+
(mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true);
413+
const { findByTestId } = renderScreen(
414+
MetaMetricsScreenWithParams,
415+
{ name: 'MetaMetricsAndDataCollectionSection' },
416+
{ state: initialStateMarketingTrue },
417+
{ analyticsLocation: 'onboarding_default_settings' },
418+
);
419+
420+
const metaMetricsSwitch = await findByTestId(
421+
SecurityPrivacyViewSelectorsIDs.METAMETRICS_SWITCH,
422+
);
423+
424+
fireEvent(metaMetricsSwitch, 'valueChange', false);
425+
426+
await waitFor(() => {
427+
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
428+
expect.objectContaining({
429+
name: MetaMetricsEvents.METRICS_OPT_OUT.category,
430+
properties: expect.objectContaining({
431+
updated_after_onboarding: true,
432+
location: 'onboarding_default_settings',
433+
}),
434+
}),
435+
);
436+
});
437+
});
438+
366439
it('dispatches storePna25Acknowledged when flag is enabled and user enables metrics', async () => {
367440
(mockAnalytics.isEnabled as jest.Mock).mockReturnValue(false);
368441
mockSelectIsPna25FlagEnabled.mockReturnValue(true);
@@ -512,7 +585,15 @@ describe('MetaMetricsAndDataCollectionSection', () => {
512585
expect(mockAnalytics.optOut).toHaveBeenCalled();
513586
expect(mockAlert).toHaveBeenCalled();
514587
expect(mockAnalytics.identify).not.toHaveBeenCalled();
515-
expect(mockAnalytics.trackEvent).not.toHaveBeenCalled();
588+
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
589+
expect.objectContaining({
590+
name: MetaMetricsEvents.METRICS_OPT_OUT.category,
591+
properties: expect.objectContaining({
592+
updated_after_onboarding: true,
593+
location: 'settings',
594+
}),
595+
}),
596+
);
516597
});
517598
});
518599

app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ import { useStyles } from '../../../../../../component-library/hooks/useStyles';
3939

4040
interface MetaMetricsAndDataCollectionSectionProps {
4141
hideMarketingSection?: boolean;
42+
analyticsLocation?: 'settings' | 'onboarding_default_settings';
4243
}
4344

4445
const MetaMetricsAndDataCollectionSection: React.FC<
4546
MetaMetricsAndDataCollectionSectionProps
46-
> = ({ hideMarketingSection = false }) => {
47+
> = ({ hideMarketingSection = false, analyticsLocation = 'settings' }) => {
4748
const { styles, theme } = useStyles(createStyles, {});
4849
const { colors } = theme;
4950
const [analyticsEnabled, setAnalyticsEnabled] = useState(false);
@@ -117,7 +118,7 @@ const MetaMetricsAndDataCollectionSection: React.FC<
117118
.addProperties({
118119
is_metrics_opted_in: true,
119120
updated_after_onboarding: true,
120-
location: 'settings',
121+
location: analyticsLocation,
121122
})
122123
.build(),
123124
);
@@ -129,8 +130,21 @@ const MetaMetricsAndDataCollectionSection: React.FC<
129130
dispatch(storePna25Acknowledged());
130131
}
131132
} else {
133+
// Track opt-out event before calling optOut() to ensure it gets sent
134+
analytics.trackEvent(
135+
AnalyticsEventBuilder.createEventBuilder(
136+
MetaMetricsEvents.METRICS_OPT_OUT,
137+
)
138+
.addProperties({
139+
updated_after_onboarding: true,
140+
location: analyticsLocation,
141+
})
142+
.build(),
143+
);
144+
132145
await analytics.optOut();
133146
setAnalyticsEnabled(false);
147+
134148
if (isDataCollectionForMarketingEnabled) {
135149
dispatch(setDataCollectionForMarketing(false));
136150
}

app/core/Analytics/MetaMetrics.events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ enum EVENT_NAME {
120120

121121
// Analytics
122122
ANALYTICS_PREFERENCE_SELECTED = 'Analytics Preference Selected',
123+
METRICS_OPT_OUT = 'Metrics Opt Out',
123124
ANALYTICS_REQUEST_DATA_DELETION = 'Delete MetaMetrics Data Request Submitted',
124125
EXPERIMENT_VIEWED = 'Experiment Viewed',
125126

@@ -792,6 +793,7 @@ const events = {
792793
ANALYTICS_PREFERENCE_SELECTED: generateOpt(
793794
EVENT_NAME.ANALYTICS_PREFERENCE_SELECTED,
794795
),
796+
METRICS_OPT_OUT: generateOpt(EVENT_NAME.METRICS_OPT_OUT),
795797
ANALYTICS_REQUEST_DATA_DELETION: generateOpt(
796798
EVENT_NAME.ANALYTICS_REQUEST_DATA_DELETION,
797799
),

0 commit comments

Comments
 (0)