Skip to content

Comments

Feat: new analytics data regulation controller#7643

Merged
NicolasMassart merged 40 commits intomainfrom
feat/7618_analytics_privacy_controller
Jan 28, 2026
Merged

Feat: new analytics data regulation controller#7643
NicolasMassart merged 40 commits intomainfrom
feat/7618_analytics_privacy_controller

Conversation

@NicolasMassart
Copy link
Contributor

@NicolasMassart NicolasMassart commented Jan 15, 2026

Explanation

This PR introduces a new @metamask/analytics-data-regulation-controller package that provides GDPR/CCPA data deletion functionality for analytics data. The package allows to extract the logic from the mobile app (and will be compatible with extension too)

Current state: MetaMask mobile app currently has a dedicated mechanism to handle user data deletion requests for analytics data in compliance with GDPR and CCPA regulations.

Solution: This package introduces:

  • AnalyticsDataRegulationController: A controller that manages the lifecycle of data deletion requests, tracks whether new data has been recorded since the last deletion request, and stores deletion regulation metadata (ID and timestamp)
  • AnalyticsDataRegulationService: A service that communicates with Segment's Regulations API via a proxy endpoint to create deletion tasks and check their status
  • State management: Tracks hasCollectedDataSinceDeletionRequest flag, deleteRegulationId, and deleteRegulationTimestamp to support compliance workflows
  • Selectors: Provides reusable selectors for accessing controller state

Implementation details:

  • The controller takes analyticsId as a constructor parameter (provided by the consumer)
  • It delegates to AnalyticsDataRegulationService via messenger actions to make HTTP requests to Segment's Regulations API
  • The service uses createServicePolicy from @metamask/controller-utils for retry logic and error handling
  • State is persisted and can be used to determine if new analytics events have been recorded since the last deletion request
  • The package includes comprehensive test coverage (100% branch, function, line, and statement coverage)

References

see also MetaMask/metamask-mobile#22016

Fixes #7618

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Cursor Bugbot is generating a summary for commit 52f4a2c. Configure here.

NicolasMassart and others added 6 commits January 15, 2026 14:21
…ad of dates

- Changed `deleteRegulationDate` to `deleteRegulationTimestamp` in the state and related methods to store timestamps in milliseconds since epoch.
- Updated relevant methods and tests to reflect the new timestamp format.
- Removed date formatting logic and adjusted selectors accordingly.
- Added new dependencies for testing and updated the test suite to ensure proper functionality with the new timestamp format.

This change enhances consistency in handling date-related data within the analytics privacy controller.
@socket-security
Copy link

socket-security bot commented Jan 15, 2026

No dependency changes detected. Learn more about Socket for GitHub.

👍 No dependency changes detected in pull request

@NicolasMassart NicolasMassart marked this pull request as draft January 15, 2026 18:17
NicolasMassart and others added 3 commits January 16, 2026 11:06
…e PascalCase

- Refactored enum values in `DataDeleteResponseStatus` and `DataDeleteStatus` to follow PascalCase naming convention.
- Updated all references in the codebase and tests to ensure consistency with the new enum values.
- This change enhances code readability and aligns with common TypeScript practices.
@NicolasMassart NicolasMassart marked this pull request as ready for review January 16, 2026 14:32
@NicolasMassart NicolasMassart requested a review from a team as a code owner January 16, 2026 14:32
…nd improve state handling

- Removed references to the `analytics-controller` in `tsconfig` files to streamline project structure.
- Enhanced `checkDataDeleteStatus` method by capturing state values before async calls to ensure consistency during concurrent operations.
- Simplified `IDeleteRegulationResponse` type to only represent the success case, removing unnecessary complexity.
- Cleaned up unused imports in `logger.ts` and removed the `IDeleteRegulationStatusResponse` type from `types.ts` to reduce clutter.

These changes improve code clarity and maintainability while ensuring the functionality remains intact.
NicolasMassart and others added 2 commits January 28, 2026 18:29
…ns for data deletion responses

- Replaced `IDeleteRegulationResponse` and `IDeleteRegulationStatus` with `DeleteRegulationResponse` and `DeleteRegulationStatus` for improved clarity and consistency.
- Updated the `checkDataDeleteStatus` method to utilize the new type definitions, ensuring type safety and better alignment with the API response structure.
- Cleaned up imports in `index.ts` to reflect the new type names.

These changes enhance code readability and maintainability while ensuring accurate type representation.
… status handling

- Updated `deletionRequestTimestamp` in `DeleteRegulationStatus` to remove the nullish coalescing operator, ensuring it directly reflects the provided timestamp.
- Added `AnalyticsDataRegulationServiceMessenger` type export in `index.ts` for better type accessibility.
- Removed the `DeleteRegulationStatusResponse` type from `types.ts` to streamline type definitions and reduce redundancy.

These changes enhance clarity and maintainability of the data regulation controller's type definitions and state management.
…etaMask/core into feat/7618_analytics_privacy_controller
@NicolasMassart NicolasMassart changed the title Feat: new analytics privacy controller Feat: new analytics data regulation controller Jan 28, 2026
@NicolasMassart
Copy link
Contributor Author

@metamaskbot publish-preview

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 25 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@github-actions
Copy link
Contributor

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/account-tree-controller": "4.0.0-preview-52f4a2ca",
  "@metamask-previews/accounts-controller": "35.0.2-preview-52f4a2ca",
  "@metamask-previews/address-book-controller": "7.0.1-preview-52f4a2ca",
  "@metamask-previews/ai-controllers": "0.0.0-preview-52f4a2ca",
  "@metamask-previews/analytics-controller": "1.0.0-preview-52f4a2ca",
  "@metamask-previews/analytics-data-regulation-controller": "0.0.0-preview-52f4a2ca",
  "@metamask-previews/announcement-controller": "8.0.0-preview-52f4a2ca",
  "@metamask-previews/app-metadata-controller": "2.0.0-preview-52f4a2ca",
  "@metamask-previews/approval-controller": "8.0.0-preview-52f4a2ca",
  "@metamask-previews/assets-controller": "0.0.0-preview-52f4a2ca",
  "@metamask-previews/assets-controllers": "99.0.0-preview-52f4a2ca",
  "@metamask-previews/base-controller": "9.0.0-preview-52f4a2ca",
  "@metamask-previews/bridge-controller": "65.0.1-preview-52f4a2ca",
  "@metamask-previews/bridge-status-controller": "65.0.0-preview-52f4a2ca",
  "@metamask-previews/build-utils": "3.0.4-preview-52f4a2ca",
  "@metamask-previews/chain-agnostic-permission": "1.4.0-preview-52f4a2ca",
  "@metamask-previews/claims-controller": "0.4.1-preview-52f4a2ca",
  "@metamask-previews/composable-controller": "12.0.0-preview-52f4a2ca",
  "@metamask-previews/connectivity-controller": "0.1.0-preview-52f4a2ca",
  "@metamask-previews/controller-utils": "11.18.0-preview-52f4a2ca",
  "@metamask-previews/core-backend": "5.0.0-preview-52f4a2ca",
  "@metamask-previews/delegation-controller": "2.0.0-preview-52f4a2ca",
  "@metamask-previews/earn-controller": "11.1.0-preview-52f4a2ca",
  "@metamask-previews/eip-5792-middleware": "2.1.0-preview-52f4a2ca",
  "@metamask-previews/eip-7702-internal-rpc-middleware": "0.1.0-preview-52f4a2ca",
  "@metamask-previews/eip1193-permission-middleware": "1.0.3-preview-52f4a2ca",
  "@metamask-previews/ens-controller": "19.0.2-preview-52f4a2ca",
  "@metamask-previews/error-reporting-service": "3.0.1-preview-52f4a2ca",
  "@metamask-previews/eth-block-tracker": "15.0.1-preview-52f4a2ca",
  "@metamask-previews/eth-json-rpc-middleware": "23.0.0-preview-52f4a2ca",
  "@metamask-previews/eth-json-rpc-provider": "6.0.0-preview-52f4a2ca",
  "@metamask-previews/foundryup": "1.0.1-preview-52f4a2ca",
  "@metamask-previews/gas-fee-controller": "26.0.2-preview-52f4a2ca",
  "@metamask-previews/gator-permissions-controller": "1.1.1-preview-52f4a2ca",
  "@metamask-previews/json-rpc-engine": "10.2.1-preview-52f4a2ca",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.8-preview-52f4a2ca",
  "@metamask-previews/keyring-controller": "25.1.0-preview-52f4a2ca",
  "@metamask-previews/logging-controller": "7.0.1-preview-52f4a2ca",
  "@metamask-previews/message-manager": "14.1.0-preview-52f4a2ca",
  "@metamask-previews/messenger": "0.3.0-preview-52f4a2ca",
  "@metamask-previews/multichain-account-service": "5.1.0-preview-52f4a2ca",
  "@metamask-previews/multichain-api-middleware": "1.2.6-preview-52f4a2ca",
  "@metamask-previews/multichain-network-controller": "3.0.2-preview-52f4a2ca",
  "@metamask-previews/multichain-transactions-controller": "7.0.0-preview-52f4a2ca",
  "@metamask-previews/name-controller": "9.0.0-preview-52f4a2ca",
  "@metamask-previews/network-controller": "29.0.0-preview-52f4a2ca",
  "@metamask-previews/network-enablement-controller": "4.1.0-preview-52f4a2ca",
  "@metamask-previews/notification-services-controller": "21.0.0-preview-52f4a2ca",
  "@metamask-previews/permission-controller": "12.2.0-preview-52f4a2ca",
  "@metamask-previews/permission-log-controller": "5.0.0-preview-52f4a2ca",
  "@metamask-previews/perps-controller": "0.0.0-preview-52f4a2ca",
  "@metamask-previews/phishing-controller": "16.1.0-preview-52f4a2ca",
  "@metamask-previews/polling-controller": "16.0.2-preview-52f4a2ca",
  "@metamask-previews/preferences-controller": "22.0.0-preview-52f4a2ca",
  "@metamask-previews/profile-metrics-controller": "3.0.0-preview-52f4a2ca",
  "@metamask-previews/profile-sync-controller": "27.0.0-preview-52f4a2ca",
  "@metamask-previews/ramps-controller": "4.1.0-preview-52f4a2ca",
  "@metamask-previews/rate-limit-controller": "7.0.0-preview-52f4a2ca",
  "@metamask-previews/remote-feature-flag-controller": "4.0.0-preview-52f4a2ca",
  "@metamask-previews/sample-controllers": "4.0.2-preview-52f4a2ca",
  "@metamask-previews/seedless-onboarding-controller": "7.1.0-preview-52f4a2ca",
  "@metamask-previews/selected-network-controller": "26.0.2-preview-52f4a2ca",
  "@metamask-previews/shield-controller": "5.0.0-preview-52f4a2ca",
  "@metamask-previews/signature-controller": "39.0.1-preview-52f4a2ca",
  "@metamask-previews/storage-service": "0.0.1-preview-52f4a2ca",
  "@metamask-previews/subscription-controller": "5.4.1-preview-52f4a2ca",
  "@metamask-previews/token-search-discovery-controller": "4.0.0-preview-52f4a2ca",
  "@metamask-previews/transaction-controller": "62.11.0-preview-52f4a2ca",
  "@metamask-previews/transaction-pay-controller": "12.0.1-preview-52f4a2ca",
  "@metamask-previews/user-operation-controller": "41.0.2-preview-52f4a2ca"
}

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

* Type union for data deletion response status values.
*/
export type DataDeleteResponseStatus =
(typeof DATA_DELETE_RESPONSE_STATUSES)[keyof typeof DATA_DELETE_RESPONSE_STATUSES];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused DataDeleteResponseStatus type is never referenced

Low Severity

The DataDeleteResponseStatus type is defined and exported but never used as a type annotation anywhere in the codebase. The service methods explicitly return typeof DATA_DELETE_RESPONSE_STATUSES.Success in their return types rather than using this union type. This type exists only because it's derived from the status constants, but it provides no value.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had some more comments, but they are fairly minor at this point, implementation looks pretty good to me. I'll go ahead and approve.

actions: ['AnalyticsDataRegulationService:createDataDeletionTask'],
});

const controller = new AnalyticsDataRegulationController({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can we use setupController in this test to remove some of the boilerplate? There are some other tests in this file that are also creating the controller and messengers manually?

});

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _controller = new AnalyticsDataRegulationController({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. It seems if we used setupController we could only destructure the messenger and avoid from needing an eslint-disable comment.

Comment on lines +788 to +831

describe('stateChange event', () => {
it('emits stateChange event with new state when hasCollectedDataSinceDeletionRequest is updated', () => {
const { rootMessenger, messenger } = setupController({
state: {
hasCollectedDataSinceDeletionRequest: false,
},
});

const eventListener = jest.fn();
messenger.subscribe(
'AnalyticsDataRegulationController:stateChange',
eventListener,
);

rootMessenger.call(
'AnalyticsDataRegulationController:updateDataRecordingFlag',
);

expect(eventListener).toHaveBeenCalled();
const [newState] = eventListener.mock.calls[0];
expect(newState.hasCollectedDataSinceDeletionRequest).toBe(true);
});

it('does not emit stateChange event when hasCollectedDataSinceDeletionRequest is already true', () => {
const { rootMessenger, messenger } = setupController({
state: {
hasCollectedDataSinceDeletionRequest: true,
},
});

const eventListener = jest.fn();
messenger.subscribe(
'AnalyticsDataRegulationController:stateChange',
eventListener,
);

rootMessenger.call(
'AnalyticsDataRegulationController:updateDataRecordingFlag',
);

expect(eventListener).not.toHaveBeenCalled();
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this describe? Typically we don't need to test that the stateChange event is triggered for controllers, as this behavior is already tested in the BaseController tests.

Suggested change
describe('stateChange event', () => {
it('emits stateChange event with new state when hasCollectedDataSinceDeletionRequest is updated', () => {
const { rootMessenger, messenger } = setupController({
state: {
hasCollectedDataSinceDeletionRequest: false,
},
});
const eventListener = jest.fn();
messenger.subscribe(
'AnalyticsDataRegulationController:stateChange',
eventListener,
);
rootMessenger.call(
'AnalyticsDataRegulationController:updateDataRecordingFlag',
);
expect(eventListener).toHaveBeenCalled();
const [newState] = eventListener.mock.calls[0];
expect(newState.hasCollectedDataSinceDeletionRequest).toBe(true);
});
it('does not emit stateChange event when hasCollectedDataSinceDeletionRequest is already true', () => {
const { rootMessenger, messenger } = setupController({
state: {
hasCollectedDataSinceDeletionRequest: true,
},
});
const eventListener = jest.fn();
messenger.subscribe(
'AnalyticsDataRegulationController:stateChange',
eventListener,
);
rootMessenger.call(
'AnalyticsDataRegulationController:updateDataRecordingFlag',
);
expect(eventListener).not.toHaveBeenCalled();
});
});

Comment on lines +22 to +23
cleanAll();
disableNetConnect();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to do this? It should already be happening for all tests in tests/setupAfterEnv/nock.ts.

Suggested change
cleanAll();
disableNetConnect();

Comment on lines +28 to +29
cleanAll();
enableNetConnect();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to do this? It should already be happening for all tests in tests/setupAfterEnv/nock.ts.

Suggested change
cleanAll();
enableNetConnect();

});

describe('onBreak', () => {
it('calls break listener when circuit breaker opens after multiple failures', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The "it" here is the onBreak listener, so maybe this would be more accurate?

Suggested change
it('calls break listener when circuit breaker opens after multiple failures', async () => {
it('is called when circuit breaker opens after multiple failures', async () => {

(Similar for the other onBreak and onDegraded tests)

Comment on lines +248 to +255
if (!this.#analyticsId || this.#analyticsId.trim() === '') {
const error = new Error(
'Analytics ID not found. You need to provide a valid analytics ID when initializing the AnalyticsDataRegulationController.',
);
log('Analytics Deletion Task Error', error);
throw error;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this runtime check necessary? There are other instances within core where we perform runtime checks, but I think a lot of those instances are left over from when the code used to be written in JavaScript. I believe we have been largely relying on TypeScript to verify that required inputs have been provided. Of course if you think it's necessary, then that's fine, but I wanted to double-check first.

Suggested change
if (!this.#analyticsId || this.#analyticsId.trim() === '') {
const error = new Error(
'Analytics ID not found. You need to provide a valid analytics ID when initializing the AnalyticsDataRegulationController.',
);
log('Analytics Deletion Task Error', error);
throw error;
}

Comment on lines +286 to +288
const { deleteRegulationId } = this.state;
const { deleteRegulationTimestamp } = this.state;
const { hasCollectedDataSinceDeletionRequest } = this.state;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these be grouped together?

Suggested change
const { deleteRegulationId } = this.state;
const { deleteRegulationTimestamp } = this.state;
const { hasCollectedDataSinceDeletionRequest } = this.state;
const {
deleteRegulationId,
deleteRegulationTimestamp,
hasCollectedDataSinceDeletionRequest
} = this.state;

Comment on lines +300 to +307
// Service validates and throws on all errors, so if we reach here, the response
// is guaranteed to be a success response with dataDeleteStatus present
const dataDeletionTaskStatus = await this.messenger.call(
'AnalyticsDataRegulationService:checkDataDeleteStatus',
deleteRegulationId,
);

status.dataDeletionRequestStatus = dataDeletionTaskStatus.dataDeleteStatus;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think this comment might be misplaced? Maybe it was meant to go after the call.

Suggested change
// Service validates and throws on all errors, so if we reach here, the response
// is guaranteed to be a success response with dataDeleteStatus present
const dataDeletionTaskStatus = await this.messenger.call(
'AnalyticsDataRegulationService:checkDataDeleteStatus',
deleteRegulationId,
);
status.dataDeletionRequestStatus = dataDeletionTaskStatus.dataDeleteStatus;
const dataDeletionTaskStatus = await this.messenger.call(
'AnalyticsDataRegulationService:checkDataDeleteStatus',
deleteRegulationId,
);
// Service validates and throws on all errors, so if we reach here, the response
// is guaranteed to be a success response with dataDeleteStatus present
status.dataDeletionRequestStatus = dataDeletionTaskStatus.dataDeleteStatus;

@NicolasMassart NicolasMassart added this pull request to the merge queue Jan 28, 2026
@github-project-automation github-project-automation bot moved this from Needs dev review to Review finalised - Ready to be merged in PR review queue Jan 28, 2026
Merged via the queue into main with commit c871428 Jan 28, 2026
312 checks passed
@NicolasMassart NicolasMassart deleted the feat/7618_analytics_privacy_controller branch January 28, 2026 18:28
@github-project-automation github-project-automation bot moved this from Review finalised - Ready to be merged to Merged, Closed or Archived in PR review queue Jan 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

feat: create analytics data deletion controller

2 participants