diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 96197d3ce09..c83b3f27f84 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 ([#10055](https://github.com/MetaMask/core/pull/10055)) + ### Changed - Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076)) diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index 90e31863782..ea001638dc2 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. Persistent fragments that have not been written to for longer than `EVENT_FRAGMENT_MAX_AGE` (24 hours, measured from `lastUpdated`) are also discarded, so abandoned journeys cannot keep `properties` or `sensitiveProperties` in storage indefinitely. All fragments are discarded when the consent state no longer allows accumulation. Nothing is emitted for a discarded fragment: a journey that never reached its own finalization is unfinished, not failed. + +This feature is disabled by default. When disabled, every fragment method is a logged no-op and no fragment is written to state. + ## Lifecycle Hooks ### `onSetupCompleted` diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index 8e223ee1f0b..c0882256c38 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -41,6 +41,111 @@ export type AnalyticsControllerTrackViewAction = { handler: AnalyticsController['trackView']; }; +/** + * Create an event fragment. + * + * A fragment accumulates properties across a user journey so that several + * parts of a client can contribute to the same set of events without + * re-deriving them. Declaring `successEvent` and `failureEvent` turns the + * fragment into a funnel that {@link finalizeEventFragment} closes. Declaring + * none of the event names makes it a pure property bag that the client reads + * back with {@link getEventFragmentById} when it emits its own events. + * + * Any existing fragment with the same ID is replaced, so a new journey never + * inherits properties from a stale one. + * + * Nothing is created unless the user is opted in, or undecided with the + * pre-consent queue enabled, so an opted-out user accumulates no fragment + * data. + * + * @param options - The fragment definition. An ID is generated when one is + * not supplied. + * @returns A read-only copy of the created fragment, or `undefined` when the + * event fragments feature is disabled or the consent state does not allow + * capture. Mutating the returned object does not change controller state. + * Use {@link updateEventFragment} or {@link upsertEventFragment} to write. + */ +export type AnalyticsControllerCreateEventFragmentAction = { + type: `AnalyticsController:createEventFragment`; + handler: AnalyticsController['createEventFragment']; +}; + +/** + * Write to an event fragment, creating a property bag if none exists. + * + * This is the ergonomic entry point for contributors that do not know + * whether the journey has been started yet, and it avoids the read then + * write race a caller would otherwise have to implement itself. + * + * @param id - The fragment ID. + * @param payload - The properties and context to merge in. + */ +export type AnalyticsControllerUpsertEventFragmentAction = { + type: `AnalyticsController:upsertEventFragment`; + handler: AnalyticsController['upsertEventFragment']; +}; + +/** + * Write to an existing event fragment. + * + * @param id - The fragment ID. + * @param payload - The properties and context to merge in. + * @throws Error if no fragment has that ID when the call is not ignored. + * Use {@link upsertEventFragment} when the fragment may not exist yet. + * When the event fragments feature is disabled or the consent state does not + * allow capture, the call is a logged no-op and does not throw. + */ +export type AnalyticsControllerUpdateEventFragmentAction = { + type: `AnalyticsController:updateEventFragment`; + handler: AnalyticsController['updateEventFragment']; +}; + +/** + * Read an event fragment. + * + * @param id - The fragment ID. + * @returns A read-only copy of the fragment, or `undefined` when no fragment + * has that ID, the event fragments feature is disabled, or the consent state + * does not allow capture. Mutating the returned object does not change + * controller state. Use {@link updateEventFragment} or + * {@link upsertEventFragment} to write. + */ +export type AnalyticsControllerGetEventFragmentByIdAction = { + type: `AnalyticsController:getEventFragmentById`; + handler: AnalyticsController['getEventFragmentById']; +}; + +/** + * Discard an event fragment without emitting anything. + * + * @param id - The fragment ID. + */ +export type AnalyticsControllerDeleteEventFragmentAction = { + type: `AnalyticsController:deleteEventFragment`; + handler: AnalyticsController['deleteEventFragment']; +}; + +/** + * Close an event fragment, emitting its closing event and discarding it. + * + * The event emitted is `failureEvent` when the journey was abandoned and + * `successEvent` otherwise. A fragment that does not declare the relevant + * event name is discarded silently, which is what makes a pure property bag + * possible. + * + * @param id - The fragment ID. + * @param options - Finalization options. + * @param options.abandoned - Whether the journey was abandoned. + * @param options.context - Context merged over the fragment's own context. + * @throws Error if no fragment has that ID when the call is not ignored. + * When the event fragments feature is disabled or the consent state does not + * allow capture, the call is a logged no-op and does not throw. + */ +export type AnalyticsControllerFinalizeEventFragmentAction = { + type: `AnalyticsController:finalizeEventFragment`; + handler: AnalyticsController['finalizeEventFragment']; +}; + /** * Opt in to analytics. * @@ -62,7 +167,8 @@ export type AnalyticsControllerOptInAction = { * Opt out of analytics. * * Records that a consent decision has been made and discards any persisted - * events so nothing captured before the decision is ever delivered. + * events and in-progress event fragments so nothing captured before the + * decision is ever delivered. */ export type AnalyticsControllerOptOutAction = { type: `AnalyticsController:optOut`; @@ -76,6 +182,10 @@ export type AnalyticsControllerOptOutAction = { * preference and discards the delivery queue, but preserves any pre-consent * events so they can still be replayed if the user opts in again. The user is * treated as undecided again. + * + * In-progress event fragments are kept only while the undecided user can + * still accumulate them, and discarded otherwise, so no fragment outlives the + * consent state that allowed it. */ export type AnalyticsControllerResetConsentDecisionAction = { type: `AnalyticsController:resetConsentDecision`; @@ -89,6 +199,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..4ef837a5942 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'; @@ -23,6 +24,7 @@ import type { AnalyticsTrackingEvent, AnalyticsControllerState, AnalyticsContext, + AnalyticsEventFragment, } from './index.js'; /** @@ -40,6 +42,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. @@ -55,6 +58,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 = { @@ -91,9 +98,11 @@ 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 + * @param options.skipInit - When true, init is not called automatically * @returns The controller and messenger */ async function setupController( @@ -106,9 +115,11 @@ async function setupController( isEventQueuePersistenceEnabled = false, isPreConsentQueueEnabled = false, isGeolocationEnabled = true, + isEventFragmentsEnabled = false, geolocation, geolocationHandler, omitGeolocationAction = false, + skipInit = false, } = options; const adapter = @@ -157,9 +168,12 @@ async function setupController( isEventQueuePersistenceEnabled, isPreConsentQueueEnabled, isGeolocationEnabled, + isEventFragmentsEnabled, }); - await controller.init(); + if (!skipInit) { + await controller.init(); + } return { controller, @@ -411,6 +425,57 @@ describe('AnalyticsController', () => { ).toHaveProperty('preConsentEventQueue', state.preConsentEventQueue); }); + it('persists eventFragments but excludes them from logs, snapshots, and UI', async () => { + const now = Date.now(); + 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: now, + lastUpdated: now, + }, + }, + }; + 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 +2956,1190 @@ 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 { + const now = Date.now(); + return { + properties: {}, + sensitiveProperties: {}, + createdAt: now, + lastUpdated: now, + ...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('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(); + + 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('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' }); + + 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 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(); + + 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('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( + () => + 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('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: { + 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, + ); + }); + }); + }); +}); + +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 085142bba6c..9d282137088 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -25,6 +25,14 @@ import type { AnalyticsUserTraits, AnalyticsTrackingEvent, } from './AnalyticsPlatformAdapter.types'; +import type { + AnalyticsEventFragment, + AnalyticsEventFragmentFinalizeOptions, + AnalyticsEventFragmentOptions, + AnalyticsEventFragmentPayload, + AnalyticsEventFragments, + ReadonlyAnalyticsEventFragment, +} from './EventFragment.types.js'; import { analyticsControllerSelectors } from './selectors.js'; // === GENERAL === @@ -36,6 +44,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 === /** @@ -81,6 +100,16 @@ 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. 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; }; /** @@ -206,6 +235,12 @@ const analyticsControllerMetadata = { includeInDebugSnapshot: false, usedInUi: false, }, + eventFragments: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, } satisfies StateMetadata; // === MESSENGER === @@ -217,6 +252,12 @@ const MESSENGER_EXPOSED_METHODS = [ 'optIn', 'optOut', 'resetConsentDecision', + 'createEventFragment', + 'upsertEventFragment', + 'updateEventFragment', + 'getEventFragmentById', + 'deleteEventFragment', + 'finalizeEventFragment', ] as const; /** @@ -330,6 +371,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 +476,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 +583,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 +612,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 +624,7 @@ export class AnalyticsController extends BaseController< isEventQueuePersistenceEnabled = false, isPreConsentQueueEnabled = false, isGeolocationEnabled = false, + isEventFragmentsEnabled = false, }: AnalyticsControllerOptions) { const initialState: AnalyticsControllerState = { ...getDefaultAnalyticsControllerState(), @@ -510,6 +647,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 +665,7 @@ export class AnalyticsController extends BaseController< eventQueuePersistenceEnabled: this.#isEventQueuePersistenceEnabled, preConsentQueueEnabled: this.#isPreConsentQueueEnabled, geolocationEnabled: this.#isGeolocationEnabled, + eventFragmentsEnabled: this.#isEventFragmentsEnabled, }); } @@ -563,6 +702,22 @@ export class AnalyticsController extends BaseController< * and pre-consent events. */ async #performInit(): Promise { + // 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 // already-opted-in session has location available before events replay. @@ -579,6 +734,7 @@ export class AnalyticsController extends BaseController< this.#replayQueuedEvents(); this.#reconcilePreConsentEvents(); + this.#reconcileEventFragments(initEventFragmentSnapshot); } /** @@ -1023,6 +1179,249 @@ 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` 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 + * 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 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, as long as they have not expired. + * + * @param initEventFragmentSnapshot - Fragment IDs and `createdAt` values + * present when {@link init} began. + */ + #reconcileEventFragments( + initEventFragmentSnapshot: Map, + ): void { + const fragments = this.state.eventFragments; + + if (!fragments) { + return; + } + + if (!this.#isEventFragmentsEnabled || !this.#isAnalyticsCaptureAllowed()) { + this.#clearEventFragments(); + return; + } + + this.#purgeStaleEventFragments(fragments, initEventFragmentSnapshot); + } + + /** + * 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. + * + * @param currentEventFragments - The persisted fragments to filter. + * @param initEventFragmentSnapshot - Fragment IDs and `createdAt` values + * present when {@link init} began. + */ + #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) { + log('Dropping invalid persisted event fragment', { id }); + continue; + } + + if (now - fragment.lastUpdated > EVENT_FRAGMENT_MAX_AGE) { + log('Dropping expired persisted event fragment', { id }); + continue; + } + + const snapshotCreatedAt = initEventFragmentSnapshot.get(id); + + if ( + fragment.persist === true || + snapshotCreatedAt === undefined || + fragment.createdAt !== snapshotCreatedAt + ) { + 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 +1431,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 +1526,204 @@ 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 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 = {}, + ): ReadonlyAnalyticsEventFragment | 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 cloneDeep(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 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, + 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 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): ReadonlyAnalyticsEventFragment | undefined { + if (this.#shouldIgnoreEventFragmentCall('getEventFragmentById')) { + return undefined; + } + + const fragment = this.#getEventFragment(id); + + return fragment === undefined ? undefined : cloneDeep(fragment); + } + + /** + * 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 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, + { 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 +1757,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 +1768,7 @@ export class AnalyticsController extends BaseController< this.#clearQueuedEvents(); this.#clearPreConsentEvents(); + this.#clearEventFragments(); } /** @@ -1184,6 +1778,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 +1790,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..61a03996d03 --- /dev/null +++ b/packages/analytics-controller/src/EventFragment.types.ts @@ -0,0 +1,144 @@ +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. Even with this set, + * a fragment whose {@link lastUpdated} is older than the controller's max + * fragment age is discarded on init. + */ + persist?: boolean; + + /** + * `Date.now()` when the fragment was created. + */ + createdAt: number; + + /** + * `Date.now()` when the fragment was last written to. Used on + * {@link AnalyticsController.init} to expire abandoned persisted fragments. + */ + 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. + */ +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..872b1812cb0 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'; @@ -21,6 +22,16 @@ export type { AnalyticsTrackingEvent, } from './AnalyticsPlatformAdapter.types'; +// Export event fragment types +export type { + AnalyticsEventFragment, + AnalyticsEventFragmentFinalizeOptions, + AnalyticsEventFragmentOptions, + AnalyticsEventFragmentPayload, + AnalyticsEventFragments, + ReadonlyAnalyticsEventFragment, +} from './EventFragment.types.js'; + // Export state types export type { AnalyticsControllerState, @@ -52,5 +63,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..d94322a29c8 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,93 @@ 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({}); + }); + + 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', () => { + 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..79591012ffb 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 = Object.freeze({}) as 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, };