diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 017f7dbc01c..2ec60f7c1ea 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add independent marketing consent and purpose-aware event classification ([#10232](https://github.com/MetaMask/core/pull/10232)) + - Adds `optedInToMarketing`, `optInToMarketing` / `optOutOfMarketing` / `resetMarketingConsentDecision`, and a persisted `eventsConfig` whose unlisted events default to product-only + - Named `track` and `view` payloads are delivered once with their allowed purposes in `context.consent.categoryPreferences` and their capture-time config version in `context.eventsConfigVersion` + - Queues and fragments retain capture-time purpose classification so config changes cannot reclassify captured events. Mixed-purpose fragments classify each declared lifecycle event independently + ### Changed - Bump `uuid` from `^8.3.2` to `^9.0.1` ([#10117](https://github.com/MetaMask/core/pull/10117)) diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index ea001638dc2..a765fbed7d1 100644 --- a/packages/analytics-controller/README.md +++ b/packages/analytics-controller/README.md @@ -16,12 +16,16 @@ The AnalyticsController provides a unified interface for tracking analytics even ## State -| Field | Type | Description | Persisted | -| ---------------- | --------- | --------------------------------------------- | --------- | -| `analyticsId` | `string` | UUIDv4 identifier (client platform-generated) | Yes | -| `optedIn` | `boolean` | User opt-in status | Yes | -| `eventQueue` | `object` | Optional persisted delivery queue | Yes | -| `eventFragments` | `object` | Optional in-progress event fragments | Yes | +| Field | Type | Description | Persisted | +| ------------------------------ | ----------------------- | ------------------------------------------------------------------- | --------- | +| `analyticsId` | `string` | UUIDv4 identifier (client platform-generated) | Yes | +| `optedIn` | `boolean` | Product analytics opt-in status | Yes | +| `consentDecisionMade` | `boolean` | Whether a product consent decision has been made | Yes | +| `optedInToMarketing` | `boolean` | Marketing analytics opt-in status | Yes | +| `marketingConsentDecisionMade` | `boolean` | Whether a marketing consent decision has been made | Yes | +| `eventsConfig` | `AnalyticsEventsConfig` | Cached event-purpose classification and its config registry version | Yes | +| `eventQueue` | `object` | Optional persisted delivery queue | Yes | +| `eventFragments` | `object` | Optional in-progress event fragments | Yes | ### Client Platform Responsibilities @@ -30,6 +34,30 @@ The AnalyticsController provides a unified interface for tracking analytics even 3. **Subscribe to state changes**: Persist changes to isolated storage 4. **Persist to isolated storage**: Keep analytics settings separate from main state (protects against state corruption) +`eventsConfig.events` maps event names to one or both `AnalyticsPurpose` values (`product` and `marketing`). Unlisted names default to product-only. Phase 1 uses the config already persisted in state. Loading it from config registry will be added later. + +Each `track` and `view` payload is emitted once when at least one eligible purpose is opted in. A dual-purpose event is still emitted once when both consents are enabled. Its allowed purposes are stamped using Segment's consent context: + +```json +{ + "context": { + "consent": { + "categoryPreferences": { + "product": true, + "marketing": true + } + }, + "eventsConfigVersion": "a1b2c3d" + } +} +``` + +The booleans are the intersection of event classification and current user consent. `eventsConfigVersion` is included when classification came from a persisted config. `identify` is always product-only. + +Classification and config version are captured with queued events and fragments. A later config update cannot reclassify an event that was already captured. If one purpose is opted in while another is undecided, a dual-purpose event is sent immediately for the allowed purpose and is not replayed after the second decision. + +Event properties are unchanged by purpose consent. + ## Anonymous Events Feature When `isAnonymousEventsFeatureEnabled` is enabled in the constructor, events with sensitive properties are split into separate events: @@ -79,11 +107,11 @@ controller.finalizeEventFragment(`signature-${requestId}`); Use `upsertEventFragment` when a contributor cannot know whether the journey has been started yet. It merges into an existing fragment, or creates a property bag when none exists. -Emission goes through `trackEvent`, so consent gating, anonymous event splitting, the pre-consent queue and geolocation enrichment all apply to a fragment's events exactly as they do to a direct call. +Emission uses the same consent gating, anonymous event splitting, pre-consent queue and geolocation enrichment as a direct `trackEvent` call. Each declared lifecycle event keeps its own capture-time purposes. A fragment can therefore combine product-only and marketing-only lifecycle events without reclassifying the whole journey. The consent gate also applies to accumulation, not just to emission, so a fragment never stores data for an event that could not be delivered. A fragment only holds data while the user is opted in, or while they are still undecided and `isPreConsentQueueEnabled` is holding their events until they decide. In any other consent state, and in particular after an explicit opt-out, every fragment method is a logged no-op. -Fragments are removed when they are finalized, deleted, or when the user opts out. `resetConsentDecision` keeps them only while the now-undecided user can still accumulate them. On `init`, any fragment that did not set `persist: true` is discarded, since the journey it belonged to cannot be resumed. Persistent fragments that have not been written to for longer than `EVENT_FRAGMENT_MAX_AGE` (24 hours, measured from `lastUpdated`) are also discarded, so abandoned journeys cannot keep `properties` or `sensitiveProperties` in storage indefinitely. All fragments are discarded when the consent state no longer allows accumulation. Nothing is emitted for a discarded fragment: a journey that never reached its own finalization is unfinished, not failed. +Fragments are removed when they are finalized, deleted, or when no eligible purpose remains allowed or undecided. Opting out of one purpose retains a dual-purpose fragment when another purpose still permits capture. `resetConsentDecision` keeps fragments only while the now-undecided user can still accumulate them. On `init`, any fragment that did not set `persist: true` is discarded, since the journey it belonged to cannot be resumed. Persistent fragments that have not been written to for longer than `EVENT_FRAGMENT_MAX_AGE` (24 hours, measured from `lastUpdated`) are also discarded, so abandoned journeys cannot keep `properties` or `sensitiveProperties` in storage indefinitely. Nothing is emitted for a discarded fragment: a journey that never reached its own finalization is unfinished, not failed. This feature is disabled by default. When disabled, every fragment method is a logged no-op and no fragment is written to state. diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index c0882256c38..a35ef325630 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -192,6 +192,39 @@ export type AnalyticsControllerResetConsentDecisionAction = { handler: AnalyticsController['resetConsentDecision']; }; +/** + * Opt in to marketing analytics. + * + * Independent of {@link optIn}. Replays queued marketing events. + * + * @returns A promise that resolves once opt-in processing has completed. + */ +export type AnalyticsControllerOptInToMarketingAction = { + type: `AnalyticsController:optInToMarketing`; + handler: AnalyticsController['optInToMarketing']; +}; + +/** + * Opt out of marketing analytics. + * + * Independent of {@link optOut}. Discards queued marketing events and + * marketing event fragments. + */ +export type AnalyticsControllerOptOutOfMarketingAction = { + type: `AnalyticsController:optOutOfMarketing`; + handler: AnalyticsController['optOutOfMarketing']; +}; + +/** + * Reset the marketing consent decision back to undecided. + * + * Independent of {@link resetConsentDecision}. + */ +export type AnalyticsControllerResetMarketingConsentDecisionAction = { + type: `AnalyticsController:resetMarketingConsentDecision`; + handler: AnalyticsController['resetMarketingConsentDecision']; +}; + /** * Union of all AnalyticsController action types. */ @@ -207,4 +240,7 @@ export type AnalyticsControllerMethodActions = | AnalyticsControllerFinalizeEventFragmentAction | AnalyticsControllerOptInAction | AnalyticsControllerOptOutAction - | AnalyticsControllerResetConsentDecisionAction; + | AnalyticsControllerResetConsentDecisionAction + | AnalyticsControllerOptInToMarketingAction + | AnalyticsControllerOptOutOfMarketingAction + | AnalyticsControllerResetMarketingConsentDecisionAction; diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 4ef837a5942..40b1ac4dc5d 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, + AnalyticsPurpose, EVENT_FRAGMENT_MAX_AGE, getDefaultAnalyticsControllerState, analyticsControllerSelectors, @@ -224,6 +225,30 @@ function createMockAdapter(): MockAnalyticsPlatformAdapter { }; } +/** + * Expected context with allowed analytics purposes and optional config version. + * + * @param preferences - Allowed purposes for the event. + * @param preferences.product - Whether product analytics use is allowed. + * @param preferences.marketing - Whether marketing use is allowed. + * @param context - Optional caller context to merge. + * @param version - Optional events config version. + * @returns Context including Segment consent category preferences. + */ +function withPurposeConsent( + preferences: { product: boolean; marketing: boolean }, + context: AnalyticsContext = {}, + version?: string, +): AnalyticsContext { + return { + ...context, + consent: { + categoryPreferences: preferences, + }, + ...(version === undefined ? {} : { eventsConfigVersion: version }), + }; +} + /** * Gets delivery options from a mock adapter call. * @@ -246,6 +271,8 @@ describe('AnalyticsController', () => { expect(defaults).toStrictEqual({ optedIn: false, consentDecisionMade: false, + optedInToMarketing: false, + marketingConsentDecisionMade: false, }); expect('analyticsId' in defaults).toBe(false); }); @@ -280,7 +307,9 @@ describe('AnalyticsController', () => { { "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", "consentDecisionMade": true, + "marketingConsentDecisionMade": false, "optedIn": true, + "optedInToMarketing": false, } `); }); @@ -300,7 +329,9 @@ describe('AnalyticsController', () => { { "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", "consentDecisionMade": true, + "marketingConsentDecisionMade": false, "optedIn": true, + "optedInToMarketing": false, } `); }); @@ -320,7 +351,9 @@ describe('AnalyticsController', () => { { "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", "consentDecisionMade": true, + "marketingConsentDecisionMade": false, "optedIn": true, + "optedInToMarketing": false, } `); }); @@ -490,7 +523,9 @@ describe('AnalyticsController', () => { ).toMatchInlineSnapshot(` { "consentDecisionMade": true, + "marketingConsentDecisionMade": false, "optedIn": true, + "optedInToMarketing": false, } `); }); @@ -624,7 +659,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }), - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -911,7 +946,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', expect.any(Object), - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1008,7 +1043,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1032,7 +1067,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - context, + withPurposeConsent({ product: true, marketing: false }, context), ); }); @@ -1056,7 +1091,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - context, + withPurposeConsent({ product: true, marketing: false }, context), ); }); @@ -1076,7 +1111,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1106,7 +1141,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1135,7 +1170,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1179,7 +1214,7 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1189,7 +1224,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1220,7 +1255,7 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - context, + withPurposeConsent({ product: true, marketing: false }, context), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1230,7 +1265,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - context, + withPurposeConsent({ product: true, marketing: false }, context), ); }); @@ -1257,7 +1292,7 @@ describe('AnalyticsController', () => { 1, 'test_event', {}, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1266,7 +1301,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1288,7 +1323,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1310,7 +1345,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); }); @@ -1339,7 +1374,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, traits, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1359,7 +1394,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, undefined, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1384,7 +1419,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, traits, - context, + withPurposeConsent({ product: true, marketing: false }, context), ); }); @@ -1425,7 +1460,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.view).toHaveBeenCalledWith( 'home', { referrer: 'test' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1448,7 +1483,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.view).toHaveBeenCalledWith( 'settings', { section: 'security' }, - context, + withPurposeConsent({ product: true, marketing: false }, context), ); }); @@ -1499,7 +1534,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - { location: fullLocationContext }, + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), ); }); @@ -1513,9 +1551,16 @@ describe('AnalyticsController', () => { controller.trackEvent(createTestEvent('test_event')); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - location: fullLocationContext, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withPurposeConsent( + { product: true, marketing: false }, + { + location: fullLocationContext, + }, + ), + ); }); it('adds location to identify events', async () => { @@ -1531,7 +1576,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, { trait: 'value' }, - { location: fullLocationContext }, + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), ); }); @@ -1545,9 +1593,16 @@ describe('AnalyticsController', () => { controller.trackView('home'); - expect(mockAdapter.view).toHaveBeenCalledWith('home', undefined, { - location: fullLocationContext, - }); + expect(mockAdapter.view).toHaveBeenCalledWith( + 'home', + undefined, + withPurposeConsent( + { product: true, marketing: false }, + { + location: fullLocationContext, + }, + ), + ); }); it('preserves unrelated caller context', async () => { @@ -1562,10 +1617,17 @@ describe('AnalyticsController', () => { app: { name: 'MetaMask' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - app: { name: 'MetaMask' }, - location: fullLocationContext, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withPurposeConsent( + { product: true, marketing: false }, + { + app: { name: 'MetaMask' }, + location: fullLocationContext, + }, + ), + ); }); it('preserves caller location fields the controller does not resolve', async () => { @@ -1580,9 +1642,16 @@ describe('AnalyticsController', () => { location: { city: 'Seattle' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - location: { city: 'Seattle', ...fullLocationContext }, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withPurposeConsent( + { product: true, marketing: false }, + { + location: { city: 'Seattle', ...fullLocationContext }, + }, + ), + ); }); it('overrides caller location fields the controller resolves', async () => { @@ -1597,9 +1666,16 @@ describe('AnalyticsController', () => { location: { country_code: 'FR' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - location: fullLocationContext, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withPurposeConsent( + { product: true, marketing: false }, + { + location: fullLocationContext, + }, + ), + ); }); it('replaces a non-record caller location', async () => { @@ -1614,9 +1690,16 @@ describe('AnalyticsController', () => { location: 'Seattle', }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - location: fullLocationContext, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withPurposeConsent( + { product: true, marketing: false }, + { + location: fullLocationContext, + }, + ), + ); }); it('omits fields the geolocation API could not determine', async () => { @@ -1629,9 +1712,16 @@ describe('AnalyticsController', () => { controller.trackEvent(createTestEvent('test_event')); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - location: { country_code: 'FR' }, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withPurposeConsent( + { product: true, marketing: false }, + { + location: { country_code: 'FR' }, + }, + ), + ); }); it('leaves the context untouched when the geolocation is unknown', async () => { @@ -1645,9 +1735,14 @@ describe('AnalyticsController', () => { app: { name: 'MetaMask' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - app: { name: 'MetaMask' }, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withPurposeConsent( + { product: true, marketing: false }, + { app: { name: 'MetaMask' } }, + ), + ); }); it('leaves the context untouched when the geolocation lookup fails', async () => { @@ -1664,7 +1759,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1686,7 +1781,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1703,9 +1798,14 @@ describe('AnalyticsController', () => { location: { city: 'Seattle' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - location: { city: 'Seattle' }, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withPurposeConsent( + { product: true, marketing: false }, + { location: { city: 'Seattle' } }, + ), + ); }); it('omits location from the anonymous payload when the anonymous events feature is enabled', async () => { @@ -1730,7 +1830,10 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - { location: fullLocationContext }, + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1740,7 +1843,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1767,7 +1870,10 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - { location: fullLocationContext }, + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), ); }); @@ -1787,7 +1893,12 @@ describe('AnalyticsController', () => { ) as { context?: AnalyticsContext }[]; expect(queuedEvent.context).toStrictEqual({ - location: fullLocationContext, + ...withPurposeConsent( + { product: true, marketing: false }, + { + location: fullLocationContext, + }, + ), }); }); @@ -1822,7 +1933,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'preconsent_event', undefined, - { location: fullLocationContext }, + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), expect.any(Object), ); @@ -1831,7 +1945,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenLastCalledWith( 'postconsent_event', undefined, - { location: fullLocationContext }, + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), ); }); @@ -1864,7 +1981,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - { location: fullLocationContext }, + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), expect.any(Object), ); // ...but the anonymous payload carries no location. @@ -1875,7 +1995,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withPurposeConsent({ product: true, marketing: false }), expect.any(Object), ); }); @@ -1943,6 +2063,53 @@ describe('AnalyticsController', () => { Object.values(controller.state.preConsentEventQueue ?? {}), ).toHaveLength(1); }); + + it('still replays pre-consent events if another purpose is opted out while geolocation resolves', async () => { + let resolveGeolocation: (data: GeolocationData) => void = () => undefined; + const geolocationHandler = jest.fn( + () => + new Promise((resolve) => { + resolveGeolocation = resolve; + }), + ); + const mockAdapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + optedIn: false, + consentDecisionMade: false, + analyticsId, + }, + platformAdapter: mockAdapter, + isPreConsentQueueEnabled: true, + isGeolocationEnabled: true, + geolocationHandler, + }); + + controller.trackEvent(createTestEvent('preconsent_event')); + expect(mockAdapter.track).not.toHaveBeenCalled(); + + const optInPromise = controller.optIn(); + expect(geolocationHandler).toHaveBeenCalledTimes(1); + + // Marketing opt-out prunes during the await. Product-eligible pre-consent + // events must survive until optIn finishes replaying. + controller.optOutOfMarketing(); + + resolveGeolocation(buildGeolocationData(fullGeolocation)); + await optInPromise; + + expect(mockAdapter.track).toHaveBeenCalledTimes(1); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'preconsent_event', + undefined, + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), + expect.any(Object), + ); + expect(controller.state.preConsentEventQueue).toStrictEqual({}); + }); }); describe('event queue persistence', () => { @@ -1962,7 +2129,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); expect(mockAdapter.track.mock.calls[0]).toHaveLength(3); }); @@ -1993,6 +2160,8 @@ describe('AnalyticsController', () => { messageId: deliveryOptions.messageId, timestamp: deliveryOptions.timestamp?.toISOString(), properties: { prop: 'value' }, + context: withPurposeConsent({ product: true, marketing: false }), + eventPurposes: [AnalyticsPurpose.Product], }, }); @@ -2185,21 +2354,30 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, { trait: 'value' }, - identifyContext, + withPurposeConsent( + { product: true, marketing: false }, + identifyContext, + ), expect.objectContaining({ messageId: identifyOptions.messageId }), ); expect(mockAdapter.view).toHaveBeenCalledWith( 'home', { referrer: 'test' }, - viewContext, + withPurposeConsent({ product: true, marketing: false }, viewContext), expect.objectContaining({ messageId: viewOptions.messageId }), ); expect(controller.state.eventQueue).toMatchObject({ [identifyOptions.messageId as string]: { - context: identifyContext, + context: withPurposeConsent( + { product: true, marketing: false }, + identifyContext, + ), }, [viewOptions.messageId as string]: { - context: viewContext, + context: withPurposeConsent( + { product: true, marketing: false }, + viewContext, + ), }, }); expect(Object.keys(controller.state.eventQueue ?? {})).toHaveLength(2); @@ -2236,18 +2414,24 @@ describe('AnalyticsController', () => { eventName: 'test_event', messageId: trackOptions.messageId, timestamp: trackOptions.timestamp?.toISOString(), + context: withPurposeConsent({ product: true, marketing: false }), + eventPurposes: [AnalyticsPurpose.Product], }, [identifyOptions.messageId as string]: { type: 'identify', userId: analyticsId, messageId: identifyOptions.messageId, timestamp: identifyOptions.timestamp?.toISOString(), + context: withPurposeConsent({ product: true, marketing: false }), + eventPurposes: [AnalyticsPurpose.Product], }, [viewOptions.messageId as string]: { type: 'view', name: 'home', messageId: viewOptions.messageId, timestamp: viewOptions.timestamp?.toISOString(), + context: withPurposeConsent({ product: true, marketing: false }), + eventPurposes: [AnalyticsPurpose.Product], }, }); }); @@ -2294,7 +2478,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: 'track-message-id', timestamp: new Date(trackEvent.timestamp), @@ -2304,7 +2488,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, { trait: 'value' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: 'identify-message-id', timestamp: new Date(identifyEvent.timestamp), @@ -2314,7 +2498,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.view).toHaveBeenCalledWith( 'home', { referrer: 'test' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: 'view-message-id', timestamp: new Date(viewEvent.timestamp), @@ -2746,7 +2930,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -2777,7 +2961,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -2853,7 +3037,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); expect(controller.state.preConsentEventQueue).toStrictEqual({}); @@ -2905,13 +3089,16 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'first_event', { a: 1 }, - { source: 'onboarding' }, + withPurposeConsent( + { product: true, marketing: false }, + { source: 'onboarding' }, + ), expect.objectContaining({ messageId: expect.any(String) }), ); expect(mockAdapter.track).toHaveBeenCalledWith( 'second_event', { b: 2 }, - undefined, + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); expect(controller.state.preConsentEventQueue).toStrictEqual({}); @@ -2950,7 +3137,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); expect(controller.state.preConsentEventQueue).toStrictEqual({}); @@ -3022,6 +3209,40 @@ describe('AnalyticsController', () => { }; } + /** + * Geolocation handler that hangs until {@link resolveGeolocation} is called, + * and exposes {@link geolocationRequested} so tests can wait until init is + * blocked on that call (after the fragment snapshot, before reconcile). + * + * @returns The handler and coordination promises. + */ + function createBlockingGeolocationHandler(): { + geolocationHandler: jest.Mock, []>; + geolocationRequested: Promise; + resolveGeolocation: (value: GeolocationData) => void; + } { + let resolveGeolocation!: (value: GeolocationData) => void; + let notifyGeolocationRequested!: () => void; + const geolocationRequested = new Promise((resolve) => { + notifyGeolocationRequested = resolve; + }); + + const geolocationHandler = jest.fn((): Promise => { + notifyGeolocationRequested(); + return new Promise((resolve) => { + resolveGeolocation = resolve; + }); + }); + + return { + geolocationHandler, + geolocationRequested, + resolveGeolocation: (value: GeolocationData): void => { + resolveGeolocation(value); + }, + }; + } + describe('when the feature is disabled', () => { it('ignores every fragment method and writes nothing to state', async () => { const { controller, mockAdapter } = await setupFragmentController({ @@ -3108,7 +3329,9 @@ describe('AnalyticsController', () => { const fragment = controller.createEventFragment({ id: 'signature-1', properties: { signature_type: 'personal_sign' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: { + referrer: { url: 'https://dapp.test' }, + }, }); expect(fragment).toBeDefined(); @@ -3122,7 +3345,9 @@ describe('AnalyticsController', () => { expect(controller.state.eventFragments?.['signature-1']).toStrictEqual( expect.objectContaining({ properties: { signature_type: 'personal_sign' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: { + referrer: { url: 'https://dapp.test' }, + }, }), ); }); @@ -3137,7 +3362,9 @@ describe('AnalyticsController', () => { failureEvent: 'Signature Rejected', properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: { + referrer: { url: 'https://dapp.test' }, + }, persist: true, }); @@ -3146,9 +3373,16 @@ describe('AnalyticsController', () => { initialEvent: 'Signature Requested', successEvent: 'Signature Approved', failureEvent: 'Signature Rejected', + eventPurposes: { + 'Signature Requested': [AnalyticsPurpose.Product], + 'Signature Approved': [AnalyticsPurpose.Product], + 'Signature Rejected': [AnalyticsPurpose.Product], + }, properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: { + referrer: { url: 'https://dapp.test' }, + }, persist: true, createdAt: expect.any(Number), lastUpdated: expect.any(Number), @@ -3166,14 +3400,21 @@ describe('AnalyticsController', () => { initialEvent: 'Signature Requested', successEvent: 'Signature Approved', properties: { signature_type: 'personal_sign' }, - context: { referrer: { url: 'https://dapp.test' } }, + 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' } }, + withPurposeConsent( + { product: true, marketing: false }, + { + referrer: { url: 'https://dapp.test' }, + }, + ), ); }); @@ -3260,6 +3501,9 @@ describe('AnalyticsController', () => { ).toStrictEqual({ id: 'transaction-ui-1', successEvent: 'Transaction Finalized', + eventPurposes: { + 'Transaction Finalized': [AnalyticsPurpose.Product], + }, properties: { simulation_response: 'no_changes', gas_edit_attempted: 'basic', @@ -3327,7 +3571,9 @@ describe('AnalyticsController', () => { const { controller } = await setupFragmentController(); controller.createEventFragment({ id: 'signature-1', - context: { referrer: { url: 'https://dapp.test' } }, + context: { + referrer: { url: 'https://dapp.test' }, + }, }); controller.updateEventFragment('signature-1', { @@ -3341,7 +3587,7 @@ describe('AnalyticsController', () => { }); }); - it('leaves the context unset when neither side has one', async () => { + it('omits context when create and update both leave it unset', async () => { const { controller } = await setupFragmentController(); controller.createEventFragment({ id: 'signature-1' }); @@ -3350,8 +3596,8 @@ describe('AnalyticsController', () => { }); expect( - controller.state.eventFragments?.['signature-1'], - ).not.toHaveProperty('context'); + controller.state.eventFragments?.['signature-1']?.context, + ).toBeUndefined(); }); it('advances lastUpdated but preserves createdAt', async () => { @@ -3394,7 +3640,9 @@ describe('AnalyticsController', () => { id: 'signature-1', properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: { + referrer: { url: 'https://dapp.test' }, + }, }); const fragment = controller.getEventFragmentById('signature-1'); @@ -3416,7 +3664,9 @@ describe('AnalyticsController', () => { expect.objectContaining({ properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: { + referrer: { url: 'https://dapp.test' }, + }, }), ); }); @@ -3472,7 +3722,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign', alert_triggered_count: 1 }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); expect(controller.state.eventFragments).toStrictEqual({}); }); @@ -3490,7 +3740,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Rejected', undefined, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -3528,7 +3778,13 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', undefined, - { referrer: { url: 'https://other.test' }, keep: 'me' }, + withPurposeConsent( + { product: true, marketing: false }, + { + referrer: { url: 'https://other.test' }, + keep: 'me', + }, + ), ); }); @@ -3550,7 +3806,7 @@ describe('AnalyticsController', () => { 1, 'Signature Approved', { signature_type: 'personal_sign' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -3560,7 +3816,7 @@ describe('AnalyticsController', () => { eip712_primary_type: 'Permit', anonymous: true, }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -3643,7 +3899,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Requested', undefined, - undefined, + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -3815,13 +4071,8 @@ describe('AnalyticsController', () => { }); 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 { geolocationHandler, geolocationRequested, resolveGeolocation } = + createBlockingGeolocationHandler(); const mockAdapter = createMockAdapter(); const analyticsId = '11111111-2222-4333-8444-555555555555'; const now = 1_800_000_000_000; @@ -3850,6 +4101,7 @@ describe('AnalyticsController', () => { }); const initPromise = controller.init(); + await geolocationRequested; controller.createEventFragment({ id: 'signature-123', @@ -3871,13 +4123,8 @@ describe('AnalyticsController', () => { }); 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 { geolocationHandler, geolocationRequested, resolveGeolocation } = + createBlockingGeolocationHandler(); const mockAdapter = createMockAdapter(); const analyticsId = '11111111-2222-4333-8444-555555555555'; @@ -3898,6 +4145,7 @@ describe('AnalyticsController', () => { }); const initPromise = controller.init(); + await geolocationRequested; controller.createEventFragment({ id: 'signature-1', @@ -3921,19 +4169,14 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); 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 { geolocationHandler, geolocationRequested, resolveGeolocation } = + createBlockingGeolocationHandler(); const mockAdapter = createMockAdapter(); const analyticsId = '11111111-2222-4333-8444-555555555555'; const staleCreatedAt = 1700000000000; @@ -3959,6 +4202,7 @@ describe('AnalyticsController', () => { }); const initPromise = controller.init(); + await geolocationRequested; controller.createEventFragment({ id: 'signature-123', @@ -3986,7 +4230,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); expect(controller.state.eventFragments).toStrictEqual({}); }); @@ -4127,11 +4371,1455 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign' }, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); }); }); + + describe('marketing consent', () => { + const marketingEvent = 'Deep Link Used'; + const productEvent = 'Button Clicked'; + const dualPurposeEvent = 'Perp Trade Completed'; + const eventsConfigVersion = 'a1b2c3d'; + const eventsConfig = { + schemaVersion: '1.0.0', + version: eventsConfigVersion, + timestamp: 1_740_000_000_000, + events: { + [marketingEvent]: [AnalyticsPurpose.Marketing], + [dualPurposeEvent]: [ + AnalyticsPurpose.Product, + AnalyticsPurpose.Marketing, + ], + }, + }; + const withMarketingList = { + eventsConfig, + }; + const withConfiguredPurposeConsent = ( + preferences: { product: boolean; marketing: boolean }, + context: AnalyticsContext = {}, + ): AnalyticsContext => + withPurposeConsent(preferences, context, eventsConfigVersion); + + it('discards invalid persisted events config before classifying events', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: true, + eventsConfig: { + ...eventsConfig, + events: { + [productEvent]: [], + }, + } as unknown as AnalyticsControllerState['eventsConfig'], + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent(createTestEvent(productEvent)); + + expect(controller.state.eventsConfig).toBeUndefined(); + expect(adapter.track).toHaveBeenCalledWith( + productEvent, + undefined, + withPurposeConsent({ product: true, marketing: false }), + ); + }); + + it('emits a dual-purpose event once with both allowed purposes', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent( + createTestEvent(dualPurposeEvent, { value: 42, amount: '10.0' }), + ); + + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + dualPurposeEvent, + { value: 42, amount: '10.0' }, + withPurposeConsent( + { product: true, marketing: true }, + {}, + eventsConfigVersion, + ), + ); + }); + + it('emits a dual-purpose event once for the only opted-in purpose', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent(createTestEvent(dualPurposeEvent)); + + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + dualPurposeEvent, + undefined, + withPurposeConsent( + { product: false, marketing: true }, + {}, + eventsConfigVersion, + ), + ); + }); + + it('sends immediately when one purpose is opted in and the other is undecided', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: false, + eventsConfig, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isPreConsentQueueEnabled: true, + }); + + controller.trackEvent(createTestEvent(dualPurposeEvent)); + await controller.optInToMarketing(); + + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + dualPurposeEvent, + undefined, + withPurposeConsent( + { product: true, marketing: false }, + {}, + eventsConfigVersion, + ), + ); + }); + + it('updates a queued dual-purpose retry to the remaining allowed purpose', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isEventQueuePersistenceEnabled: true, + }); + + controller.trackEvent(createTestEvent(dualPurposeEvent)); + const { messageId } = getDeliveryOptions(adapter.track); + + controller.optOutOfMarketing(); + + expect(controller.state.eventQueue?.[messageId as string]).toMatchObject({ + eventPurposes: [AnalyticsPurpose.Product, AnalyticsPurpose.Marketing], + eventsConfigVersion, + context: withPurposeConsent( + { product: true, marketing: false }, + {}, + eventsConfigVersion, + ), + }); + expect(adapter.track).toHaveBeenCalledTimes(1); + }); + + it('keeps an already-correct consent stamp when pruning the delivery queue', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: true, + eventsConfig, + eventQueue: { + queued: { + type: 'track', + eventName: productEvent, + messageId: 'queued', + timestamp: '2026-01-01T00:00:00.000Z', + eventPurposes: [AnalyticsPurpose.Product], + eventsConfigVersion, + context: withPurposeConsent( + { product: true, marketing: false }, + {}, + eventsConfigVersion, + ), + }, + }, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isEventQueuePersistenceEnabled: true, + skipInit: true, + }); + + const queuedBefore = controller.state.eventQueue?.queued; + controller.optOutOfMarketing(); + + expect(controller.state.eventQueue?.queued).toBe(queuedBefore); + }); + + it('refreshes stale delivery-queue consent stamps on init replay', async () => { + const adapter = createMockAdapter(); + await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig, + eventQueue: { + stale: { + type: 'track', + eventName: dualPurposeEvent, + messageId: 'stale', + timestamp: '2026-01-01T00:00:00.000Z', + eventPurposes: [ + AnalyticsPurpose.Product, + AnalyticsPurpose.Marketing, + ], + eventsConfigVersion, + // Stale stamp from before marketing opt-in. + context: withPurposeConsent( + { product: true, marketing: false }, + {}, + eventsConfigVersion, + ), + }, + }, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isEventQueuePersistenceEnabled: true, + }); + + expect(adapter.track).toHaveBeenCalledWith( + dualPurposeEvent, + undefined, + withPurposeConsent( + { product: true, marketing: true }, + {}, + eventsConfigVersion, + ), + expect.objectContaining({ messageId: 'stale' }), + ); + }); + + it('replays a queued event using its capture-time purposes and version', async () => { + const adapter = createMockAdapter(); + const capturedVersion = 'previous-config'; + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: false, + eventsConfig: { + ...eventsConfig, + events: { [marketingEvent]: [AnalyticsPurpose.Product] }, + }, + preConsentEventQueue: { + captured: { + type: 'track', + eventName: marketingEvent, + messageId: 'captured', + timestamp: '2026-01-01T00:00:00.000Z', + eventPurposes: [AnalyticsPurpose.Marketing], + eventsConfigVersion: capturedVersion, + }, + }, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isPreConsentQueueEnabled: true, + skipInit: true, + }); + + await controller.optInToMarketing(); + + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + marketingEvent, + undefined, + withPurposeConsent( + { product: false, marketing: true }, + {}, + capturedVersion, + ), + expect.anything(), + ); + }); + + it('stamps each mixed fragment lifecycle event with its own purposes', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + }); + + controller.createEventFragment({ + id: 'mixed-lifecycle', + initialEvent: productEvent, + successEvent: marketingEvent, + }); + controller.finalizeEventFragment('mixed-lifecycle'); + + expect(adapter.track).toHaveBeenCalledTimes(2); + expect(adapter.track).toHaveBeenNthCalledWith( + 1, + productEvent, + undefined, + withConfiguredPurposeConsent({ product: true, marketing: false }), + ); + expect(adapter.track).toHaveBeenNthCalledWith( + 2, + marketingEvent, + undefined, + withConfiguredPurposeConsent({ product: false, marketing: true }), + ); + }); + + it('uses a persisted fragment purpose snapshot after config changes', async () => { + const adapter = createMockAdapter(); + const capturedVersion = 'previous-config'; + const now = Date.now(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig: { + ...eventsConfig, + events: { [marketingEvent]: [AnalyticsPurpose.Product] }, + }, + eventFragments: { + captured: { + id: 'captured', + successEvent: marketingEvent, + properties: {}, + sensitiveProperties: {}, + createdAt: now, + lastUpdated: now, + eventPurposes: { [marketingEvent]: [AnalyticsPurpose.Marketing] }, + eventsConfigVersion: capturedVersion, + }, + }, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + skipInit: true, + }); + + controller.finalizeEventFragment('captured'); + + expect(adapter.track).toHaveBeenCalledWith( + marketingEvent, + undefined, + withPurposeConsent( + { product: false, marketing: true }, + {}, + capturedVersion, + ), + ); + }); + + it('classifies trackView names the same way as trackEvent', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackView(marketingEvent); + controller.trackView(productEvent); + + expect(adapter.view).toHaveBeenCalledTimes(1); + expect(adapter.view).toHaveBeenCalledWith( + marketingEvent, + undefined, + withConfiguredPurposeConsent({ product: false, marketing: true }), + ); + }); + + it('emits marketing events when only marketing consent is on', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent(createTestEvent(marketingEvent)); + controller.trackEvent(createTestEvent(productEvent)); + + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + marketingEvent, + undefined, + withConfiguredPurposeConsent({ product: false, marketing: true }), + ); + }); + + it('stamps marketing consent on marketing track and view payloads', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent(createTestEvent(marketingEvent), { + page: { path: '/home' }, + }); + controller.trackView(marketingEvent, undefined, { + page: { path: '/home' }, + }); + + expect(adapter.track).toHaveBeenCalledWith( + marketingEvent, + undefined, + withConfiguredPurposeConsent( + { product: false, marketing: true }, + { page: { path: '/home' } }, + ), + ); + expect(adapter.view).toHaveBeenCalledWith( + marketingEvent, + undefined, + withConfiguredPurposeConsent( + { product: false, marketing: true }, + { page: { path: '/home' } }, + ), + ); + }); + + it('stamps product consent on product payloads', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent(createTestEvent(productEvent)); + + expect(adapter.track).toHaveBeenCalledWith( + productEvent, + undefined, + withConfiguredPurposeConsent({ product: true, marketing: false }), + ); + }); + + it('stamps the same consent on identified and anonymous payloads', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + platformAdapter: adapter, + isAnonymousEventsFeatureEnabled: true, + geolocation: { + country: 'US', + region: 'WA', + timezone: 'America/Los_Angeles', + }, + }); + + controller.trackEvent( + createTestEvent( + marketingEvent, + { prop: 'value' }, + { sensitive_prop: 'secret' }, + ), + { page: { path: '/home' } }, + ); + + expect(adapter.track).toHaveBeenNthCalledWith( + 1, + marketingEvent, + { prop: 'value' }, + withConfiguredPurposeConsent( + { product: false, marketing: true }, + { + page: { path: '/home' }, + location: { + country_code: 'US', + region: 'WA', + timezone: 'America/Los_Angeles', + }, + }, + ), + ); + expect(adapter.track).toHaveBeenNthCalledWith( + 2, + marketingEvent, + { + prop: 'value', + sensitive_prop: 'secret', + anonymous: true, + }, + withConfiguredPurposeConsent( + { product: false, marketing: true }, + { page: { path: '/home' } }, + ), + ); + }); + + it('emits product events when only product consent is on', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent(createTestEvent(marketingEvent)); + controller.trackEvent(createTestEvent(productEvent)); + + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + productEvent, + undefined, + withConfiguredPurposeConsent({ product: true, marketing: false }), + ); + }); + + it('does not emit any event when both consents are off', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent(createTestEvent(marketingEvent)); + controller.trackEvent(createTestEvent(productEvent)); + + expect(adapter.track).not.toHaveBeenCalled(); + }); + + it('uses the persisted events config for classification', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig: { + ...eventsConfig, + events: { 'Campaign Opened': [AnalyticsPurpose.Marketing] }, + }, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent(createTestEvent('Campaign Opened')); + controller.trackEvent(createTestEvent(marketingEvent)); + + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + 'Campaign Opened', + undefined, + withConfiguredPurposeConsent({ product: false, marketing: true }), + ); + }); + + it('replays only marketing pre-consent events on optInToMarketing', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: false, + optedInToMarketing: false, + marketingConsentDecisionMade: false, + ...withMarketingList, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isPreConsentQueueEnabled: true, + }); + + controller.trackEvent(createTestEvent(marketingEvent)); + controller.trackEvent(createTestEvent(productEvent)); + expect(adapter.track).not.toHaveBeenCalled(); + + await controller.optInToMarketing(); + + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + marketingEvent, + undefined, + withConfiguredPurposeConsent({ product: false, marketing: true }), + expect.anything(), + ); + }); + + it('drops marketing fragments on optOutOfMarketing and keeps product fragments', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + }); + + controller.createEventFragment({ + id: 'marketing-1', + successEvent: marketingEvent, + }); + controller.createEventFragment({ + id: 'product-1', + successEvent: productEvent, + }); + + controller.optOutOfMarketing(); + + expect(controller.state.eventFragments).toStrictEqual({ + 'product-1': expect.objectContaining({ id: 'product-1' }), + }); + }); + + it('retains a mixed-purpose fragment when one declared purpose is allowed', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + }); + + const fragment = controller.createEventFragment({ + id: 'mixed-1', + initialEvent: productEvent, + successEvent: marketingEvent, + }); + + expect(fragment?.eventPurposes).toStrictEqual({ + [productEvent]: [AnalyticsPurpose.Product], + [marketingEvent]: [AnalyticsPurpose.Marketing], + }); + expect(fragment?.context).toBeUndefined(); + expect(controller.state.eventFragments).toHaveProperty('mixed-1'); + + controller.optOutOfMarketing(); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('creates a mixed-purpose fragment and emits only its allowed initial event', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + platformAdapter: adapter, + }); + + const fragment = controller.createEventFragment({ + id: 'mixed-1', + initialEvent: productEvent, + successEvent: marketingEvent, + }); + + expect(fragment?.eventPurposes).toStrictEqual({ + [productEvent]: [AnalyticsPurpose.Product], + [marketingEvent]: [AnalyticsPurpose.Marketing], + }); + expect(controller.state.eventFragments).toHaveProperty('mixed-1'); + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + productEvent, + undefined, + withConfiguredPurposeConsent({ product: true, marketing: false }), + ); + }); + + it('classifies fragments by event names, not caller consent context', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + }); + + // Disagreeing caller consent must not affect classification, and must + // remain as plain caller metadata on the fragment. + const callerContext = { + page: { path: '/settings' }, + consent: { + categoryPreferences: { product: false, marketing: true }, + }, + }; + const fragment = controller.createEventFragment({ + id: 'product-1', + successEvent: productEvent, + context: callerContext, + }); + + expect(fragment).toStrictEqual( + expect.objectContaining({ + id: 'product-1', + successEvent: productEvent, + eventPurposes: { + [productEvent]: [AnalyticsPurpose.Product], + }, + context: callerContext, + }), + ); + expect( + controller.state.eventFragments?.['product-1']?.context, + ).toStrictEqual(callerContext); + }); + + it('keeps a purpose-stamped fragment when config is absent', async () => { + const now = Date.now(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + // Config missing: name lookup would otherwise treat this as product. + eventFragments: { + 'marketing-1': { + id: 'marketing-1', + successEvent: marketingEvent, + properties: {}, + sensitiveProperties: {}, + createdAt: now, + lastUpdated: now, + persist: true, + eventPurposes: { + [marketingEvent]: [AnalyticsPurpose.Marketing], + }, + eventsConfigVersion, + }, + }, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + skipInit: true, + }); + + controller.optOut(); + + expect(controller.state.eventFragments).toStrictEqual({ + 'marketing-1': expect.objectContaining({ + id: 'marketing-1', + eventPurposes: { + [marketingEvent]: [AnalyticsPurpose.Marketing], + }, + eventsConfigVersion, + }), + }); + + controller.optOutOfMarketing(); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('classifies a marketing fragment from event names even if caller consent context disagrees', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + }); + + const callerContext = { + consent: { + categoryPreferences: { product: true, marketing: false }, + }, + }; + const fragment = controller.createEventFragment({ + id: 'marketing-1', + successEvent: marketingEvent, + context: callerContext, + }); + + expect(fragment).toStrictEqual( + expect.objectContaining({ + id: 'marketing-1', + successEvent: marketingEvent, + eventPurposes: { + [marketingEvent]: [AnalyticsPurpose.Marketing], + }, + context: callerContext, + }), + ); + }); + + it('stops emitting marketing events after optOutOfMarketing', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + ...withMarketingList, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.optOutOfMarketing(); + controller.trackEvent(createTestEvent(marketingEvent)); + controller.trackEvent(createTestEvent(productEvent)); + + expect(controller.state.optedInToMarketing).toBe(false); + expect(controller.state.marketingConsentDecisionMade).toBe(true); + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + productEvent, + undefined, + withConfiguredPurposeConsent({ product: true, marketing: false }), + ); + }); + + it('resets marketing consent without changing product consent', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + }, + isGeolocationEnabled: false, + }); + + controller.resetMarketingConsentDecision(); + + expect(controller.state.optedIn).toBe(true); + expect(controller.state.optedInToMarketing).toBe(false); + expect(controller.state.marketingConsentDecisionMade).toBe(false); + }); + + it('keeps marketing fragments when marketing consent is reset to undecided with pre-consent enabled', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + isPreConsentQueueEnabled: true, + }); + + controller.createEventFragment({ + id: 'marketing-1', + successEvent: marketingEvent, + }); + + controller.resetMarketingConsentDecision(); + + expect(controller.state.eventFragments).toStrictEqual({ + 'marketing-1': expect.objectContaining({ id: 'marketing-1' }), + }); + }); + + it('preserves fragment context when an update omits context', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + }); + + controller.createEventFragment({ + id: 'bag-1', + successEvent: productEvent, + }); + controller.updateEventFragment('bag-1', { + context: { page: { path: '/settings' } }, + }); + controller.updateEventFragment('bag-1', { + properties: { step: '1' }, + }); + + expect(controller.getEventFragmentById('bag-1')).toStrictEqual( + expect.objectContaining({ + properties: { step: '1' }, + context: { + page: { path: '/settings' }, + }, + }), + ); + }); + + it('keeps the persisted events config across init', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig: { + ...eventsConfig, + events: { 'Campaign Opened': [AnalyticsPurpose.Marketing] }, + }, + }, + isGeolocationEnabled: false, + }); + + expect(controller.state.eventsConfig).toStrictEqual({ + ...eventsConfig, + events: { 'Campaign Opened': [AnalyticsPurpose.Marketing] }, + }); + }); + + it('treats every name as product when config is absent', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent(createTestEvent(marketingEvent)); + + expect(controller.state.eventsConfig).toBeUndefined(); + expect(adapter.track).not.toHaveBeenCalled(); + }); + + it('queues marketing views while marketing consent is undecided', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: false, + ...withMarketingList, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isPreConsentQueueEnabled: true, + }); + + controller.trackView(marketingEvent); + + expect(adapter.view).not.toHaveBeenCalled(); + await controller.optInToMarketing(); + expect(adapter.view).toHaveBeenCalledWith( + marketingEvent, + undefined, + withConfiguredPurposeConsent({ product: false, marketing: true }), + expect.anything(), + ); + }); + + it('allows nameless fragment calls when only marketing consent is on', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + }); + + expect(controller.getEventFragmentById('missing')).toBeUndefined(); + }); + + it('drops invalid delivery-queue items when reconciling consent', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig, + eventQueue: { + invalid: 'not-an-event', + 'keep-me': { + type: 'track', + eventName: marketingEvent, + messageId: 'keep-me', + timestamp: '2026-01-01T00:00:00.000Z', + }, + identify: { + type: 'identify', + userId: '550e8400-e29b-41d4-a716-446655440000', + messageId: 'identify', + timestamp: '2026-01-01T00:00:01.000Z', + }, + } as unknown as AnalyticsControllerState['eventQueue'], + }, + isGeolocationEnabled: false, + isEventQueuePersistenceEnabled: true, + skipInit: true, + }); + + controller.optOut(); + + expect(controller.state.eventQueue).toStrictEqual({ + 'keep-me': expect.objectContaining({ + eventName: marketingEvent, + }), + }); + expect(controller.state.eventQueue).not.toHaveProperty('identify'); + }); + + it('drops queued identify on product opt-out while keeping marketing tracks', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + ...withMarketingList, + eventQueue: { + identify: { + type: 'identify', + userId: '550e8400-e29b-41d4-a716-446655440000', + messageId: 'identify', + timestamp: '2026-01-01T00:00:01.000Z', + }, + marketing: { + type: 'track', + eventName: marketingEvent, + messageId: 'marketing', + timestamp: '2026-01-01T00:00:02.000Z', + }, + }, + }, + isGeolocationEnabled: false, + isEventQueuePersistenceEnabled: true, + skipInit: true, + }); + + controller.optOut(); + + expect(controller.state.eventQueue).toStrictEqual({ + marketing: expect.objectContaining({ + eventName: marketingEvent, + }), + }); + expect(controller.state.eventQueue).not.toHaveProperty('identify'); + }); + + it('filters unstamped queued events by event name', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig, + eventQueue: { + 'legacy-product': { + type: 'track', + eventName: productEvent, + messageId: 'legacy-product', + timestamp: '2026-01-01T00:00:00.000Z', + }, + 'legacy-marketing': { + type: 'track', + eventName: marketingEvent, + messageId: 'legacy-marketing', + timestamp: '2026-01-01T00:00:01.000Z', + }, + }, + }, + isGeolocationEnabled: false, + isEventQueuePersistenceEnabled: true, + skipInit: true, + }); + + controller.optOut(); + + expect(controller.state.eventQueue).toStrictEqual({ + 'legacy-marketing': expect.objectContaining({ + eventName: marketingEvent, + }), + }); + }); + + it('filters unstamped queued views by name', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig, + eventQueue: { + 'legacy-view': { + type: 'view', + name: marketingEvent, + messageId: 'legacy-view', + timestamp: '2026-01-01T00:00:00.000Z', + }, + }, + }, + isGeolocationEnabled: false, + isEventQueuePersistenceEnabled: true, + skipInit: true, + }); + + controller.optOut(); + + expect(controller.state.eventQueue).toStrictEqual({ + 'legacy-view': expect.objectContaining({ + name: marketingEvent, + }), + }); + }); + + it('drops unstamped marketing fragments on optOutOfMarketing', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + eventsConfig, + eventFragments: { + legacy: { + id: 'legacy', + successEvent: marketingEvent, + properties: {}, + sensitiveProperties: {}, + createdAt: 1, + lastUpdated: Date.now(), + }, + }, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + skipInit: true, + }); + + controller.optOutOfMarketing(); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('merges caller context onto a persisted fragment that has none', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + eventFragments: { + bag: { + id: 'bag', + properties: {}, + sensitiveProperties: {}, + createdAt: 1, + lastUpdated: Date.now(), + }, + }, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + skipInit: true, + }); + + controller.updateEventFragment('bag', { + context: { page: { path: '/home' } }, + }); + + expect(controller.state.eventFragments?.bag?.context).toStrictEqual({ + page: { path: '/home' }, + }); + }); + + it('keeps context unset when updating a persisted fragment that has none', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + eventFragments: { + bag: { + id: 'bag', + properties: {}, + sensitiveProperties: {}, + createdAt: 1, + lastUpdated: Date.now(), + }, + }, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + skipInit: true, + }); + + controller.updateEventFragment('bag', { + properties: { step: '1' }, + }); + + expect(controller.state.eventFragments?.bag).toStrictEqual( + expect.objectContaining({ + properties: { step: '1' }, + }), + ); + expect(controller.state.eventFragments?.bag).not.toHaveProperty( + 'context', + ); + }); + + it('drops invalid pre-consent items when replaying marketing events', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: false, + eventsConfig, + preConsentEventQueue: { + invalid: 'not-an-event', + 'keep-me': { + type: 'track', + eventName: marketingEvent, + messageId: 'keep-me', + timestamp: '2026-01-01T00:00:00.000Z', + eventPurposes: [AnalyticsPurpose.Marketing], + eventsConfigVersion, + }, + } as unknown as AnalyticsControllerState['preConsentEventQueue'], + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isPreConsentQueueEnabled: true, + skipInit: true, + }); + + await controller.optInToMarketing(); + + expect(adapter.track).toHaveBeenCalledWith( + marketingEvent, + undefined, + withConfiguredPurposeConsent({ product: false, marketing: true }), + expect.anything(), + ); + }); + + it('drops invalid pre-consent items when consent is declined', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: false, + eventsConfig, + preConsentEventQueue: { + invalid: 'not-an-event', + } as unknown as AnalyticsControllerState['preConsentEventQueue'], + }, + isGeolocationEnabled: false, + isPreConsentQueueEnabled: true, + skipInit: true, + }); + + controller.optOutOfMarketing(); + + expect(controller.state.preConsentEventQueue).toStrictEqual({}); + }); + + it('clears an empty fragment map when the feature is disabled', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + eventFragments: {}, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: false, + }); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('does not drop marketing queued events when opting out of product analytics', async () => { + const adapter = createMockAdapter(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: false, + marketingConsentDecisionMade: false, + ...withMarketingList, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isPreConsentQueueEnabled: true, + }); + + controller.trackEvent(createTestEvent(marketingEvent)); + controller.optOut(); + await controller.optInToMarketing(); + + expect(adapter.track).toHaveBeenCalledWith( + marketingEvent, + undefined, + withConfiguredPurposeConsent({ product: false, marketing: true }), + expect.anything(), + ); + }); + }); }); describe('AnalyticsPlatformAdapterSetupError', () => { diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 8045f12d662..6b887e8916d 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -55,6 +55,40 @@ export const controllerName = 'AnalyticsController'; */ export const EVENT_FRAGMENT_MAX_AGE = 24 * 60 * 60 * 1000; +/** + * Purposes for which an analytics payload can be used. + */ +export const AnalyticsPurpose = { + Product: 'product', + Marketing: 'marketing', +} as const; + +/** + * A purpose for which an analytics payload can be used. + */ +export type AnalyticsPurpose = + (typeof AnalyticsPurpose)[keyof typeof AnalyticsPurpose]; + +/** + * Persisted event-purpose classification fetched from config registry. + */ +export type AnalyticsEventsConfig = { + schemaVersion: string; + version: string; + timestamp: number; + events: Record; +}; + +/** + * Persisted queues on {@link AnalyticsControllerState}. + */ +const AnalyticsQueue = { + EventQueue: 'eventQueue', + PreConsentEventQueue: 'preConsentEventQueue', +} as const; + +type AnalyticsQueue = (typeof AnalyticsQueue)[keyof typeof AnalyticsQueue]; + // === STATE === /** @@ -66,6 +100,32 @@ export type AnalyticsControllerState = { */ optedIn: boolean; + /** + * Whether the user has opted in to marketing analytics. + * + * Independent of {@link optedIn}. Named events in the remote marketing list + * are governed only by this flag. Optional for backward compatibility with + * persisted state that predates this field. Missing values are treated as + * `false`. + */ + optedInToMarketing?: boolean; + + /** + * Whether the user has made a marketing consent decision (opted in or opted + * out). Mirrors {@link consentDecisionMade} for the marketing purpose. + * Optional for backward compatibility. Missing values are treated as `false`. + */ + marketingConsentDecisionMade?: boolean; + + /** + * Cached event-purpose configuration. Optional for backward compatibility. + * + * Phase 1 does not load this from a remote source. Until a later phase wires + * that up, classification uses the persisted config. Unlisted names are + * product-only. + */ + eventsConfig?: AnalyticsEventsConfig; + /** * User's UUIDv4 analytics identifier. * This is an identity (unique per user), not a preference. @@ -135,6 +195,16 @@ export type AnalyticsQueuedEventBase = { * Original payload timestamp serialized for persistence. */ timestamp: string; + + /** + * Event purposes captured with the payload, before user consent is applied. + */ + eventPurposes?: AnalyticsPurpose[]; + + /** + * Events config version used to classify the payload. + */ + eventsConfigVersion?: string; }; /** @@ -195,6 +265,8 @@ export function getDefaultAnalyticsControllerState(): Omit< return { optedIn: false, consentDecisionMade: false, + optedInToMarketing: false, + marketingConsentDecisionMade: false, }; } @@ -211,6 +283,24 @@ const analyticsControllerMetadata = { includeInDebugSnapshot: true, usedInUi: true, }, + optedInToMarketing: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + marketingConsentDecisionMade: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + eventsConfig: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, analyticsId: { includeInStateLogs: true, persist: true, @@ -252,6 +342,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'optIn', 'optOut', 'resetConsentDecision', + 'optInToMarketing', + 'optOutOfMarketing', + 'resetMarketingConsentDecision', 'createEventFragment', 'upsertEventFragment', 'updateEventFragment', @@ -396,6 +489,38 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } +function isAnalyticsPurpose(value: unknown): value is AnalyticsPurpose { + return ( + value === AnalyticsPurpose.Product || value === AnalyticsPurpose.Marketing + ); +} + +function isEventPurposesRecord( + value: unknown, +): value is Record { + return ( + isRecord(value) && + Object.values(value).every( + (purposes) => + Array.isArray(purposes) && + purposes.length > 0 && + purposes.every(isAnalyticsPurpose), + ) + ); +} + +function isAnalyticsEventsConfig( + value: unknown, +): value is AnalyticsEventsConfig { + return ( + isRecord(value) && + typeof value.schemaVersion === 'string' && + typeof value.version === 'string' && + typeof value.timestamp === 'number' && + isEventPurposesRecord(value.events) + ); +} + /** * Returns whether a JSON value is a non-array object. * @@ -444,7 +569,12 @@ function isAnalyticsQueuedEvent(value: unknown): value is AnalyticsQueuedEvent { if ( typeof value.messageId !== 'string' || - typeof value.timestamp !== 'string' + typeof value.timestamp !== 'string' || + (value.eventPurposes !== undefined && + (!Array.isArray(value.eventPurposes) || + !value.eventPurposes.every(isAnalyticsPurpose))) || + (value.eventsConfigVersion !== undefined && + typeof value.eventsConfigVersion !== 'string') ) { return false; } @@ -501,6 +631,10 @@ function isAnalyticsEventFragment( typeof value.successEvent === 'string') && (value.failureEvent === undefined || typeof value.failureEvent === 'string') && + (value.eventPurposes === undefined || + isEventPurposesRecord(value.eventPurposes)) && + (value.eventsConfigVersion === undefined || + typeof value.eventsConfigVersion === 'string') && (value.context === undefined || isRecord(value.context)) && (value.persist === undefined || typeof value.persist === 'boolean') ); @@ -537,22 +671,21 @@ function mergeEventFragment( } /** - * Merges two optional analytics contexts, preserving `undefined` when neither - * side has one so an empty context is never sent. + * Merges two optional analytics contexts. * * @param base - The context to merge into. - * @param override - The context whose fields win. + * @param override - The context whose fields win. When omitted, `base` is kept. * @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; + if (override === undefined) { + return base; } - return { ...(base ?? {}), ...(override ?? {}) }; + return { ...(base ?? {}), ...override }; } /** @@ -585,6 +718,13 @@ export class AnalyticsController extends BaseController< readonly #isEventFragmentsEnabled: boolean; + /** + * In-memory event-purpose lookup from persisted state. + */ + readonly #eventPurposes: Map; + + readonly #eventsConfigVersion: string | undefined; + /** * The in-flight (or settled) initialization promise. Set on the first * {@link init} call and returned by subsequent calls so overlapping callers @@ -630,6 +770,14 @@ export class AnalyticsController extends BaseController< ...getDefaultAnalyticsControllerState(), ...state, }; + const eventsConfig = isAnalyticsEventsConfig(initialState.eventsConfig) + ? initialState.eventsConfig + : undefined; + if (eventsConfig === undefined) { + delete initialState.eventsConfig; + } else { + initialState.eventsConfig = eventsConfig; + } validateAnalyticsControllerState( initialState, @@ -651,6 +799,8 @@ export class AnalyticsController extends BaseController< this.#platformAdapter = platformAdapter; this.#initPromise = undefined; this.#locationResolvePromise = undefined; + this.#eventPurposes = new Map(Object.entries(eventsConfig?.events ?? {})); + this.#eventsConfigVersion = eventsConfig?.version; this.messenger.registerMethodActionHandlers( this, @@ -660,6 +810,9 @@ export class AnalyticsController extends BaseController< log('AnalyticsController initialized and ready', { enabled: analyticsControllerSelectors.selectEnabled(this.state), optedIn: this.state.optedIn, + optedInToMarketing: this.state.optedInToMarketing === true, + marketingConsentDecisionMade: + this.state.marketingConsentDecisionMade === true, consentDecisionMade: this.state.consentDecisionMade, analyticsId: this.state.analyticsId, eventQueuePersistenceEnabled: this.#isEventQueuePersistenceEnabled, @@ -675,10 +828,11 @@ export class AnalyticsController extends BaseController< * method must be called after construction to complete the setup process. * * When geolocation enrichment is enabled (`isGeolocationEnabled`), geolocation - * is resolved only for a user who is already opted in; for undecided or - * opted-out users it is deferred until they opt in (see {@link optIn}), so a - * user's location is never requested before they consent to analytics. In - * either case the `GeolocationController` and its + * is resolved only for a user who is already opted in to product or marketing + * analytics. For users undecided or opted out of both purposes, it is + * deferred until they opt in to either one (see {@link optIn} and + * {@link optInToMarketing}), so a user's location is never requested before + * they consent to analytics. In either case the `GeolocationController` and its * `GeolocationController:getGeolocationData` action must be registered before * resolution occurs, or enrichment is skipped for the session (a message is * logged, see {@link #resolveLocationContext}). @@ -718,9 +872,12 @@ export class AnalyticsController extends BaseController< } } - // 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. + await this.#fetchEventsConfig(); + + // Resolve geolocation only when the user is already opted in to product or + // marketing analytics. For undecided or opted-out users it is deferred to + // {@link optIn} / {@link optInToMarketing}. Awaited so that an already-opted-in + // session has location available before events replay. await this.#maybeResolveLocation(); // Call onSetupCompleted lifecycle hook after initialization @@ -733,21 +890,21 @@ export class AnalyticsController extends BaseController< } this.#replayQueuedEvents(); - this.#reconcilePreConsentEvents(); + this.#replayPreConsentEvents(); this.#reconcileEventFragments(initEventFragmentSnapshot); } /** * Start resolving the geolocation context if warranted, and return the * in-flight (or settled) resolution so callers can await it. No-op unless - * enrichment is enabled, the user is opted in, and a resolution has not - * already been started. Deferring resolution until opt-in ensures a user's - * location is never requested before they consent to analytics (for example, - * during onboarding). + * enrichment is enabled, the user is opted in to product or marketing + * analytics, and a resolution has not already been started. Deferring + * resolution until consent ensures a user's location is never requested before + * they consent to analytics (for example, during onboarding). * * Resolution runs at most once per controller session: the settled promise - * is retained, so the outcome — including a failure (see - * {@link #resolveLocationContext}) — is not retried, and events are delivered + * is retained, so the outcome, including a failure (see + * {@link #resolveLocationContext}) is not retried, and events are delivered * without location for the rest of the session. * * @returns The geolocation resolution promise, or `undefined` when no @@ -757,7 +914,7 @@ export class AnalyticsController extends BaseController< if ( this.#isGeolocationEnabled && this.#locationResolvePromise === undefined && - analyticsControllerSelectors.selectEnabled(this.state) + (this.state.optedIn || this.state.optedInToMarketing === true) ) { this.#locationResolvePromise = this.#resolveLocationContext(); } @@ -812,24 +969,200 @@ export class AnalyticsController extends BaseController< }; } + /** + * Stamp the purposes currently allowed for a payload using Segment's consent + * context shape. + * + * @param purposes - Purposes for which the payload is eligible. + * @param context - Optional caller-provided context. + * @param version - Config version captured with the payload. + * @returns Context with allowed purpose preferences. + */ + #withConsentContext( + purposes: AnalyticsPurpose[], + context?: AnalyticsContext, + version?: string, + ): AnalyticsContext { + const preferences = this.#allowedPurposePreferences(purposes); + const { + consent: existingConsent, + eventsConfigVersion: _ignoredVersion, + ...unmanagedContext + } = context ?? {}; + const consent: Record = isJsonRecord(existingConsent) + ? existingConsent + : {}; + const existingCategoryPreferences = isJsonRecord( + consent.categoryPreferences, + ) + ? consent.categoryPreferences + : {}; + + return { + ...unmanagedContext, + consent: { + ...consent, + categoryPreferences: { + ...existingCategoryPreferences, + ...preferences, + }, + }, + ...(version === undefined ? {} : { eventsConfigVersion: version }), + }; + } + + /** + * Load event-purpose configuration. + * + * Phase 1 stub. Persisted configuration remains authoritative until a remote + * source is wired up. + */ + async #fetchEventsConfig(): Promise { + // Intentionally empty until an events-config source is wired up. + } + + #purposesFromName(name: string): AnalyticsPurpose[] { + return [...(this.#eventPurposes.get(name) ?? [AnalyticsPurpose.Product])]; + } + + #purposesFromQueuedEvent( + queuedEvent: AnalyticsQueuedEvent, + ): AnalyticsPurpose[] { + if (queuedEvent.type === 'identify') { + return [AnalyticsPurpose.Product]; + } + + if (queuedEvent.eventPurposes !== undefined) { + return [...queuedEvent.eventPurposes]; + } + + return this.#purposesFromName( + queuedEvent.type === 'view' ? queuedEvent.name : queuedEvent.eventName, + ); + } + + #eventNamesFromFragment( + fragment: Pick< + AnalyticsEventFragment, + 'initialEvent' | 'successEvent' | 'failureEvent' + >, + ): string[] { + return [ + fragment.initialEvent, + fragment.successEvent, + fragment.failureEvent, + ].filter((name): name is string => typeof name === 'string'); + } + + #purposesFromFragmentEvent( + fragment: Pick, + name: string, + ): AnalyticsPurpose[] { + return [ + ...(fragment.eventPurposes?.[name] ?? this.#purposesFromName(name)), + ]; + } + + #purposesFromFragment( + fragment: Pick< + AnalyticsEventFragment, + 'initialEvent' | 'successEvent' | 'failureEvent' | 'eventPurposes' + >, + ): AnalyticsPurpose[] { + const names = this.#eventNamesFromFragment(fragment); + if (names.length === 0) { + // Nameless property bags have no event to classify. Treat them as + // eligible for any purpose so they can accumulate when either consent + // allows capture. + return Object.values(AnalyticsPurpose); + } + + return [ + ...new Set( + names.flatMap((name) => + this.#purposesFromFragmentEvent(fragment, name), + ), + ), + ]; + } + + #consent(purpose: AnalyticsPurpose): { + optedIn: boolean; + decisionMade: boolean; + } { + return purpose === AnalyticsPurpose.Marketing + ? { + optedIn: this.state.optedInToMarketing === true, + decisionMade: this.state.marketingConsentDecisionMade === true, + } + : { + optedIn: this.state.optedIn, + decisionMade: this.state.consentDecisionMade === true, + }; + } + + #allowedPurposePreferences(purposes: AnalyticsPurpose[]): { + product: boolean; + marketing: boolean; + } { + return { + product: + purposes.includes(AnalyticsPurpose.Product) && + this.#consent(AnalyticsPurpose.Product).optedIn, + marketing: + purposes.includes(AnalyticsPurpose.Marketing) && + this.#consent(AnalyticsPurpose.Marketing).optedIn, + }; + } + + #hasAllowedPurpose(purposes: AnalyticsPurpose[]): boolean { + const { product, marketing } = this.#allowedPurposePreferences(purposes); + return product || marketing; + } + + #hasUndecidedPurpose(purposes: AnalyticsPurpose[]): boolean { + return purposes.some((purpose) => !this.#consent(purpose).decisionMade); + } + + #isCaptureAllowed(purposes: AnalyticsPurpose[]): boolean { + return ( + this.#hasAllowedPurpose(purposes) || + (this.#isPreConsentQueueEnabled && this.#hasUndecidedPurpose(purposes)) + ); + } + + #replaceQueue(field: AnalyticsQueue, nextQueue: Record): void { + this.update((state) => { + state[field] = nextQueue as never; + }); + } + /** * Send final track payload through the platform adapter or queue it if persistence is enabled. * * @param eventName - The name of the event. * @param properties - Optional event properties. * @param context - Optional platform-specific context. + * @param purposes - Capture-time purposes for the event. + * @param version - Capture-time events config version. */ #sendOrQueueTrackEvent( eventName: string, - properties?: AnalyticsEventProperties, - context?: AnalyticsContext, + properties: AnalyticsEventProperties | undefined, + context: AnalyticsContext | undefined, + purposes: AnalyticsPurpose[], + version: string | undefined, ): void { + const isAllowed = this.#hasAllowedPurpose(purposes); + const contextWithConsent = this.#withConsentContext( + purposes, + context, + version, + ); + // Direct delivery: enabled and not persisting. - if ( - analyticsControllerSelectors.selectEnabled(this.state) && - !this.#isEventQueuePersistenceEnabled - ) { - this.#platformAdapter.track(eventName, properties, context); + if (isAllowed && !this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.track(eventName, properties, contextWithConsent); return; } @@ -839,12 +1172,12 @@ export class AnalyticsController extends BaseController< messageId: uuid(), timestamp: new Date().toISOString(), ...(properties === undefined ? {} : { properties }), - ...(context === undefined ? {} : { context }), + context: contextWithConsent, + eventPurposes: purposes, + ...(version === undefined ? {} : { eventsConfigVersion: version }), }; - // 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)) { + if (!isAllowed) { this.#enqueuePreConsentEvent(queuedEvent); return; } @@ -864,8 +1197,11 @@ export class AnalyticsController extends BaseController< traits?: AnalyticsUserTraits, context?: AnalyticsContext, ): void { + const purposes = [AnalyticsPurpose.Product]; + const contextWithConsent = this.#withConsentContext(purposes, context); + if (!this.#isEventQueuePersistenceEnabled) { - this.#platformAdapter.identify(userId, traits, context); + this.#platformAdapter.identify(userId, traits, contextWithConsent); return; } @@ -875,7 +1211,8 @@ export class AnalyticsController extends BaseController< messageId: uuid(), timestamp: new Date().toISOString(), ...(traits === undefined ? {} : { traits }), - ...(context === undefined ? {} : { context }), + context: contextWithConsent, + eventPurposes: purposes, }; this.#enqueueEvent(queuedEvent); @@ -887,14 +1224,25 @@ export class AnalyticsController extends BaseController< * @param name - The view name. * @param properties - Optional view properties. * @param context - Optional platform-specific context. + * @param purposes - Capture-time purposes for the view. + * @param version - Capture-time events config version. */ #sendOrQueueViewEvent( name: string, - properties?: AnalyticsEventProperties, - context?: AnalyticsContext, + properties: AnalyticsEventProperties | undefined, + context: AnalyticsContext | undefined, + purposes: AnalyticsPurpose[], + version: string | undefined, ): void { - if (!this.#isEventQueuePersistenceEnabled) { - this.#platformAdapter.view(name, properties, context); + const isAllowed = this.#hasAllowedPurpose(purposes); + const contextWithConsent = this.#withConsentContext( + purposes, + context, + version, + ); + + if (isAllowed && !this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.view(name, properties, contextWithConsent); return; } @@ -904,9 +1252,16 @@ export class AnalyticsController extends BaseController< messageId: uuid(), timestamp: new Date().toISOString(), ...(properties === undefined ? {} : { properties }), - ...(context === undefined ? {} : { context }), + context: contextWithConsent, + eventPurposes: purposes, + ...(version === undefined ? {} : { eventsConfigVersion: version }), }; + if (!isAllowed) { + this.#enqueuePreConsentEvent(queuedEvent); + return; + } + this.#enqueueEvent(queuedEvent); } @@ -998,10 +1353,8 @@ export class AnalyticsController extends BaseController< return; } - if (!analyticsControllerSelectors.selectEnabled(this.state)) { - this.#clearQueuedEvents(); - return; - } + const remainingQueue: Record = {}; + const eventsToSend: AnalyticsQueuedEvent[] = []; for (const [messageId, queuedEvent] of Object.entries( this.state.eventQueue, @@ -1011,10 +1364,21 @@ export class AnalyticsController extends BaseController< queuedEvent.messageId !== messageId ) { log('Dropping invalid queued analytics event', { messageId }); - this.#removeQueuedEvent(messageId); continue; } + const purposes = this.#purposesFromQueuedEvent(queuedEvent); + + if (this.#hasAllowedPurpose(purposes)) { + const refreshedEvent = this.#refreshQueuedEventConsent(queuedEvent); + remainingQueue[messageId] = refreshedEvent as unknown as Json; + eventsToSend.push(refreshedEvent); + } + } + + this.#replaceQueue(AnalyticsQueue.EventQueue, remainingQueue); + + for (const queuedEvent of eventsToSend) { this.#sendQueuedEvent(queuedEvent); } } @@ -1041,69 +1405,95 @@ export class AnalyticsController extends BaseController< }); } - /** - * Clear all queued analytics events. - */ - #clearQueuedEvents(): void { + #refreshQueuedEventConsent( + queuedEvent: AnalyticsQueuedEvent, + ): AnalyticsQueuedEvent { + // Refresh only the consent stamp. Capture-time `eventPurposes` and + // `eventsConfigVersion` stay as a pair and are never rewritten here. + const purposes = this.#purposesFromQueuedEvent(queuedEvent); + const preferences = this.#allowedPurposePreferences(purposes); + const existingPreferences = isJsonRecord(queuedEvent.context?.consent) + ? queuedEvent.context.consent.categoryPreferences + : undefined; + if ( - !this.state.eventQueue || - Object.keys(this.state.eventQueue).length === 0 + isJsonRecord(existingPreferences) && + existingPreferences.product === preferences.product && + existingPreferences.marketing === preferences.marketing ) { - return; + return queuedEvent; } - this.update((state) => { - state.eventQueue = {} as never; - }); - } - - /** - * 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, + return { + ...queuedEvent, + context: this.#withConsentContext( + purposes, + queuedEvent.context, + queuedEvent.eventsConfigVersion, + ), }; - - this.update((state) => { - state.preConsentEventQueue = preConsentEventQueue as never; - }); } /** - * Replay queued pre-consent events through the delivery path. + * Prune a queue after a consent change. * - * Only called by {@link #reconcilePreConsentEvents}, which guarantees the - * pre-consent queue is enabled and that the user is opted in. The queue is - * cleared before replaying so events cannot be re-queued or replayed twice. + * For {@link AnalyticsQueue.EventQueue}, entries are kept only while at least + * one capture-time purpose is opted in, and their consent stamp is refreshed. + * For {@link AnalyticsQueue.PreConsentEventQueue}, entries are kept while at + * least one purpose is still allowed or undecided. * - * @param queue - The pre-consent event queue to replay. + * @param field - The queue to prune. */ - #replayPreConsentEvents(queue: Record): void { - this.#clearPreConsentEvents(); + #pruneQueueForConsent(field: AnalyticsQueue): void { + const queue = this.state[field]; + if (!queue) { + return; + } + const nextQueue: Record = {}; for (const [messageId, queuedEvent] of Object.entries(queue)) { if ( !isAnalyticsQueuedEvent(queuedEvent) || queuedEvent.messageId !== messageId ) { - log('Dropping invalid queued pre-consent analytics event', { - messageId, - }); continue; } - const eventToReplay = this.#enrichPreConsentEvent(queuedEvent); + const purposes = this.#purposesFromQueuedEvent(queuedEvent); + const isAllowed = this.#hasAllowedPurpose(purposes); - if (this.#isEventQueuePersistenceEnabled) { - this.#enqueueEvent(eventToReplay); - } else { - this.#sendQueuedEvent(eventToReplay); + if (field === AnalyticsQueue.EventQueue) { + if (isAllowed) { + nextQueue[messageId] = this.#refreshQueuedEventConsent( + queuedEvent, + ) as unknown as Json; + } + } + + if (field === AnalyticsQueue.PreConsentEventQueue) { + if (isAllowed || this.#hasUndecidedPurpose(purposes)) { + nextQueue[messageId] = queuedEvent as unknown as Json; + } } } + + this.#replaceQueue(field, nextQueue); + } + + /** + * 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; + }); } /** @@ -1136,31 +1526,18 @@ export class AnalyticsController extends BaseController< } /** - * 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. + * Replay eligible pre-consent events against current consent. * - * 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. + * Allowed entries are replayed, undecided entries are kept for later, and + * entries with no remaining allowed or undecided purpose are dropped. The + * keep set is written before replay so events cannot be re-queued or + * replayed twice. * * 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 { + #replayPreConsentEvents(): void { const queue = this.state.preConsentEventQueue; if (!queue) { @@ -1168,14 +1545,44 @@ export class AnalyticsController extends BaseController< } if (!this.#isPreConsentQueueEnabled) { - this.#clearPreConsentEvents(); + this.update((state) => { + state.preConsentEventQueue = {} as never; + }); return; } - if (this.state.optedIn) { - this.#replayPreConsentEvents(queue); - } else if (this.state.consentDecisionMade) { - this.#clearPreConsentEvents(); + const keep: Record = {}; + const replay: AnalyticsQueuedEvent[] = []; + + for (const [messageId, queuedEvent] of Object.entries(queue)) { + if ( + !isAnalyticsQueuedEvent(queuedEvent) || + queuedEvent.messageId !== messageId + ) { + continue; + } + + const purposes = this.#purposesFromQueuedEvent(queuedEvent); + + if (this.#hasAllowedPurpose(purposes)) { + replay.push(queuedEvent); + } else if (this.#hasUndecidedPurpose(purposes)) { + keep[messageId] = queuedEvent as unknown as Json; + } + } + + this.#replaceQueue(AnalyticsQueue.PreConsentEventQueue, keep); + + for (const queuedEvent of replay) { + const eventToReplay = this.#refreshQueuedEventConsent( + this.#enrichPreConsentEvent(queuedEvent), + ); + + if (this.#isEventQueuePersistenceEnabled) { + this.#enqueueEvent(eventToReplay); + } else { + this.#sendQueuedEvent(eventToReplay); + } } } @@ -1189,9 +1596,8 @@ export class AnalyticsController extends BaseController< * 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. + * consent state no longer allows capture for any of a fragment's purposes, + * those fragments are 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 @@ -1210,34 +1616,15 @@ export class AnalyticsController extends BaseController< return; } - if (!this.#isEventFragmentsEnabled || !this.#isAnalyticsCaptureAllowed()) { + if (!this.#isEventFragmentsEnabled) { 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)) { + for (const [id, fragment] of Object.entries(fragments)) { if (!isAnalyticsEventFragment(fragment) || fragment.id !== id) { log('Dropping invalid persisted event fragment', { id }); continue; @@ -1248,6 +1635,10 @@ export class AnalyticsController extends BaseController< continue; } + if (!this.#isCaptureAllowed(this.#purposesFromFragment(fragment))) { + continue; + } + const snapshotCreatedAt = initEventFragmentSnapshot.get(id); if ( @@ -1259,10 +1650,12 @@ export class AnalyticsController extends BaseController< } } - if ( - Object.keys(eventFragments).length === - Object.keys(currentEventFragments).length - ) { + if (Object.keys(eventFragments).length === 0) { + this.#clearEventFragments(); + return; + } + + if (Object.keys(eventFragments).length === Object.keys(fragments).length) { return; } @@ -1285,16 +1678,36 @@ export class AnalyticsController extends BaseController< * Write an event fragment to state, replacing any fragment with the same ID. * * @param fragment - The fragment to store. + * @returns The stored fragment with capture-time purpose metadata. */ - #setEventFragment(fragment: AnalyticsEventFragment): void { + #setEventFragment(fragment: AnalyticsEventFragment): AnalyticsEventFragment { + // Snapshot classification only. Consent is stamped at emit time by + // {@link #trackEvent}, so fragment.context stays caller metadata. + let fragmentWithPurposeSnapshot = fragment; + if (fragment.eventPurposes === undefined) { + const names = this.#eventNamesFromFragment(fragment); + const eventPurposes = Object.fromEntries( + names.map((name) => [name, this.#purposesFromName(name)]), + ); + fragmentWithPurposeSnapshot = { + ...fragment, + ...(names.length === 0 ? {} : { eventPurposes }), + ...(this.#eventsConfigVersion === undefined + ? {} + : { eventsConfigVersion: this.#eventsConfigVersion }), + }; + } + const eventFragments: AnalyticsEventFragments = { ...this.state.eventFragments, - [fragment.id]: fragment, + [fragmentWithPurposeSnapshot.id]: fragmentWithPurposeSnapshot, }; this.update((state) => { state.eventFragments = eventFragments as never; }); + + return fragmentWithPurposeSnapshot; } /** @@ -1319,6 +1732,42 @@ export class AnalyticsController extends BaseController< }); } + /** + * Drop event fragments that the current consent state no longer allows to + * accumulate. + */ + #pruneEventFragmentsForConsent(): void { + const fragments = this.state.eventFragments; + + if (!fragments || Object.keys(fragments).length === 0) { + return; + } + + const eventFragments: AnalyticsEventFragments = {}; + for (const [id, fragment] of Object.entries(fragments)) { + if ( + isAnalyticsEventFragment(fragment) && + this.#isCaptureAllowed(this.#purposesFromFragment(fragment)) + ) { + eventFragments[id] = fragment; + } + } + + this.update((state) => { + state.eventFragments = eventFragments as never; + }); + } + + /** + * Drop queued events and fragments that the current consent state no longer + * allows to keep. + */ + #pruneAllForConsent(): void { + this.#pruneQueueForConsent(AnalyticsQueue.EventQueue); + this.#pruneQueueForConsent(AnalyticsQueue.PreConsentEventQueue); + this.#pruneEventFragmentsForConsent(); + } + /** * Clear all event fragments. */ @@ -1345,9 +1794,16 @@ export class AnalyticsController extends BaseController< * fragment never accumulates data for an event that could not be delivered. * * @param method - The name of the method that was called. + * @param fragment - The fragment being read or written, when one is known. * @returns True when the call should be ignored. */ - #shouldIgnoreEventFragmentCall(method: string): boolean { + #shouldIgnoreEventFragmentCall( + method: string, + fragment?: Pick< + AnalyticsEventFragment, + 'initialEvent' | 'successEvent' | 'failureEvent' | 'eventPurposes' + >, + ): boolean { if (!this.#isEventFragmentsEnabled) { log( 'Ignoring event fragment call because the event fragments feature is disabled', @@ -1357,7 +1813,11 @@ export class AnalyticsController extends BaseController< return true; } - if (!this.#isAnalyticsCaptureAllowed()) { + const captureAllowed = fragment + ? this.#isCaptureAllowed(this.#purposesFromFragment(fragment)) + : this.#isCaptureAllowed(Object.values(AnalyticsPurpose)); + + if (!captureAllowed) { log( 'Ignoring event fragment call because the consent state does not allow capturing analytics', { method }, @@ -1388,7 +1848,7 @@ export class AnalyticsController extends BaseController< const properties = { ...fragment.properties }; const sensitiveProperties = { ...fragment.sensitiveProperties }; - this.trackEvent( + this.#trackEvent( { name, properties, @@ -1399,29 +1859,11 @@ export class AnalyticsController extends BaseController< Object.keys(sensitiveProperties).length > 0, }, context, + this.#purposesFromFragmentEvent(fragment, name), + fragment.eventsConfigVersion, ); } - /** - * 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. * @@ -1431,10 +1873,24 @@ export class AnalyticsController extends BaseController< * @param context - Optional platform-specific context forwarded to the platform adapter. */ trackEvent(event: AnalyticsTrackingEvent, context?: AnalyticsContext): void { + this.#trackEvent( + event, + context, + this.#purposesFromName(event.name), + this.#eventsConfigVersion, + ); + } + + #trackEvent( + event: AnalyticsTrackingEvent, + context: AnalyticsContext | undefined, + purposes: AnalyticsPurpose[], + version: string | undefined, + ): void { // 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()) { + if (!this.#isCaptureAllowed(purposes)) { return; } @@ -1445,6 +1901,8 @@ export class AnalyticsController extends BaseController< event.name, undefined, this.#withLocationContext(context), + purposes, + version, ); return; } @@ -1459,6 +1917,8 @@ export class AnalyticsController extends BaseController< ...event.properties, }, this.#withLocationContext(context), + purposes, + version, ); } @@ -1479,6 +1939,8 @@ export class AnalyticsController extends BaseController< this.#isAnonymousEventsFeatureEnabled ? context : this.#withLocationContext(context), + purposes, + version, ); } } @@ -1494,7 +1956,6 @@ export class AnalyticsController extends BaseController< return; } - // Delegate to platform adapter using the current analytics ID this.#sendOrQueueIdentifyEvent( this.state.analyticsId, traits, @@ -1514,7 +1975,9 @@ export class AnalyticsController extends BaseController< properties?: AnalyticsEventProperties, context?: AnalyticsContext, ): void { - if (!analyticsControllerSelectors.selectEnabled(this.state)) { + const purposes = this.#purposesFromName(name); + const version = this.#eventsConfigVersion; + if (!this.#isCaptureAllowed(purposes)) { return; } @@ -1523,6 +1986,8 @@ export class AnalyticsController extends BaseController< name, properties, this.#withLocationContext(context), + purposes, + version, ); } @@ -1553,13 +2018,21 @@ export class AnalyticsController extends BaseController< createEventFragment( options: AnalyticsEventFragmentOptions = {}, ): ReadonlyAnalyticsEventFragment | undefined { - if (this.#shouldIgnoreEventFragmentCall('createEventFragment')) { + // Classify create from event names only. Caller consent context must not + // affect the event-purpose snapshot. + if ( + this.#shouldIgnoreEventFragmentCall('createEventFragment', { + initialEvent: options.initialEvent, + successEvent: options.successEvent, + failureEvent: options.failureEvent, + }) + ) { return undefined; } const now = Date.now(); - const fragment: AnalyticsEventFragment = { + const fragment = this.#setEventFragment({ id: options.id ?? uuid(), properties: { ...(options.properties ?? {}) }, sensitiveProperties: { ...(options.sensitiveProperties ?? {}) }, @@ -1578,9 +2051,7 @@ export class AnalyticsController extends BaseController< ? {} : { context: { ...options.context } }), ...(options.persist === undefined ? {} : { persist: options.persist }), - }; - - this.#setEventFragment(fragment); + }); if (fragment.initialEvent) { this.#emitEventFragment( @@ -1607,12 +2078,13 @@ export class AnalyticsController extends BaseController< id: string, payload: AnalyticsEventFragmentPayload = {}, ): void { - if (this.#shouldIgnoreEventFragmentCall('upsertEventFragment')) { + const fragment = this.#getEventFragment(id); + if ( + this.#shouldIgnoreEventFragmentCall('upsertEventFragment', fragment ?? {}) + ) { return; } - const fragment = this.#getEventFragment(id); - if (!fragment) { this.createEventFragment({ id, ...payload }); return; @@ -1635,12 +2107,11 @@ export class AnalyticsController extends BaseController< id: string, payload: AnalyticsEventFragmentPayload = {}, ): void { - if (this.#shouldIgnoreEventFragmentCall('updateEventFragment')) { + const fragment = this.#getEventFragment(id); + if (this.#shouldIgnoreEventFragmentCall('updateEventFragment', fragment)) { return; } - const fragment = this.#getEventFragment(id); - if (!fragment) { throw new Error(`Event fragment with id ${id} does not exist.`); } @@ -1659,12 +2130,11 @@ export class AnalyticsController extends BaseController< * {@link upsertEventFragment} to write. */ getEventFragmentById(id: string): ReadonlyAnalyticsEventFragment | undefined { - if (this.#shouldIgnoreEventFragmentCall('getEventFragmentById')) { + const fragment = this.#getEventFragment(id); + if (this.#shouldIgnoreEventFragmentCall('getEventFragmentById', fragment)) { return undefined; } - const fragment = this.#getEventFragment(id); - return fragment === undefined ? undefined : cloneDeep(fragment); } @@ -1674,7 +2144,8 @@ export class AnalyticsController extends BaseController< * @param id - The fragment ID. */ deleteEventFragment(id: string): void { - if (this.#shouldIgnoreEventFragmentCall('deleteEventFragment')) { + const fragment = this.#getEventFragment(id); + if (this.#shouldIgnoreEventFragmentCall('deleteEventFragment', fragment)) { return; } @@ -1701,12 +2172,13 @@ export class AnalyticsController extends BaseController< id: string, { abandoned = false, context }: AnalyticsEventFragmentFinalizeOptions = {}, ): void { - if (this.#shouldIgnoreEventFragmentCall('finalizeEventFragment')) { + const fragment = this.#getEventFragment(id); + if ( + this.#shouldIgnoreEventFragmentCall('finalizeEventFragment', fragment) + ) { return; } - const fragment = this.#getEventFragment(id); - if (!fragment) { throw new Error(`Event fragment with id ${id} does not exist.`); } @@ -1750,7 +2222,7 @@ export class AnalyticsController extends BaseController< // consent decision may have changed while geolocation was resolving (e.g. // resetConsentDecision ran during the await), and preserved pre-consent // events must not be delivered once the user is no longer opted in. - this.#reconcilePreConsentEvents(); + this.#replayPreConsentEvents(); } /** @@ -1766,9 +2238,7 @@ export class AnalyticsController extends BaseController< state.consentDecisionMade = true; }); - this.#clearQueuedEvents(); - this.#clearPreConsentEvents(); - this.#clearEventFragments(); + this.#pruneAllForConsent(); } /** @@ -1789,10 +2259,52 @@ export class AnalyticsController extends BaseController< state.consentDecisionMade = false; }); - this.#clearQueuedEvents(); + this.#pruneAllForConsent(); + } - if (!this.#isAnalyticsCaptureAllowed()) { - this.#clearEventFragments(); - } + /** + * Opt in to marketing analytics. + * + * Independent of {@link optIn}. Replays queued marketing events. + * + * @returns A promise that resolves once opt-in processing has completed. + */ + async optInToMarketing(): Promise { + this.update((state) => { + state.optedInToMarketing = true; + state.marketingConsentDecisionMade = true; + }); + + await this.#maybeResolveLocation(); + this.#replayPreConsentEvents(); + } + + /** + * Opt out of marketing analytics. + * + * Independent of {@link optOut}. Discards queued marketing events and + * marketing event fragments. + */ + optOutOfMarketing(): void { + this.update((state) => { + state.optedInToMarketing = false; + state.marketingConsentDecisionMade = true; + }); + + this.#pruneAllForConsent(); + } + + /** + * Reset the marketing consent decision back to undecided. + * + * Independent of {@link resetConsentDecision}. + */ + resetMarketingConsentDecision(): void { + this.update((state) => { + state.optedInToMarketing = false; + state.marketingConsentDecisionMade = false; + }); + + this.#pruneAllForConsent(); } } diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index c67ccfc99ef..8bbfb032050 100644 --- a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts +++ b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts @@ -56,9 +56,23 @@ export type AnalyticsTrackingEvent = { }; /** - * Optional analytics context payload (for example Segment-style context). + * Optional analytics context payload. */ -export type AnalyticsContext = Record; +export type AnalyticsContext = Record & { + /** + * Segment consent context. The controller writes optional `product` and + * `marketing` entries under `categoryPreferences` from the intersection of + * eligible purposes and current consent, and preserves other caller consent + * fields. + */ + consent?: Record & { + categoryPreferences?: Record & { + product?: boolean; + marketing?: boolean; + }; + }; + eventsConfigVersion?: string; +}; /** * Names of the geolocation fields attached to an analytics event. diff --git a/packages/analytics-controller/src/EventFragment.types.ts b/packages/analytics-controller/src/EventFragment.types.ts index 22501b1d3c7..6be4ca61814 100644 --- a/packages/analytics-controller/src/EventFragment.types.ts +++ b/packages/analytics-controller/src/EventFragment.types.ts @@ -1,3 +1,4 @@ +import type { AnalyticsPurpose } from './AnalyticsController.js'; import type { AnalyticsContext, AnalyticsEventProperties, @@ -49,6 +50,16 @@ export type AnalyticsEventFragment = { */ failureEvent?: string; + /** + * Capture-time purpose classification keyed by declared event name. + */ + eventPurposes?: Record; + + /** + * Events config version used for the capture-time classification. + */ + eventsConfigVersion?: string; + /** * Platform-specific context forwarded with every event this fragment emits. */ @@ -90,6 +101,8 @@ export type ReadonlyAnalyticsEventFragment = Readonly<{ initialEvent?: string; successEvent?: string; failureEvent?: string; + eventPurposes?: Readonly>; + eventsConfigVersion?: string; context?: Readonly; persist?: boolean; createdAt: number; diff --git a/packages/analytics-controller/src/index.ts b/packages/analytics-controller/src/index.ts index 47e2813e2ea..05153a56851 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, + AnalyticsPurpose, EVENT_FRAGMENT_MAX_AGE, getDefaultAnalyticsControllerState, } from './AnalyticsController.js'; @@ -36,6 +37,7 @@ export type { export type { AnalyticsControllerState, AnalyticsEventQueue, + AnalyticsEventsConfig, AnalyticsQueuedEvent, AnalyticsQueuedEventType, AnalyticsQueuedTrackEvent, @@ -63,6 +65,9 @@ export type { AnalyticsControllerOptInAction, AnalyticsControllerOptOutAction, AnalyticsControllerResetConsentDecisionAction, + AnalyticsControllerOptInToMarketingAction, + AnalyticsControllerOptOutOfMarketingAction, + AnalyticsControllerResetMarketingConsentDecisionAction, AnalyticsControllerCreateEventFragmentAction, AnalyticsControllerUpsertEventFragmentAction, AnalyticsControllerUpdateEventFragmentAction, diff --git a/packages/analytics-controller/src/selectors.test.ts b/packages/analytics-controller/src/selectors.test.ts index d94322a29c8..9a400859457 100644 --- a/packages/analytics-controller/src/selectors.test.ts +++ b/packages/analytics-controller/src/selectors.test.ts @@ -31,6 +31,35 @@ describe('analyticsControllerSelectors', () => { }); }); + describe('selectOptedInToMarketing', () => { + it.each([[true], [false]])( + 'returns %s when optedInToMarketing is %s', + (optedInToMarketing) => { + const state: AnalyticsControllerState = { + optedIn: false, + optedInToMarketing, + analyticsId: defaultAnalyticsId, + }; + + const result = + analyticsControllerSelectors.selectOptedInToMarketing(state); + + expect(result).toBe(optedInToMarketing); + }, + ); + + it('defaults to false when the field is absent', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: defaultAnalyticsId, + }; + + expect(analyticsControllerSelectors.selectOptedInToMarketing(state)).toBe( + false, + ); + }); + }); + describe('selectEnabled', () => { it.each([ [false, false], @@ -92,6 +121,36 @@ describe('analyticsControllerSelectors', () => { }); }); + describe('selectMarketingConsentDecisionMade', () => { + it.each([[true], [false]])( + 'returns %s when marketingConsentDecisionMade is %s', + (marketingConsentDecisionMade) => { + const state: AnalyticsControllerState = { + optedIn: false, + marketingConsentDecisionMade, + analyticsId: defaultAnalyticsId, + }; + + expect( + analyticsControllerSelectors.selectMarketingConsentDecisionMade( + state, + ), + ).toBe(marketingConsentDecisionMade); + }, + ); + + it('defaults to false when the field is absent', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: defaultAnalyticsId, + }; + + expect( + analyticsControllerSelectors.selectMarketingConsentDecisionMade(state), + ).toBe(false); + }); + }); + describe('event fragment selectors', () => { const fragment: AnalyticsEventFragment = { id: 'signature-1', diff --git a/packages/analytics-controller/src/selectors.ts b/packages/analytics-controller/src/selectors.ts index 79591012ffb..c3dfb586626 100644 --- a/packages/analytics-controller/src/selectors.ts +++ b/packages/analytics-controller/src/selectors.ts @@ -25,6 +25,15 @@ const selectAnalyticsId = (state: AnalyticsControllerState): string => const selectOptedIn = (state: AnalyticsControllerState): boolean => state.optedIn; +/** + * Selects the marketing opt-in status from the controller state. + * + * @param state - The controller state + * @returns Whether the user has opted in to marketing analytics + */ +const selectOptedInToMarketing = (state: AnalyticsControllerState): boolean => + state.optedInToMarketing === true; + /** * Selects whether analytics tracking is enabled. * Use this selector to determine if tracking should occur (e.g., in controller methods). @@ -46,6 +55,16 @@ const selectEnabled = (state: AnalyticsControllerState): boolean => const selectConsentDecisionMade = (state: AnalyticsControllerState): boolean => state.consentDecisionMade ?? false; +/** + * Selects whether the user has made a marketing consent decision. + * + * @param state - The controller state + * @returns Whether the user has made a marketing consent decision + */ +const selectMarketingConsentDecisionMade = ( + state: AnalyticsControllerState, +): boolean => state.marketingConsentDecisionMade ?? false; + /** * Selects the in-progress event fragments from the controller state. * @@ -76,8 +95,10 @@ const selectEventFragmentById = ( export const analyticsControllerSelectors = { selectAnalyticsId, selectOptedIn, + selectOptedInToMarketing, selectEnabled, selectConsentDecisionMade, + selectMarketingConsentDecisionMade, selectEventFragments, selectEventFragmentById, };