diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 4f9a137d54..f7310aefb6 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 pre-consent event queue to `AnalyticsController` (disabled by default via `isPreConsentQueueEnabled`), with a `consentDecisionMade` state field, `selectConsentDecisionMade` selector, and `resetConsentDecision` action ([#9252](https://github.com/MetaMask/core/pull/9252)) + ### Changed - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index 75aec7cb17..794b68e09e 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -43,6 +43,9 @@ export type AnalyticsControllerTrackViewAction = { /** * Opt in to analytics. + * + * Records that a consent decision has been made and replays any events that + * were queued while the user was undecided. */ export type AnalyticsControllerOptInAction = { type: `AnalyticsController:optIn`; @@ -51,12 +54,28 @@ 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. */ export type AnalyticsControllerOptOutAction = { type: `AnalyticsController:optOut`; handler: AnalyticsController['optOut']; }; +/** + * Reset the consent decision back to undecided. + * + * Intended for client flows that restart onboarding. Clears the opt-in + * preference and discards both the delivery queue and any pre-consent events, + * so nothing captured before the reset is delivered and the user is treated + * as undecided again. + */ +export type AnalyticsControllerResetConsentDecisionAction = { + type: `AnalyticsController:resetConsentDecision`; + handler: AnalyticsController['resetConsentDecision']; +}; + /** * Union of all AnalyticsController action types. */ @@ -65,4 +84,5 @@ export type AnalyticsControllerMethodActions = | AnalyticsControllerIdentifyAction | AnalyticsControllerTrackViewAction | AnalyticsControllerOptInAction - | AnalyticsControllerOptOutAction; + | AnalyticsControllerOptOutAction + | AnalyticsControllerResetConsentDecisionAction; diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 377dcae63e..61b422bc2a 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -1,6 +1,7 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; import { AnalyticsController, @@ -25,6 +26,7 @@ type SetupControllerOptions = { platformAdapter?: AnalyticsPlatformAdapter; isAnonymousEventsFeatureEnabled?: boolean; isEventQueuePersistenceEnabled?: boolean; + isPreConsentQueueEnabled?: boolean; }; type SetupControllerReturn = { @@ -47,6 +49,7 @@ type MockAnalyticsPlatformAdapter = AnalyticsPlatformAdapter & { * @param options.platformAdapter - Optional platform adapter * @param options.isAnonymousEventsFeatureEnabled - Optional anonymous events feature flag (default: false) * @param options.isEventQueuePersistenceEnabled - Optional event queue persistence flag (default: false) + * @param options.isPreConsentQueueEnabled - Optional pre-consent queue flag (default: false) * @returns The controller and messenger */ async function setupController( @@ -57,6 +60,7 @@ async function setupController( platformAdapter, isAnonymousEventsFeatureEnabled = false, isEventQueuePersistenceEnabled = false, + isPreConsentQueueEnabled = false, } = options; const adapter = @@ -90,6 +94,7 @@ async function setupController( state, isAnonymousEventsFeatureEnabled, isEventQueuePersistenceEnabled, + isPreConsentQueueEnabled, }); controller.init(); @@ -164,6 +169,7 @@ describe('AnalyticsController', () => { expect(defaults).toStrictEqual({ optedIn: false, + consentDecisionMade: false, }); expect('analyticsId' in defaults).toBe(false); }); @@ -179,6 +185,7 @@ describe('AnalyticsController', () => { describe('metadata', () => { const metadataFixtureState: AnalyticsControllerState = { optedIn: true, + consentDecisionMade: true, analyticsId: '6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d', }; @@ -196,6 +203,7 @@ describe('AnalyticsController', () => { ).toMatchInlineSnapshot(` { "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", + "consentDecisionMade": true, "optedIn": true, } `); @@ -215,6 +223,7 @@ describe('AnalyticsController', () => { ).toMatchInlineSnapshot(` { "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", + "consentDecisionMade": true, "optedIn": true, } `); @@ -234,6 +243,7 @@ describe('AnalyticsController', () => { ).toMatchInlineSnapshot(` { "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", + "consentDecisionMade": true, "optedIn": true, } `); @@ -286,6 +296,59 @@ describe('AnalyticsController', () => { ).toHaveProperty('eventQueue', state.eventQueue); }); + it('persists preConsentEventQueue but excludes it from logs, snapshots, and UI', async () => { + // Undecided + queue enabled, so init() leaves the queue untouched. + const state: AnalyticsControllerState = { + ...metadataFixtureState, + optedIn: false, + consentDecisionMade: false, + preConsentEventQueue: { + 'message-id-1': { + type: 'track', + eventName: 'test_event', + messageId: 'message-id-1', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { + sensitive_prop: 'sensitive value', + }, + }, + }, + }; + const { controller } = await setupController({ + state, + isPreConsentQueueEnabled: true, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).not.toHaveProperty('preConsentEventQueue'); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).not.toHaveProperty('preConsentEventQueue'); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).not.toHaveProperty('preConsentEventQueue'); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toHaveProperty('preConsentEventQueue', state.preConsentEventQueue); + }); + it('exposes expected state to UI', async () => { const { controller } = await setupController({ state: metadataFixtureState, @@ -299,6 +362,7 @@ describe('AnalyticsController', () => { ), ).toMatchInlineSnapshot(` { + "consentDecisionMade": true, "optedIn": true, } `); @@ -1857,7 +1921,7 @@ describe('AnalyticsController', () => { }); describe('optIn', () => { - it('sets optedIn to true', async () => { + it('sets optedIn to true and records the consent decision', async () => { const { controller } = await setupController({ state: { ...getDefaultAnalyticsControllerState(), @@ -1868,14 +1932,16 @@ describe('AnalyticsController', () => { controller.optIn(); expect(controller.state.optedIn).toBe(true); + expect(controller.state.consentDecisionMade).toBe(true); }); }); describe('optOut', () => { - it('sets optedIn to false', async () => { + it('sets optedIn to false and records the consent decision', async () => { const { controller } = await setupController({ state: { optedIn: true, + consentDecisionMade: true, analyticsId: '01234567-89ab-4cde-8f01-23456789abcd', }, }); @@ -1883,6 +1949,355 @@ describe('AnalyticsController', () => { controller.optOut(); expect(controller.state.optedIn).toBe(false); + expect(controller.state.consentDecisionMade).toBe(true); + }); + }); + + describe('pre-consent event queue', () => { + const ANALYTICS_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; + + /** + * Sets up an undecided controller with the pre-consent queue enabled and + * queues a single track event. + * + * @returns The controller, mock adapter, and the queued event name. + */ + async function setupControllerWithQueuedEvent(): Promise<{ + controller: AnalyticsController; + mockAdapter: MockAnalyticsPlatformAdapter; + }> { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + ...getDefaultAnalyticsControllerState(), + analyticsId: ANALYTICS_ID, + }, + platformAdapter: mockAdapter, + isPreConsentQueueEnabled: true, + }); + + controller.trackEvent(createTestEvent('queued_event', { foo: 'bar' })); + + return { controller, mockAdapter }; + } + + it('queues track events while the user is undecided', async () => { + const { controller, mockAdapter } = + await setupControllerWithQueuedEvent(); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + + const entries = Object.values( + controller.state.preConsentEventQueue ?? {}, + ); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + type: 'track', + eventName: 'queued_event', + properties: { foo: 'bar' }, + }); + }); + + it('does not queue events when the pre-consent queue is disabled', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + ...getDefaultAnalyticsControllerState(), + analyticsId: ANALYTICS_ID, + }, + platformAdapter: mockAdapter, + }); + + controller.trackEvent(createTestEvent('event', { foo: 'bar' })); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + expect(controller.state.preConsentEventQueue).toBeUndefined(); + }); + + it('drops a stale persisted queue on init when the queue is disabled', async () => { + // A queue persisted while the feature was enabled... + const { controller: enabled } = await setupControllerWithQueuedEvent(); + const persistedQueue = enabled.state.preConsentEventQueue; + + // ...is dropped on init if the feature is later disabled, so it can never + // be replayed by a future enabled instance. + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: false, + consentDecisionMade: false, + analyticsId: ANALYTICS_ID, + preConsentEventQueue: persistedQueue, + }, + platformAdapter: mockAdapter, + // isPreConsentQueueEnabled defaults to false + }); + + expect(controller.state.preConsentEventQueue).toStrictEqual({}); + + controller.optIn(); + + expect(controller.state.optedIn).toBe(true); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('opting in is a no-op when the queue is enabled but empty', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + ...getDefaultAnalyticsControllerState(), + analyticsId: ANALYTICS_ID, + }, + platformAdapter: mockAdapter, + isPreConsentQueueEnabled: true, + }); + + controller.optIn(); + + expect(controller.state.optedIn).toBe(true); + expect(controller.state.consentDecisionMade).toBe(true); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('drops events once a decision is made (opted out), without queuing', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: false, + consentDecisionMade: true, + analyticsId: ANALYTICS_ID, + }, + platformAdapter: mockAdapter, + isPreConsentQueueEnabled: true, + }); + + controller.trackEvent(createTestEvent('event', { foo: 'bar' })); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + expect(controller.state.preConsentEventQueue ?? {}).toStrictEqual({}); + }); + + it('replays queued events on opt-in and clears the queue', async () => { + const { controller, mockAdapter } = + await setupControllerWithQueuedEvent(); + + controller.optIn(); + + expect(controller.state.optedIn).toBe(true); + expect(controller.state.consentDecisionMade).toBe(true); + expect(controller.state.preConsentEventQueue).toStrictEqual({}); + expect(mockAdapter.track).toHaveBeenCalledTimes(1); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'queued_event', + { foo: 'bar' }, + undefined, + expect.objectContaining({ messageId: expect.any(String) }), + ); + }); + + it('moves replayed events into the delivery queue when persistence is enabled', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + ...getDefaultAnalyticsControllerState(), + analyticsId: ANALYTICS_ID, + }, + platformAdapter: mockAdapter, + isPreConsentQueueEnabled: true, + isEventQueuePersistenceEnabled: true, + }); + + controller.trackEvent(createTestEvent('queued_event', { foo: 'bar' })); + // Held in the pre-consent queue, not yet in the delivery queue. + expect(controller.state.eventQueue ?? {}).toStrictEqual({}); + + controller.optIn(); + + // The pre-consent queue is drained and the event is now tracked for + // delivery (the mock adapter never acks, so it remains in eventQueue). + expect(controller.state.preConsentEventQueue).toStrictEqual({}); + expect(Object.values(controller.state.eventQueue ?? {})).toHaveLength(1); + expect(mockAdapter.track).toHaveBeenCalledTimes(1); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'queued_event', + { foo: 'bar' }, + undefined, + expect.objectContaining({ messageId: expect.any(String) }), + ); + }); + + it('discards queued events on opt-out without delivering them', async () => { + const { controller, mockAdapter } = + await setupControllerWithQueuedEvent(); + + controller.optOut(); + + expect(controller.state.consentDecisionMade).toBe(true); + expect(controller.state.preConsentEventQueue).toStrictEqual({}); + expect(controller.state.eventQueue ?? {}).toStrictEqual({}); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('clears the queue and resets the decision on resetConsentDecision', async () => { + const { controller, mockAdapter } = + await setupControllerWithQueuedEvent(); + + controller.resetConsentDecision(); + + expect(controller.state.optedIn).toBe(false); + expect(controller.state.consentDecisionMade).toBe(false); + expect(controller.state.preConsentEventQueue).toStrictEqual({}); + expect(mockAdapter.track).not.toHaveBeenCalled(); + }); + + it('also clears the delivery queue on resetConsentDecision', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + consentDecisionMade: true, + analyticsId: ANALYTICS_ID, + }, + platformAdapter: mockAdapter, + isPreConsentQueueEnabled: true, + isEventQueuePersistenceEnabled: true, + }); + + // Opted in: this event lands in the persisted delivery queue. + controller.trackEvent(createTestEvent('delivery_event', { foo: 'bar' })); + expect(Object.values(controller.state.eventQueue ?? {})).toHaveLength(1); + + controller.resetConsentDecision(); + + expect(controller.state.optedIn).toBe(false); + expect(controller.state.consentDecisionMade).toBe(false); + expect(controller.state.eventQueue).toStrictEqual({}); + expect(controller.state.preConsentEventQueue ?? {}).toStrictEqual({}); + }); + + it('replays a persisted queue on init when already opted in', async () => { + // Build a persisted queue via a first, undecided controller. + const { controller: undecided } = await setupControllerWithQueuedEvent(); + const persistedQueue = undecided.state.preConsentEventQueue; + + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: true, + consentDecisionMade: true, + analyticsId: ANALYTICS_ID, + preConsentEventQueue: persistedQueue, + }, + platformAdapter: mockAdapter, + isPreConsentQueueEnabled: true, + }); + + // init() runs in setupController and reconciles the leftover queue. + expect(mockAdapter.track).toHaveBeenCalledTimes(1); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'queued_event', + { foo: 'bar' }, + undefined, + expect.objectContaining({ messageId: expect.any(String) }), + ); + expect(controller.state.preConsentEventQueue).toStrictEqual({}); + }); + + it('clears a persisted queue on init when already opted out', async () => { + const { controller: undecided } = await setupControllerWithQueuedEvent(); + const persistedQueue = undecided.state.preConsentEventQueue; + + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: false, + consentDecisionMade: true, + analyticsId: ANALYTICS_ID, + preConsentEventQueue: persistedQueue, + }, + platformAdapter: mockAdapter, + isPreConsentQueueEnabled: true, + }); + + expect(mockAdapter.track).not.toHaveBeenCalled(); + expect(controller.state.preConsentEventQueue).toStrictEqual({}); + }); + + it('queues multiple events and replays them with their context preserved', async () => { + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + ...getDefaultAnalyticsControllerState(), + analyticsId: ANALYTICS_ID, + }, + platformAdapter: mockAdapter, + isPreConsentQueueEnabled: true, + }); + + controller.trackEvent(createTestEvent('first_event', { a: 1 }), { + source: 'onboarding', + }); + controller.trackEvent(createTestEvent('second_event', { b: 2 })); + + expect( + Object.values(controller.state.preConsentEventQueue ?? {}), + ).toHaveLength(2); + + controller.optIn(); + + expect(mockAdapter.track).toHaveBeenCalledTimes(2); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'first_event', + { a: 1 }, + { source: 'onboarding' }, + expect.objectContaining({ messageId: expect.any(String) }), + ); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'second_event', + { b: 2 }, + undefined, + expect.objectContaining({ messageId: expect.any(String) }), + ); + expect(controller.state.preConsentEventQueue).toStrictEqual({}); + }); + + it('skips invalid entries when replaying the queue', async () => { + // Start from a valid persisted entry, then add malformed entries that are + // not valid queued events. + const { controller: undecided } = await setupControllerWithQueuedEvent(); + const queueWithInvalid: Record = { + ...undecided.state.preConsentEventQueue, + 'not-a-record': 'just a string', + 'unknown-type': { + type: 'mystery', + messageId: 'unknown-type', + timestamp: '2026-01-01T00:00:00.000Z', + }, + }; + + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: false, + consentDecisionMade: false, + analyticsId: ANALYTICS_ID, + preConsentEventQueue: queueWithInvalid, + }, + platformAdapter: mockAdapter, + isPreConsentQueueEnabled: true, + }); + + controller.optIn(); + + // Only the valid entry is replayed; every malformed entry is dropped. + expect(mockAdapter.track).toHaveBeenCalledTimes(1); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'queued_event', + { foo: 'bar' }, + undefined, + expect.objectContaining({ messageId: expect.any(String) }), + ); + expect(controller.state.preConsentEventQueue).toStrictEqual({}); }); }); }); diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 0981690487..83ea6c58f2 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -54,6 +54,26 @@ export type AnalyticsControllerState = { * This is only used when event queue persistence is enabled. */ eventQueue?: Record; + + /** + * Whether the user has made a consent decision (opted in or opted out). + * + * This distinguishes the "undecided" state (e.g. during onboarding, before + * the user has answered the analytics prompt) from an explicit opt-out. + * Defaults to `false` and is set to `true` by {@link AnalyticsController.optIn} + * or {@link AnalyticsController.optOut}, and back to `false` by + * {@link AnalyticsController.resetConsentDecision}. Optional for backward + * compatibility with persisted state that predates this field. + */ + consentDecisionMade?: boolean; + + /** + * Persisted queue of track events ({@link AnalyticsQueuedTrackEvent}) captured + * while the user is undecided (no consent decision made yet). Replayed on + * opt-in and cleared on opt-out. This is only used when the pre-consent queue + * is enabled. + */ + preConsentEventQueue?: Record; }; /** @@ -138,6 +158,7 @@ export function getDefaultAnalyticsControllerState(): Omit< > { return { optedIn: false, + consentDecisionMade: false, }; } @@ -166,6 +187,18 @@ const analyticsControllerMetadata = { includeInDebugSnapshot: false, usedInUi: false, }, + consentDecisionMade: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + preConsentEventQueue: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, } satisfies StateMetadata; // === MESSENGER === @@ -176,6 +209,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'trackView', 'optIn', 'optOut', + 'resetConsentDecision', ] as const; /** @@ -263,6 +297,18 @@ export type AnalyticsControllerOptions = { * @default false */ isEventQueuePersistenceEnabled?: boolean; + + /** + * Whether the pre-consent event queue is enabled. + * + * When enabled, track events received while the user is undecided + * (no consent decision made yet) are persisted and replayed on opt-in, + * or dropped on opt-out. When disabled, such events are dropped immediately, + * preserving the legacy behavior. + * + * @default false + */ + isPreConsentQueueEnabled?: boolean; }; /** @@ -344,6 +390,8 @@ export class AnalyticsController extends BaseController< readonly #isEventQueuePersistenceEnabled: boolean; + readonly #isPreConsentQueueEnabled: boolean; + #initialized: boolean; /** @@ -356,6 +404,7 @@ export class AnalyticsController extends BaseController< * @param options.platformAdapter - Platform adapter implementation for tracking * @param options.isAnonymousEventsFeatureEnabled - Whether the anonymous events feature is enabled * @param options.isEventQueuePersistenceEnabled - Whether analytics event queue persistence is enabled + * @param options.isPreConsentQueueEnabled - Whether the pre-consent event queue is enabled * @throws Error if state.analyticsId is missing or not a valid UUIDv4 * @remarks After construction, call {@link AnalyticsController.init} to complete initialization. */ @@ -365,6 +414,7 @@ export class AnalyticsController extends BaseController< platformAdapter, isAnonymousEventsFeatureEnabled = false, isEventQueuePersistenceEnabled = false, + isPreConsentQueueEnabled = false, }: AnalyticsControllerOptions) { const initialState: AnalyticsControllerState = { ...getDefaultAnalyticsControllerState(), @@ -385,6 +435,7 @@ export class AnalyticsController extends BaseController< this.#isAnonymousEventsFeatureEnabled = isAnonymousEventsFeatureEnabled; this.#isEventQueuePersistenceEnabled = isEventQueuePersistenceEnabled; + this.#isPreConsentQueueEnabled = isPreConsentQueueEnabled; this.#platformAdapter = platformAdapter; this.#initialized = false; @@ -396,8 +447,10 @@ export class AnalyticsController extends BaseController< log('AnalyticsController initialized and ready', { enabled: analyticsControllerSelectors.selectEnabled(this.state), optedIn: this.state.optedIn, + consentDecisionMade: this.state.consentDecisionMade, analyticsId: this.state.analyticsId, eventQueuePersistenceEnabled: this.#isEventQueuePersistenceEnabled, + preConsentQueueEnabled: this.#isPreConsentQueueEnabled, }); } @@ -423,6 +476,7 @@ export class AnalyticsController extends BaseController< } this.#replayQueuedEvents(); + this.#reconcilePreConsentEvents(); } /** @@ -437,7 +491,11 @@ export class AnalyticsController extends BaseController< properties?: AnalyticsEventProperties, context?: AnalyticsContext, ): void { - if (!this.#isEventQueuePersistenceEnabled) { + // Direct delivery: enabled and not persisting. + if ( + analyticsControllerSelectors.selectEnabled(this.state) && + !this.#isEventQueuePersistenceEnabled + ) { this.#platformAdapter.track(eventName, properties, context); return; } @@ -451,6 +509,13 @@ export class AnalyticsController extends BaseController< ...(context === undefined ? {} : { context }), }; + // Not yet enabled (reached only while undecided with the pre-consent queue + // enabled): hold the event until the user opts in. + if (!analyticsControllerSelectors.selectEnabled(this.state)) { + this.#enqueuePreConsentEvent(queuedEvent); + return; + } + this.#enqueueEvent(queuedEvent); } @@ -659,6 +724,104 @@ export class AnalyticsController extends BaseController< }); } + /** + * Add an event to the pre-consent queue without delivering it. + * + * @param queuedEvent - The event to hold until the user opts in. + */ + #enqueuePreConsentEvent(queuedEvent: AnalyticsQueuedEvent): void { + const preConsentEventQueue: Record = { + ...(this.state.preConsentEventQueue ?? {}), + [queuedEvent.messageId]: queuedEvent as unknown as Json, + }; + + this.update((state) => { + state.preConsentEventQueue = preConsentEventQueue as never; + }); + } + + /** + * Replay queued pre-consent events through the delivery path. + * + * Called on opt-in, once analytics is enabled. The queue is cleared before + * replaying so events cannot be re-queued or replayed twice. + */ + #replayPreConsentEvents(): void { + if (!this.#isPreConsentQueueEnabled) { + return; + } + + const queue = this.state.preConsentEventQueue; + + if (!queue) { + return; + } + + this.#clearPreConsentEvents(); + + for (const [messageId, queuedEvent] of Object.entries(queue)) { + if ( + !isAnalyticsQueuedEvent(queuedEvent) || + queuedEvent.messageId !== messageId + ) { + log('Dropping invalid queued pre-consent analytics event', { + messageId, + }); + continue; + } + + if (this.#isEventQueuePersistenceEnabled) { + this.#enqueueEvent(queuedEvent); + } else { + this.#sendQueuedEvent(queuedEvent); + } + } + } + + /** + * Clear all queued pre-consent events. + */ + #clearPreConsentEvents(): void { + if (!this.state.preConsentEventQueue) { + return; + } + + this.update((state) => { + state.preConsentEventQueue = {} as never; + }); + } + + /** + * Reconcile the pre-consent queue on initialization. + * + * The queue should normally be empty unless the user is still undecided. This + * handles the rare cases where a consent decision was persisted but the queue + * was not flushed/cleared (e.g. an interrupted shutdown): replay it if the + * user is opted in, or clear it if they opted out. + * + * If the pre-consent queue is disabled, any stale persisted entries (e.g. from + * a previous session where it was enabled) are dropped so they can never be + * replayed. + */ + #reconcilePreConsentEvents(): void { + const queue = this.state.preConsentEventQueue; + + if (!queue) { + return; + } + + if (!this.#isPreConsentQueueEnabled) { + this.#clearPreConsentEvents(); + return; + } + + if (this.state.optedIn) { + this.#replayPreConsentEvents(); + } else if (this.state.consentDecisionMade) { + this.#clearPreConsentEvents(); + } + } + /** * Track an analytics event. * @@ -668,9 +831,16 @@ export class AnalyticsController extends BaseController< * @param context - Optional platform-specific context forwarded to the platform adapter. */ trackEvent(event: AnalyticsTrackingEvent, context?: AnalyticsContext): void { - // Don't track if analytics is disabled if (!analyticsControllerSelectors.selectEnabled(this.state)) { - return; + // 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; + } } // if event does not have properties, send event without properties @@ -746,21 +916,50 @@ export class AnalyticsController extends BaseController< /** * Opt in to analytics. + * + * Records that a consent decision has been made and replays any events that + * were queued while the user was undecided. */ optIn(): void { this.update((state) => { state.optedIn = true; + state.consentDecisionMade = true; }); + + this.#replayPreConsentEvents(); } /** * 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. */ optOut(): void { this.update((state) => { state.optedIn = false; + state.consentDecisionMade = true; + }); + + this.#clearQueuedEvents(); + this.#clearPreConsentEvents(); + } + + /** + * Reset the consent decision back to undecided. + * + * Intended for client flows that restart onboarding. Clears the opt-in + * preference and discards both the delivery queue and any pre-consent events, + * so nothing captured before the reset is delivered and the user is treated + * as undecided again. + */ + resetConsentDecision(): void { + this.update((state) => { + state.optedIn = false; + state.consentDecisionMade = false; }); this.#clearQueuedEvents(); + this.#clearPreConsentEvents(); } } diff --git a/packages/analytics-controller/src/index.ts b/packages/analytics-controller/src/index.ts index 8b74f33609..6a7a61d17a 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -49,5 +49,6 @@ export type { AnalyticsControllerTrackViewAction, AnalyticsControllerOptInAction, AnalyticsControllerOptOutAction, + AnalyticsControllerResetConsentDecisionAction, AnalyticsControllerMethodActions, } from './AnalyticsController-method-action-types'; diff --git a/packages/analytics-controller/src/selectors.test.ts b/packages/analytics-controller/src/selectors.test.ts index e522e85fa5..0a65cbbc20 100644 --- a/packages/analytics-controller/src/selectors.test.ts +++ b/packages/analytics-controller/src/selectors.test.ts @@ -60,4 +60,34 @@ describe('analyticsControllerSelectors', () => { }, ); }); + + describe('selectConsentDecisionMade', () => { + it.each([[true], [false]])( + 'returns %s when consentDecisionMade is %s', + (consentDecisionMade) => { + const state: AnalyticsControllerState = { + optedIn: false, + consentDecisionMade, + analyticsId: defaultAnalyticsId, + }; + + const result = + analyticsControllerSelectors.selectConsentDecisionMade(state); + + expect(result).toBe(consentDecisionMade); + }, + ); + + it('defaults to false when the field is absent', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: defaultAnalyticsId, + }; + + const result = + analyticsControllerSelectors.selectConsentDecisionMade(state); + + expect(result).toBe(false); + }); + }); }); diff --git a/packages/analytics-controller/src/selectors.ts b/packages/analytics-controller/src/selectors.ts index 392909ec3a..9386e91546 100644 --- a/packages/analytics-controller/src/selectors.ts +++ b/packages/analytics-controller/src/selectors.ts @@ -29,6 +29,17 @@ const selectOptedIn = (state: AnalyticsControllerState): boolean => const selectEnabled = (state: AnalyticsControllerState): boolean => state.optedIn; +/** + * Selects whether the user has made a consent decision (opted in or opted out). + * Use this selector to distinguish the "undecided" state (e.g. during + * onboarding) from an explicit opt-out. + * + * @param state - The controller state + * @returns Whether the user has made a consent decision + */ +const selectConsentDecisionMade = (state: AnalyticsControllerState): boolean => + state.consentDecisionMade ?? false; + /** * Selectors for the AnalyticsController state. * These can be used with Redux or directly with controller state. @@ -37,4 +48,5 @@ export const analyticsControllerSelectors = { selectAnalyticsId, selectOptedIn, selectEnabled, + selectConsentDecisionMade, };