Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/analytics-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add independent marketing consent and purpose-aware event classification ([#10232](https://github.com/MetaMask/core/pull/10232))
- Adds `optedInToMarketing`, `optInToMarketing` / `optOutOfMarketing` / `resetMarketingConsentDecision`, and a persisted `eventsConfig` whose unlisted events default to product-only
- Named `track` and `view` payloads are delivered once with their allowed purposes in `context.consent.categoryPreferences` and their capture-time config version in `context.eventsConfigVersion`
- Queues and fragments retain capture-time purpose classification so config changes cannot reclassify captured events. Mixed-purpose fragments classify each declared lifecycle event independently

### Changed

- Bump `uuid` from `^8.3.2` to `^9.0.1` ([#10117](https://github.com/MetaMask/core/pull/10117))
Expand Down
44 changes: 36 additions & 8 deletions packages/analytics-controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ The AnalyticsController provides a unified interface for tracking analytics even

## State

| Field | Type | Description | Persisted |
| ---------------- | --------- | --------------------------------------------- | --------- |
| `analyticsId` | `string` | UUIDv4 identifier (client platform-generated) | Yes |
| `optedIn` | `boolean` | User opt-in status | Yes |
| `eventQueue` | `object` | Optional persisted delivery queue | Yes |
| `eventFragments` | `object` | Optional in-progress event fragments | Yes |
| Field | Type | Description | Persisted |
| ------------------------------ | ----------------------- | ------------------------------------------------------------------- | --------- |
| `analyticsId` | `string` | UUIDv4 identifier (client platform-generated) | Yes |
| `optedIn` | `boolean` | Product analytics opt-in status | Yes |
| `consentDecisionMade` | `boolean` | Whether a product consent decision has been made | Yes |
| `optedInToMarketing` | `boolean` | Marketing analytics opt-in status | Yes |
| `marketingConsentDecisionMade` | `boolean` | Whether a marketing consent decision has been made | Yes |
| `eventsConfig` | `AnalyticsEventsConfig` | Cached event-purpose classification and its config registry version | Yes |
| `eventQueue` | `object` | Optional persisted delivery queue | Yes |
| `eventFragments` | `object` | Optional in-progress event fragments | Yes |

### Client Platform Responsibilities

Expand All @@ -30,6 +34,30 @@ The AnalyticsController provides a unified interface for tracking analytics even
3. **Subscribe to state changes**: Persist changes to isolated storage
4. **Persist to isolated storage**: Keep analytics settings separate from main state (protects against state corruption)

`eventsConfig.events` maps event names to one or both `AnalyticsPurpose` values (`product` and `marketing`). Unlisted names default to product-only. Phase 1 uses the config already persisted in state. Loading it from config registry will be added later.

Each `track` and `view` payload is emitted once when at least one eligible purpose is opted in. A dual-purpose event is still emitted once when both consents are enabled. Its allowed purposes are stamped using Segment's consent context:

```json
{
"context": {
"consent": {
"categoryPreferences": {
"product": true,
"marketing": true
}
},
"eventsConfigVersion": "a1b2c3d"
}
}
```

The booleans are the intersection of event classification and current user consent. `eventsConfigVersion` is included when classification came from a persisted config. `identify` is always product-only.

Classification and config version are captured with queued events and fragments. A later config update cannot reclassify an event that was already captured. If one purpose is opted in while another is undecided, a dual-purpose event is sent immediately for the allowed purpose and is not replayed after the second decision.

Event properties are unchanged by purpose consent.

## Anonymous Events Feature

When `isAnonymousEventsFeatureEnabled` is enabled in the constructor, events with sensitive properties are split into separate events:
Expand Down Expand Up @@ -79,11 +107,11 @@ controller.finalizeEventFragment(`signature-${requestId}`);

Use `upsertEventFragment` when a contributor cannot know whether the journey has been started yet. It merges into an existing fragment, or creates a property bag when none exists.

Emission goes through `trackEvent`, so consent gating, anonymous event splitting, the pre-consent queue and geolocation enrichment all apply to a fragment's events exactly as they do to a direct call.
Emission uses the same consent gating, anonymous event splitting, pre-consent queue and geolocation enrichment as a direct `trackEvent` call. Each declared lifecycle event keeps its own capture-time purposes. A fragment can therefore combine product-only and marketing-only lifecycle events without reclassifying the whole journey.

The consent gate also applies to accumulation, not just to emission, so a fragment never stores data for an event that could not be delivered. A fragment only holds data while the user is opted in, or while they are still undecided and `isPreConsentQueueEnabled` is holding their events until they decide. In any other consent state, and in particular after an explicit opt-out, every fragment method is a logged no-op.

Fragments are removed when they are finalized, deleted, or when the user opts out. `resetConsentDecision` keeps them only while the now-undecided user can still accumulate them. On `init`, any fragment that did not set `persist: true` is discarded, since the journey it belonged to cannot be resumed. Persistent fragments that have not been written to for longer than `EVENT_FRAGMENT_MAX_AGE` (24 hours, measured from `lastUpdated`) are also discarded, so abandoned journeys cannot keep `properties` or `sensitiveProperties` in storage indefinitely. All fragments are discarded when the consent state no longer allows accumulation. Nothing is emitted for a discarded fragment: a journey that never reached its own finalization is unfinished, not failed.
Fragments are removed when they are finalized, deleted, or when no eligible purpose remains allowed or undecided. Opting out of one purpose retains a dual-purpose fragment when another purpose still permits capture. `resetConsentDecision` keeps fragments only while the now-undecided user can still accumulate them. On `init`, any fragment that did not set `persist: true` is discarded, since the journey it belonged to cannot be resumed. Persistent fragments that have not been written to for longer than `EVENT_FRAGMENT_MAX_AGE` (24 hours, measured from `lastUpdated`) are also discarded, so abandoned journeys cannot keep `properties` or `sensitiveProperties` in storage indefinitely. Nothing is emitted for a discarded fragment: a journey that never reached its own finalization is unfinished, not failed.

This feature is disabled by default. When disabled, every fragment method is a logged no-op and no fragment is written to state.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,39 @@ export type AnalyticsControllerResetConsentDecisionAction = {
handler: AnalyticsController['resetConsentDecision'];
};

/**
* Opt in to marketing analytics.
*
* Independent of {@link optIn}. Replays queued marketing events.
*
* @returns A promise that resolves once opt-in processing has completed.
*/
export type AnalyticsControllerOptInToMarketingAction = {
type: `AnalyticsController:optInToMarketing`;
handler: AnalyticsController['optInToMarketing'];
};

/**
* Opt out of marketing analytics.
*
* Independent of {@link optOut}. Discards queued marketing events and
* marketing event fragments.
*/
export type AnalyticsControllerOptOutOfMarketingAction = {
type: `AnalyticsController:optOutOfMarketing`;
handler: AnalyticsController['optOutOfMarketing'];
};

/**
* Reset the marketing consent decision back to undecided.
*
* Independent of {@link resetConsentDecision}.
*/
export type AnalyticsControllerResetMarketingConsentDecisionAction = {
type: `AnalyticsController:resetMarketingConsentDecision`;
handler: AnalyticsController['resetMarketingConsentDecision'];
};

/**
* Union of all AnalyticsController action types.
*/
Expand All @@ -207,4 +240,7 @@ export type AnalyticsControllerMethodActions =
| AnalyticsControllerFinalizeEventFragmentAction
| AnalyticsControllerOptInAction
| AnalyticsControllerOptOutAction
| AnalyticsControllerResetConsentDecisionAction;
| AnalyticsControllerResetConsentDecisionAction
| AnalyticsControllerOptInToMarketingAction
| AnalyticsControllerOptOutOfMarketingAction
| AnalyticsControllerResetMarketingConsentDecisionAction;
Loading
Loading