Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions packages/analytics-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional event fragments to `AnalyticsController` (disabled by default via `isEventFragmentsEnabled`), letting clients accumulate analytics properties across a user journey and optionally emit an initial, success, or failure event for it ([#10055](https://github.com/MetaMask/core/pull/10055))

## [2.0.0]

### Changed
Expand Down
51 changes: 46 additions & 5 deletions packages/analytics-controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ 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 |
| 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 |

### Client Platform Responsibilities

Expand All @@ -46,6 +47,46 @@ This feature is disabled by default. Client platforms that already rely on SDK-l

Platforms without SDK-level persistence, such as MetaMask Extension, can enable it to replay queued payloads after restart. The queue stores the final adapter calls, so anonymous event splitting persists the identified and anonymous payloads separately.

## Event Fragments

When `isEventFragmentsEnabled` is enabled in the constructor, clients can accumulate analytics properties across a user journey instead of re-deriving them for every event in that journey.

A fragment is a persisted bag of `properties` and `sensitiveProperties` that any part of the client can contribute to while the journey is in progress. It supports two shapes, and the difference is only which event names it declares:

- **Funnel.** Declare `initialEvent`, `successEvent` and `failureEvent`. The initial event is emitted as soon as the fragment is created, and `finalizeEventFragment` emits the success event, or the failure event when called with `{ abandoned: true }`. A signature request is the canonical example: the request, approval and rejection events all carry the properties that the confirmation UI attached while the user was deciding.
- **Property bag.** Declare no event names. Nothing is ever emitted. The client reads the fragment back with `getEventFragmentById` at the moment it emits its own event and merges the accumulated properties in. A transaction confirmation is the canonical example.

```ts
controller.createEventFragment({
id: `signature-${requestId}`,
initialEvent: 'Signature Requested',
successEvent: 'Signature Approved',
failureEvent: 'Signature Rejected',
properties: { signature_type: 'personal_sign' },
context: { referrer: { url: origin } },
persist: true,
});

// Any number of contributors, at any later point.
controller.updateEventFragment(`signature-${requestId}`, {
properties: { alert_triggered_count: 1 },
});

// Emits 'Signature Approved' with every accumulated property, then discards
// the fragment. Pass `{ abandoned: true }` to emit 'Signature Rejected'.
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.

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.

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

## Lifecycle Hooks

### `onSetupCompleted`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,111 @@ export type AnalyticsControllerTrackViewAction = {
handler: AnalyticsController['trackView'];
};

/**
* Create an event fragment.
*
* A fragment accumulates properties across a user journey so that several
* parts of a client can contribute to the same set of events without
* re-deriving them. Declaring `successEvent` and `failureEvent` turns the
* fragment into a funnel that {@link finalizeEventFragment} closes. Declaring
* none of the event names makes it a pure property bag that the client reads
* back with {@link getEventFragmentById} when it emits its own events.
*
* Any existing fragment with the same ID is replaced, so a new journey never
* inherits properties from a stale one.
*
* Nothing is created unless the user is opted in, or undecided with the
* pre-consent queue enabled, so an opted-out user accumulates no fragment
* data.
*
* @param options - The fragment definition. An ID is generated when one is
* not supplied.
* @returns A read-only copy of the created fragment, or `undefined` when the
* event fragments feature is disabled or the consent state does not allow
* capture. Mutating the returned object does not change controller state.
* Use {@link updateEventFragment} or {@link upsertEventFragment} to write.
*/
export type AnalyticsControllerCreateEventFragmentAction = {
type: `AnalyticsController:createEventFragment`;
handler: AnalyticsController['createEventFragment'];
};

/**
* Write to an event fragment, creating a property bag if none exists.
*
* This is the ergonomic entry point for contributors that do not know
* whether the journey has been started yet, and it avoids the read then
* write race a caller would otherwise have to implement itself.
*
* @param id - The fragment ID.
* @param payload - The properties and context to merge in.
*/
export type AnalyticsControllerUpsertEventFragmentAction = {
type: `AnalyticsController:upsertEventFragment`;
handler: AnalyticsController['upsertEventFragment'];
};

/**
* Write to an existing event fragment.
*
* @param id - The fragment ID.
* @param payload - The properties and context to merge in.
* @throws Error if no fragment has that ID when the call is not ignored.
* Use {@link upsertEventFragment} when the fragment may not exist yet.
* When the event fragments feature is disabled or the consent state does not
* allow capture, the call is a logged no-op and does not throw.
*/
export type AnalyticsControllerUpdateEventFragmentAction = {
type: `AnalyticsController:updateEventFragment`;
handler: AnalyticsController['updateEventFragment'];
};

/**
* Read an event fragment.
*
* @param id - The fragment ID.
* @returns A read-only copy of the fragment, or `undefined` when no fragment
* has that ID, the event fragments feature is disabled, or the consent state
* does not allow capture. Mutating the returned object does not change
* controller state. Use {@link updateEventFragment} or
* {@link upsertEventFragment} to write.
*/
export type AnalyticsControllerGetEventFragmentByIdAction = {
type: `AnalyticsController:getEventFragmentById`;
handler: AnalyticsController['getEventFragmentById'];
};

/**
* Discard an event fragment without emitting anything.
*
* @param id - The fragment ID.
*/
export type AnalyticsControllerDeleteEventFragmentAction = {
type: `AnalyticsController:deleteEventFragment`;
handler: AnalyticsController['deleteEventFragment'];
};

/**
* Close an event fragment, emitting its closing event and discarding it.
*
* The event emitted is `failureEvent` when the journey was abandoned and
* `successEvent` otherwise. A fragment that does not declare the relevant
* event name is discarded silently, which is what makes a pure property bag
* possible.
*
* @param id - The fragment ID.
* @param options - Finalization options.
* @param options.abandoned - Whether the journey was abandoned.
* @param options.context - Context merged over the fragment's own context.
* @throws Error if no fragment has that ID when the call is not ignored.
* When the event fragments feature is disabled or the consent state does not
* allow capture, the call is a logged no-op and does not throw.
*/
export type AnalyticsControllerFinalizeEventFragmentAction = {
type: `AnalyticsController:finalizeEventFragment`;
handler: AnalyticsController['finalizeEventFragment'];
};

/**
* Opt in to analytics.
*
Expand All @@ -62,7 +167,8 @@ export type AnalyticsControllerOptInAction = {
* Opt out of analytics.
*
* Records that a consent decision has been made and discards any persisted
* events so nothing captured before the decision is ever delivered.
* events and in-progress event fragments so nothing captured before the
* decision is ever delivered.
*/
export type AnalyticsControllerOptOutAction = {
type: `AnalyticsController:optOut`;
Expand All @@ -76,6 +182,10 @@ export type AnalyticsControllerOptOutAction = {
* preference and discards the delivery queue, but preserves any pre-consent
* events so they can still be replayed if the user opts in again. The user is
* treated as undecided again.
*
* In-progress event fragments are kept only while the undecided user can
* still accumulate them, and discarded otherwise, so no fragment outlives the
* consent state that allowed it.
*/
export type AnalyticsControllerResetConsentDecisionAction = {
type: `AnalyticsController:resetConsentDecision`;
Expand All @@ -89,6 +199,12 @@ export type AnalyticsControllerMethodActions =
| AnalyticsControllerTrackEventAction
| AnalyticsControllerIdentifyAction
| AnalyticsControllerTrackViewAction
| AnalyticsControllerCreateEventFragmentAction
| AnalyticsControllerUpsertEventFragmentAction
| AnalyticsControllerUpdateEventFragmentAction
| AnalyticsControllerGetEventFragmentByIdAction
| AnalyticsControllerDeleteEventFragmentAction
| AnalyticsControllerFinalizeEventFragmentAction
| AnalyticsControllerOptInAction
| AnalyticsControllerOptOutAction
| AnalyticsControllerResetConsentDecisionAction;
Loading