From 9b468c3b18a2d5d4e74cd228c6dd0e6a50341353 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 1 Sep 2026 19:56:32 +0200 Subject: [PATCH 01/11] feat(analytics-controller): add optional event fragments for journey-scoped properties Clients currently re-derive the same analytics properties for every event in a signature or transaction flow. Fragments let those properties accumulate once, with consent, persistence, and emission going through the existing trackEvent path. Co-authored-by: Cursor --- packages/analytics-controller/CHANGELOG.md | 4 + packages/analytics-controller/README.md | 51 +- ...AnalyticsController-method-action-types.ts | 101 +- .../src/AnalyticsController.test.ts | 925 ++++++++++++++++++ .../src/AnalyticsController.ts | 555 ++++++++++- .../src/EventFragment.types.ts | 120 +++ packages/analytics-controller/src/index.ts | 15 + .../src/selectors.test.ts | 69 ++ .../analytics-controller/src/selectors.ts | 31 + 9 files changed, 1854 insertions(+), 17 deletions(-) create mode 100644 packages/analytics-controller/src/EventFragment.types.ts diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 90ce54d0c1c..363fd85bece 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -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 + ## [2.0.0] ### Changed diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index 90e31863782..119cce8ee07 100644 --- a/packages/analytics-controller/README.md +++ b/packages/analytics-controller/README.md @@ -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 @@ -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, and all of them 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` diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index 8e223ee1f0b..de0e8300efd 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -41,6 +41,98 @@ 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. + * + * @param options - The fragment definition. An ID is generated when one is + * not supplied. + * @returns The created fragment, or `undefined` when the event fragments + * feature is disabled. + */ +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. Use {@link upsertEventFragment} + * when the fragment may not exist yet. + */ +export type AnalyticsControllerUpdateEventFragmentAction = { + type: `AnalyticsController:updateEventFragment`; + handler: AnalyticsController['updateEventFragment']; +}; + +/** + * Read an event fragment. + * + * @param id - The fragment ID. + * @returns The fragment, or `undefined` when no fragment has that ID or the + * event fragments feature is disabled. + */ +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. + */ +export type AnalyticsControllerFinalizeEventFragmentAction = { + type: `AnalyticsController:finalizeEventFragment`; + handler: AnalyticsController['finalizeEventFragment']; +}; + /** * Opt in to analytics. * @@ -62,7 +154,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`; @@ -89,6 +182,12 @@ export type AnalyticsControllerMethodActions = | AnalyticsControllerTrackEventAction | AnalyticsControllerIdentifyAction | AnalyticsControllerTrackViewAction + | AnalyticsControllerCreateEventFragmentAction + | AnalyticsControllerUpsertEventFragmentAction + | AnalyticsControllerUpdateEventFragmentAction + | AnalyticsControllerGetEventFragmentByIdAction + | AnalyticsControllerDeleteEventFragmentAction + | AnalyticsControllerFinalizeEventFragmentAction | AnalyticsControllerOptInAction | AnalyticsControllerOptOutAction | AnalyticsControllerResetConsentDecisionAction; diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index dd4e58067d0..659a4559509 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -23,6 +23,7 @@ import type { AnalyticsTrackingEvent, AnalyticsControllerState, AnalyticsContext, + AnalyticsEventFragment, } from './index.js'; /** @@ -40,6 +41,7 @@ type SetupControllerOptions = { isEventQueuePersistenceEnabled?: boolean; isPreConsentQueueEnabled?: boolean; isGeolocationEnabled?: boolean; + isEventFragmentsEnabled?: boolean; /** * Geolocation returned by the mocked `GeolocationController` action. * Defaults to unknown geolocation so that events are not enriched. @@ -91,6 +93,7 @@ type MockAnalyticsPlatformAdapter = AnalyticsPlatformAdapter & { * @param options.isEventQueuePersistenceEnabled - Optional event queue persistence flag (default: false) * @param options.isPreConsentQueueEnabled - Optional pre-consent queue flag (default: false) * @param options.isGeolocationEnabled - Optional geolocation enrichment flag (default: true) + * @param options.isEventFragmentsEnabled - Optional event fragments flag (default: false) * @param options.geolocation - Optional geolocation returned by the mocked geolocation action * @param options.geolocationHandler - Optional handler for the mocked geolocation action * @param options.omitGeolocationAction - When true, the geolocation action is not registered @@ -106,6 +109,7 @@ async function setupController( isEventQueuePersistenceEnabled = false, isPreConsentQueueEnabled = false, isGeolocationEnabled = true, + isEventFragmentsEnabled = false, geolocation, geolocationHandler, omitGeolocationAction = false, @@ -157,6 +161,7 @@ async function setupController( isEventQueuePersistenceEnabled, isPreConsentQueueEnabled, isGeolocationEnabled, + isEventFragmentsEnabled, }); await controller.init(); @@ -411,6 +416,56 @@ describe('AnalyticsController', () => { ).toHaveProperty('preConsentEventQueue', state.preConsentEventQueue); }); + it('persists eventFragments but excludes them from logs, snapshots, and UI', async () => { + const state: AnalyticsControllerState = { + ...metadataFixtureState, + eventFragments: { + 'signature-1': { + id: 'signature-1', + properties: { signature_type: 'personal_sign' }, + sensitiveProperties: { eip712_primary_type: 'Permit' }, + successEvent: 'Signature Approved', + persist: true, + createdAt: 1700000000000, + lastUpdated: 1700000000000, + }, + }, + }; + const { controller } = await setupController({ + state, + isEventFragmentsEnabled: true, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).not.toHaveProperty('eventFragments'); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).not.toHaveProperty('eventFragments'); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).not.toHaveProperty('eventFragments'); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toHaveProperty('eventFragments', state.eventFragments); + }); + it('exposes expected state to UI', async () => { const { controller } = await setupController({ state: metadataFixtureState, @@ -2891,4 +2946,874 @@ describe('AnalyticsController', () => { expect(controller.state.preConsentEventQueue).toStrictEqual({}); }); }); + + describe('event fragments', () => { + const ANALYTICS_ID = '11111111-2222-4333-8444-555555555555'; + + /** + * Sets up a controller with the event fragments feature enabled and the + * user opted in. + * + * @param options - Overrides for the controller setup. + * @param options.state - Extra state merged over the opted-in defaults. + * @param options.isEventFragmentsEnabled - Whether the feature is enabled. + * @param options.isAnonymousEventsFeatureEnabled - Whether anonymous events are enabled. + * @param options.isPreConsentQueueEnabled - Whether the pre-consent queue is enabled. + * @returns The controller and its mock adapter. + */ + async function setupFragmentController({ + state, + isEventFragmentsEnabled = true, + isAnonymousEventsFeatureEnabled = false, + isPreConsentQueueEnabled = false, + }: { + state?: Partial; + isEventFragmentsEnabled?: boolean; + isAnonymousEventsFeatureEnabled?: boolean; + isPreConsentQueueEnabled?: boolean; + } = {}): Promise<{ + controller: AnalyticsController; + messenger: AnalyticsControllerMessenger; + mockAdapter: MockAnalyticsPlatformAdapter; + }> { + const mockAdapter = createMockAdapter(); + const { controller, messenger } = await setupController({ + state: { + optedIn: true, + consentDecisionMade: true, + analyticsId: ANALYTICS_ID, + ...state, + }, + platformAdapter: mockAdapter, + isEventFragmentsEnabled, + isAnonymousEventsFeatureEnabled, + isPreConsentQueueEnabled, + }); + + return { controller, messenger, mockAdapter }; + } + + /** + * Builds a stored event fragment fixture. + * + * @param overrides - Fields to override on the fixture. + * @returns An event fragment. + */ + function buildFragment( + overrides: Partial & { id: string }, + ): AnalyticsEventFragment { + return { + properties: {}, + sensitiveProperties: {}, + createdAt: 1700000000000, + lastUpdated: 1700000000000, + ...overrides, + }; + } + + describe('when the feature is disabled', () => { + it('ignores every fragment method and writes nothing to state', async () => { + const { controller, mockAdapter } = await setupFragmentController({ + isEventFragmentsEnabled: false, + }); + + expect( + controller.createEventFragment({ + id: 'signature-1', + initialEvent: 'Signature Requested', + }), + ).toBeUndefined(); + controller.upsertEventFragment('signature-1', { + properties: { foo: 'bar' }, + }); + controller.updateEventFragment('signature-1', { + properties: { foo: 'bar' }, + }); + controller.deleteEventFragment('signature-1'); + controller.finalizeEventFragment('signature-1'); + + expect(controller.getEventFragmentById('signature-1')).toBeUndefined(); + expect(controller.state.eventFragments).toBeUndefined(); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('does not throw from updateEventFragment or finalizeEventFragment for a missing fragment', async () => { + const { controller } = await setupFragmentController({ + isEventFragmentsEnabled: false, + }); + + expect(() => controller.updateEventFragment('missing')).not.toThrow(); + expect(() => controller.finalizeEventFragment('missing')).not.toThrow(); + }); + + it('clears fragments persisted by a session that had the feature enabled', async () => { + const { controller } = await setupFragmentController({ + isEventFragmentsEnabled: false, + state: { + eventFragments: { + 'signature-1': buildFragment({ + id: 'signature-1', + persist: true, + }), + }, + }, + }); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + }); + + describe('createEventFragment', () => { + it('stores a fragment under a generated ID', async () => { + const { controller } = await setupFragmentController(); + + const fragment = controller.createEventFragment(); + + expect(fragment).toBeDefined(); + expect(isValidUUIDv4(fragment?.id as string)).toBe(true); + expect(controller.state.eventFragments).toStrictEqual({ + [fragment?.id as string]: fragment, + }); + }); + + it('defaults the property bags and timestamps', async () => { + const now = 1700000000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const { controller } = await setupFragmentController(); + + const fragment = controller.createEventFragment({ id: 'bag-1' }); + + expect(fragment).toStrictEqual({ + id: 'bag-1', + properties: {}, + sensitiveProperties: {}, + createdAt: now, + lastUpdated: now, + }); + }); + + it('stores every supplied field under the supplied ID', async () => { + const { controller } = await setupFragmentController(); + + const fragment = controller.createEventFragment({ + id: 'signature-1', + initialEvent: 'Signature Requested', + successEvent: 'Signature Approved', + failureEvent: 'Signature Rejected', + properties: { signature_type: 'personal_sign' }, + sensitiveProperties: { eip712_primary_type: 'Permit' }, + context: { referrer: { url: 'https://dapp.test' } }, + persist: true, + }); + + expect(controller.state.eventFragments?.['signature-1']).toStrictEqual({ + id: 'signature-1', + initialEvent: 'Signature Requested', + successEvent: 'Signature Approved', + failureEvent: 'Signature Rejected', + properties: { signature_type: 'personal_sign' }, + sensitiveProperties: { eip712_primary_type: 'Permit' }, + context: { referrer: { url: 'https://dapp.test' } }, + persist: true, + createdAt: expect.any(Number), + lastUpdated: expect.any(Number), + }); + expect(fragment).toStrictEqual( + controller.state.eventFragments?.['signature-1'], + ); + }); + + it('emits the initial event with the fragment properties and context', async () => { + const { controller, mockAdapter } = await setupFragmentController(); + + controller.createEventFragment({ + id: 'signature-1', + initialEvent: 'Signature Requested', + successEvent: 'Signature Approved', + properties: { signature_type: 'personal_sign' }, + context: { referrer: { url: 'https://dapp.test' } }, + }); + + expect(mockAdapter.track).toHaveBeenCalledTimes(1); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'Signature Requested', + { signature_type: 'personal_sign' }, + { referrer: { url: 'https://dapp.test' } }, + ); + }); + + it('emits nothing when no initial event is declared', async () => { + const { controller, mockAdapter } = await setupFragmentController(); + + controller.createEventFragment({ + id: 'transaction-ui-1', + properties: { gas_edit_attempted: 'basic' }, + }); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('replaces an existing fragment so a new journey inherits nothing', async () => { + const { controller } = await setupFragmentController({ + state: { + eventFragments: { + 'signature-1': buildFragment({ + id: 'signature-1', + persist: true, + properties: { stale: true }, + }), + }, + }, + }); + + controller.createEventFragment({ + id: 'signature-1', + properties: { fresh: true }, + }); + + expect( + controller.state.eventFragments?.['signature-1']?.properties, + ).toStrictEqual({ fresh: true }); + }); + }); + + describe('upsertEventFragment', () => { + it('creates a property bag when the fragment does not exist', async () => { + const { controller, mockAdapter } = await setupFragmentController(); + + controller.upsertEventFragment('transaction-ui-1', { + properties: { simulation_response: 'no_changes' }, + }); + + expect( + controller.state.eventFragments?.['transaction-ui-1'], + ).toStrictEqual({ + id: 'transaction-ui-1', + properties: { simulation_response: 'no_changes' }, + sensitiveProperties: {}, + createdAt: expect.any(Number), + lastUpdated: expect.any(Number), + }); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('creates an empty bag when no payload is supplied', async () => { + const { controller } = await setupFragmentController(); + + controller.upsertEventFragment('transaction-ui-1'); + + expect( + controller.state.eventFragments?.['transaction-ui-1']?.properties, + ).toStrictEqual({}); + }); + + it('merges into an existing fragment without disturbing its event names', async () => { + const { controller } = await setupFragmentController(); + controller.createEventFragment({ + id: 'transaction-ui-1', + successEvent: 'Transaction Finalized', + properties: { simulation_response: 'no_changes' }, + }); + + controller.upsertEventFragment('transaction-ui-1', { + properties: { gas_edit_attempted: 'basic' }, + sensitiveProperties: { sending_value: '0x1' }, + }); + + expect( + controller.state.eventFragments?.['transaction-ui-1'], + ).toStrictEqual({ + id: 'transaction-ui-1', + successEvent: 'Transaction Finalized', + properties: { + simulation_response: 'no_changes', + gas_edit_attempted: 'basic', + }, + sensitiveProperties: { sending_value: '0x1' }, + createdAt: expect.any(Number), + lastUpdated: expect.any(Number), + }); + }); + }); + + describe('updateEventFragment', () => { + it('merges properties into an existing fragment', async () => { + const { controller } = await setupFragmentController(); + controller.createEventFragment({ + id: 'signature-1', + properties: { alert_triggered_count: 1 }, + }); + + controller.updateEventFragment('signature-1', { + properties: { alert_resolved_count: 1 }, + }); + + expect( + controller.state.eventFragments?.['signature-1']?.properties, + ).toStrictEqual({ alert_triggered_count: 1, alert_resolved_count: 1 }); + }); + + it('replaces an array property wholesale instead of merging it by index', async () => { + const { controller } = await setupFragmentController(); + controller.createEventFragment({ + id: 'transaction-ui-1', + properties: { simulation_receiving_assets_petname: ['a', 'b', 'c'] }, + }); + + controller.updateEventFragment('transaction-ui-1', { + properties: { simulation_receiving_assets_petname: ['x', 'y'] }, + }); + + expect( + controller.state.eventFragments?.['transaction-ui-1']?.properties, + ).toStrictEqual({ simulation_receiving_assets_petname: ['x', 'y'] }); + }); + + it('merges context over the fragment context', async () => { + const { controller } = await setupFragmentController(); + controller.createEventFragment({ + id: 'signature-1', + context: { referrer: { url: 'https://dapp.test' }, keep: 'me' }, + }); + + controller.updateEventFragment('signature-1', { + context: { referrer: { url: 'https://other.test' } }, + }); + + expect( + controller.state.eventFragments?.['signature-1']?.context, + ).toStrictEqual({ + referrer: { url: 'https://other.test' }, + keep: 'me', + }); + }); + + it('leaves the context unset when neither side has one', async () => { + const { controller } = await setupFragmentController(); + controller.createEventFragment({ id: 'signature-1' }); + + controller.updateEventFragment('signature-1', { + properties: { foo: 'bar' }, + }); + + expect( + controller.state.eventFragments?.['signature-1'], + ).not.toHaveProperty('context'); + }); + + it('advances lastUpdated but preserves createdAt', async () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000); + const { controller } = await setupFragmentController(); + controller.createEventFragment({ id: 'signature-1' }); + nowSpy.mockReturnValue(2000); + + controller.updateEventFragment('signature-1', { + properties: { foo: 'bar' }, + }); + + expect(controller.state.eventFragments?.['signature-1']).toStrictEqual( + expect.objectContaining({ createdAt: 1000, lastUpdated: 2000 }), + ); + }); + + it('throws when the fragment does not exist', async () => { + const { controller } = await setupFragmentController(); + + expect(() => controller.updateEventFragment('missing')).toThrow( + 'Event fragment with id missing does not exist.', + ); + }); + }); + + describe('getEventFragmentById', () => { + it('returns the stored fragment', async () => { + const { controller } = await setupFragmentController(); + const fragment = controller.createEventFragment({ id: 'signature-1' }); + + expect(controller.getEventFragmentById('signature-1')).toStrictEqual( + fragment, + ); + }); + + it('returns undefined for an unknown ID', async () => { + const { controller } = await setupFragmentController(); + + expect(controller.getEventFragmentById('missing')).toBeUndefined(); + }); + }); + + describe('deleteEventFragment', () => { + it('removes the fragment without emitting anything', async () => { + const { controller, mockAdapter } = await setupFragmentController(); + controller.createEventFragment({ + id: 'signature-1', + successEvent: 'Signature Approved', + }); + mockAdapter.track.mockClear(); + + controller.deleteEventFragment('signature-1'); + + expect(controller.state.eventFragments).toStrictEqual({}); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('leaves state untouched for an unknown ID', async () => { + const { controller } = await setupFragmentController(); + const stateBefore = controller.state; + + controller.deleteEventFragment('missing'); + + expect(controller.state).toBe(stateBefore); + }); + }); + + describe('finalizeEventFragment', () => { + it('emits the success event with the accumulated properties and deletes the fragment', async () => { + const { controller, mockAdapter } = await setupFragmentController(); + controller.createEventFragment({ + id: 'signature-1', + successEvent: 'Signature Approved', + failureEvent: 'Signature Rejected', + properties: { signature_type: 'personal_sign' }, + }); + controller.updateEventFragment('signature-1', { + properties: { alert_triggered_count: 1 }, + }); + + controller.finalizeEventFragment('signature-1'); + + expect(mockAdapter.track).toHaveBeenCalledTimes(1); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'Signature Approved', + { signature_type: 'personal_sign', alert_triggered_count: 1 }, + undefined, + ); + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('emits the failure event when the journey was abandoned', async () => { + const { controller, mockAdapter } = await setupFragmentController(); + controller.createEventFragment({ + id: 'signature-1', + successEvent: 'Signature Approved', + failureEvent: 'Signature Rejected', + }); + + controller.finalizeEventFragment('signature-1', { abandoned: true }); + + expect(mockAdapter.track).toHaveBeenCalledWith( + 'Signature Rejected', + undefined, + undefined, + ); + }); + + it('deletes without emitting when the relevant event name is not declared', async () => { + const { controller, mockAdapter } = await setupFragmentController(); + controller.createEventFragment({ + id: 'transaction-ui-1', + properties: { gas_edit_attempted: 'basic' }, + }); + + controller.finalizeEventFragment('transaction-ui-1'); + controller.upsertEventFragment('transaction-ui-2', { + properties: { gas_edit_attempted: 'basic' }, + }); + controller.finalizeEventFragment('transaction-ui-2', { + abandoned: true, + }); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('merges the finalize context over the fragment context', async () => { + const { controller, mockAdapter } = await setupFragmentController(); + controller.createEventFragment({ + id: 'signature-1', + successEvent: 'Signature Approved', + context: { referrer: { url: 'https://dapp.test' }, keep: 'me' }, + }); + + controller.finalizeEventFragment('signature-1', { + context: { referrer: { url: 'https://other.test' } }, + }); + + expect(mockAdapter.track).toHaveBeenCalledWith( + 'Signature Approved', + undefined, + { referrer: { url: 'https://other.test' }, keep: 'me' }, + ); + }); + + it('sends the sensitive properties on a separate anonymous payload', async () => { + const { controller, mockAdapter } = await setupFragmentController({ + isAnonymousEventsFeatureEnabled: true, + }); + controller.createEventFragment({ + id: 'signature-1', + successEvent: 'Signature Approved', + properties: { signature_type: 'personal_sign' }, + sensitiveProperties: { eip712_primary_type: 'Permit' }, + }); + + controller.finalizeEventFragment('signature-1'); + + expect(mockAdapter.track).toHaveBeenCalledTimes(2); + expect(mockAdapter.track).toHaveBeenNthCalledWith( + 1, + 'Signature Approved', + { signature_type: 'personal_sign' }, + undefined, + ); + expect(mockAdapter.track).toHaveBeenNthCalledWith( + 2, + 'Signature Approved', + { + signature_type: 'personal_sign', + eip712_primary_type: 'Permit', + anonymous: true, + }, + undefined, + ); + }); + + it('throws when the fragment does not exist', async () => { + const { controller } = await setupFragmentController(); + + expect(() => controller.finalizeEventFragment('missing')).toThrow( + 'Event fragment with id missing does not exist.', + ); + }); + }); + + describe('consent gating', () => { + it('ignores every fragment method and writes nothing when the user has opted out', async () => { + const { controller, mockAdapter } = await setupFragmentController({ + state: { optedIn: false, consentDecisionMade: true }, + }); + + expect( + controller.createEventFragment({ + id: 'signature-1', + initialEvent: 'Signature Requested', + successEvent: 'Signature Approved', + sensitiveProperties: { eip712_primary_type: 'Permit' }, + }), + ).toBeUndefined(); + controller.upsertEventFragment('signature-1', { + properties: { foo: 'bar' }, + }); + controller.updateEventFragment('signature-1', { + properties: { foo: 'bar' }, + }); + controller.deleteEventFragment('signature-1'); + controller.finalizeEventFragment('signature-1'); + + expect(controller.getEventFragmentById('signature-1')).toBeUndefined(); + expect(controller.state.eventFragments).toBeUndefined(); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('does not throw from updateEventFragment or finalizeEventFragment when the user has opted out', async () => { + const { controller } = await setupFragmentController({ + state: { optedIn: false, consentDecisionMade: true }, + }); + + expect(() => controller.updateEventFragment('missing')).not.toThrow(); + expect(() => controller.finalizeEventFragment('missing')).not.toThrow(); + }); + + it('writes nothing while the user is undecided and the pre-consent queue is disabled', async () => { + const { controller, mockAdapter } = await setupFragmentController({ + state: { optedIn: false, consentDecisionMade: false }, + }); + + controller.createEventFragment({ + id: 'signature-1', + initialEvent: 'Signature Requested', + properties: { signature_type: 'personal_sign' }, + }); + + expect(controller.state.eventFragments).toBeUndefined(); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('accumulates while the user is undecided when the pre-consent queue holds their events, then replays them', async () => { + const { controller, mockAdapter } = await setupFragmentController({ + state: { optedIn: false, consentDecisionMade: false }, + isPreConsentQueueEnabled: true, + }); + controller.createEventFragment({ + id: 'signature-1', + initialEvent: 'Signature Requested', + }); + + expect(controller.state.eventFragments).toHaveProperty('signature-1'); + expect(mockAdapter.track).not.toHaveBeenCalled(); + + await controller.optIn(); + + expect(mockAdapter.track).toHaveBeenCalledWith( + 'Signature Requested', + undefined, + undefined, + expect.objectContaining({ messageId: expect.any(String) }), + ); + }); + + it('accumulates again once the user opts in', async () => { + const { controller } = await setupFragmentController({ + state: { optedIn: false, consentDecisionMade: true }, + }); + + await controller.optIn(); + controller.createEventFragment({ id: 'signature-1' }); + + expect(controller.state.eventFragments).toHaveProperty('signature-1'); + }); + + it('drops fragments persisted before the user opted out during init', async () => { + const { controller, mockAdapter } = await setupFragmentController({ + state: { + optedIn: false, + consentDecisionMade: true, + eventFragments: { + 'signature-1': buildFragment({ + id: 'signature-1', + persist: true, + successEvent: 'Signature Approved', + sensitiveProperties: { eip712_primary_type: 'Permit' }, + }), + }, + }, + }); + + expect(controller.state.eventFragments).toStrictEqual({}); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('drops persisted fragments during init while the user is undecided and the pre-consent queue is disabled', async () => { + const { controller } = await setupFragmentController({ + state: { + optedIn: false, + consentDecisionMade: false, + eventFragments: { + 'signature-1': buildFragment({ + id: 'signature-1', + persist: true, + }), + }, + }, + }); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('keeps persisted fragments during init when the pre-consent queue holds events for an undecided user', async () => { + const persisted = buildFragment({ id: 'signature-1', persist: true }); + const { controller } = await setupFragmentController({ + state: { + optedIn: false, + consentDecisionMade: false, + eventFragments: { 'signature-1': persisted }, + }, + isPreConsentQueueEnabled: true, + }); + + expect(controller.state.eventFragments).toStrictEqual({ + 'signature-1': persisted, + }); + }); + + it('discards fragments on resetConsentDecision when they can no longer accumulate', async () => { + const { controller } = await setupFragmentController(); + controller.createEventFragment({ id: 'signature-1', persist: true }); + + controller.resetConsentDecision(); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('keeps fragments on resetConsentDecision when the pre-consent queue is enabled', async () => { + const { controller } = await setupFragmentController({ + isPreConsentQueueEnabled: true, + }); + const fragment = controller.createEventFragment({ + id: 'signature-1', + persist: true, + }); + + controller.resetConsentDecision(); + + expect(controller.state.eventFragments).toStrictEqual({ + 'signature-1': fragment, + }); + }); + }); + + describe('init', () => { + it('keeps fragments that opted into persistence and drops the rest', async () => { + const persisted = buildFragment({ id: 'signature-1', persist: true }); + const { controller, mockAdapter } = await setupFragmentController({ + state: { + eventFragments: { + 'signature-1': persisted, + 'transaction-ui-1': buildFragment({ id: 'transaction-ui-1' }), + 'transaction-ui-2': buildFragment({ + id: 'transaction-ui-2', + persist: false, + failureEvent: 'Transaction Rejected', + }), + }, + }, + }); + + expect(controller.state.eventFragments).toStrictEqual({ + 'signature-1': persisted, + }); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('leaves state untouched when every fragment is persistent', async () => { + const { controller } = await setupFragmentController({ + state: { + eventFragments: { + 'signature-1': buildFragment({ + id: 'signature-1', + persist: true, + }), + }, + }, + }); + const stateBefore = controller.state; + + await controller.init(); + + expect(controller.state).toBe(stateBefore); + }); + + it('leaves state untouched when there are no fragments', async () => { + const { controller } = await setupFragmentController(); + + expect(controller.state.eventFragments).toBeUndefined(); + }); + + it.each([ + ['a non-object', 'not-a-fragment'], + ['a missing id', { ...buildFragment({ id: 'x' }), id: undefined }], + ['an id that does not match its key', buildFragment({ id: 'other' })], + [ + 'a missing createdAt', + { ...buildFragment({ id: 'x' }), createdAt: undefined }, + ], + [ + 'a missing lastUpdated', + { ...buildFragment({ id: 'x' }), lastUpdated: undefined }, + ], + [ + 'non-object properties', + { ...buildFragment({ id: 'x' }), properties: 'nope' }, + ], + [ + 'non-object sensitiveProperties', + { ...buildFragment({ id: 'x' }), sensitiveProperties: 'nope' }, + ], + [ + 'a non-string initialEvent', + { ...buildFragment({ id: 'x' }), initialEvent: 1 }, + ], + [ + 'a non-string successEvent', + { ...buildFragment({ id: 'x' }), successEvent: 1 }, + ], + [ + 'a non-string failureEvent', + { ...buildFragment({ id: 'x' }), failureEvent: 1 }, + ], + ['a non-object context', { ...buildFragment({ id: 'x' }), context: 1 }], + [ + 'a non-boolean persist', + { ...buildFragment({ id: 'x' }), persist: 'yes' }, + ], + ])('drops a persisted fragment with %s', async (_description, value) => { + const { controller } = await setupFragmentController({ + state: { + eventFragments: { + x: value as unknown as AnalyticsEventFragment, + }, + }, + }); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + }); + + describe('optOut', () => { + it('discards in-progress fragments', async () => { + const { controller } = await setupFragmentController(); + controller.createEventFragment({ + id: 'signature-1', + successEvent: 'Signature Approved', + persist: true, + }); + + controller.optOut(); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('leaves state untouched when there is nothing to discard', async () => { + const { controller } = await setupFragmentController(); + controller.optOut(); + const stateBefore = controller.state; + + controller.optOut(); + + expect(controller.state).toBe(stateBefore); + }); + }); + + describe('messenger actions', () => { + it('exposes the fragment lifecycle', async () => { + const { messenger, mockAdapter } = await setupFragmentController(); + + messenger.call('AnalyticsController:createEventFragment', { + id: 'signature-1', + successEvent: 'Signature Approved', + }); + messenger.call('AnalyticsController:upsertEventFragment', 'bag-1', { + properties: { foo: 'bar' }, + }); + messenger.call( + 'AnalyticsController:updateEventFragment', + 'signature-1', + { properties: { signature_type: 'personal_sign' } }, + ); + + expect( + messenger.call( + 'AnalyticsController:getEventFragmentById', + 'signature-1', + ), + ).toStrictEqual( + expect.objectContaining({ + properties: { signature_type: 'personal_sign' }, + }), + ); + + messenger.call('AnalyticsController:deleteEventFragment', 'bag-1'); + messenger.call( + 'AnalyticsController:finalizeEventFragment', + 'signature-1', + ); + + expect(mockAdapter.track).toHaveBeenCalledWith( + 'Signature Approved', + { signature_type: 'personal_sign' }, + undefined, + ); + }); + }); + }); }); diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 085142bba6c..9f1780d9a6f 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -25,6 +25,13 @@ import type { AnalyticsUserTraits, AnalyticsTrackingEvent, } from './AnalyticsPlatformAdapter.types'; +import type { + AnalyticsEventFragment, + AnalyticsEventFragmentFinalizeOptions, + AnalyticsEventFragmentOptions, + AnalyticsEventFragmentPayload, + AnalyticsEventFragments, +} from './EventFragment.types.js'; import { analyticsControllerSelectors } from './selectors.js'; // === GENERAL === @@ -81,6 +88,14 @@ export type AnalyticsControllerState = { * This is only used when the pre-consent queue is enabled. */ preConsentEventQueue?: Record; + + /** + * Persisted event fragments ({@link AnalyticsEventFragment}) keyed by + * fragment ID. Fragments accumulate properties across a user journey and are + * removed when the journey is finalized or deleted. + * This is only used when the event fragments feature is enabled. + */ + eventFragments?: AnalyticsEventFragments; }; /** @@ -206,6 +221,12 @@ const analyticsControllerMetadata = { includeInDebugSnapshot: false, usedInUi: false, }, + eventFragments: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, } satisfies StateMetadata; // === MESSENGER === @@ -217,6 +238,12 @@ const MESSENGER_EXPOSED_METHODS = [ 'optIn', 'optOut', 'resetConsentDecision', + 'createEventFragment', + 'upsertEventFragment', + 'updateEventFragment', + 'getEventFragmentById', + 'deleteEventFragment', + 'finalizeEventFragment', ] as const; /** @@ -330,6 +357,19 @@ export type AnalyticsControllerOptions = { * @default false */ isGeolocationEnabled?: boolean; + + /** + * Whether the event fragments feature is enabled. + * + * When enabled, clients can accumulate analytics properties across a user + * journey with {@link AnalyticsController.createEventFragment} and friends, + * as long as the consent state allows analytics to be captured. When + * disabled, every fragment method is a logged no-op and no fragment is ever + * written to state. + * + * @default false + */ + isEventFragmentsEnabled?: boolean; }; /** @@ -422,6 +462,85 @@ function isAnalyticsQueuedEvent(value: unknown): value is AnalyticsQueuedEvent { return false; } +/** + * Returns whether a value is a valid persisted event fragment. + * + * @param value - The value to check. + * @returns True if the value is an event fragment. + */ +function isAnalyticsEventFragment( + value: unknown, +): value is AnalyticsEventFragment { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.id === 'string' && + typeof value.createdAt === 'number' && + typeof value.lastUpdated === 'number' && + isRecord(value.properties) && + isRecord(value.sensitiveProperties) && + (value.initialEvent === undefined || + typeof value.initialEvent === 'string') && + (value.successEvent === undefined || + typeof value.successEvent === 'string') && + (value.failureEvent === undefined || + typeof value.failureEvent === 'string') && + (value.context === undefined || isRecord(value.context)) && + (value.persist === undefined || typeof value.persist === 'boolean') + ); +} + +/** + * Merges a payload into an event fragment. + * + * `properties`, `sensitiveProperties` and `context` are merged one level deep, + * so a key written twice is replaced rather than combined. This keeps array + * values predictable: writing a shorter array replaces the longer one instead + * of leaving stale trailing entries behind. + * + * @param fragment - The fragment to merge into. + * @param payload - The payload to merge. + * @returns A new fragment with the payload applied. + */ +function mergeEventFragment( + fragment: AnalyticsEventFragment, + payload: AnalyticsEventFragmentPayload, +): AnalyticsEventFragment { + const context = mergeEventFragmentContext(fragment.context, payload.context); + + return { + ...fragment, + properties: { ...fragment.properties, ...payload.properties }, + sensitiveProperties: { + ...fragment.sensitiveProperties, + ...payload.sensitiveProperties, + }, + ...(context === undefined ? {} : { context }), + lastUpdated: Date.now(), + }; +} + +/** + * Merges two optional analytics contexts, preserving `undefined` when neither + * side has one so an empty context is never sent. + * + * @param base - The context to merge into. + * @param override - The context whose fields win. + * @returns The merged context, or `undefined` when both sides are unset. + */ +function mergeEventFragmentContext( + base: AnalyticsContext | undefined, + override: AnalyticsContext | undefined, +): AnalyticsContext | undefined { + if (base === undefined && override === undefined) { + return undefined; + } + + return { ...base, ...override }; +} + /** * The AnalyticsController manages analytics tracking across platforms (Mobile/Extension). * It provides a unified interface for tracking events, identifying users, and managing @@ -450,6 +569,8 @@ export class AnalyticsController extends BaseController< readonly #isGeolocationEnabled: boolean; + readonly #isEventFragmentsEnabled: boolean; + /** * The in-flight (or settled) initialization promise. Set on the first * {@link init} call and returned by subsequent calls so overlapping callers @@ -477,6 +598,7 @@ export class AnalyticsController extends BaseController< * @param options.isEventQueuePersistenceEnabled - Whether analytics event queue persistence is enabled * @param options.isPreConsentQueueEnabled - Whether the pre-consent event queue is enabled * @param options.isGeolocationEnabled - Whether geolocation enrichment is enabled + * @param options.isEventFragmentsEnabled - Whether the event fragments feature is enabled * @throws Error if state.analyticsId is missing or not a valid UUIDv4 * @remarks After construction, call {@link AnalyticsController.init} to complete initialization. */ @@ -488,6 +610,7 @@ export class AnalyticsController extends BaseController< isEventQueuePersistenceEnabled = false, isPreConsentQueueEnabled = false, isGeolocationEnabled = false, + isEventFragmentsEnabled = false, }: AnalyticsControllerOptions) { const initialState: AnalyticsControllerState = { ...getDefaultAnalyticsControllerState(), @@ -510,6 +633,7 @@ export class AnalyticsController extends BaseController< this.#isEventQueuePersistenceEnabled = isEventQueuePersistenceEnabled; this.#isPreConsentQueueEnabled = isPreConsentQueueEnabled; this.#isGeolocationEnabled = isGeolocationEnabled; + this.#isEventFragmentsEnabled = isEventFragmentsEnabled; this.#platformAdapter = platformAdapter; this.#initPromise = undefined; this.#locationResolvePromise = undefined; @@ -527,6 +651,7 @@ export class AnalyticsController extends BaseController< eventQueuePersistenceEnabled: this.#isEventQueuePersistenceEnabled, preConsentQueueEnabled: this.#isPreConsentQueueEnabled, geolocationEnabled: this.#isGeolocationEnabled, + eventFragmentsEnabled: this.#isEventFragmentsEnabled, }); } @@ -579,6 +704,7 @@ export class AnalyticsController extends BaseController< this.#replayQueuedEvents(); this.#reconcilePreConsentEvents(); + this.#reconcileEventFragments(); } /** @@ -1023,6 +1149,222 @@ export class AnalyticsController extends BaseController< } } + /** + * Reconcile persisted event fragments on initialization. + * + * A fragment describes a journey that was in progress when the previous + * session ended. Only fragments that opted into `persist` can be resumed, so + * the rest are discarded. Nothing is emitted: a journey that never reached + * its own finalization is not a failure, just an unfinished one. + * + * If the feature is disabled (e.g. a previous session had it enabled), or the + * consent state no longer allows capture (e.g. the fragments were written + * before the user opted out), every persisted fragment is dropped so none of + * them can linger. + */ + #reconcileEventFragments(): void { + const fragments = this.state.eventFragments; + + if (!fragments) { + return; + } + + if (!this.#isEventFragmentsEnabled || !this.#isAnalyticsCaptureAllowed()) { + this.#clearEventFragments(); + return; + } + + this.#purgeNonPersistentEventFragments(fragments); + } + + /** + * Drop every persisted fragment that is invalid or did not opt into + * `persist`. + * + * Only called by {@link #reconcileEventFragments}, which guarantees the + * fragments exist and that the event fragments feature is enabled. + * + * @param currentEventFragments - The persisted fragments to filter. + */ + #purgeNonPersistentEventFragments( + currentEventFragments: AnalyticsEventFragments, + ): void { + const eventFragments: AnalyticsEventFragments = {}; + + for (const [id, fragment] of Object.entries(currentEventFragments)) { + if (!isAnalyticsEventFragment(fragment) || fragment.id !== id) { + log('Dropping invalid persisted event fragment', { id }); + continue; + } + + if (fragment.persist === true) { + eventFragments[id] = fragment; + } + } + + if ( + Object.keys(eventFragments).length === + Object.keys(currentEventFragments).length + ) { + return; + } + + this.update((state) => { + state.eventFragments = eventFragments as never; + }); + } + + /** + * Read an event fragment from state without the feature guard. + * + * @param id - The fragment ID. + * @returns The fragment, or `undefined` when no fragment has that ID. + */ + #getEventFragment(id: string): AnalyticsEventFragment | undefined { + return this.state.eventFragments?.[id]; + } + + /** + * Write an event fragment to state, replacing any fragment with the same ID. + * + * @param fragment - The fragment to store. + */ + #setEventFragment(fragment: AnalyticsEventFragment): void { + const eventFragments: AnalyticsEventFragments = { + ...this.state.eventFragments, + [fragment.id]: fragment, + }; + + this.update((state) => { + state.eventFragments = eventFragments as never; + }); + } + + /** + * Remove an event fragment from state. + * + * @param id - The fragment ID. + */ + #removeEventFragment(id: string): void { + const currentEventFragments = this.state.eventFragments; + + if ( + !currentEventFragments || + !Object.prototype.hasOwnProperty.call(currentEventFragments, id) + ) { + return; + } + + const { [id]: _deletedFragment, ...eventFragments } = currentEventFragments; + + this.update((state) => { + state.eventFragments = eventFragments as never; + }); + } + + /** + * Clear all event fragments. + */ + #clearEventFragments(): void { + if ( + !this.state.eventFragments || + Object.keys(this.state.eventFragments).length === 0 + ) { + return; + } + + this.update((state) => { + state.eventFragments = {} as never; + }); + } + + /** + * Returns whether an event fragment call should be ignored, either because + * the feature is disabled or because the consent state does not allow + * capture. The ignored call is logged so a missing `isEventFragmentsEnabled` + * or an unexpected consent state is diagnosable rather than silent. + * + * Consent is checked on every call, not just on the ones that emit, so a + * fragment never accumulates data for an event that could not be delivered. + * + * @param method - The name of the method that was called. + * @returns True when the call should be ignored. + */ + #shouldIgnoreEventFragmentCall(method: string): boolean { + if (!this.#isEventFragmentsEnabled) { + log( + 'Ignoring event fragment call because the event fragments feature is disabled', + { method }, + ); + + return true; + } + + if (!this.#isAnalyticsCaptureAllowed()) { + log( + 'Ignoring event fragment call because the consent state does not allow capturing analytics', + { method }, + ); + + return true; + } + + return false; + } + + /** + * Emit one of an event fragment's events, carrying the properties the + * fragment has accumulated. + * + * Delivery goes through {@link trackEvent}, so consent gating, the anonymous + * payload split, the pre-consent queue and geolocation enrichment all apply. + * + * @param fragment - The fragment supplying the properties. + * @param name - The name of the event to emit. + * @param context - The context to send with the event. + */ + #emitEventFragment( + fragment: AnalyticsEventFragment, + name: string, + context: AnalyticsContext | undefined, + ): void { + const properties = { ...fragment.properties }; + const sensitiveProperties = { ...fragment.sensitiveProperties }; + + this.trackEvent( + { + name, + properties, + sensitiveProperties, + saveDataRecording: false, + hasProperties: + Object.keys(properties).length > 0 || + Object.keys(sensitiveProperties).length > 0, + }, + context, + ); + } + + /** + * Returns whether the current consent state allows analytics data to be + * captured, either for immediate delivery or to be held until the user + * decides. + * + * Capture is allowed once the user has opted in, and also while they are + * undecided if the pre-consent queue is enabled: what is captured then is + * replayed when they opt in (see {@link optIn}) and discarded if they opt out + * (see {@link optOut}). An explicit opt-out never allows capture. + * + * @returns True when analytics data may be captured. + */ + #isAnalyticsCaptureAllowed(): boolean { + if (analyticsControllerSelectors.selectEnabled(this.state)) { + return true; + } + + return this.#isPreConsentQueueEnabled && !this.state.consentDecisionMade; + } + /** * Track an analytics event. * @@ -1032,16 +1374,11 @@ export class AnalyticsController extends BaseController< * @param context - Optional platform-specific context forwarded to the platform adapter. */ trackEvent(event: AnalyticsTrackingEvent, context?: AnalyticsContext): void { - if (!analyticsControllerSelectors.selectEnabled(this.state)) { - // While the user is undecided, fall through so the event is processed and - // captured in the pre-consent queue (see #sendOrQueueTrackEvent) to be - // replayed if they later opt in. Otherwise (opted out, or pre-consent - // queue disabled) drop it. - const shouldQueuePreConsent = - this.#isPreConsentQueueEnabled && !this.state.consentDecisionMade; - if (!shouldQueuePreConsent) { - return; - } + // An event captured while the user is still undecided is held in the + // pre-consent queue (see #sendOrQueueTrackEvent) instead of being + // delivered, and replayed if they later opt in. + if (!this.#isAnalyticsCaptureAllowed()) { + return; } // if event does not have properties, send event without properties @@ -1132,6 +1469,192 @@ export class AnalyticsController extends BaseController< ); } + /** + * 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 The created fragment, or `undefined` when the event fragments + * feature is disabled or the consent state does not allow capture. + */ + createEventFragment( + options: AnalyticsEventFragmentOptions = {}, + ): AnalyticsEventFragment | undefined { + if (this.#shouldIgnoreEventFragmentCall('createEventFragment')) { + return undefined; + } + + const now = Date.now(); + + const fragment: AnalyticsEventFragment = { + id: options.id ?? uuid(), + properties: { ...options.properties }, + sensitiveProperties: { ...options.sensitiveProperties }, + createdAt: now, + lastUpdated: now, + ...(options.initialEvent === undefined + ? {} + : { initialEvent: options.initialEvent }), + ...(options.successEvent === undefined + ? {} + : { successEvent: options.successEvent }), + ...(options.failureEvent === undefined + ? {} + : { failureEvent: options.failureEvent }), + ...(options.context === undefined ? {} : { context: options.context }), + ...(options.persist === undefined ? {} : { persist: options.persist }), + }; + + this.#setEventFragment(fragment); + + if (fragment.initialEvent) { + this.#emitEventFragment( + fragment, + fragment.initialEvent, + fragment.context, + ); + } + + return fragment; + } + + /** + * 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. + */ + upsertEventFragment( + id: string, + payload: AnalyticsEventFragmentPayload = {}, + ): void { + if (this.#shouldIgnoreEventFragmentCall('upsertEventFragment')) { + return; + } + + const fragment = this.#getEventFragment(id); + + if (!fragment) { + this.createEventFragment({ id, ...payload }); + return; + } + + this.#setEventFragment(mergeEventFragment(fragment, payload)); + } + + /** + * 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. Use {@link upsertEventFragment} + * when the fragment may not exist yet. + */ + updateEventFragment( + id: string, + payload: AnalyticsEventFragmentPayload = {}, + ): void { + if (this.#shouldIgnoreEventFragmentCall('updateEventFragment')) { + return; + } + + const fragment = this.#getEventFragment(id); + + if (!fragment) { + throw new Error(`Event fragment with id ${id} does not exist.`); + } + + this.#setEventFragment(mergeEventFragment(fragment, payload)); + } + + /** + * Read an event fragment. + * + * @param id - The fragment ID. + * @returns The fragment, or `undefined` when no fragment has that ID, the + * event fragments feature is disabled, or the consent state does not allow + * capture. + */ + getEventFragmentById(id: string): AnalyticsEventFragment | undefined { + if (this.#shouldIgnoreEventFragmentCall('getEventFragmentById')) { + return undefined; + } + + return this.#getEventFragment(id); + } + + /** + * Discard an event fragment without emitting anything. + * + * @param id - The fragment ID. + */ + deleteEventFragment(id: string): void { + if (this.#shouldIgnoreEventFragmentCall('deleteEventFragment')) { + return; + } + + this.#removeEventFragment(id); + } + + /** + * 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. + */ + finalizeEventFragment( + id: string, + { abandoned = false, context }: AnalyticsEventFragmentFinalizeOptions = {}, + ): void { + if (this.#shouldIgnoreEventFragmentCall('finalizeEventFragment')) { + return; + } + + const fragment = this.#getEventFragment(id); + + if (!fragment) { + throw new Error(`Event fragment with id ${id} does not exist.`); + } + + const eventName = abandoned ? fragment.failureEvent : fragment.successEvent; + + if (eventName) { + this.#emitEventFragment( + fragment, + eventName, + mergeEventFragmentContext(fragment.context, context), + ); + } + + this.#removeEventFragment(id); + } + /** * Opt in to analytics. * @@ -1165,7 +1688,8 @@ export class AnalyticsController extends BaseController< * 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. */ optOut(): void { this.update((state) => { @@ -1175,6 +1699,7 @@ export class AnalyticsController extends BaseController< this.#clearQueuedEvents(); this.#clearPreConsentEvents(); + this.#clearEventFragments(); } /** @@ -1184,6 +1709,10 @@ export class AnalyticsController extends BaseController< * 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. */ resetConsentDecision(): void { this.update((state) => { @@ -1192,5 +1721,9 @@ export class AnalyticsController extends BaseController< }); this.#clearQueuedEvents(); + + if (!this.#isAnalyticsCaptureAllowed()) { + this.#clearEventFragments(); + } } } diff --git a/packages/analytics-controller/src/EventFragment.types.ts b/packages/analytics-controller/src/EventFragment.types.ts new file mode 100644 index 00000000000..6a60aadff98 --- /dev/null +++ b/packages/analytics-controller/src/EventFragment.types.ts @@ -0,0 +1,120 @@ +import type { + AnalyticsContext, + AnalyticsEventProperties, +} from './AnalyticsPlatformAdapter.types'; + +/** + * A bag of analytics properties that accumulates across a user journey. + * + * A fragment lets several parts of a client contribute properties to the same + * logical journey (a signature request, a transaction confirmation) without + * having to re-derive them for every event, and lets the journey be closed as + * a success or a failure. + * + * All three event names are optional. A fragment that declares none of them + * never emits anything and acts purely as a property bag that the client reads + * back with {@link AnalyticsController.getEventFragmentById} at the moment it + * emits its own event. + */ +export type AnalyticsEventFragment = { + /** + * The fragment identifier, unique per client. + */ + id: string; + + /** + * Properties tracked with every event this fragment emits. + */ + properties: AnalyticsEventProperties; + + /** + * Properties that must not be linked to the user's analytics ID. They are + * delivered on a separate anonymous payload when the anonymous events + * feature is enabled. + */ + sensitiveProperties: AnalyticsEventProperties; + + /** + * Name of an event emitted immediately when the fragment is created. + */ + initialEvent?: string; + + /** + * Name of the event emitted when the fragment is finalized normally. + */ + successEvent?: string; + + /** + * Name of the event emitted when the fragment is finalized as abandoned. + */ + failureEvent?: string; + + /** + * Platform-specific context forwarded with every event this fragment emits. + */ + context?: AnalyticsContext; + + /** + * Whether the fragment survives {@link AnalyticsController.init}. Fragments + * that do not set this are discarded when the controller re-initializes, + * since the journey they belonged to cannot be resumed. + */ + persist?: boolean; + + /** + * `Date.now()` when the fragment was created. + */ + createdAt: number; + + /** + * `Date.now()` when the fragment was last written to. + */ + lastUpdated: number; +}; + +/** + * Event fragments keyed by fragment ID. + */ +export type AnalyticsEventFragments = Record; + +/** + * Options accepted when creating an event fragment. An `id` is generated when + * one is not supplied. + */ +export type AnalyticsEventFragmentOptions = Partial< + Pick< + AnalyticsEventFragment, + | 'id' + | 'initialEvent' + | 'successEvent' + | 'failureEvent' + | 'properties' + | 'sensitiveProperties' + | 'context' + | 'persist' + > +>; + +/** + * The fields that can be written to an existing event fragment. + */ +export type AnalyticsEventFragmentPayload = Pick< + AnalyticsEventFragmentOptions, + 'properties' | 'sensitiveProperties' | 'context' +>; + +/** + * Options accepted when finalizing an event fragment. + */ +export type AnalyticsEventFragmentFinalizeOptions = { + /** + * Whether the journey was abandoned, which selects `failureEvent` instead of + * `successEvent`. + */ + abandoned?: boolean; + + /** + * Context merged over the fragment's own context for the emitted event. + */ + context?: AnalyticsContext; +}; diff --git a/packages/analytics-controller/src/index.ts b/packages/analytics-controller/src/index.ts index a89cc035e82..5e598c4630d 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -21,6 +21,15 @@ export type { AnalyticsTrackingEvent, } from './AnalyticsPlatformAdapter.types'; +// Export event fragment types +export type { + AnalyticsEventFragment, + AnalyticsEventFragmentFinalizeOptions, + AnalyticsEventFragmentOptions, + AnalyticsEventFragmentPayload, + AnalyticsEventFragments, +} from './EventFragment.types.js'; + // Export state types export type { AnalyticsControllerState, @@ -52,5 +61,11 @@ export type { AnalyticsControllerOptInAction, AnalyticsControllerOptOutAction, AnalyticsControllerResetConsentDecisionAction, + AnalyticsControllerCreateEventFragmentAction, + AnalyticsControllerUpsertEventFragmentAction, + AnalyticsControllerUpdateEventFragmentAction, + AnalyticsControllerGetEventFragmentByIdAction, + AnalyticsControllerDeleteEventFragmentAction, + AnalyticsControllerFinalizeEventFragmentAction, AnalyticsControllerMethodActions, } from './AnalyticsController-method-action-types.js'; diff --git a/packages/analytics-controller/src/selectors.test.ts b/packages/analytics-controller/src/selectors.test.ts index 3833250aa29..9ccc1072c69 100644 --- a/packages/analytics-controller/src/selectors.test.ts +++ b/packages/analytics-controller/src/selectors.test.ts @@ -1,4 +1,5 @@ import type { AnalyticsControllerState } from './AnalyticsController.js'; +import type { AnalyticsEventFragment } from './EventFragment.types.js'; import { analyticsControllerSelectors } from './selectors.js'; describe('analyticsControllerSelectors', () => { @@ -90,4 +91,72 @@ describe('analyticsControllerSelectors', () => { expect(result).toBe(false); }); }); + + describe('event fragment selectors', () => { + const fragment: AnalyticsEventFragment = { + id: 'signature-1', + properties: { signature_type: 'personal_sign' }, + sensitiveProperties: {}, + successEvent: 'Signature Approved', + createdAt: 1700000000000, + lastUpdated: 1700000000000, + }; + + const stateWithFragment: AnalyticsControllerState = { + optedIn: true, + analyticsId: defaultAnalyticsId, + eventFragments: { 'signature-1': fragment }, + }; + + const stateWithoutFragments: AnalyticsControllerState = { + optedIn: true, + analyticsId: defaultAnalyticsId, + }; + + describe('selectEventFragments', () => { + it('returns the fragments from state', () => { + const result = + analyticsControllerSelectors.selectEventFragments(stateWithFragment); + + expect(result).toStrictEqual({ 'signature-1': fragment }); + }); + + it('returns an empty record when the field is absent', () => { + const result = analyticsControllerSelectors.selectEventFragments( + stateWithoutFragments, + ); + + expect(result).toStrictEqual({}); + }); + }); + + describe('selectEventFragmentById', () => { + it('returns the matching fragment', () => { + const result = analyticsControllerSelectors.selectEventFragmentById( + stateWithFragment, + 'signature-1', + ); + + expect(result).toStrictEqual(fragment); + }); + + it('returns undefined for an unknown ID', () => { + const result = analyticsControllerSelectors.selectEventFragmentById( + stateWithFragment, + 'missing', + ); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when the field is absent', () => { + const result = analyticsControllerSelectors.selectEventFragmentById( + stateWithoutFragments, + 'signature-1', + ); + + expect(result).toBeUndefined(); + }); + }); + }); }); diff --git a/packages/analytics-controller/src/selectors.ts b/packages/analytics-controller/src/selectors.ts index b5509571989..b014d21f7f3 100644 --- a/packages/analytics-controller/src/selectors.ts +++ b/packages/analytics-controller/src/selectors.ts @@ -1,4 +1,10 @@ import type { AnalyticsControllerState } from './AnalyticsController.js'; +import type { + AnalyticsEventFragment, + AnalyticsEventFragments, +} from './EventFragment.types.js'; + +const EMPTY_EVENT_FRAGMENTS: AnalyticsEventFragments = {}; /** * Selects the analytics ID from the controller state. @@ -40,6 +46,29 @@ const selectEnabled = (state: AnalyticsControllerState): boolean => const selectConsentDecisionMade = (state: AnalyticsControllerState): boolean => state.consentDecisionMade ?? false; +/** + * Selects the in-progress event fragments from the controller state. + * + * @param state - The controller state + * @returns The event fragments keyed by fragment ID, empty when the event + * fragments feature has never written any + */ +const selectEventFragments = ( + state: AnalyticsControllerState, +): AnalyticsEventFragments => state.eventFragments ?? EMPTY_EVENT_FRAGMENTS; + +/** + * Selects a single event fragment from the controller state. + * + * @param state - The controller state + * @param id - The fragment ID + * @returns The fragment, or `undefined` when no fragment has that ID + */ +const selectEventFragmentById = ( + state: AnalyticsControllerState, + id: string, +): AnalyticsEventFragment | undefined => state.eventFragments?.[id]; + /** * Selectors for the AnalyticsController state. * These can be used with Redux or directly with controller state. @@ -49,4 +78,6 @@ export const analyticsControllerSelectors = { selectOptedIn, selectEnabled, selectConsentDecisionMade, + selectEventFragments, + selectEventFragmentById, }; From ba173bdbe3d0b7674af119fee217c6055bd7dea3 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 1 Sep 2026 20:05:00 +0200 Subject: [PATCH 02/11] chore(analytics-controller): sync messenger action types and link changelog to PR Regenerate AnalyticsController method action types after JSDoc updates and add the PR link required by the changelog CI check. Co-authored-by: Cursor --- packages/analytics-controller/CHANGELOG.md | 4 +++- .../AnalyticsController-method-action-types.ts | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 363fd85bece..49ae9a774ea 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 +- 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)) + - Fragments apply the same consent gate as `trackEvent` to accumulation as well as emission, so a fragment only holds data while the user is opted in, or while they are undecided and the pre-consent queue is enabled + - `eventFragments` is persisted but excluded from state logs, debug snapshots, and UI, matching `eventQueue` and `preConsentEventQueue` ## [2.0.0] diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index de0e8300efd..34641e2c463 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -54,10 +54,14 @@ export type AnalyticsControllerTrackViewAction = { * 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 The created fragment, or `undefined` when the event fragments - * feature is disabled. + * feature is disabled or the consent state does not allow capture. */ export type AnalyticsControllerCreateEventFragmentAction = { type: `AnalyticsController:createEventFragment`; @@ -96,8 +100,9 @@ export type AnalyticsControllerUpdateEventFragmentAction = { * Read an event fragment. * * @param id - The fragment ID. - * @returns The fragment, or `undefined` when no fragment has that ID or the - * event fragments feature is disabled. + * @returns The fragment, or `undefined` when no fragment has that ID, the + * event fragments feature is disabled, or the consent state does not allow + * capture. */ export type AnalyticsControllerGetEventFragmentByIdAction = { type: `AnalyticsController:getEventFragmentById`; @@ -169,6 +174,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`; From 89a1d5cab345e25602e93c8e36c7a0faa7b51cef Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 1 Sep 2026 20:21:34 +0200 Subject: [PATCH 03/11] docs(analytics-controller): simplify unreleased changelog entry Keep a single event fragments bullet with the PR link and remove nested detail bullets from the Unreleased section. Co-authored-by: Cursor --- packages/analytics-controller/CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 49ae9a774ea..bcbd17d886e 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -10,8 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) - - Fragments apply the same consent gate as `trackEvent` to accumulation as well as emission, so a fragment only holds data while the user is opted in, or while they are undecided and the pre-consent queue is enabled - - `eventFragments` is persisted but excluded from state logs, debug snapshots, and UI, matching `eventQueue` and `preConsentEventQueue` ## [2.0.0] From 088f4d2a744d54c5ebd9225ebc282b91493f3135 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 2 Sep 2026 05:48:43 +0200 Subject: [PATCH 04/11] fix(analytics-controller): freeze empty event fragment selector fallback Prevent accidental mutation of the shared empty record returned by selectEventFragments when state has no fragments yet. Co-authored-by: Cursor --- .../src/selectors.test.ts | 21 +++++++++++++++++++ .../analytics-controller/src/selectors.ts | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/analytics-controller/src/selectors.test.ts b/packages/analytics-controller/src/selectors.test.ts index 9ccc1072c69..d94322a29c8 100644 --- a/packages/analytics-controller/src/selectors.test.ts +++ b/packages/analytics-controller/src/selectors.test.ts @@ -128,6 +128,27 @@ describe('analyticsControllerSelectors', () => { expect(result).toStrictEqual({}); }); + + it('returns the same empty record on repeated reads', () => { + const first = analyticsControllerSelectors.selectEventFragments( + stateWithoutFragments, + ); + const second = analyticsControllerSelectors.selectEventFragments( + stateWithoutFragments, + ); + + expect(first).toBe(second); + }); + + it('does not allow mutating the empty fallback record', () => { + const result = analyticsControllerSelectors.selectEventFragments( + stateWithoutFragments, + ); + + expect(() => { + result['signature-1'] = fragment; + }).toThrow('Cannot add property'); + }); }); describe('selectEventFragmentById', () => { diff --git a/packages/analytics-controller/src/selectors.ts b/packages/analytics-controller/src/selectors.ts index b014d21f7f3..f948263a4f2 100644 --- a/packages/analytics-controller/src/selectors.ts +++ b/packages/analytics-controller/src/selectors.ts @@ -4,7 +4,9 @@ import type { AnalyticsEventFragments, } from './EventFragment.types.js'; -const EMPTY_EVENT_FRAGMENTS: AnalyticsEventFragments = {}; +const EMPTY_EVENT_FRAGMENTS = Object.freeze( + {}, +) as AnalyticsEventFragments; /** * Selects the analytics ID from the controller state. From 65b80067bda6597207c2b75080b645977011a13b Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 2 Sep 2026 05:53:51 +0200 Subject: [PATCH 05/11] fix(analytics-controller): coalesce optional fragment fields before spreading Default omitted properties, sensitiveProperties, and context to empty objects so fragment merge and create paths stay type-safe. Co-authored-by: Cursor --- .../analytics-controller/src/AnalyticsController.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 9f1780d9a6f..56af7ef4b20 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -512,10 +512,10 @@ function mergeEventFragment( return { ...fragment, - properties: { ...fragment.properties, ...payload.properties }, + properties: { ...fragment.properties, ...(payload.properties ?? {}) }, sensitiveProperties: { ...fragment.sensitiveProperties, - ...payload.sensitiveProperties, + ...(payload.sensitiveProperties ?? {}), }, ...(context === undefined ? {} : { context }), lastUpdated: Date.now(), @@ -538,7 +538,7 @@ function mergeEventFragmentContext( return undefined; } - return { ...base, ...override }; + return { ...(base ?? {}), ...(override ?? {}) }; } /** @@ -1502,8 +1502,8 @@ export class AnalyticsController extends BaseController< const fragment: AnalyticsEventFragment = { id: options.id ?? uuid(), - properties: { ...options.properties }, - sensitiveProperties: { ...options.sensitiveProperties }, + properties: { ...(options.properties ?? {}) }, + sensitiveProperties: { ...(options.sensitiveProperties ?? {}) }, createdAt: now, lastUpdated: now, ...(options.initialEvent === undefined From 75ec146fe97bc55195d0079d510ce468cde575e2 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 2 Sep 2026 05:56:34 +0200 Subject: [PATCH 06/11] docs(analytics-controller): clarify fragment throw behavior when ignored Document that updateEventFragment and finalizeEventFragment only throw for a missing fragment when the call is not ignored by consent or feature flags. Co-authored-by: Cursor --- .../src/AnalyticsController-method-action-types.ts | 10 +++++++--- .../analytics-controller/src/AnalyticsController.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index 34641e2c463..f39809783e6 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -88,8 +88,10 @@ export type AnalyticsControllerUpsertEventFragmentAction = { * * @param id - The fragment ID. * @param payload - The properties and context to merge in. - * @throws Error if no fragment has that ID. Use {@link upsertEventFragment} - * when the fragment may not exist yet. + * @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`; @@ -131,7 +133,9 @@ export type AnalyticsControllerDeleteEventFragmentAction = { * @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. + * @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`; diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 56af7ef4b20..a1f8942d2ea 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -1565,8 +1565,10 @@ export class AnalyticsController extends BaseController< * * @param id - The fragment ID. * @param payload - The properties and context to merge in. - * @throws Error if no fragment has that ID. Use {@link upsertEventFragment} - * when the fragment may not exist yet. + * @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. */ updateEventFragment( id: string, @@ -1626,7 +1628,9 @@ export class AnalyticsController extends BaseController< * @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. + * @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. */ finalizeEventFragment( id: string, From 1b12a93e1ef8232be8765374f8b15cf02112435e Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 2 Sep 2026 06:07:59 +0200 Subject: [PATCH 07/11] fix(analytics-controller): keep event fragments created during init Snapshot fragment IDs before awaited init work so reconciliation drops only stale non-persistent leftovers, not in-flight journeys started while init runs. Co-authored-by: Cursor --- .../src/AnalyticsController.test.ts | 66 ++++++++++++++++++- .../src/AnalyticsController.ts | 29 ++++++-- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 659a4559509..21ea96d82df 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -57,6 +57,10 @@ type SetupControllerOptions = { * a client that has not wired up geolocation. */ omitGeolocationAction?: boolean; + /** + * When true, {@link AnalyticsController.init} is not called automatically. + */ + skipInit?: boolean; }; type SetupControllerReturn = { @@ -97,6 +101,7 @@ type MockAnalyticsPlatformAdapter = AnalyticsPlatformAdapter & { * @param options.geolocation - Optional geolocation returned by the mocked geolocation action * @param options.geolocationHandler - Optional handler for the mocked geolocation action * @param options.omitGeolocationAction - When true, the geolocation action is not registered + * @param options.skipInit - When true, init is not called automatically * @returns The controller and messenger */ async function setupController( @@ -113,6 +118,7 @@ async function setupController( geolocation, geolocationHandler, omitGeolocationAction = false, + skipInit = false, } = options; const adapter = @@ -164,7 +170,9 @@ async function setupController( isEventFragmentsEnabled, }); - await controller.init(); + if (!skipInit) { + await controller.init(); + } return { controller, @@ -3675,6 +3683,62 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).not.toHaveBeenCalled(); }); + it('keeps fragments created while init is in flight and still drops stale non-persistent ones', async () => { + let resolveGeolocation!: (value: GeolocationData) => void; + const geolocationHandler = jest.fn( + () => + new Promise((resolve) => { + resolveGeolocation = resolve; + }), + ); + const mockAdapter = createMockAdapter(); + const analyticsId = '11111111-2222-4333-8444-555555555555'; + + const { controller } = await setupController({ + state: { + optedIn: true, + consentDecisionMade: true, + analyticsId, + eventFragments: { + 'transaction-ui-1': buildFragment({ id: 'transaction-ui-1' }), + }, + }, + platformAdapter: mockAdapter, + isEventFragmentsEnabled: true, + isGeolocationEnabled: true, + geolocationHandler, + skipInit: true, + }); + + const initPromise = controller.init(); + + controller.createEventFragment({ + id: 'signature-1', + successEvent: 'Signature Approved', + properties: { signature_type: 'personal_sign' }, + }); + + resolveGeolocation(buildGeolocationData()); + await initPromise; + + expect(controller.state.eventFragments).toStrictEqual({ + 'signature-1': expect.objectContaining({ + id: 'signature-1', + successEvent: 'Signature Approved', + properties: { signature_type: 'personal_sign' }, + }), + }); + + controller.finalizeEventFragment('signature-1'); + + expect(mockAdapter.track).toHaveBeenCalledWith( + 'Signature Approved', + { signature_type: 'personal_sign' }, + undefined, + ); + expect(controller.state.eventFragments).toStrictEqual({}); + }); + it('leaves state untouched when every fragment is persistent', async () => { const { controller } = await setupFragmentController({ state: { diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index a1f8942d2ea..2a4e32a61c0 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -688,6 +688,12 @@ export class AnalyticsController extends BaseController< * and pre-consent events. */ async #performInit(): Promise { + // Snapshot fragment IDs before any awaited init work so reconciliation can + // tell previous-session leftovers from fragments created while init runs. + const initEventFragmentIds = new Set( + Object.keys(this.state.eventFragments ?? {}), + ); + // Resolve geolocation only when the user is already opted in; for undecided // or opted-out users it is deferred to {@link optIn}. Awaited so that an // already-opted-in session has location available before events replay. @@ -704,7 +710,7 @@ export class AnalyticsController extends BaseController< this.#replayQueuedEvents(); this.#reconcilePreConsentEvents(); - this.#reconcileEventFragments(); + this.#reconcileEventFragments(initEventFragmentIds); } /** @@ -1161,8 +1167,14 @@ export class AnalyticsController extends BaseController< * consent state no longer allows capture (e.g. the fragments were written * before the user opted out), every persisted fragment is dropped so none of * them can linger. + * + * Non-persistent fragments are dropped only when their ID was already present + * at the start of {@link init}. Fragments created while init is in flight are + * kept so a slow startup path cannot discard an in-progress journey. + * + * @param initEventFragmentIds - Fragment IDs present when {@link init} began. */ - #reconcileEventFragments(): void { + #reconcileEventFragments(initEventFragmentIds: Set): void { const fragments = this.state.eventFragments; if (!fragments) { @@ -1174,20 +1186,22 @@ export class AnalyticsController extends BaseController< return; } - this.#purgeNonPersistentEventFragments(fragments); + this.#purgeNonPersistentEventFragments(fragments, initEventFragmentIds); } /** - * Drop every persisted fragment that is invalid or did not opt into - * `persist`. + * Drop every persisted fragment that is invalid, did not opt into `persist`, + * or was already present when {@link init} began. * * Only called by {@link #reconcileEventFragments}, which guarantees the * fragments exist and that the event fragments feature is enabled. * * @param currentEventFragments - The persisted fragments to filter. + * @param initEventFragmentIds - Fragment IDs present when {@link init} began. */ #purgeNonPersistentEventFragments( currentEventFragments: AnalyticsEventFragments, + initEventFragmentIds: Set, ): void { const eventFragments: AnalyticsEventFragments = {}; @@ -1197,7 +1211,10 @@ export class AnalyticsController extends BaseController< continue; } - if (fragment.persist === true) { + if ( + fragment.persist === true || + !initEventFragmentIds.has(id) + ) { eventFragments[id] = fragment; } } From 720824199d244721ec22164b0370c36a492af6da Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 2 Sep 2026 06:27:14 +0200 Subject: [PATCH 08/11] fix(analytics-controller): keep replaced event fragments during init Snapshot fragment createdAt at init start so purge can distinguish stale leftovers from fragments replaced via createEventFragment while init runs. Co-authored-by: Cursor --- .../src/AnalyticsController.test.ts | 65 +++++++++++++++++++ .../src/AnalyticsController.ts | 51 ++++++++++----- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 21ea96d82df..3e26f662710 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -3739,6 +3739,71 @@ describe('AnalyticsController', () => { expect(controller.state.eventFragments).toStrictEqual({}); }); + it('keeps a fragment that reuses an ID from a stale leftover during init', async () => { + let resolveGeolocation!: (value: GeolocationData) => void; + const geolocationHandler = jest.fn( + () => + new Promise((resolve) => { + resolveGeolocation = resolve; + }), + ); + const mockAdapter = createMockAdapter(); + const analyticsId = '11111111-2222-4333-8444-555555555555'; + const staleCreatedAt = 1700000000000; + + const { controller } = await setupController({ + state: { + optedIn: true, + consentDecisionMade: true, + analyticsId, + eventFragments: { + 'signature-123': buildFragment({ + id: 'signature-123', + createdAt: staleCreatedAt, + lastUpdated: staleCreatedAt, + }), + }, + }, + platformAdapter: mockAdapter, + isEventFragmentsEnabled: true, + isGeolocationEnabled: true, + geolocationHandler, + skipInit: true, + }); + + const initPromise = controller.init(); + + controller.createEventFragment({ + id: 'signature-123', + successEvent: 'Signature Approved', + properties: { signature_type: 'personal_sign' }, + }); + + resolveGeolocation(buildGeolocationData()); + await initPromise; + + expect(controller.state.eventFragments).toStrictEqual({ + 'signature-123': expect.objectContaining({ + id: 'signature-123', + successEvent: 'Signature Approved', + properties: { signature_type: 'personal_sign' }, + createdAt: expect.any(Number), + }), + }); + expect( + controller.state.eventFragments?.['signature-123']?.createdAt, + ).not.toBe(staleCreatedAt); + + controller.finalizeEventFragment('signature-123'); + + expect(mockAdapter.track).toHaveBeenCalledWith( + 'Signature Approved', + { signature_type: 'personal_sign' }, + undefined, + ); + expect(controller.state.eventFragments).toStrictEqual({}); + }); + it('leaves state untouched when every fragment is persistent', async () => { const { controller } = await setupFragmentController({ state: { diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 2a4e32a61c0..1e365a649e5 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -688,11 +688,21 @@ export class AnalyticsController extends BaseController< * and pre-consent events. */ async #performInit(): Promise { - // Snapshot fragment IDs before any awaited init work so reconciliation can - // tell previous-session leftovers from fragments created while init runs. - const initEventFragmentIds = new Set( - Object.keys(this.state.eventFragments ?? {}), - ); + // Snapshot fragment IDs and createdAt before any awaited init work so + // reconciliation can tell previous-session leftovers from fragments + // created or replaced while init runs. + const initEventFragmentSnapshot = new Map(); + for (const [id, fragment] of Object.entries( + this.state.eventFragments ?? {}, + )) { + if ( + isAnalyticsEventFragment(fragment) && + fragment.id === id && + typeof fragment.createdAt === 'number' + ) { + initEventFragmentSnapshot.set(id, fragment.createdAt); + } + } // Resolve geolocation only when the user is already opted in; for undecided // or opted-out users it is deferred to {@link optIn}. Awaited so that an @@ -710,7 +720,7 @@ export class AnalyticsController extends BaseController< this.#replayQueuedEvents(); this.#reconcilePreConsentEvents(); - this.#reconcileEventFragments(initEventFragmentIds); + this.#reconcileEventFragments(initEventFragmentSnapshot); } /** @@ -1168,13 +1178,15 @@ export class AnalyticsController extends BaseController< * before the user opted out), every persisted fragment is dropped so none of * them can linger. * - * Non-persistent fragments are dropped only when their ID was already present - * at the start of {@link init}. Fragments created while init is in flight are - * kept so a slow startup path cannot discard an in-progress journey. + * Non-persistent fragments are dropped only when their ID and `createdAt` + * match a fragment present at the start of {@link init}. Fragments created + * or replaced while init is in flight are kept so a slow startup path cannot + * discard an in-progress journey. * - * @param initEventFragmentIds - Fragment IDs present when {@link init} began. + * @param initEventFragmentSnapshot - Fragment IDs and `createdAt` values + * present when {@link init} began. */ - #reconcileEventFragments(initEventFragmentIds: Set): void { + #reconcileEventFragments(initEventFragmentSnapshot: Map): void { const fragments = this.state.eventFragments; if (!fragments) { @@ -1186,22 +1198,26 @@ export class AnalyticsController extends BaseController< return; } - this.#purgeNonPersistentEventFragments(fragments, initEventFragmentIds); + this.#purgeNonPersistentEventFragments( + fragments, + initEventFragmentSnapshot, + ); } /** * Drop every persisted fragment that is invalid, did not opt into `persist`, - * or was already present when {@link init} began. + * or was already present with the same `createdAt` when {@link init} began. * * Only called by {@link #reconcileEventFragments}, which guarantees the * fragments exist and that the event fragments feature is enabled. * * @param currentEventFragments - The persisted fragments to filter. - * @param initEventFragmentIds - Fragment IDs present when {@link init} began. + * @param initEventFragmentSnapshot - Fragment IDs and `createdAt` values + * present when {@link init} began. */ #purgeNonPersistentEventFragments( currentEventFragments: AnalyticsEventFragments, - initEventFragmentIds: Set, + initEventFragmentSnapshot: Map, ): void { const eventFragments: AnalyticsEventFragments = {}; @@ -1211,9 +1227,12 @@ export class AnalyticsController extends BaseController< continue; } + const snapshotCreatedAt = initEventFragmentSnapshot.get(id); + if ( fragment.persist === true || - !initEventFragmentIds.has(id) + snapshotCreatedAt === undefined || + fragment.createdAt !== snapshotCreatedAt ) { eventFragments[id] = fragment; } From 0d04fb6220e392cc69fad10d027745bde83fe76f Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 2 Sep 2026 07:08:38 +0200 Subject: [PATCH 09/11] fix(analytics-controller): fix formatting and restore branch coverage Apply Prettier formatting for lint:misc:check and add tests covering fragment context preservation and AnalyticsPlatformAdapterSetupError. Co-authored-by: Cursor --- .../src/AnalyticsController.test.ts | 28 +++++++++++++++++++ .../src/AnalyticsController.ts | 4 ++- .../analytics-controller/src/selectors.ts | 4 +-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 3e26f662710..009a05d589b 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -3296,6 +3296,24 @@ describe('AnalyticsController', () => { }); }); + it('preserves fragment context when an update omits context', async () => { + const { controller } = await setupFragmentController(); + controller.createEventFragment({ + id: 'signature-1', + context: { referrer: { url: 'https://dapp.test' } }, + }); + + controller.updateEventFragment('signature-1', { + properties: { signature_type: 'personal_sign' }, + }); + + expect( + controller.state.eventFragments?.['signature-1']?.context, + ).toStrictEqual({ + referrer: { url: 'https://dapp.test' }, + }); + }); + it('leaves the context unset when neither side has one', async () => { const { controller } = await setupFragmentController(); controller.createEventFragment({ id: 'signature-1' }); @@ -3946,3 +3964,13 @@ describe('AnalyticsController', () => { }); }); }); + +describe('AnalyticsPlatformAdapterSetupError', () => { + it('can be constructed without a cause', () => { + const error = new AnalyticsPlatformAdapterSetupError('setup failed'); + + expect(error.message).toBe('setup failed'); + expect(error.name).toBe('AnalyticsPlatformAdapterSetupError'); + expect(error.cause).toBeUndefined(); + }); +}); diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 1e365a649e5..72d030faa70 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -1186,7 +1186,9 @@ export class AnalyticsController extends BaseController< * @param initEventFragmentSnapshot - Fragment IDs and `createdAt` values * present when {@link init} began. */ - #reconcileEventFragments(initEventFragmentSnapshot: Map): void { + #reconcileEventFragments( + initEventFragmentSnapshot: Map, + ): void { const fragments = this.state.eventFragments; if (!fragments) { diff --git a/packages/analytics-controller/src/selectors.ts b/packages/analytics-controller/src/selectors.ts index f948263a4f2..79591012ffb 100644 --- a/packages/analytics-controller/src/selectors.ts +++ b/packages/analytics-controller/src/selectors.ts @@ -4,9 +4,7 @@ import type { AnalyticsEventFragments, } from './EventFragment.types.js'; -const EMPTY_EVENT_FRAGMENTS = Object.freeze( - {}, -) as AnalyticsEventFragments; +const EMPTY_EVENT_FRAGMENTS = Object.freeze({}) as AnalyticsEventFragments; /** * Selects the analytics ID from the controller state. From 2cd4bd23b6735a8d15f61588d50b11d9ccd7d0d6 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 2 Sep 2026 12:34:10 +0200 Subject: [PATCH 10/11] feat(analytics-controller): expire abandoned persisted event fragments Drop persisted fragments whose lastUpdated is older than 24 hours on init so abandoned journeys cannot keep properties in storage indefinitely. Co-authored-by: Cursor --- packages/analytics-controller/README.md | 2 +- .../src/AnalyticsController.test.ts | 120 +++++++++++++++++- .../src/AnalyticsController.ts | 42 ++++-- .../src/EventFragment.types.ts | 7 +- packages/analytics-controller/src/index.ts | 1 + 5 files changed, 153 insertions(+), 19 deletions(-) diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index 119cce8ee07..ea001638dc2 100644 --- a/packages/analytics-controller/README.md +++ b/packages/analytics-controller/README.md @@ -83,7 +83,7 @@ Emission goes through `trackEvent`, so consent gating, anonymous event splitting 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, and all of them 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 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. diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 009a05d589b..29b52470171 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -11,6 +11,7 @@ import { isValidUUIDv4 } from './analyticsControllerStateValidator.js'; import { AnalyticsController, AnalyticsPlatformAdapterSetupError, + EVENT_FRAGMENT_MAX_AGE, getDefaultAnalyticsControllerState, analyticsControllerSelectors, } from './index.js'; @@ -425,6 +426,7 @@ describe('AnalyticsController', () => { }); it('persists eventFragments but excludes them from logs, snapshots, and UI', async () => { + const now = Date.now(); const state: AnalyticsControllerState = { ...metadataFixtureState, eventFragments: { @@ -434,8 +436,8 @@ describe('AnalyticsController', () => { sensitiveProperties: { eip712_primary_type: 'Permit' }, successEvent: 'Signature Approved', persist: true, - createdAt: 1700000000000, - lastUpdated: 1700000000000, + createdAt: now, + lastUpdated: now, }, }, }; @@ -3010,11 +3012,12 @@ describe('AnalyticsController', () => { function buildFragment( overrides: Partial & { id: string }, ): AnalyticsEventFragment { + const now = Date.now(); return { properties: {}, sensitiveProperties: {}, - createdAt: 1700000000000, - lastUpdated: 1700000000000, + createdAt: now, + lastUpdated: now, ...overrides, }; } @@ -3701,6 +3704,115 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).not.toHaveBeenCalled(); }); + it('drops persisted fragments whose lastUpdated is older than EVENT_FRAGMENT_MAX_AGE', async () => { + const now = 1_800_000_000_000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const fresh = buildFragment({ + id: 'signature-fresh', + persist: true, + createdAt: now - EVENT_FRAGMENT_MAX_AGE + 1, + lastUpdated: now - EVENT_FRAGMENT_MAX_AGE + 1, + }); + const expired = buildFragment({ + id: 'signature-expired', + persist: true, + createdAt: now - EVENT_FRAGMENT_MAX_AGE - 1, + lastUpdated: now - EVENT_FRAGMENT_MAX_AGE - 1, + sensitiveProperties: { eip712_primary_type: 'Permit' }, + }); + + const { controller, mockAdapter } = await setupFragmentController({ + state: { + eventFragments: { + 'signature-fresh': fresh, + 'signature-expired': expired, + }, + }, + }); + + expect(controller.state.eventFragments).toStrictEqual({ + 'signature-fresh': fresh, + }); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('drops a fragment whose lastUpdated exceeds max age even when createdAt is recent', async () => { + const now = 1_800_000_000_000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const recentlyCreatedButStale = buildFragment({ + id: 'signature-1', + persist: true, + createdAt: now, + lastUpdated: now - EVENT_FRAGMENT_MAX_AGE - 1, + }); + + const { controller } = await setupFragmentController({ + state: { + eventFragments: { + 'signature-1': recentlyCreatedButStale, + }, + }, + }); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('keeps a fragment replaced during init even when the leftover ID was expired', async () => { + let resolveGeolocation!: (value: GeolocationData) => void; + const geolocationHandler = jest.fn( + () => + new Promise((resolve) => { + resolveGeolocation = resolve; + }), + ); + const mockAdapter = createMockAdapter(); + const analyticsId = '11111111-2222-4333-8444-555555555555'; + const now = 1_800_000_000_000; + jest.spyOn(Date, 'now').mockReturnValue(now); + + const { controller } = await setupController({ + state: { + optedIn: true, + consentDecisionMade: true, + analyticsId, + eventFragments: { + 'signature-123': buildFragment({ + id: 'signature-123', + persist: true, + createdAt: now - EVENT_FRAGMENT_MAX_AGE - 1, + lastUpdated: now - EVENT_FRAGMENT_MAX_AGE - 1, + properties: { stale: true }, + }), + }, + }, + platformAdapter: mockAdapter, + isEventFragmentsEnabled: true, + isGeolocationEnabled: true, + geolocationHandler, + skipInit: true, + }); + + const initPromise = controller.init(); + + controller.createEventFragment({ + id: 'signature-123', + successEvent: 'Signature Approved', + properties: { signature_type: 'personal_sign' }, + }); + + resolveGeolocation(buildGeolocationData()); + await initPromise; + + expect(controller.state.eventFragments).toStrictEqual({ + 'signature-123': expect.objectContaining({ + id: 'signature-123', + successEvent: 'Signature Approved', + properties: { signature_type: 'personal_sign' }, + lastUpdated: now, + }), + }); + }); + it('keeps fragments created while init is in flight and still drops stale non-persistent ones', async () => { let resolveGeolocation!: (value: GeolocationData) => void; const geolocationHandler = jest.fn( diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 72d030faa70..921850ffe66 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -43,6 +43,17 @@ import { analyticsControllerSelectors } from './selectors.js'; */ export const controllerName = 'AnalyticsController'; +/** + * Maximum age of a persisted event fragment, measured from + * {@link AnalyticsEventFragment.lastUpdated}. + * + * Fragments older than this are discarded during {@link AnalyticsController.init} + * without emitting a success or failure event. Confirmation journeys that span a + * restart are expected to resume within this window; abandoned ones must not keep + * `properties` or `sensitiveProperties` in storage indefinitely. + */ +export const EVENT_FRAGMENT_MAX_AGE = 24 * 60 * 60 * 1000; + // === STATE === /** @@ -92,7 +103,9 @@ export type AnalyticsControllerState = { /** * Persisted event fragments ({@link AnalyticsEventFragment}) keyed by * fragment ID. Fragments accumulate properties across a user journey and are - * removed when the journey is finalized or deleted. + * removed when the journey is finalized or deleted. Fragments that set + * `persist: true` can survive {@link AnalyticsController.init}, but only + * while younger than {@link EVENT_FRAGMENT_MAX_AGE}. * This is only used when the event fragments feature is enabled. */ eventFragments?: AnalyticsEventFragments; @@ -1169,9 +1182,10 @@ export class AnalyticsController extends BaseController< * Reconcile persisted event fragments on initialization. * * A fragment describes a journey that was in progress when the previous - * session ended. Only fragments that opted into `persist` can be resumed, so - * the rest are discarded. Nothing is emitted: a journey that never reached - * its own finalization is not a failure, just an unfinished one. + * session ended. Only fragments that opted into `persist` and are younger + * than {@link EVENT_FRAGMENT_MAX_AGE} can be resumed, so the rest are + * discarded. Nothing is emitted: a journey that never reached its own + * finalization is not a failure, just an unfinished one. * * If the feature is disabled (e.g. a previous session had it enabled), or the * consent state no longer allows capture (e.g. the fragments were written @@ -1181,7 +1195,7 @@ export class AnalyticsController extends BaseController< * Non-persistent fragments are dropped only when their ID and `createdAt` * match a fragment present at the start of {@link init}. Fragments created * or replaced while init is in flight are kept so a slow startup path cannot - * discard an in-progress journey. + * discard an in-progress journey, as long as they have not expired. * * @param initEventFragmentSnapshot - Fragment IDs and `createdAt` values * present when {@link init} began. @@ -1200,15 +1214,13 @@ export class AnalyticsController extends BaseController< return; } - this.#purgeNonPersistentEventFragments( - fragments, - initEventFragmentSnapshot, - ); + this.#purgeStaleEventFragments(fragments, initEventFragmentSnapshot); } /** - * Drop every persisted fragment that is invalid, did not opt into `persist`, - * or was already present with the same `createdAt` when {@link init} began. + * Drop every persisted fragment that is invalid, expired, did not opt into + * `persist`, or was already present with the same `createdAt` when + * {@link init} began. * * Only called by {@link #reconcileEventFragments}, which guarantees the * fragments exist and that the event fragments feature is enabled. @@ -1217,11 +1229,12 @@ export class AnalyticsController extends BaseController< * @param initEventFragmentSnapshot - Fragment IDs and `createdAt` values * present when {@link init} began. */ - #purgeNonPersistentEventFragments( + #purgeStaleEventFragments( currentEventFragments: AnalyticsEventFragments, initEventFragmentSnapshot: Map, ): void { const eventFragments: AnalyticsEventFragments = {}; + const now = Date.now(); for (const [id, fragment] of Object.entries(currentEventFragments)) { if (!isAnalyticsEventFragment(fragment) || fragment.id !== id) { @@ -1229,6 +1242,11 @@ export class AnalyticsController extends BaseController< continue; } + if (now - fragment.lastUpdated > EVENT_FRAGMENT_MAX_AGE) { + log('Dropping expired persisted event fragment', { id }); + continue; + } + const snapshotCreatedAt = initEventFragmentSnapshot.get(id); if ( diff --git a/packages/analytics-controller/src/EventFragment.types.ts b/packages/analytics-controller/src/EventFragment.types.ts index 6a60aadff98..869b6df0a12 100644 --- a/packages/analytics-controller/src/EventFragment.types.ts +++ b/packages/analytics-controller/src/EventFragment.types.ts @@ -57,7 +57,9 @@ export type AnalyticsEventFragment = { /** * Whether the fragment survives {@link AnalyticsController.init}. Fragments * that do not set this are discarded when the controller re-initializes, - * since the journey they belonged to cannot be resumed. + * since the journey they belonged to cannot be resumed. Even with this set, + * a fragment whose {@link lastUpdated} is older than the controller's max + * fragment age is discarded on init. */ persist?: boolean; @@ -67,7 +69,8 @@ export type AnalyticsEventFragment = { createdAt: number; /** - * `Date.now()` when the fragment was last written to. + * `Date.now()` when the fragment was last written to. Used on + * {@link AnalyticsController.init} to expire abandoned persisted fragments. */ lastUpdated: number; }; diff --git a/packages/analytics-controller/src/index.ts b/packages/analytics-controller/src/index.ts index 5e598c4630d..3aaeaaea975 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -1,6 +1,7 @@ // Export controller class and state utilities export { AnalyticsController, + EVENT_FRAGMENT_MAX_AGE, getDefaultAnalyticsControllerState, } from './AnalyticsController.js'; export type { AnalyticsControllerOptions } from './AnalyticsController.js'; From 6a23a60591662f47fca6149ae9ac4d899a472359 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 2 Sep 2026 13:57:38 +0200 Subject: [PATCH 11/11] fix(analytics-controller): return readonly copies of event fragments Expose ReadonlyAnalyticsEventFragment from create and get so callers cannot mutate controller state without going through update or upsert. Co-authored-by: Cursor --- ...AnalyticsController-method-action-types.ts | 14 +++-- .../src/AnalyticsController.test.ts | 57 +++++++++++++++++++ .../src/AnalyticsController.ts | 29 ++++++---- .../src/EventFragment.types.ts | 21 +++++++ packages/analytics-controller/src/index.ts | 1 + 5 files changed, 107 insertions(+), 15 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index f39809783e6..c0882256c38 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -60,8 +60,10 @@ export type AnalyticsControllerTrackViewAction = { * * @param options - The fragment definition. An ID is generated when one is * not supplied. - * @returns The created fragment, or `undefined` when the event fragments - * feature is disabled or the consent state does not allow capture. + * @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`; @@ -102,9 +104,11 @@ export type AnalyticsControllerUpdateEventFragmentAction = { * Read an event fragment. * * @param id - The fragment ID. - * @returns The fragment, or `undefined` when no fragment has that ID, the - * event fragments feature is disabled, or the consent state does not allow - * capture. + * @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`; diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 29b52470171..4ef837a5942 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -3103,6 +3103,30 @@ describe('AnalyticsController', () => { }); }); + it('returns a copy that cannot mutate controller state', async () => { + const { controller } = await setupFragmentController(); + const fragment = controller.createEventFragment({ + id: 'signature-1', + properties: { signature_type: 'personal_sign' }, + context: { referrer: { url: 'https://dapp.test' } }, + }); + expect(fragment).toBeDefined(); + + (fragment as AnalyticsEventFragment).properties.signature_type = + 'eth_signTypedData_v4'; + const context = (fragment as AnalyticsEventFragment).context as { + referrer: { url: string }; + }; + context.referrer.url = 'https://evil.test'; + + expect(controller.state.eventFragments?.['signature-1']).toStrictEqual( + expect.objectContaining({ + properties: { signature_type: 'personal_sign' }, + context: { referrer: { url: 'https://dapp.test' } }, + }), + ); + }); + it('stores every supplied field under the supplied ID', async () => { const { controller } = await setupFragmentController(); @@ -3364,6 +3388,39 @@ describe('AnalyticsController', () => { ); }); + it('returns a copy that cannot mutate controller state', async () => { + const { controller } = await setupFragmentController(); + controller.createEventFragment({ + id: 'signature-1', + properties: { signature_type: 'personal_sign' }, + sensitiveProperties: { eip712_primary_type: 'Permit' }, + context: { referrer: { url: 'https://dapp.test' } }, + }); + + const fragment = controller.getEventFragmentById('signature-1'); + expect(fragment).toBeDefined(); + + // Readonly is a type-level contract. At runtime we still return a deep + // copy so accidental writes cannot reach controller state. + (fragment as AnalyticsEventFragment).properties.signature_type = + 'eth_signTypedData_v4'; + ( + fragment as AnalyticsEventFragment + ).sensitiveProperties.eip712_primary_type = 'Order'; + const context = (fragment as AnalyticsEventFragment).context as { + referrer: { url: string }; + }; + context.referrer.url = 'https://evil.test'; + + expect(controller.state.eventFragments?.['signature-1']).toStrictEqual( + expect.objectContaining({ + properties: { signature_type: 'personal_sign' }, + sensitiveProperties: { eip712_primary_type: 'Permit' }, + context: { referrer: { url: 'https://dapp.test' } }, + }), + ); + }); + it('returns undefined for an unknown ID', async () => { const { controller } = await setupFragmentController(); diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 921850ffe66..9d282137088 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -31,6 +31,7 @@ import type { AnalyticsEventFragmentOptions, AnalyticsEventFragmentPayload, AnalyticsEventFragments, + ReadonlyAnalyticsEventFragment, } from './EventFragment.types.js'; import { analyticsControllerSelectors } from './selectors.js'; @@ -1544,12 +1545,14 @@ export class AnalyticsController extends BaseController< * * @param options - The fragment definition. An ID is generated when one is * not supplied. - * @returns The created fragment, or `undefined` when the event fragments - * feature is disabled or the consent state does not allow capture. + * @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. */ createEventFragment( options: AnalyticsEventFragmentOptions = {}, - ): AnalyticsEventFragment | undefined { + ): ReadonlyAnalyticsEventFragment | undefined { if (this.#shouldIgnoreEventFragmentCall('createEventFragment')) { return undefined; } @@ -1571,7 +1574,9 @@ export class AnalyticsController extends BaseController< ...(options.failureEvent === undefined ? {} : { failureEvent: options.failureEvent }), - ...(options.context === undefined ? {} : { context: options.context }), + ...(options.context === undefined + ? {} + : { context: { ...options.context } }), ...(options.persist === undefined ? {} : { persist: options.persist }), }; @@ -1585,7 +1590,7 @@ export class AnalyticsController extends BaseController< ); } - return fragment; + return cloneDeep(fragment); } /** @@ -1647,16 +1652,20 @@ export class AnalyticsController extends BaseController< * Read an event fragment. * * @param id - The fragment ID. - * @returns The fragment, or `undefined` when no fragment has that ID, the - * event fragments feature is disabled, or the consent state does not allow - * capture. + * @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. */ - getEventFragmentById(id: string): AnalyticsEventFragment | undefined { + getEventFragmentById(id: string): ReadonlyAnalyticsEventFragment | undefined { if (this.#shouldIgnoreEventFragmentCall('getEventFragmentById')) { return undefined; } - return this.#getEventFragment(id); + const fragment = this.#getEventFragment(id); + + return fragment === undefined ? undefined : cloneDeep(fragment); } /** diff --git a/packages/analytics-controller/src/EventFragment.types.ts b/packages/analytics-controller/src/EventFragment.types.ts index 869b6df0a12..61a03996d03 100644 --- a/packages/analytics-controller/src/EventFragment.types.ts +++ b/packages/analytics-controller/src/EventFragment.types.ts @@ -75,6 +75,27 @@ export type AnalyticsEventFragment = { lastUpdated: number; }; +/** + * Public, read-only view of an {@link AnalyticsEventFragment}. + * + * Returned by {@link AnalyticsController.createEventFragment} and + * {@link AnalyticsController.getEventFragmentById}. Callers must use + * {@link AnalyticsController.updateEventFragment} (or + * {@link AnalyticsController.upsertEventFragment}) to modify fragment data. + */ +export type ReadonlyAnalyticsEventFragment = Readonly<{ + id: string; + properties: Readonly; + sensitiveProperties: Readonly; + initialEvent?: string; + successEvent?: string; + failureEvent?: string; + context?: Readonly; + persist?: boolean; + createdAt: number; + lastUpdated: number; +}>; + /** * Event fragments keyed by fragment ID. */ diff --git a/packages/analytics-controller/src/index.ts b/packages/analytics-controller/src/index.ts index 3aaeaaea975..872b1812cb0 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -29,6 +29,7 @@ export type { AnalyticsEventFragmentOptions, AnalyticsEventFragmentPayload, AnalyticsEventFragments, + ReadonlyAnalyticsEventFragment, } from './EventFragment.types.js'; // Export state types