From 15829c6bba1154d89c1cd752c017d68b6d4a232f Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Mon, 14 Sep 2026 21:37:50 +0200 Subject: [PATCH 01/14] feat(analytics-controller): add independent marketing consent and Segment context flag Classify named track/view events as marketing or product, gate each lane on its own consent, and stamp context.marketing for destinations. Phase 1 keeps marketingEventNames as a persisted/seeded list without a remote fetch. Co-authored-by: Cursor --- packages/analytics-controller/CHANGELOG.md | 7 + packages/analytics-controller/README.md | 22 +- ...AnalyticsController-method-action-types.ts | 39 +- .../src/AnalyticsController.test.ts | 1090 +++++++++++++++-- .../src/AnalyticsController.ts | 652 +++++++--- packages/analytics-controller/src/index.ts | 3 + .../src/selectors.test.ts | 59 + .../analytics-controller/src/selectors.ts | 21 + 8 files changed, 1635 insertions(+), 258 deletions(-) diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 017f7dbc01c..e62276c9ecd 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 classify named events by lane + - New state and methods: `optedInToMarketing`, `optInToMarketing` / `optOutOfMarketing` / `resetMarketingConsentDecision`, and a persisted `marketingEventNames` list (remote loading arrives in a later phase) + - Named `track` / `view` payloads stamp `context.marketing` (`true` or `false`) at capture so Segment can tell marketing events from product events + - Queues and fragments follow that lane. A fragment that declares both marketing and product event names is treated as marketing + ### 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..9a7ee10b91a 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 | +| `marketingEventNames` | `string[]`| Cached marketing event names (empty until a source is wired) | Yes | +| `eventQueue` | `object` | Optional persisted delivery queue | Yes | +| `eventFragments` | `object` | Optional in-progress event fragments | Yes | ### Client Platform Responsibilities @@ -30,6 +34,12 @@ 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) +Named events in `marketingEventNames` are governed only by `optedInToMarketing`. Every other named payload is governed only by `optedIn`. Queues, fragments, and delivery use the same machinery for both lanes. `identify` has no event name, so it follows `optedIn`. + +Until a later phase loads marketing event names from a remote source, `marketingEventNames` stays empty unless the client seeds or persists a list. With an empty list, every named event is treated as product, so marketing consent has no classification impact yet. + +Named `track` and `view` payloads are classified once at capture. That lane is stamped on `context.marketing` (`true` or `false`) so a Segment source can tell marketing events from product events without reading properties. Queues and fragments then follow the stamp. `identify` does not set this flag. Destinations should treat a missing `context.marketing` as product, since older app versions never send the field. + ## Anonymous Events Feature When `isAnonymousEventsFeatureEnabled` is enabled in the constructor, events with sensitive properties are split into separate events: diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index c0882256c38..3b317fbe017 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -192,6 +192,40 @@ export type AnalyticsControllerResetConsentDecisionAction = { handler: AnalyticsController['resetConsentDecision']; }; +/** + * Opt in to marketing analytics. + * + * Independent of {@link AnalyticsController.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 AnalyticsController.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 AnalyticsController.resetConsentDecision}. + */ +export type AnalyticsControllerResetMarketingConsentDecisionAction = { + type: `AnalyticsController:resetMarketingConsentDecision`; + handler: AnalyticsController['resetMarketingConsentDecision']; +}; + /** * Union of all AnalyticsController action types. */ @@ -207,4 +241,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..59704f0d487 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -224,6 +224,20 @@ function createMockAdapter(): MockAnalyticsPlatformAdapter { }; } +/** + * Expected named-event context with the Segment marketing flag. + * + * @param marketing - Whether the payload is classified as marketing. + * @param context - Optional caller context to merge. + * @returns Context including `marketing`. + */ +function withMarketingFlag( + marketing: boolean, + context: AnalyticsContext = {}, +): AnalyticsContext { + return { ...context, marketing }; +} + /** * Gets delivery options from a mock adapter call. * @@ -246,6 +260,8 @@ describe('AnalyticsController', () => { expect(defaults).toStrictEqual({ optedIn: false, consentDecisionMade: false, + optedInToMarketing: false, + marketingConsentDecisionMade: false, }); expect('analyticsId' in defaults).toBe(false); }); @@ -280,7 +296,9 @@ describe('AnalyticsController', () => { { "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", "consentDecisionMade": true, + "marketingConsentDecisionMade": false, "optedIn": true, + "optedInToMarketing": false, } `); }); @@ -300,7 +318,9 @@ describe('AnalyticsController', () => { { "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", "consentDecisionMade": true, + "marketingConsentDecisionMade": false, "optedIn": true, + "optedInToMarketing": false, } `); }); @@ -320,7 +340,9 @@ describe('AnalyticsController', () => { { "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", "consentDecisionMade": true, + "marketingConsentDecisionMade": false, "optedIn": true, + "optedInToMarketing": false, } `); }); @@ -490,7 +512,9 @@ describe('AnalyticsController', () => { ).toMatchInlineSnapshot(` { "consentDecisionMade": true, + "marketingConsentDecisionMade": false, "optedIn": true, + "optedInToMarketing": false, } `); }); @@ -624,7 +648,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }), - undefined, + withMarketingFlag(false), ); }); @@ -911,7 +935,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', expect.any(Object), - undefined, + withMarketingFlag(false), ); }); @@ -1008,7 +1032,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - undefined, + withMarketingFlag(false), ); }); @@ -1032,7 +1056,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - context, + withMarketingFlag(false, context), ); }); @@ -1056,7 +1080,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - context, + withMarketingFlag(false, context), ); }); @@ -1076,7 +1100,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - undefined, + withMarketingFlag(false), ); }); @@ -1106,7 +1130,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withMarketingFlag(false), ); }); @@ -1135,7 +1159,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withMarketingFlag(false), ); }); @@ -1179,7 +1203,7 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - undefined, + withMarketingFlag(false), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1189,7 +1213,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withMarketingFlag(false), ); }); @@ -1220,7 +1244,7 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - context, + withMarketingFlag(false, context), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1230,7 +1254,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - context, + withMarketingFlag(false, context), ); }); @@ -1257,7 +1281,7 @@ describe('AnalyticsController', () => { 1, 'test_event', {}, - undefined, + withMarketingFlag(false), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1266,7 +1290,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withMarketingFlag(false), ); }); @@ -1288,7 +1312,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - undefined, + withMarketingFlag(false), ); }); @@ -1310,7 +1334,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - undefined, + withMarketingFlag(false), ); }); }); @@ -1425,7 +1449,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.view).toHaveBeenCalledWith( 'home', { referrer: 'test' }, - undefined, + withMarketingFlag(false), ); }); @@ -1448,7 +1472,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.view).toHaveBeenCalledWith( 'settings', { section: 'security' }, - context, + withMarketingFlag(false, context), ); }); @@ -1499,7 +1523,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - { location: fullLocationContext }, + withMarketingFlag(false, { location: fullLocationContext }), ); }); @@ -1513,9 +1537,9 @@ describe('AnalyticsController', () => { controller.trackEvent(createTestEvent('test_event')); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { + expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, withMarketingFlag(false, { location: fullLocationContext, - }); + })); }); it('adds location to identify events', async () => { @@ -1545,9 +1569,9 @@ describe('AnalyticsController', () => { controller.trackView('home'); - expect(mockAdapter.view).toHaveBeenCalledWith('home', undefined, { + expect(mockAdapter.view).toHaveBeenCalledWith('home', undefined, withMarketingFlag(false, { location: fullLocationContext, - }); + })); }); it('preserves unrelated caller context', async () => { @@ -1562,10 +1586,10 @@ describe('AnalyticsController', () => { app: { name: 'MetaMask' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { + expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, withMarketingFlag(false, { app: { name: 'MetaMask' }, location: fullLocationContext, - }); + })); }); it('preserves caller location fields the controller does not resolve', async () => { @@ -1582,6 +1606,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { location: { city: 'Seattle', ...fullLocationContext }, + marketing: false, }); }); @@ -1597,9 +1622,9 @@ describe('AnalyticsController', () => { location: { country_code: 'FR' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { + expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, withMarketingFlag(false, { location: fullLocationContext, - }); + })); }); it('replaces a non-record caller location', async () => { @@ -1614,9 +1639,9 @@ describe('AnalyticsController', () => { location: 'Seattle', }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { + expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, withMarketingFlag(false, { location: fullLocationContext, - }); + })); }); it('omits fields the geolocation API could not determine', async () => { @@ -1631,6 +1656,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { location: { country_code: 'FR' }, + marketing: false, }); }); @@ -1647,6 +1673,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { app: { name: 'MetaMask' }, + marketing: false, }); }); @@ -1664,7 +1691,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - undefined, + withMarketingFlag(false), ); }); @@ -1686,7 +1713,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - undefined, + withMarketingFlag(false), ); }); @@ -1705,6 +1732,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { location: { city: 'Seattle' }, + marketing: false, }); }); @@ -1730,7 +1758,7 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - { location: fullLocationContext }, + withMarketingFlag(false, { location: fullLocationContext }), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1740,7 +1768,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withMarketingFlag(false), ); }); @@ -1767,7 +1795,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - { location: fullLocationContext }, + withMarketingFlag(false, { location: fullLocationContext }), ); }); @@ -1788,6 +1816,7 @@ describe('AnalyticsController', () => { expect(queuedEvent.context).toStrictEqual({ location: fullLocationContext, + marketing: false, }); }); @@ -1822,7 +1851,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'preconsent_event', undefined, - { location: fullLocationContext }, + withMarketingFlag(false, { location: fullLocationContext }), expect.any(Object), ); @@ -1831,7 +1860,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenLastCalledWith( 'postconsent_event', undefined, - { location: fullLocationContext }, + withMarketingFlag(false, { location: fullLocationContext }), ); }); @@ -1864,7 +1893,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - { location: fullLocationContext }, + withMarketingFlag(false, { location: fullLocationContext }), expect.any(Object), ); // ...but the anonymous payload carries no location. @@ -1875,7 +1904,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withMarketingFlag(false), expect.any(Object), ); }); @@ -1962,7 +1991,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - undefined, + withMarketingFlag(false), ); expect(mockAdapter.track.mock.calls[0]).toHaveLength(3); }); @@ -1993,6 +2022,7 @@ describe('AnalyticsController', () => { messageId: deliveryOptions.messageId, timestamp: deliveryOptions.timestamp?.toISOString(), properties: { prop: 'value' }, + context: withMarketingFlag(false), }, }); @@ -2191,7 +2221,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.view).toHaveBeenCalledWith( 'home', { referrer: 'test' }, - viewContext, + withMarketingFlag(false, viewContext), expect.objectContaining({ messageId: viewOptions.messageId }), ); expect(controller.state.eventQueue).toMatchObject({ @@ -2199,7 +2229,7 @@ describe('AnalyticsController', () => { context: identifyContext, }, [viewOptions.messageId as string]: { - context: viewContext, + context: withMarketingFlag(false, viewContext), }, }); expect(Object.keys(controller.state.eventQueue ?? {})).toHaveLength(2); @@ -2236,6 +2266,7 @@ describe('AnalyticsController', () => { eventName: 'test_event', messageId: trackOptions.messageId, timestamp: trackOptions.timestamp?.toISOString(), + context: withMarketingFlag(false), }, [identifyOptions.messageId as string]: { type: 'identify', @@ -2248,6 +2279,7 @@ describe('AnalyticsController', () => { name: 'home', messageId: viewOptions.messageId, timestamp: viewOptions.timestamp?.toISOString(), + context: withMarketingFlag(false), }, }); }); @@ -2746,7 +2778,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - undefined, + withMarketingFlag(false), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -2777,7 +2809,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - undefined, + withMarketingFlag(false), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -2853,7 +2885,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - undefined, + withMarketingFlag(false), expect.objectContaining({ messageId: expect.any(String) }), ); expect(controller.state.preConsentEventQueue).toStrictEqual({}); @@ -2905,13 +2937,13 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'first_event', { a: 1 }, - { source: 'onboarding' }, + withMarketingFlag(false, { source: 'onboarding' }), expect.objectContaining({ messageId: expect.any(String) }), ); expect(mockAdapter.track).toHaveBeenCalledWith( 'second_event', { b: 2 }, - undefined, + withMarketingFlag(false), expect.objectContaining({ messageId: expect.any(String) }), ); expect(controller.state.preConsentEventQueue).toStrictEqual({}); @@ -2950,7 +2982,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - undefined, + withMarketingFlag(false), expect.objectContaining({ messageId: expect.any(String) }), ); expect(controller.state.preConsentEventQueue).toStrictEqual({}); @@ -3022,6 +3054,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({ @@ -3098,6 +3164,7 @@ describe('AnalyticsController', () => { id: 'bag-1', properties: {}, sensitiveProperties: {}, + context: withMarketingFlag(false), createdAt: now, lastUpdated: now, }); @@ -3108,7 +3175,7 @@ describe('AnalyticsController', () => { const fragment = controller.createEventFragment({ id: 'signature-1', properties: { signature_type: 'personal_sign' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), }); expect(fragment).toBeDefined(); @@ -3122,7 +3189,9 @@ describe('AnalyticsController', () => { expect(controller.state.eventFragments?.['signature-1']).toStrictEqual( expect.objectContaining({ properties: { signature_type: 'personal_sign' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: withMarketingFlag(false, { + referrer: { url: 'https://dapp.test' }, + }), }), ); }); @@ -3137,7 +3206,7 @@ describe('AnalyticsController', () => { failureEvent: 'Signature Rejected', properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), persist: true, }); @@ -3148,7 +3217,7 @@ describe('AnalyticsController', () => { failureEvent: 'Signature Rejected', properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), persist: true, createdAt: expect.any(Number), lastUpdated: expect.any(Number), @@ -3166,14 +3235,14 @@ describe('AnalyticsController', () => { initialEvent: 'Signature Requested', successEvent: 'Signature Approved', properties: { signature_type: 'personal_sign' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: withMarketingFlag(false, { 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' } }, + withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), ); }); @@ -3226,6 +3295,7 @@ describe('AnalyticsController', () => { id: 'transaction-ui-1', properties: { simulation_response: 'no_changes' }, sensitiveProperties: {}, + context: withMarketingFlag(false), createdAt: expect.any(Number), lastUpdated: expect.any(Number), }); @@ -3265,6 +3335,7 @@ describe('AnalyticsController', () => { gas_edit_attempted: 'basic', }, sensitiveProperties: { sending_value: '0x1' }, + context: withMarketingFlag(false), createdAt: expect.any(Number), lastUpdated: expect.any(Number), }); @@ -3317,17 +3388,19 @@ describe('AnalyticsController', () => { expect( controller.state.eventFragments?.['signature-1']?.context, - ).toStrictEqual({ - referrer: { url: 'https://other.test' }, - keep: 'me', - }); + ).toStrictEqual( + withMarketingFlag(false, { + referrer: { url: 'https://other.test' }, + keep: 'me', + }), + ); }); it('preserves fragment context when an update omits context', async () => { const { controller } = await setupFragmentController(); controller.createEventFragment({ id: 'signature-1', - context: { referrer: { url: 'https://dapp.test' } }, + context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), }); controller.updateEventFragment('signature-1', { @@ -3336,12 +3409,12 @@ describe('AnalyticsController', () => { expect( controller.state.eventFragments?.['signature-1']?.context, - ).toStrictEqual({ - referrer: { url: 'https://dapp.test' }, - }); + ).toStrictEqual( + withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), + ); }); - it('leaves the context unset when neither side has one', async () => { + it('stamps context.marketing when neither side has caller context', async () => { const { controller } = await setupFragmentController(); controller.createEventFragment({ id: 'signature-1' }); @@ -3350,8 +3423,8 @@ describe('AnalyticsController', () => { }); expect( - controller.state.eventFragments?.['signature-1'], - ).not.toHaveProperty('context'); + controller.state.eventFragments?.['signature-1']?.context, + ).toStrictEqual(withMarketingFlag(false)); }); it('advances lastUpdated but preserves createdAt', async () => { @@ -3394,7 +3467,7 @@ describe('AnalyticsController', () => { id: 'signature-1', properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), }); const fragment = controller.getEventFragmentById('signature-1'); @@ -3416,7 +3489,9 @@ describe('AnalyticsController', () => { expect.objectContaining({ properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: { referrer: { url: 'https://dapp.test' } }, + context: withMarketingFlag(false, { + referrer: { url: 'https://dapp.test' }, + }), }), ); }); @@ -3472,7 +3547,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign', alert_triggered_count: 1 }, - undefined, + withMarketingFlag(false), ); expect(controller.state.eventFragments).toStrictEqual({}); }); @@ -3490,7 +3565,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Rejected', undefined, - undefined, + withMarketingFlag(false), ); }); @@ -3528,7 +3603,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', undefined, - { referrer: { url: 'https://other.test' }, keep: 'me' }, + withMarketingFlag(false, { referrer: { url: 'https://other.test' }, keep: 'me' }), ); }); @@ -3550,7 +3625,7 @@ describe('AnalyticsController', () => { 1, 'Signature Approved', { signature_type: 'personal_sign' }, - undefined, + withMarketingFlag(false), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -3560,7 +3635,7 @@ describe('AnalyticsController', () => { eip712_primary_type: 'Permit', anonymous: true, }, - undefined, + withMarketingFlag(false), ); }); @@ -3643,7 +3718,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Requested', undefined, - undefined, + withMarketingFlag(false), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -3815,13 +3890,11 @@ 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 +3923,7 @@ describe('AnalyticsController', () => { }); const initPromise = controller.init(); + await geolocationRequested; controller.createEventFragment({ id: 'signature-123', @@ -3871,13 +3945,11 @@ 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 +3970,7 @@ describe('AnalyticsController', () => { }); const initPromise = controller.init(); + await geolocationRequested; controller.createEventFragment({ id: 'signature-1', @@ -3921,19 +3994,17 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign' }, - undefined, + withMarketingFlag(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 +4030,7 @@ describe('AnalyticsController', () => { }); const initPromise = controller.init(); + await geolocationRequested; controller.createEventFragment({ id: 'signature-123', @@ -3986,7 +4058,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign' }, - undefined, + withMarketingFlag(false), ); expect(controller.state.eventFragments).toStrictEqual({}); }); @@ -4127,11 +4199,837 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign' }, - undefined, + withMarketingFlag(false), ); }); }); }); + + describe('marketing consent', () => { + const marketingEvent = 'Deep Link Used'; + const productEvent = 'Button Clicked'; + const withMarketingList = { + marketingEventNames: [marketingEvent], + }; + + 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, + withMarketingFlag(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, + withMarketingFlag(true), + ); + }); + + it('stamps context.marketing true 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, + withMarketingFlag(true, { page: { path: '/home' } }), + ); + expect(adapter.view).toHaveBeenCalledWith( + marketingEvent, + undefined, + withMarketingFlag(true, { page: { path: '/home' } }), + ); + }); + + it('stamps context.marketing false 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, + withMarketingFlag(false), + ); + }); + + it('stamps context.marketing on both 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' }, + withMarketingFlag(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, + }, + withMarketingFlag(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, + withMarketingFlag(false), + ); + }); + + it('does not emit either lane 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 persisted marketingEventNames 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, + marketingEventNames: ['Campaign Opened'], + }, + 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, + withMarketingFlag(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, + withMarketingFlag(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('treats a mixed-name fragment as marketing', 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?.context).toStrictEqual(withMarketingFlag(true)); + expect(controller.state.eventFragments).toHaveProperty('mixed-1'); + + controller.optOutOfMarketing(); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + + it('does not create a mixed-name fragment when only product consent is on', 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, + }); + + expect( + controller.createEventFragment({ + id: 'mixed-1', + initialEvent: productEvent, + successEvent: marketingEvent, + }), + ).toBeUndefined(); + expect(controller.state.eventFragments).toBeUndefined(); + }); + + 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, + withMarketingFlag(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: withMarketingFlag(false, { page: { path: '/settings' } }), + }), + ); + }); + + it('keeps persisted marketingEventNames across init', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + marketingEventNames: ['Campaign Opened'], + }, + isGeolocationEnabled: false, + }); + + expect(controller.state.marketingEventNames).toStrictEqual([ + 'Campaign Opened', + ]); + }); + + it('treats every name as product when marketingEventNames is empty', 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.marketingEventNames).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, + withMarketingFlag(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 filtering by consent lane', async () => { + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: true, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + marketingEventNames: [marketingEvent], + 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('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, + marketingEventNames: [marketingEvent], + 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, + marketingEventNames: [marketingEvent], + 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, + marketingEventNames: [marketingEvent], + 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( + withMarketingFlag(false, { 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' }, + context: withMarketingFlag(false), + }), + ); + }); + + 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, + marketingEventNames: [marketingEvent], + preConsentEventQueue: { + invalid: 'not-an-event', + 'keep-me': { + type: 'track', + eventName: marketingEvent, + messageId: 'keep-me', + timestamp: '2026-01-01T00:00:00.000Z', + context: withMarketingFlag(true), + }, + } as unknown as AnalyticsControllerState['preConsentEventQueue'], + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isPreConsentQueueEnabled: true, + skipInit: true, + }); + + await controller.optInToMarketing(); + + expect(adapter.track).toHaveBeenCalledWith( + marketingEvent, + undefined, + withMarketingFlag(true), + expect.anything(), + ); + }); + + 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, + withMarketingFlag(true), + expect.anything(), + ); + }); + }); }); describe('AnalyticsPlatformAdapterSetupError', () => { diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 8045f12d662..e2f1b12fd2c 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -55,6 +55,29 @@ export const controllerName = 'AnalyticsController'; */ export const EVENT_FRAGMENT_MAX_AGE = 24 * 60 * 60 * 1000; +/** + * Consent lane for a named analytics payload. + * + * Chosen from the marketing-events list at capture, then stored as + * `context.marketing` so queues and fragments do not look up the name again. + */ +const AnalyticsLane = { + Marketing: 'marketing', + Product: 'product', +} as const; + +type AnalyticsLane = (typeof AnalyticsLane)[keyof typeof AnalyticsLane]; + +/** + * Persisted queues on {@link AnalyticsControllerState}. + */ +const AnalyticsQueue = { + EventQueue: 'eventQueue', + PreConsentEventQueue: 'preConsentEventQueue', +} as const; + +type AnalyticsQueue = (typeof AnalyticsQueue)[keyof typeof AnalyticsQueue]; + // === STATE === /** @@ -66,6 +89,33 @@ 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 lane. + * Optional for backward compatibility. Missing values are treated as `false`. + */ + marketingConsentDecisionMade?: boolean; + + /** + * Cached marketing event names. Used to classify named payloads into the + * marketing lane. Optional for backward compatibility. + * + * Phase 1 does not load names from a remote source. Until a later phase + * wires that up, classification uses whatever list is already persisted, or + * an empty list (every named event is treated as product). + */ + marketingEventNames?: string[]; + /** * User's UUIDv4 analytics identifier. * This is an identity (unique per user), not a preference. @@ -195,6 +245,8 @@ export function getDefaultAnalyticsControllerState(): Omit< return { optedIn: false, consentDecisionMade: false, + optedInToMarketing: false, + marketingConsentDecisionMade: false, }; } @@ -211,6 +263,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, + }, + marketingEventNames: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, analyticsId: { includeInStateLogs: true, persist: true, @@ -252,6 +322,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'optIn', 'optOut', 'resetConsentDecision', + 'optInToMarketing', + 'optOutOfMarketing', + 'resetMarketingConsentDecision', 'createEventFragment', 'upsertEventFragment', 'updateEventFragment', @@ -537,22 +610,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 +657,13 @@ export class AnalyticsController extends BaseController< readonly #isEventFragmentsEnabled: boolean; + /** + * In-memory lookup of marketing event names from persisted state. + * Empty until a list is available. A later phase will refresh this from a + * remote source. + */ + readonly #marketingEventNames: Set; + /** * The in-flight (or settled) initialization promise. Set on the first * {@link init} call and returned by subsequent calls so overlapping callers @@ -651,6 +730,9 @@ export class AnalyticsController extends BaseController< this.#platformAdapter = platformAdapter; this.#initPromise = undefined; this.#locationResolvePromise = undefined; + this.#marketingEventNames = new Set( + initialState.marketingEventNames ?? [], + ); this.messenger.registerMethodActionHandlers( this, @@ -660,6 +742,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, @@ -718,9 +803,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.#fetchMarketingEventNames(); + + // 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 @@ -757,7 +845,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 +900,179 @@ export class AnalyticsController extends BaseController< }; } + /** + * Stamp the capture lane on context as `marketing` for Segment. + * + * @param lane - Marketing or product. + * @param context - Optional caller-provided context. + * @returns Context with `marketing` set. + */ + #withMarketingContext( + lane: AnalyticsLane, + context?: AnalyticsContext, + ): AnalyticsContext { + return { + ...context, + marketing: lane === AnalyticsLane.Marketing, + }; + } + + /** + * Load marketing event names used to classify named payloads. + * + * Phase 1 stub: there is no remote source yet, so this is a no-op. Any + * persisted {@link AnalyticsControllerState.marketingEventNames} from a + * previous session stay in memory. Otherwise the marketing list stays empty + * and every named event is treated as product. + */ + async #fetchMarketingEventNames(): Promise { + // Intentionally empty until a marketing-events source is wired up. + } + + #laneFromName(name: string): AnalyticsLane { + return this.#marketingEventNames.has(name) + ? AnalyticsLane.Marketing + : AnalyticsLane.Product; + } + + #laneFromContext(context?: AnalyticsContext): AnalyticsLane { + return context?.marketing === true + ? AnalyticsLane.Marketing + : AnalyticsLane.Product; + } + + #laneFromQueuedEvent(queuedEvent: AnalyticsQueuedEvent): AnalyticsLane { + if (typeof queuedEvent.context?.marketing === 'boolean') { + return this.#laneFromContext(queuedEvent.context); + } + + if (queuedEvent.type === 'track') { + return this.#laneFromName(queuedEvent.eventName); + } + + if (queuedEvent.type === 'view') { + return this.#laneFromName(queuedEvent.name); + } + + return AnalyticsLane.Product; + } + + #laneFromFragmentNames( + fragment: Pick< + AnalyticsEventFragment, + 'initialEvent' | 'successEvent' | 'failureEvent' + >, + ): AnalyticsLane { + const names = [ + fragment.initialEvent, + fragment.successEvent, + fragment.failureEvent, + ].filter((name): name is string => typeof name === 'string'); + + return names.some( + (name) => this.#laneFromName(name) === AnalyticsLane.Marketing, + ) + ? AnalyticsLane.Marketing + : AnalyticsLane.Product; + } + + #laneFromFragment( + fragment: Pick< + AnalyticsEventFragment, + 'initialEvent' | 'successEvent' | 'failureEvent' | 'context' + >, + ): AnalyticsLane { + if (typeof fragment.context?.marketing === 'boolean') { + return this.#laneFromContext(fragment.context); + } + + return this.#laneFromFragmentNames(fragment); + } + + #consent(lane: AnalyticsLane): { + optedIn: boolean; + decisionMade: boolean; + } { + return lane === AnalyticsLane.Marketing + ? { + optedIn: this.state.optedInToMarketing === true, + decisionMade: this.state.marketingConsentDecisionMade === true, + } + : { + optedIn: this.state.optedIn, + decisionMade: this.state.consentDecisionMade === true, + }; + } + + #isCaptureAllowed(lane: AnalyticsLane): boolean { + const { optedIn, decisionMade } = this.#consent(lane); + return optedIn || (this.#isPreConsentQueueEnabled && !decisionMade); + } + + #filterQueuedEvents( + queue: Record, + laneToClear: AnalyticsLane, + ): Record { + const nextQueue: Record = {}; + + for (const [messageId, queuedEvent] of Object.entries(queue)) { + if ( + !isAnalyticsQueuedEvent(queuedEvent) || + queuedEvent.messageId !== messageId + ) { + continue; + } + + if (this.#laneFromQueuedEvent(queuedEvent) !== laneToClear) { + nextQueue[messageId] = queuedEvent as unknown as Json; + } + } + + return nextQueue; + } + + #replaceQueue( + field: AnalyticsQueue, + nextQueue: Record, + ): void { + const currentQueue = this.state[field] as Record; + const currentKeys = Object.keys(currentQueue); + const nextKeys = Object.keys(nextQueue); + + if ( + currentKeys.length === nextKeys.length && + currentKeys.every((key) => + Object.prototype.hasOwnProperty.call(nextQueue, key), + ) + ) { + return; + } + + 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 lane - Capture lane stamped on `context`. */ #sendOrQueueTrackEvent( eventName: string, - properties?: AnalyticsEventProperties, - context?: AnalyticsContext, + properties: AnalyticsEventProperties | undefined, + context: AnalyticsContext | undefined, + lane: AnalyticsLane, ): void { + const { optedIn } = this.#consent(lane); + const contextWithLane = this.#withMarketingContext(lane, context); + // Direct delivery: enabled and not persisting. - if ( - analyticsControllerSelectors.selectEnabled(this.state) && - !this.#isEventQueuePersistenceEnabled - ) { - this.#platformAdapter.track(eventName, properties, context); + if (optedIn && !this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.track(eventName, properties, contextWithLane); return; } @@ -839,12 +1082,10 @@ export class AnalyticsController extends BaseController< messageId: uuid(), timestamp: new Date().toISOString(), ...(properties === undefined ? {} : { properties }), - ...(context === undefined ? {} : { context }), + context: contextWithLane, }; - // 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 (!optedIn) { this.#enqueuePreConsentEvent(queuedEvent); return; } @@ -887,14 +1128,19 @@ export class AnalyticsController extends BaseController< * @param name - The view name. * @param properties - Optional view properties. * @param context - Optional platform-specific context. + * @param lane - Capture lane stamped on `context`. */ #sendOrQueueViewEvent( name: string, - properties?: AnalyticsEventProperties, - context?: AnalyticsContext, + properties: AnalyticsEventProperties | undefined, + context: AnalyticsContext | undefined, + lane: AnalyticsLane, ): void { - if (!this.#isEventQueuePersistenceEnabled) { - this.#platformAdapter.view(name, properties, context); + const { optedIn } = this.#consent(lane); + const contextWithLane = this.#withMarketingContext(lane, context); + + if (optedIn && !this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.view(name, properties, contextWithLane); return; } @@ -904,9 +1150,14 @@ export class AnalyticsController extends BaseController< messageId: uuid(), timestamp: new Date().toISOString(), ...(properties === undefined ? {} : { properties }), - ...(context === undefined ? {} : { context }), + context: contextWithLane, }; + if (!optedIn) { + this.#enqueuePreConsentEvent(queuedEvent); + return; + } + this.#enqueueEvent(queuedEvent); } @@ -998,10 +1249,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 +1260,22 @@ export class AnalyticsController extends BaseController< queuedEvent.messageId !== messageId ) { log('Dropping invalid queued analytics event', { messageId }); - this.#removeQueuedEvent(messageId); continue; } + const { optedIn } = this.#consent( + this.#laneFromQueuedEvent(queuedEvent), + ); + + if (optedIn) { + remainingQueue[messageId] = queuedEvent as unknown as Json; + eventsToSend.push(queuedEvent); + } + } + + this.#replaceQueue(AnalyticsQueue.EventQueue, remainingQueue); + + for (const queuedEvent of eventsToSend) { this.#sendQueuedEvent(queuedEvent); } } @@ -1041,20 +1302,16 @@ export class AnalyticsController extends BaseController< }); } - /** - * Clear all queued analytics events. - */ - #clearQueuedEvents(): void { - if ( - !this.state.eventQueue || - Object.keys(this.state.eventQueue).length === 0 - ) { + #clearQueuedEventsInLane( + field: AnalyticsQueue, + laneToClear: AnalyticsLane, + ): void { + const queue = this.state[field]; + if (!queue) { return; } - this.update((state) => { - state.eventQueue = {} as never; - }); + this.#replaceQueue(field, this.#filterQueuedEvents(queue, laneToClear)); } /** @@ -1082,20 +1339,10 @@ export class AnalyticsController extends BaseController< * * @param queue - The pre-consent event queue to replay. */ - #replayPreConsentEvents(queue: Record): void { - this.#clearPreConsentEvents(); - - for (const [messageId, queuedEvent] of Object.entries(queue)) { - if ( - !isAnalyticsQueuedEvent(queuedEvent) || - queuedEvent.messageId !== messageId - ) { - log('Dropping invalid queued pre-consent analytics event', { - messageId, - }); - continue; - } - + #replayPreConsentEvents( + queue: Record, + ): void { + for (const queuedEvent of Object.values(queue)) { const eventToReplay = this.#enrichPreConsentEvent(queuedEvent); if (this.#isEventQueuePersistenceEnabled) { @@ -1135,26 +1382,11 @@ 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. * - * 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. + * Each queued item is replayed, kept, or dropped according to the consent + * lane stamped at capture. Product and marketing items are independent. * * 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 @@ -1168,15 +1400,36 @@ 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: Record = {}; + + for (const [messageId, queuedEvent] of Object.entries(queue)) { + if ( + !isAnalyticsQueuedEvent(queuedEvent) || + queuedEvent.messageId !== messageId + ) { + continue; + } + + const { optedIn, decisionMade } = this.#consent( + this.#laneFromQueuedEvent(queuedEvent), + ); + + if (optedIn) { + replay[messageId] = queuedEvent; + } else if (!decisionMade) { + keep[messageId] = queuedEvent as unknown as Json; + } } + + this.#replaceQueue(AnalyticsQueue.PreConsentEventQueue, keep); + this.#replayPreConsentEvents(replay); } /** @@ -1189,9 +1442,9 @@ 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 a fragment's lane (e.g. the + * fragment was written before the user opted out of that lane), 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 +1463,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 +1482,10 @@ export class AnalyticsController extends BaseController< continue; } + if (!this.#isCaptureAllowed(this.#laneFromFragment(fragment))) { + continue; + } + const snapshotCreatedAt = initEventFragmentSnapshot.get(id); if ( @@ -1259,9 +1497,13 @@ export class AnalyticsController extends BaseController< } } + if (Object.keys(eventFragments).length === 0) { + this.#clearEventFragments(); + return; + } + if ( - Object.keys(eventFragments).length === - Object.keys(currentEventFragments).length + Object.keys(eventFragments).length === Object.keys(fragments).length ) { return; } @@ -1285,16 +1527,26 @@ 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 marketing context. */ - #setEventFragment(fragment: AnalyticsEventFragment): void { + #setEventFragment(fragment: AnalyticsEventFragment): AnalyticsEventFragment { + const fragmentWithMarketingContext: AnalyticsEventFragment = { + ...fragment, + context: this.#withMarketingContext( + this.#laneFromFragmentNames(fragment), + fragment.context, + ), + }; const eventFragments: AnalyticsEventFragments = { ...this.state.eventFragments, - [fragment.id]: fragment, + [fragmentWithMarketingContext.id]: fragmentWithMarketingContext, }; this.update((state) => { state.eventFragments = eventFragments as never; }); + + return fragmentWithMarketingContext; } /** @@ -1319,6 +1571,28 @@ export class AnalyticsController extends BaseController< }); } + #clearEventFragmentsInLane(laneToClear: AnalyticsLane): 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.#laneFromFragment(fragment) !== laneToClear + ) { + eventFragments[id] = fragment; + } + } + + this.update((state) => { + state.eventFragments = eventFragments as never; + }); + } + /** * Clear all event fragments. */ @@ -1345,9 +1619,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' + >, + ): boolean { if (!this.#isEventFragmentsEnabled) { log( 'Ignoring event fragment call because the event fragments feature is disabled', @@ -1357,7 +1638,12 @@ export class AnalyticsController extends BaseController< return true; } - if (!this.#isAnalyticsCaptureAllowed()) { + const captureAllowed = fragment + ? this.#isCaptureAllowed(this.#laneFromFragment(fragment)) + : this.#isCaptureAllowed(AnalyticsLane.Product) || + this.#isCaptureAllowed(AnalyticsLane.Marketing); + + if (!captureAllowed) { log( 'Ignoring event fragment call because the consent state does not allow capturing analytics', { method }, @@ -1402,26 +1688,6 @@ export class AnalyticsController extends BaseController< ); } - /** - * 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 +1697,12 @@ export class AnalyticsController extends BaseController< * @param context - Optional platform-specific context forwarded to the platform adapter. */ trackEvent(event: AnalyticsTrackingEvent, context?: AnalyticsContext): void { + const lane = this.#laneFromName(event.name); + // 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(lane)) { return; } @@ -1445,6 +1713,7 @@ export class AnalyticsController extends BaseController< event.name, undefined, this.#withLocationContext(context), + lane, ); return; } @@ -1459,6 +1728,7 @@ export class AnalyticsController extends BaseController< ...event.properties, }, this.#withLocationContext(context), + lane, ); } @@ -1479,6 +1749,7 @@ export class AnalyticsController extends BaseController< this.#isAnonymousEventsFeatureEnabled ? context : this.#withLocationContext(context), + lane, ); } } @@ -1494,7 +1765,6 @@ export class AnalyticsController extends BaseController< return; } - // Delegate to platform adapter using the current analytics ID this.#sendOrQueueIdentifyEvent( this.state.analyticsId, traits, @@ -1514,7 +1784,8 @@ export class AnalyticsController extends BaseController< properties?: AnalyticsEventProperties, context?: AnalyticsContext, ): void { - if (!analyticsControllerSelectors.selectEnabled(this.state)) { + const lane = this.#laneFromName(name); + if (!this.#isCaptureAllowed(lane)) { return; } @@ -1523,6 +1794,7 @@ export class AnalyticsController extends BaseController< name, properties, this.#withLocationContext(context), + lane, ); } @@ -1553,13 +1825,15 @@ export class AnalyticsController extends BaseController< createEventFragment( options: AnalyticsEventFragmentOptions = {}, ): ReadonlyAnalyticsEventFragment | undefined { - if (this.#shouldIgnoreEventFragmentCall('createEventFragment')) { + if ( + this.#shouldIgnoreEventFragmentCall('createEventFragment', options) + ) { 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 +1852,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 +1879,16 @@ 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 +1911,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 +1934,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 +1948,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 +1976,11 @@ 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.`); } @@ -1766,9 +2040,15 @@ export class AnalyticsController extends BaseController< state.consentDecisionMade = true; }); - this.#clearQueuedEvents(); - this.#clearPreConsentEvents(); - this.#clearEventFragments(); + this.#clearQueuedEventsInLane( + AnalyticsQueue.EventQueue, + AnalyticsLane.Product, + ); + this.#clearQueuedEventsInLane( + AnalyticsQueue.PreConsentEventQueue, + AnalyticsLane.Product, + ); + this.#clearEventFragmentsInLane(AnalyticsLane.Product); } /** @@ -1789,10 +2069,72 @@ export class AnalyticsController extends BaseController< state.consentDecisionMade = false; }); - this.#clearQueuedEvents(); + this.#clearQueuedEventsInLane( + AnalyticsQueue.EventQueue, + AnalyticsLane.Product, + ); + if (!this.#isCaptureAllowed(AnalyticsLane.Product)) { + this.#clearEventFragmentsInLane(AnalyticsLane.Product); + } + } - 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.#reconcilePreConsentEvents(); + } + + /** + * 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.#clearQueuedEventsInLane( + AnalyticsQueue.EventQueue, + AnalyticsLane.Marketing, + ); + this.#clearQueuedEventsInLane( + AnalyticsQueue.PreConsentEventQueue, + AnalyticsLane.Marketing, + ); + this.#clearEventFragmentsInLane(AnalyticsLane.Marketing); + } + + /** + * Reset the marketing consent decision back to undecided. + * + * Independent of {@link resetConsentDecision}. + */ + resetMarketingConsentDecision(): void { + this.update((state) => { + state.optedInToMarketing = false; + state.marketingConsentDecisionMade = false; + }); + + this.#clearQueuedEventsInLane( + AnalyticsQueue.EventQueue, + AnalyticsLane.Marketing, + ); + if (!this.#isCaptureAllowed(AnalyticsLane.Marketing)) { + this.#clearEventFragmentsInLane(AnalyticsLane.Marketing); } } } diff --git a/packages/analytics-controller/src/index.ts b/packages/analytics-controller/src/index.ts index 47e2813e2ea..19f661e0d01 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -63,6 +63,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..dac956c725a 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, }; From e56d5d46cb1b87f629d92236d5d73eb5976c8466 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Mon, 14 Sep 2026 22:01:29 +0200 Subject: [PATCH 02/14] fix(analytics-controller): address CI lint and fragment lane gating Regenerate messenger action types, fix formatting, link the changelog to #10232, and classify fragments from event names only so caller context.marketing cannot bypass the correct consent lane. Co-authored-by: Cursor --- packages/analytics-controller/CHANGELOG.md | 2 +- packages/analytics-controller/README.md | 20 +-- ...AnalyticsController-method-action-types.ts | 9 +- .../src/AnalyticsController.test.ts | 167 ++++++++++++++---- .../src/AnalyticsController.ts | 54 ++---- .../src/selectors.test.ts | 6 +- 6 files changed, 162 insertions(+), 96 deletions(-) diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index e62276c9ecd..73941122832 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add independent marketing consent and classify named events by lane +- Add independent marketing consent and classify named events by lane ([#10232](https://github.com/MetaMask/core/pull/10232)) - New state and methods: `optedInToMarketing`, `optInToMarketing` / `optOutOfMarketing` / `resetMarketingConsentDecision`, and a persisted `marketingEventNames` list (remote loading arrives in a later phase) - Named `track` / `view` payloads stamp `context.marketing` (`true` or `false`) at capture so Segment can tell marketing events from product events - Queues and fragments follow that lane. A fragment that declares both marketing and product event names is treated as marketing diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index 9a7ee10b91a..ba92a9210f1 100644 --- a/packages/analytics-controller/README.md +++ b/packages/analytics-controller/README.md @@ -16,16 +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` | 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 | -| `marketingEventNames` | `string[]`| Cached marketing event names (empty until a source is wired) | 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 | +| `marketingEventNames` | `string[]` | Cached marketing event names (empty until a source is wired) | Yes | +| `eventQueue` | `object` | Optional persisted delivery queue | Yes | +| `eventFragments` | `object` | Optional in-progress event fragments | Yes | ### Client Platform Responsibilities diff --git a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts index 3b317fbe017..a35ef325630 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -195,8 +195,7 @@ export type AnalyticsControllerResetConsentDecisionAction = { /** * Opt in to marketing analytics. * - * Independent of {@link AnalyticsController.optIn}. Replays queued marketing - * events. + * Independent of {@link optIn}. Replays queued marketing events. * * @returns A promise that resolves once opt-in processing has completed. */ @@ -208,8 +207,8 @@ export type AnalyticsControllerOptInToMarketingAction = { /** * Opt out of marketing analytics. * - * Independent of {@link AnalyticsController.optOut}. Discards queued marketing - * events and marketing event fragments. + * Independent of {@link optOut}. Discards queued marketing events and + * marketing event fragments. */ export type AnalyticsControllerOptOutOfMarketingAction = { type: `AnalyticsController:optOutOfMarketing`; @@ -219,7 +218,7 @@ export type AnalyticsControllerOptOutOfMarketingAction = { /** * Reset the marketing consent decision back to undecided. * - * Independent of {@link AnalyticsController.resetConsentDecision}. + * Independent of {@link resetConsentDecision}. */ export type AnalyticsControllerResetMarketingConsentDecisionAction = { type: `AnalyticsController:resetMarketingConsentDecision`; diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 59704f0d487..29c45683dc0 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -1537,9 +1537,13 @@ describe('AnalyticsController', () => { controller.trackEvent(createTestEvent('test_event')); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, withMarketingFlag(false, { - location: fullLocationContext, - })); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withMarketingFlag(false, { + location: fullLocationContext, + }), + ); }); it('adds location to identify events', async () => { @@ -1569,9 +1573,13 @@ describe('AnalyticsController', () => { controller.trackView('home'); - expect(mockAdapter.view).toHaveBeenCalledWith('home', undefined, withMarketingFlag(false, { - location: fullLocationContext, - })); + expect(mockAdapter.view).toHaveBeenCalledWith( + 'home', + undefined, + withMarketingFlag(false, { + location: fullLocationContext, + }), + ); }); it('preserves unrelated caller context', async () => { @@ -1586,10 +1594,14 @@ describe('AnalyticsController', () => { app: { name: 'MetaMask' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, withMarketingFlag(false, { - app: { name: 'MetaMask' }, - location: fullLocationContext, - })); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withMarketingFlag(false, { + app: { name: 'MetaMask' }, + location: fullLocationContext, + }), + ); }); it('preserves caller location fields the controller does not resolve', async () => { @@ -1622,9 +1634,13 @@ describe('AnalyticsController', () => { location: { country_code: 'FR' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, withMarketingFlag(false, { - location: fullLocationContext, - })); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withMarketingFlag(false, { + location: fullLocationContext, + }), + ); }); it('replaces a non-record caller location', async () => { @@ -1639,9 +1655,13 @@ describe('AnalyticsController', () => { location: 'Seattle', }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, withMarketingFlag(false, { - location: fullLocationContext, - })); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withMarketingFlag(false, { + location: fullLocationContext, + }), + ); }); it('omits fields the geolocation API could not determine', async () => { @@ -3175,7 +3195,9 @@ describe('AnalyticsController', () => { const fragment = controller.createEventFragment({ id: 'signature-1', properties: { signature_type: 'personal_sign' }, - context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), + context: withMarketingFlag(false, { + referrer: { url: 'https://dapp.test' }, + }), }); expect(fragment).toBeDefined(); @@ -3206,7 +3228,9 @@ describe('AnalyticsController', () => { failureEvent: 'Signature Rejected', properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), + context: withMarketingFlag(false, { + referrer: { url: 'https://dapp.test' }, + }), persist: true, }); @@ -3217,7 +3241,9 @@ describe('AnalyticsController', () => { failureEvent: 'Signature Rejected', properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), + context: withMarketingFlag(false, { + referrer: { url: 'https://dapp.test' }, + }), persist: true, createdAt: expect.any(Number), lastUpdated: expect.any(Number), @@ -3235,7 +3261,9 @@ describe('AnalyticsController', () => { initialEvent: 'Signature Requested', successEvent: 'Signature Approved', properties: { signature_type: 'personal_sign' }, - context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), + context: withMarketingFlag(false, { + referrer: { url: 'https://dapp.test' }, + }), }); expect(mockAdapter.track).toHaveBeenCalledTimes(1); @@ -3400,7 +3428,9 @@ describe('AnalyticsController', () => { const { controller } = await setupFragmentController(); controller.createEventFragment({ id: 'signature-1', - context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), + context: withMarketingFlag(false, { + referrer: { url: 'https://dapp.test' }, + }), }); controller.updateEventFragment('signature-1', { @@ -3467,7 +3497,9 @@ describe('AnalyticsController', () => { id: 'signature-1', properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), + context: withMarketingFlag(false, { + referrer: { url: 'https://dapp.test' }, + }), }); const fragment = controller.getEventFragmentById('signature-1'); @@ -3603,7 +3635,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', undefined, - withMarketingFlag(false, { referrer: { url: 'https://other.test' }, keep: 'me' }), + withMarketingFlag(false, { + referrer: { url: 'https://other.test' }, + keep: 'me', + }), ); }); @@ -3890,11 +3925,8 @@ describe('AnalyticsController', () => { }); it('keeps a fragment replaced during init even when the leftover ID was expired', async () => { - const { - geolocationHandler, - geolocationRequested, - resolveGeolocation, - } = createBlockingGeolocationHandler(); + const { geolocationHandler, geolocationRequested, resolveGeolocation } = + createBlockingGeolocationHandler(); const mockAdapter = createMockAdapter(); const analyticsId = '11111111-2222-4333-8444-555555555555'; const now = 1_800_000_000_000; @@ -3945,11 +3977,8 @@ describe('AnalyticsController', () => { }); it('keeps fragments created while init is in flight and still drops stale non-persistent ones', async () => { - const { - geolocationHandler, - geolocationRequested, - resolveGeolocation, - } = createBlockingGeolocationHandler(); + const { geolocationHandler, geolocationRequested, resolveGeolocation } = + createBlockingGeolocationHandler(); const mockAdapter = createMockAdapter(); const analyticsId = '11111111-2222-4333-8444-555555555555'; @@ -4000,11 +4029,8 @@ describe('AnalyticsController', () => { }); it('keeps a fragment that reuses an ID from a stale leftover during init', async () => { - const { - geolocationHandler, - geolocationRequested, - resolveGeolocation, - } = createBlockingGeolocationHandler(); + const { geolocationHandler, geolocationRequested, resolveGeolocation } = + createBlockingGeolocationHandler(); const mockAdapter = createMockAdapter(); const analyticsId = '11111111-2222-4333-8444-555555555555'; const staleCreatedAt = 1700000000000; @@ -4562,6 +4588,71 @@ describe('AnalyticsController', () => { expect(controller.state.eventFragments).toBeUndefined(); }); + it('classifies createEventFragment by event names, not caller context.marketing', 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, + }); + + // Reused marketing stamp must not force the marketing consent lane when + // the declared events are product-only. + const fragment = controller.createEventFragment({ + id: 'product-1', + successEvent: productEvent, + context: withMarketingFlag(true, { page: { path: '/settings' } }), + }); + + expect(fragment).toStrictEqual( + expect.objectContaining({ + id: 'product-1', + successEvent: productEvent, + context: withMarketingFlag(false, { page: { path: '/settings' } }), + }), + ); + expect( + controller.state.eventFragments?.['product-1']?.context, + ).toStrictEqual( + withMarketingFlag(false, { page: { path: '/settings' } }), + ); + }); + + it('allows a marketing fragment when only marketing consent is on even if caller stamps marketing false', 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: 'marketing-1', + successEvent: marketingEvent, + context: withMarketingFlag(false), + }); + + expect(fragment).toStrictEqual( + expect.objectContaining({ + id: 'marketing-1', + successEvent: marketingEvent, + context: withMarketingFlag(true), + }), + ); + }); + it('stops emitting marketing events after optOutOfMarketing', async () => { const adapter = createMockAdapter(); const { controller } = await setupController({ diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index e2f1b12fd2c..278a1509945 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -730,9 +730,7 @@ export class AnalyticsController extends BaseController< this.#platformAdapter = platformAdapter; this.#initPromise = undefined; this.#locationResolvePromise = undefined; - this.#marketingEventNames = new Set( - initialState.marketingEventNames ?? [], - ); + this.#marketingEventNames = new Set(initialState.marketingEventNames ?? []); this.messenger.registerMethodActionHandlers( this, @@ -957,12 +955,15 @@ export class AnalyticsController extends BaseController< return AnalyticsLane.Product; } - #laneFromFragmentNames( + #laneFromFragment( fragment: Pick< AnalyticsEventFragment, 'initialEvent' | 'successEvent' | 'failureEvent' >, ): AnalyticsLane { + // Classify from declared event names only. Caller-supplied + // `context.marketing` is not trusted: `#setEventFragment` stamps that flag + // from names after the write. const names = [ fragment.initialEvent, fragment.successEvent, @@ -976,19 +977,6 @@ export class AnalyticsController extends BaseController< : AnalyticsLane.Product; } - #laneFromFragment( - fragment: Pick< - AnalyticsEventFragment, - 'initialEvent' | 'successEvent' | 'failureEvent' | 'context' - >, - ): AnalyticsLane { - if (typeof fragment.context?.marketing === 'boolean') { - return this.#laneFromContext(fragment.context); - } - - return this.#laneFromFragmentNames(fragment); - } - #consent(lane: AnalyticsLane): { optedIn: boolean; decisionMade: boolean; @@ -1031,10 +1019,7 @@ export class AnalyticsController extends BaseController< return nextQueue; } - #replaceQueue( - field: AnalyticsQueue, - nextQueue: Record, - ): void { + #replaceQueue(field: AnalyticsQueue, nextQueue: Record): void { const currentQueue = this.state[field] as Record; const currentKeys = Object.keys(currentQueue); const nextKeys = Object.keys(nextQueue); @@ -1263,9 +1248,7 @@ export class AnalyticsController extends BaseController< continue; } - const { optedIn } = this.#consent( - this.#laneFromQueuedEvent(queuedEvent), - ); + const { optedIn } = this.#consent(this.#laneFromQueuedEvent(queuedEvent)); if (optedIn) { remainingQueue[messageId] = queuedEvent as unknown as Json; @@ -1339,9 +1322,7 @@ export class AnalyticsController extends BaseController< * * @param queue - The pre-consent event queue to replay. */ - #replayPreConsentEvents( - queue: Record, - ): void { + #replayPreConsentEvents(queue: Record): void { for (const queuedEvent of Object.values(queue)) { const eventToReplay = this.#enrichPreConsentEvent(queuedEvent); @@ -1502,9 +1483,7 @@ export class AnalyticsController extends BaseController< return; } - if ( - Object.keys(eventFragments).length === Object.keys(fragments).length - ) { + if (Object.keys(eventFragments).length === Object.keys(fragments).length) { return; } @@ -1533,7 +1512,7 @@ export class AnalyticsController extends BaseController< const fragmentWithMarketingContext: AnalyticsEventFragment = { ...fragment, context: this.#withMarketingContext( - this.#laneFromFragmentNames(fragment), + this.#laneFromFragment(fragment), fragment.context, ), }; @@ -1825,9 +1804,7 @@ export class AnalyticsController extends BaseController< createEventFragment( options: AnalyticsEventFragmentOptions = {}, ): ReadonlyAnalyticsEventFragment | undefined { - if ( - this.#shouldIgnoreEventFragmentCall('createEventFragment', options) - ) { + if (this.#shouldIgnoreEventFragmentCall('createEventFragment', options)) { return undefined; } @@ -1881,10 +1858,7 @@ export class AnalyticsController extends BaseController< ): void { const fragment = this.#getEventFragment(id); if ( - this.#shouldIgnoreEventFragmentCall( - 'upsertEventFragment', - fragment ?? {}, - ) + this.#shouldIgnoreEventFragmentCall('upsertEventFragment', fragment ?? {}) ) { return; } @@ -1977,7 +1951,9 @@ export class AnalyticsController extends BaseController< { abandoned = false, context }: AnalyticsEventFragmentFinalizeOptions = {}, ): void { const fragment = this.#getEventFragment(id); - if (this.#shouldIgnoreEventFragmentCall('finalizeEventFragment', fragment)) { + if ( + this.#shouldIgnoreEventFragmentCall('finalizeEventFragment', fragment) + ) { return; } diff --git a/packages/analytics-controller/src/selectors.test.ts b/packages/analytics-controller/src/selectors.test.ts index dac956c725a..9a400859457 100644 --- a/packages/analytics-controller/src/selectors.test.ts +++ b/packages/analytics-controller/src/selectors.test.ts @@ -54,9 +54,9 @@ describe('analyticsControllerSelectors', () => { analyticsId: defaultAnalyticsId, }; - expect( - analyticsControllerSelectors.selectOptedInToMarketing(state), - ).toBe(false); + expect(analyticsControllerSelectors.selectOptedInToMarketing(state)).toBe( + false, + ); }); }); From a1a70fe914925db7265382ef5335c5185718febc Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 15 Sep 2026 07:06:08 +0200 Subject: [PATCH 03/14] fix(analytics-controller): keep queued identify on the product lane Classify identify before trusting context.marketing so a caller-supplied marketing stamp cannot retain identify across product opt-out. Co-authored-by: Cursor --- .../src/AnalyticsController.test.ts | 41 +++++++++++++++++++ .../src/AnalyticsController.ts | 6 +++ 2 files changed, 47 insertions(+) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 29c45683dc0..2d5db8ceec3 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -4879,6 +4879,47 @@ describe('AnalyticsController', () => { expect(controller.state.eventQueue).not.toHaveProperty('identify'); }); + it('treats queued identify as product even when context.marketing is true', 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', + context: withMarketingFlag(true), + }, + marketing: { + type: 'track', + eventName: marketingEvent, + messageId: 'marketing', + timestamp: '2026-01-01T00:00:02.000Z', + context: withMarketingFlag(true), + }, + }, + }, + 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: { diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 278a1509945..b6a0077569b 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -940,6 +940,12 @@ export class AnalyticsController extends BaseController< } #laneFromQueuedEvent(queuedEvent: AnalyticsQueuedEvent): AnalyticsLane { + // Identify has no event name and is always product, even if a caller + // supplied `context.marketing`. Check type before trusting the stamp. + if (queuedEvent.type === 'identify') { + return AnalyticsLane.Product; + } + if (typeof queuedEvent.context?.marketing === 'boolean') { return this.#laneFromContext(queuedEvent.context); } From 6797e48196b1cdd73186f2e15c1b66aaa7207774 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 15 Sep 2026 07:09:35 +0200 Subject: [PATCH 04/14] fix(analytics-controller): prefer fragment marketing stamp for existing lanes Keep persisted fragments on their capture-time lane when marketingEventNames is missing or changed, while create still classifies and stamps from names. Co-authored-by: Cursor --- .../src/AnalyticsController.test.ts | 42 +++++++++++++++++++ .../src/AnalyticsController.ts | 38 +++++++++++++---- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 2d5db8ceec3..48eae1dbed6 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -4624,6 +4624,48 @@ describe('AnalyticsController', () => { ); }); + it('keeps a stamped marketing fragment when marketingEventNames is empty', async () => { + const now = Date.now(); + const { controller } = await setupController({ + state: { + analyticsId: '550e8400-e29b-41d4-a716-446655440000', + optedIn: false, + consentDecisionMade: true, + optedInToMarketing: true, + marketingConsentDecisionMade: true, + // List missing/empty: name lookup would treat this as product. + eventFragments: { + 'marketing-1': { + id: 'marketing-1', + successEvent: marketingEvent, + properties: {}, + sensitiveProperties: {}, + createdAt: now, + lastUpdated: now, + persist: true, + context: withMarketingFlag(true), + }, + }, + }, + isGeolocationEnabled: false, + isEventFragmentsEnabled: true, + skipInit: true, + }); + + controller.optOut(); + + expect(controller.state.eventFragments).toStrictEqual({ + 'marketing-1': expect.objectContaining({ + id: 'marketing-1', + context: withMarketingFlag(true), + }), + }); + + controller.optOutOfMarketing(); + + expect(controller.state.eventFragments).toStrictEqual({}); + }); + it('allows a marketing fragment when only marketing consent is on even if caller stamps marketing false', async () => { const { controller } = await setupController({ state: { diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index b6a0077569b..2f6f09de61d 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -961,15 +961,12 @@ export class AnalyticsController extends BaseController< return AnalyticsLane.Product; } - #laneFromFragment( + #laneFromFragmentNames( fragment: Pick< AnalyticsEventFragment, 'initialEvent' | 'successEvent' | 'failureEvent' >, ): AnalyticsLane { - // Classify from declared event names only. Caller-supplied - // `context.marketing` is not trusted: `#setEventFragment` stamps that flag - // from names after the write. const names = [ fragment.initialEvent, fragment.successEvent, @@ -983,6 +980,24 @@ export class AnalyticsController extends BaseController< : AnalyticsLane.Product; } + #laneFromFragment( + fragment: Pick< + AnalyticsEventFragment, + 'initialEvent' | 'successEvent' | 'failureEvent' | 'context' + >, + ): AnalyticsLane { + // Prefer the capture-time stamp so persisted fragments keep their lane + // even if `marketingEventNames` is missing or changed. Fall back to names + // for legacy unstamped fragments. Callers must not pass untrusted context + // into create gating: {@link createEventFragment} classifies from names + // only, and {@link #setEventFragment} stamps from names on write. + if (typeof fragment.context?.marketing === 'boolean') { + return this.#laneFromContext(fragment.context); + } + + return this.#laneFromFragmentNames(fragment); + } + #consent(lane: AnalyticsLane): { optedIn: boolean; decisionMade: boolean; @@ -1518,7 +1533,7 @@ export class AnalyticsController extends BaseController< const fragmentWithMarketingContext: AnalyticsEventFragment = { ...fragment, context: this.#withMarketingContext( - this.#laneFromFragment(fragment), + this.#laneFromFragmentNames(fragment), fragment.context, ), }; @@ -1611,7 +1626,7 @@ export class AnalyticsController extends BaseController< method: string, fragment?: Pick< AnalyticsEventFragment, - 'initialEvent' | 'successEvent' | 'failureEvent' + 'initialEvent' | 'successEvent' | 'failureEvent' | 'context' >, ): boolean { if (!this.#isEventFragmentsEnabled) { @@ -1810,7 +1825,16 @@ export class AnalyticsController extends BaseController< createEventFragment( options: AnalyticsEventFragmentOptions = {}, ): ReadonlyAnalyticsEventFragment | undefined { - if (this.#shouldIgnoreEventFragmentCall('createEventFragment', options)) { + // Classify create from event names only. Do not pass caller `context` into + // the consent gate: a reused `marketing` stamp must not pick the lane. + // `#setEventFragment` stamps from names after the write. + if ( + this.#shouldIgnoreEventFragmentCall('createEventFragment', { + initialEvent: options.initialEvent, + successEvent: options.successEvent, + failureEvent: options.failureEvent, + }) + ) { return undefined; } From c39f6acbcf4c4b2d5fbccc46fac4550af4cb7578 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 15 Sep 2026 07:30:14 +0200 Subject: [PATCH 05/14] fix(analytics-controller): restore full coverage for queued-event lanes Make #laneFromQueuedEvent exhaustive after the identify-first check so the unreachable product fallback no longer breaks the coverage threshold. Co-authored-by: Cursor --- packages/analytics-controller/src/AnalyticsController.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 2f6f09de61d..1d0711495e6 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -950,15 +950,11 @@ export class AnalyticsController extends BaseController< return this.#laneFromContext(queuedEvent.context); } - if (queuedEvent.type === 'track') { - return this.#laneFromName(queuedEvent.eventName); - } - if (queuedEvent.type === 'view') { return this.#laneFromName(queuedEvent.name); } - return AnalyticsLane.Product; + return this.#laneFromName(queuedEvent.eventName); } #laneFromFragmentNames( From 923df1e265dd36f5fad0736eec6f6087288fb50e Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 17 Sep 2026 06:50:57 +0200 Subject: [PATCH 06/14] feat(analytics-controller): align marketing consent on purpose-aware delivery Replace exclusive marketing/product lanes and context.marketing with purpose classification, consent.categoryPreferences stamping, and capture-time eventPurposes snapshots on queues and fragments. Co-authored-by: Cursor --- packages/analytics-controller/CHANGELOG.md | 8 +- packages/analytics-controller/README.md | 48 +- .../src/AnalyticsController.test.ts | 745 +++++++++++++----- .../src/AnalyticsController.ts | 620 +++++++++------ .../src/AnalyticsPlatformAdapter.types.ts | 16 +- .../src/EventFragment.types.ts | 13 + packages/analytics-controller/src/index.ts | 2 + 7 files changed, 1008 insertions(+), 444 deletions(-) diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 73941122832..67b2076cada 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -9,10 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add independent marketing consent and classify named events by lane ([#10232](https://github.com/MetaMask/core/pull/10232)) - - New state and methods: `optedInToMarketing`, `optInToMarketing` / `optOutOfMarketing` / `resetMarketingConsentDecision`, and a persisted `marketingEventNames` list (remote loading arrives in a later phase) - - Named `track` / `view` payloads stamp `context.marketing` (`true` or `false`) at capture so Segment can tell marketing events from product events - - Queues and fragments follow that lane. A fragment that declares both marketing and product event names is treated as marketing +- 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 `marketingEventsConfig` 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.marketingEventsVersion` + - 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 diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index ba92a9210f1..fbe9272c76c 100644 --- a/packages/analytics-controller/README.md +++ b/packages/analytics-controller/README.md @@ -16,16 +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` | 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 | -| `marketingEventNames` | `string[]` | Cached marketing event names (empty until a source is wired) | 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 | +| `marketingEventsConfig` | `AnalyticsMarketingEventsConfig` | 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 @@ -34,11 +34,29 @@ 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) -Named events in `marketingEventNames` are governed only by `optedInToMarketing`. Every other named payload is governed only by `optedIn`. Queues, fragments, and delivery use the same machinery for both lanes. `identify` has no event name, so it follows `optedIn`. +`marketingEventsConfig.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 + } + }, + "marketingEventsVersion": "a1b2c3d" + } +} +``` + +The booleans are the intersection of event classification and current user consent. `marketingEventsVersion` is included when classification came from a persisted config. `identify` is always product-only. -Until a later phase loads marketing event names from a remote source, `marketingEventNames` stays empty unless the client seeds or persists a list. With an empty list, every named event is treated as product, so marketing consent has no classification impact yet. +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. -Named `track` and `view` payloads are classified once at capture. That lane is stamped on `context.marketing` (`true` or `false`) so a Segment source can tell marketing events from product events without reading properties. Queues and fragments then follow the stamp. `identify` does not set this flag. Destinations should treat a missing `context.marketing` as product, since older app versions never send the field. +Event properties are unchanged by purpose consent. ## Anonymous Events Feature @@ -89,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.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 48eae1dbed6..0f4f6e906f6 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, @@ -225,17 +226,27 @@ function createMockAdapter(): MockAnalyticsPlatformAdapter { } /** - * Expected named-event context with the Segment marketing flag. + * Expected context with allowed analytics purposes and optional config version. * - * @param marketing - Whether the payload is classified as marketing. + * @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. - * @returns Context including `marketing`. + * @param version - Optional marketing-events config version. + * @returns Context including Segment consent category preferences. */ -function withMarketingFlag( - marketing: boolean, +function withPurposeConsent( + preferences: { product: boolean; marketing: boolean }, context: AnalyticsContext = {}, + version?: string, ): AnalyticsContext { - return { ...context, marketing }; + return { + ...context, + consent: { + categoryPreferences: preferences, + }, + ...(version === undefined ? {} : { marketingEventsVersion: version }), + }; } /** @@ -648,7 +659,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }), - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -935,7 +946,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', expect.any(Object), - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1032,7 +1043,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1056,7 +1067,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - withMarketingFlag(false, context), + withPurposeConsent({ product: true, marketing: false }, context), ); }); @@ -1080,7 +1091,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withMarketingFlag(false, context), + withPurposeConsent({ product: true, marketing: false }, context), ); }); @@ -1100,7 +1111,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1130,7 +1141,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1159,7 +1170,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1203,7 +1214,7 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1213,7 +1224,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1244,7 +1255,7 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - withMarketingFlag(false, context), + withPurposeConsent({ product: true, marketing: false }, context), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1254,7 +1265,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - withMarketingFlag(false, context), + withPurposeConsent({ product: true, marketing: false }, context), ); }); @@ -1281,7 +1292,7 @@ describe('AnalyticsController', () => { 1, 'test_event', {}, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1290,7 +1301,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1312,7 +1323,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1334,7 +1345,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); }); @@ -1363,7 +1374,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, traits, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1383,7 +1394,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, undefined, - undefined, + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1408,7 +1419,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, traits, - context, + withPurposeConsent({ product: true, marketing: false }, context), ); }); @@ -1449,7 +1460,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.view).toHaveBeenCalledWith( 'home', { referrer: 'test' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1472,7 +1483,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.view).toHaveBeenCalledWith( 'settings', { section: 'security' }, - withMarketingFlag(false, context), + withPurposeConsent({ product: true, marketing: false }, context), ); }); @@ -1523,7 +1534,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - withMarketingFlag(false, { location: fullLocationContext }), + withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), ); }); @@ -1540,8 +1551,8 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withMarketingFlag(false, { - location: fullLocationContext, + withPurposeConsent({ product: true, marketing: false }, { + location: fullLocationContext, }), ); }); @@ -1559,7 +1570,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, { trait: 'value' }, - { location: fullLocationContext }, + withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), ); }); @@ -1576,8 +1587,8 @@ describe('AnalyticsController', () => { expect(mockAdapter.view).toHaveBeenCalledWith( 'home', undefined, - withMarketingFlag(false, { - location: fullLocationContext, + withPurposeConsent({ product: true, marketing: false }, { + location: fullLocationContext, }), ); }); @@ -1597,9 +1608,9 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withMarketingFlag(false, { - app: { name: 'MetaMask' }, - location: fullLocationContext, + withPurposeConsent({ product: true, marketing: false }, { + app: { name: 'MetaMask' }, + location: fullLocationContext, }), ); }); @@ -1616,10 +1627,13 @@ describe('AnalyticsController', () => { location: { city: 'Seattle' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withPurposeConsent({ product: true, marketing: false }, { location: { city: 'Seattle', ...fullLocationContext }, - marketing: false, - }); + }), + ); }); it('overrides caller location fields the controller resolves', async () => { @@ -1637,8 +1651,8 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withMarketingFlag(false, { - location: fullLocationContext, + withPurposeConsent({ product: true, marketing: false }, { + location: fullLocationContext, }), ); }); @@ -1658,8 +1672,8 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withMarketingFlag(false, { - location: fullLocationContext, + withPurposeConsent({ product: true, marketing: false }, { + location: fullLocationContext, }), ); }); @@ -1674,10 +1688,13 @@ describe('AnalyticsController', () => { controller.trackEvent(createTestEvent('test_event')); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withPurposeConsent({ product: true, marketing: false }, { location: { country_code: 'FR' }, - marketing: false, - }); + }), + ); }); it('leaves the context untouched when the geolocation is unknown', async () => { @@ -1691,10 +1708,11 @@ describe('AnalyticsController', () => { app: { name: 'MetaMask' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - app: { name: 'MetaMask' }, - marketing: false, - }); + 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 () => { @@ -1711,7 +1729,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1733,7 +1751,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1750,10 +1768,11 @@ describe('AnalyticsController', () => { location: { city: 'Seattle' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - location: { city: 'Seattle' }, - marketing: false, - }); + 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 () => { @@ -1778,7 +1797,7 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - withMarketingFlag(false, { location: fullLocationContext }), + withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1788,7 +1807,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -1815,7 +1834,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - withMarketingFlag(false, { location: fullLocationContext }), + withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), ); }); @@ -1835,8 +1854,9 @@ describe('AnalyticsController', () => { ) as { context?: AnalyticsContext }[]; expect(queuedEvent.context).toStrictEqual({ + ...withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext, - marketing: false, + }), }); }); @@ -1871,7 +1891,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'preconsent_event', undefined, - withMarketingFlag(false, { location: fullLocationContext }), + withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), expect.any(Object), ); @@ -1880,7 +1900,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenLastCalledWith( 'postconsent_event', undefined, - withMarketingFlag(false, { location: fullLocationContext }), + withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), ); }); @@ -1913,7 +1933,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - withMarketingFlag(false, { location: fullLocationContext }), + withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), expect.any(Object), ); // ...but the anonymous payload carries no location. @@ -1924,7 +1944,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), expect.any(Object), ); }); @@ -2011,7 +2031,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); expect(mockAdapter.track.mock.calls[0]).toHaveLength(3); }); @@ -2042,7 +2062,8 @@ describe('AnalyticsController', () => { messageId: deliveryOptions.messageId, timestamp: deliveryOptions.timestamp?.toISOString(), properties: { prop: 'value' }, - context: withMarketingFlag(false), + context: withPurposeConsent({ product: true, marketing: false }), + eventPurposes: [AnalyticsPurpose.Product], }, }); @@ -2235,21 +2256,21 @@ 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' }, - withMarketingFlag(false, 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: withMarketingFlag(false, viewContext), + context: withPurposeConsent({ product: true, marketing: false }, viewContext), }, }); expect(Object.keys(controller.state.eventQueue ?? {})).toHaveLength(2); @@ -2286,20 +2307,24 @@ describe('AnalyticsController', () => { eventName: 'test_event', messageId: trackOptions.messageId, timestamp: trackOptions.timestamp?.toISOString(), - context: withMarketingFlag(false), + 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: withMarketingFlag(false), + context: withPurposeConsent({ product: true, marketing: false }), + eventPurposes: [AnalyticsPurpose.Product], }, }); }); @@ -2798,7 +2823,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -2829,7 +2854,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -2905,7 +2930,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); expect(controller.state.preConsentEventQueue).toStrictEqual({}); @@ -2957,13 +2982,13 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'first_event', { a: 1 }, - withMarketingFlag(false, { source: 'onboarding' }), + withPurposeConsent({ product: true, marketing: false }, { source: 'onboarding' }), expect.objectContaining({ messageId: expect.any(String) }), ); expect(mockAdapter.track).toHaveBeenCalledWith( 'second_event', { b: 2 }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); expect(controller.state.preConsentEventQueue).toStrictEqual({}); @@ -3002,7 +3027,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); expect(controller.state.preConsentEventQueue).toStrictEqual({}); @@ -3184,7 +3209,6 @@ describe('AnalyticsController', () => { id: 'bag-1', properties: {}, sensitiveProperties: {}, - context: withMarketingFlag(false), createdAt: now, lastUpdated: now, }); @@ -3195,9 +3219,9 @@ describe('AnalyticsController', () => { const fragment = controller.createEventFragment({ id: 'signature-1', properties: { signature_type: 'personal_sign' }, - context: withMarketingFlag(false, { + context: { referrer: { url: 'https://dapp.test' }, - }), + }, }); expect(fragment).toBeDefined(); @@ -3211,9 +3235,9 @@ describe('AnalyticsController', () => { expect(controller.state.eventFragments?.['signature-1']).toStrictEqual( expect.objectContaining({ properties: { signature_type: 'personal_sign' }, - context: withMarketingFlag(false, { + context: { referrer: { url: 'https://dapp.test' }, - }), + }, }), ); }); @@ -3228,9 +3252,9 @@ describe('AnalyticsController', () => { failureEvent: 'Signature Rejected', properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: withMarketingFlag(false, { + context: { referrer: { url: 'https://dapp.test' }, - }), + }, persist: true, }); @@ -3239,11 +3263,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: withMarketingFlag(false, { + context: { referrer: { url: 'https://dapp.test' }, - }), + }, persist: true, createdAt: expect.any(Number), lastUpdated: expect.any(Number), @@ -3261,16 +3290,18 @@ describe('AnalyticsController', () => { initialEvent: 'Signature Requested', successEvent: 'Signature Approved', properties: { signature_type: 'personal_sign' }, - context: withMarketingFlag(false, { + context: { referrer: { url: 'https://dapp.test' }, - }), + }, }); expect(mockAdapter.track).toHaveBeenCalledTimes(1); expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Requested', { signature_type: 'personal_sign' }, - withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), + withPurposeConsent({ product: true, marketing: false }, { + referrer: { url: 'https://dapp.test' }, + }), ); }); @@ -3323,7 +3354,6 @@ describe('AnalyticsController', () => { id: 'transaction-ui-1', properties: { simulation_response: 'no_changes' }, sensitiveProperties: {}, - context: withMarketingFlag(false), createdAt: expect.any(Number), lastUpdated: expect.any(Number), }); @@ -3358,12 +3388,14 @@ describe('AnalyticsController', () => { ).toStrictEqual({ id: 'transaction-ui-1', successEvent: 'Transaction Finalized', + eventPurposes: { + 'Transaction Finalized': [AnalyticsPurpose.Product], + }, properties: { simulation_response: 'no_changes', gas_edit_attempted: 'basic', }, sensitiveProperties: { sending_value: '0x1' }, - context: withMarketingFlag(false), createdAt: expect.any(Number), lastUpdated: expect.any(Number), }); @@ -3416,21 +3448,19 @@ describe('AnalyticsController', () => { expect( controller.state.eventFragments?.['signature-1']?.context, - ).toStrictEqual( - withMarketingFlag(false, { - referrer: { url: 'https://other.test' }, - keep: 'me', - }), - ); + ).toStrictEqual({ + referrer: { url: 'https://other.test' }, + keep: 'me', + }); }); it('preserves fragment context when an update omits context', async () => { const { controller } = await setupFragmentController(); controller.createEventFragment({ id: 'signature-1', - context: withMarketingFlag(false, { + context: { referrer: { url: 'https://dapp.test' }, - }), + }, }); controller.updateEventFragment('signature-1', { @@ -3439,12 +3469,12 @@ describe('AnalyticsController', () => { expect( controller.state.eventFragments?.['signature-1']?.context, - ).toStrictEqual( - withMarketingFlag(false, { referrer: { url: 'https://dapp.test' } }), - ); + ).toStrictEqual({ + referrer: { url: 'https://dapp.test' }, + }); }); - it('stamps context.marketing when neither side has caller context', async () => { + it('omits context when create and update both leave it unset', async () => { const { controller } = await setupFragmentController(); controller.createEventFragment({ id: 'signature-1' }); @@ -3454,7 +3484,7 @@ describe('AnalyticsController', () => { expect( controller.state.eventFragments?.['signature-1']?.context, - ).toStrictEqual(withMarketingFlag(false)); + ).toBeUndefined(); }); it('advances lastUpdated but preserves createdAt', async () => { @@ -3497,9 +3527,9 @@ describe('AnalyticsController', () => { id: 'signature-1', properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: withMarketingFlag(false, { + context: { referrer: { url: 'https://dapp.test' }, - }), + }, }); const fragment = controller.getEventFragmentById('signature-1'); @@ -3521,9 +3551,9 @@ describe('AnalyticsController', () => { expect.objectContaining({ properties: { signature_type: 'personal_sign' }, sensitiveProperties: { eip712_primary_type: 'Permit' }, - context: withMarketingFlag(false, { + context: { referrer: { url: 'https://dapp.test' }, - }), + }, }), ); }); @@ -3579,7 +3609,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign', alert_triggered_count: 1 }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); expect(controller.state.eventFragments).toStrictEqual({}); }); @@ -3597,7 +3627,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Rejected', undefined, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -3635,7 +3665,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', undefined, - withMarketingFlag(false, { + withPurposeConsent({ product: true, marketing: false }, { referrer: { url: 'https://other.test' }, keep: 'me', }), @@ -3660,7 +3690,7 @@ describe('AnalyticsController', () => { 1, 'Signature Approved', { signature_type: 'personal_sign' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -3670,7 +3700,7 @@ describe('AnalyticsController', () => { eip712_primary_type: 'Permit', anonymous: true, }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); @@ -3753,7 +3783,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Requested', undefined, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -4023,7 +4053,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); expect(controller.state.eventFragments).toStrictEqual({}); }); @@ -4084,7 +4114,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); expect(controller.state.eventFragments).toStrictEqual({}); }); @@ -4225,7 +4255,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign' }, - withMarketingFlag(false), + withPurposeConsent({ product: true, marketing: false }), ); }); }); @@ -4234,9 +4264,283 @@ describe('AnalyticsController', () => { describe('marketing consent', () => { const marketingEvent = 'Deep Link Used'; const productEvent = 'Button Clicked'; + const dualPurposeEvent = 'Perp Trade Completed'; + const marketingEventsVersion = 'a1b2c3d'; + const marketingEventsConfig = { + schemaVersion: '1.0.0', + version: marketingEventsVersion, + timestamp: 1_740_000_000_000, + events: { + [marketingEvent]: [AnalyticsPurpose.Marketing], + [dualPurposeEvent]: [ + AnalyticsPurpose.Product, + AnalyticsPurpose.Marketing, + ], + }, + }; const withMarketingList = { - marketingEventNames: [marketingEvent], + marketingEventsConfig, }; + const withConfiguredPurposeConsent = ( + preferences: { product: boolean; marketing: boolean }, + context: AnalyticsContext = {}, + ): AnalyticsContext => + withPurposeConsent(preferences, context, marketingEventsVersion); + + 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, + marketingEventsConfig, + }, + 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 }, + {}, + marketingEventsVersion, + ), + ); + }); + + 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, + marketingEventsConfig, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + }); + + controller.trackEvent(createTestEvent(dualPurposeEvent)); + + expect(adapter.track).toHaveBeenCalledTimes(1); + expect(adapter.track).toHaveBeenCalledWith( + dualPurposeEvent, + undefined, + withPurposeConsent( + { product: false, marketing: true }, + {}, + marketingEventsVersion, + ), + ); + }); + + 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, + marketingEventsConfig, + }, + 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 }, + {}, + marketingEventsVersion, + ), + ); + }); + + 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, + marketingEventsConfig, + }, + 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], + marketingEventsVersion, + context: withPurposeConsent( + { product: true, marketing: false }, + {}, + marketingEventsVersion, + ), + }); + expect(adapter.track).toHaveBeenCalledTimes(1); + }); + + 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, + marketingEventsConfig: { + ...marketingEventsConfig, + events: { [marketingEvent]: [AnalyticsPurpose.Product] }, + }, + preConsentEventQueue: { + captured: { + type: 'track', + eventName: marketingEvent, + messageId: 'captured', + timestamp: '2026-01-01T00:00:00.000Z', + eventPurposes: [AnalyticsPurpose.Marketing], + marketingEventsVersion: 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, + marketingEventsConfig, + }, + 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, + marketingEventsConfig: { + ...marketingEventsConfig, + events: { [marketingEvent]: [AnalyticsPurpose.Product] }, + }, + eventFragments: { + captured: { + id: 'captured', + successEvent: marketingEvent, + properties: {}, + sensitiveProperties: {}, + createdAt: now, + lastUpdated: now, + eventPurposes: { [marketingEvent]: [AnalyticsPurpose.Marketing] }, + marketingEventsVersion: 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(); @@ -4260,7 +4564,7 @@ describe('AnalyticsController', () => { expect(adapter.view).toHaveBeenCalledWith( marketingEvent, undefined, - withMarketingFlag(true), + withConfiguredPurposeConsent({ product: false, marketing: true }), ); }); @@ -4286,11 +4590,11 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledWith( marketingEvent, undefined, - withMarketingFlag(true), + withConfiguredPurposeConsent({ product: false, marketing: true }), ); }); - it('stamps context.marketing true on marketing track and view payloads', async () => { + it('stamps marketing consent on marketing track and view payloads', async () => { const adapter = createMockAdapter(); const { controller } = await setupController({ state: { @@ -4315,16 +4619,16 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledWith( marketingEvent, undefined, - withMarketingFlag(true, { page: { path: '/home' } }), + withConfiguredPurposeConsent({ product: false, marketing: true }, { page: { path: '/home' } }), ); expect(adapter.view).toHaveBeenCalledWith( marketingEvent, undefined, - withMarketingFlag(true, { page: { path: '/home' } }), + withConfiguredPurposeConsent({ product: false, marketing: true }, { page: { path: '/home' } }), ); }); - it('stamps context.marketing false on product payloads', async () => { + it('stamps product consent on product payloads', async () => { const adapter = createMockAdapter(); const { controller } = await setupController({ state: { @@ -4344,11 +4648,11 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledWith( productEvent, undefined, - withMarketingFlag(false), + withConfiguredPurposeConsent({ product: true, marketing: false }), ); }); - it('stamps context.marketing on both identified and anonymous payloads', async () => { + it('stamps the same consent on identified and anonymous payloads', async () => { const adapter = createMockAdapter(); const { controller } = await setupController({ state: { @@ -4381,7 +4685,7 @@ describe('AnalyticsController', () => { 1, marketingEvent, { prop: 'value' }, - withMarketingFlag(true, { + withConfiguredPurposeConsent({ product: false, marketing: true }, { page: { path: '/home' }, location: { country_code: 'US', @@ -4398,7 +4702,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'secret', anonymous: true, }, - withMarketingFlag(true, { page: { path: '/home' } }), + withConfiguredPurposeConsent({ product: false, marketing: true }, { page: { path: '/home' } }), ); }); @@ -4424,11 +4728,11 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledWith( productEvent, undefined, - withMarketingFlag(false), + withConfiguredPurposeConsent({ product: true, marketing: false }), ); }); - it('does not emit either lane when both consents are off', async () => { + it('does not emit any event when both consents are off', async () => { const adapter = createMockAdapter(); const { controller } = await setupController({ state: { @@ -4449,7 +4753,7 @@ describe('AnalyticsController', () => { expect(adapter.track).not.toHaveBeenCalled(); }); - it('uses persisted marketingEventNames for classification', async () => { + it('uses the persisted marketing-events config for classification', async () => { const adapter = createMockAdapter(); const { controller } = await setupController({ state: { @@ -4458,7 +4762,10 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventNames: ['Campaign Opened'], + marketingEventsConfig: { + ...marketingEventsConfig, + events: { 'Campaign Opened': [AnalyticsPurpose.Marketing] }, + }, }, platformAdapter: adapter, isGeolocationEnabled: false, @@ -4471,7 +4778,7 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledWith( 'Campaign Opened', undefined, - withMarketingFlag(true), + withConfiguredPurposeConsent({ product: false, marketing: true }), ); }); @@ -4501,7 +4808,7 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledWith( marketingEvent, undefined, - withMarketingFlag(true), + withConfiguredPurposeConsent({ product: false, marketing: true }), expect.anything(), ); }); @@ -4536,7 +4843,7 @@ describe('AnalyticsController', () => { }); }); - it('treats a mixed-name fragment as marketing', async () => { + it('retains a mixed-purpose fragment when one declared purpose is allowed', async () => { const { controller } = await setupController({ state: { analyticsId: '550e8400-e29b-41d4-a716-446655440000', @@ -4556,7 +4863,11 @@ describe('AnalyticsController', () => { successEvent: marketingEvent, }); - expect(fragment?.context).toStrictEqual(withMarketingFlag(true)); + expect(fragment?.eventPurposes).toStrictEqual({ + [productEvent]: [AnalyticsPurpose.Product], + [marketingEvent]: [AnalyticsPurpose.Marketing], + }); + expect(fragment?.context).toBeUndefined(); expect(controller.state.eventFragments).toHaveProperty('mixed-1'); controller.optOutOfMarketing(); @@ -4564,7 +4875,8 @@ describe('AnalyticsController', () => { expect(controller.state.eventFragments).toStrictEqual({}); }); - it('does not create a mixed-name fragment when only product consent is on', async () => { + 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', @@ -4576,19 +4888,29 @@ describe('AnalyticsController', () => { }, isGeolocationEnabled: false, isEventFragmentsEnabled: true, + platformAdapter: adapter, }); - expect( - controller.createEventFragment({ - id: 'mixed-1', - initialEvent: productEvent, - successEvent: marketingEvent, - }), - ).toBeUndefined(); - expect(controller.state.eventFragments).toBeUndefined(); + 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 createEventFragment by event names, not caller context.marketing', async () => { + it('classifies fragments by event names, not caller consent context', async () => { const { controller } = await setupController({ state: { analyticsId: '550e8400-e29b-41d4-a716-446655440000', @@ -4602,29 +4924,36 @@ describe('AnalyticsController', () => { isEventFragmentsEnabled: true, }); - // Reused marketing stamp must not force the marketing consent lane when - // the declared events are product-only. + // 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: withMarketingFlag(true, { page: { path: '/settings' } }), + context: callerContext, }); expect(fragment).toStrictEqual( expect.objectContaining({ id: 'product-1', successEvent: productEvent, - context: withMarketingFlag(false, { page: { path: '/settings' } }), + eventPurposes: { + [productEvent]: [AnalyticsPurpose.Product], + }, + context: callerContext, }), ); expect( controller.state.eventFragments?.['product-1']?.context, - ).toStrictEqual( - withMarketingFlag(false, { page: { path: '/settings' } }), - ); + ).toStrictEqual(callerContext); }); - it('keeps a stamped marketing fragment when marketingEventNames is empty', async () => { + it('keeps a purpose-stamped fragment when config is absent', async () => { const now = Date.now(); const { controller } = await setupController({ state: { @@ -4633,7 +4962,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - // List missing/empty: name lookup would treat this as product. + // Config missing: name lookup would otherwise treat this as product. eventFragments: { 'marketing-1': { id: 'marketing-1', @@ -4643,7 +4972,10 @@ describe('AnalyticsController', () => { createdAt: now, lastUpdated: now, persist: true, - context: withMarketingFlag(true), + eventPurposes: { + [marketingEvent]: [AnalyticsPurpose.Marketing], + }, + marketingEventsVersion, }, }, }, @@ -4657,7 +4989,10 @@ describe('AnalyticsController', () => { expect(controller.state.eventFragments).toStrictEqual({ 'marketing-1': expect.objectContaining({ id: 'marketing-1', - context: withMarketingFlag(true), + eventPurposes: { + [marketingEvent]: [AnalyticsPurpose.Marketing], + }, + marketingEventsVersion, }), }); @@ -4666,7 +5001,7 @@ describe('AnalyticsController', () => { expect(controller.state.eventFragments).toStrictEqual({}); }); - it('allows a marketing fragment when only marketing consent is on even if caller stamps marketing false', async () => { + 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', @@ -4680,17 +5015,25 @@ describe('AnalyticsController', () => { isEventFragmentsEnabled: true, }); + const callerContext = { + consent: { + categoryPreferences: { product: true, marketing: false }, + }, + }; const fragment = controller.createEventFragment({ id: 'marketing-1', successEvent: marketingEvent, - context: withMarketingFlag(false), + context: callerContext, }); expect(fragment).toStrictEqual( expect.objectContaining({ id: 'marketing-1', successEvent: marketingEvent, - context: withMarketingFlag(true), + eventPurposes: { + [marketingEvent]: [AnalyticsPurpose.Marketing], + }, + context: callerContext, }), ); }); @@ -4720,7 +5063,7 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledWith( productEvent, undefined, - withMarketingFlag(false), + withConfiguredPurposeConsent({ product: true, marketing: false }), ); }); @@ -4794,12 +5137,14 @@ describe('AnalyticsController', () => { expect(controller.getEventFragmentById('bag-1')).toStrictEqual( expect.objectContaining({ properties: { step: '1' }, - context: withMarketingFlag(false, { page: { path: '/settings' } }), + context: { + page: { path: '/settings' }, + }, }), ); }); - it('keeps persisted marketingEventNames across init', async () => { + it('keeps the persisted marketing-events config across init', async () => { const { controller } = await setupController({ state: { analyticsId: '550e8400-e29b-41d4-a716-446655440000', @@ -4807,17 +5152,21 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventNames: ['Campaign Opened'], + marketingEventsConfig: { + ...marketingEventsConfig, + events: { 'Campaign Opened': [AnalyticsPurpose.Marketing] }, + }, }, isGeolocationEnabled: false, }); - expect(controller.state.marketingEventNames).toStrictEqual([ - 'Campaign Opened', - ]); + expect(controller.state.marketingEventsConfig).toStrictEqual({ + ...marketingEventsConfig, + events: { 'Campaign Opened': [AnalyticsPurpose.Marketing] }, + }); }); - it('treats every name as product when marketingEventNames is empty', async () => { + it('treats every name as product when config is absent', async () => { const adapter = createMockAdapter(); const { controller } = await setupController({ state: { @@ -4833,7 +5182,7 @@ describe('AnalyticsController', () => { controller.trackEvent(createTestEvent(marketingEvent)); - expect(controller.state.marketingEventNames).toBeUndefined(); + expect(controller.state.marketingEventsConfig).toBeUndefined(); expect(adapter.track).not.toHaveBeenCalled(); }); @@ -4860,7 +5209,7 @@ describe('AnalyticsController', () => { expect(adapter.view).toHaveBeenCalledWith( marketingEvent, undefined, - withMarketingFlag(true), + withConfiguredPurposeConsent({ product: false, marketing: true }), expect.anything(), ); }); @@ -4881,7 +5230,7 @@ describe('AnalyticsController', () => { expect(controller.getEventFragmentById('missing')).toBeUndefined(); }); - it('drops invalid delivery-queue items when filtering by consent lane', async () => { + it('drops invalid delivery-queue items when reconciling consent', async () => { const { controller } = await setupController({ state: { analyticsId: '550e8400-e29b-41d4-a716-446655440000', @@ -4889,7 +5238,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventNames: [marketingEvent], + marketingEventsConfig, eventQueue: { invalid: 'not-an-event', 'keep-me': { @@ -4921,7 +5270,7 @@ describe('AnalyticsController', () => { expect(controller.state.eventQueue).not.toHaveProperty('identify'); }); - it('treats queued identify as product even when context.marketing is true', async () => { + it('drops queued identify on product opt-out while keeping marketing tracks', async () => { const { controller } = await setupController({ state: { analyticsId: '550e8400-e29b-41d4-a716-446655440000', @@ -4936,14 +5285,12 @@ describe('AnalyticsController', () => { userId: '550e8400-e29b-41d4-a716-446655440000', messageId: 'identify', timestamp: '2026-01-01T00:00:01.000Z', - context: withMarketingFlag(true), }, marketing: { type: 'track', eventName: marketingEvent, messageId: 'marketing', timestamp: '2026-01-01T00:00:02.000Z', - context: withMarketingFlag(true), }, }, }, @@ -4970,7 +5317,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventNames: [marketingEvent], + marketingEventsConfig, eventQueue: { 'legacy-product': { type: 'track', @@ -5008,7 +5355,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventNames: [marketingEvent], + marketingEventsConfig, eventQueue: { 'legacy-view': { type: 'view', @@ -5040,7 +5387,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventNames: [marketingEvent], + marketingEventsConfig, eventFragments: { legacy: { id: 'legacy', @@ -5087,9 +5434,9 @@ describe('AnalyticsController', () => { context: { page: { path: '/home' } }, }); - expect(controller.state.eventFragments?.bag?.context).toStrictEqual( - withMarketingFlag(false, { 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 () => { @@ -5120,9 +5467,9 @@ describe('AnalyticsController', () => { expect(controller.state.eventFragments?.bag).toStrictEqual( expect.objectContaining({ properties: { step: '1' }, - context: withMarketingFlag(false), }), ); + expect(controller.state.eventFragments?.bag).not.toHaveProperty('context'); }); it('drops invalid pre-consent items when replaying marketing events', async () => { @@ -5134,7 +5481,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: false, marketingConsentDecisionMade: false, - marketingEventNames: [marketingEvent], + marketingEventsConfig, preConsentEventQueue: { invalid: 'not-an-event', 'keep-me': { @@ -5142,7 +5489,8 @@ describe('AnalyticsController', () => { eventName: marketingEvent, messageId: 'keep-me', timestamp: '2026-01-01T00:00:00.000Z', - context: withMarketingFlag(true), + eventPurposes: [AnalyticsPurpose.Marketing], + marketingEventsVersion, }, } as unknown as AnalyticsControllerState['preConsentEventQueue'], }, @@ -5157,11 +5505,34 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledWith( marketingEvent, undefined, - withMarketingFlag(true), + 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, + marketingEventsConfig, + 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: { @@ -5199,7 +5570,7 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledWith( marketingEvent, undefined, - withMarketingFlag(true), + withConfiguredPurposeConsent({ product: false, marketing: true }), expect.anything(), ); }); diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 1d0711495e6..a3592d74a08 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -56,17 +56,28 @@ export const controllerName = 'AnalyticsController'; export const EVENT_FRAGMENT_MAX_AGE = 24 * 60 * 60 * 1000; /** - * Consent lane for a named analytics payload. - * - * Chosen from the marketing-events list at capture, then stored as - * `context.marketing` so queues and fragments do not look up the name again. + * Purposes for which an analytics payload can be used. */ -const AnalyticsLane = { - Marketing: 'marketing', +export const AnalyticsPurpose = { Product: 'product', + Marketing: 'marketing', } as const; -type AnalyticsLane = (typeof AnalyticsLane)[keyof typeof AnalyticsLane]; +/** + * A purpose for which an analytics payload can be used. + */ +export type AnalyticsPurpose = + (typeof AnalyticsPurpose)[keyof typeof AnalyticsPurpose]; + +/** + * Persisted marketing-events classification fetched from config registry. + */ +export type AnalyticsMarketingEventsConfig = { + schemaVersion: string; + version: string; + timestamp: number; + events: Record; +}; /** * Persisted queues on {@link AnalyticsControllerState}. @@ -101,20 +112,19 @@ export type AnalyticsControllerState = { /** * Whether the user has made a marketing consent decision (opted in or opted - * out). Mirrors {@link consentDecisionMade} for the marketing lane. + * out). Mirrors {@link consentDecisionMade} for the marketing purpose. * Optional for backward compatibility. Missing values are treated as `false`. */ marketingConsentDecisionMade?: boolean; /** - * Cached marketing event names. Used to classify named payloads into the - * marketing lane. Optional for backward compatibility. + * Cached event-purpose configuration. Optional for backward compatibility. * - * Phase 1 does not load names from a remote source. Until a later phase - * wires that up, classification uses whatever list is already persisted, or - * an empty list (every named event is treated as product). + * 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. */ - marketingEventNames?: string[]; + marketingEventsConfig?: AnalyticsMarketingEventsConfig; /** * User's UUIDv4 analytics identifier. @@ -185,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[]; + + /** + * Marketing-events config version used to classify the payload. + */ + marketingEventsVersion?: string; }; /** @@ -275,7 +295,7 @@ const analyticsControllerMetadata = { includeInDebugSnapshot: true, usedInUi: true, }, - marketingEventNames: { + marketingEventsConfig: { includeInStateLogs: true, persist: true, includeInDebugSnapshot: true, @@ -469,6 +489,26 @@ 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), + ) + ); +} + /** * Returns whether a JSON value is a non-array object. * @@ -517,7 +557,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.marketingEventsVersion !== undefined && + typeof value.marketingEventsVersion !== 'string') ) { return false; } @@ -574,6 +619,10 @@ function isAnalyticsEventFragment( typeof value.successEvent === 'string') && (value.failureEvent === undefined || typeof value.failureEvent === 'string') && + (value.eventPurposes === undefined || + isEventPurposesRecord(value.eventPurposes)) && + (value.marketingEventsVersion === undefined || + typeof value.marketingEventsVersion === 'string') && (value.context === undefined || isRecord(value.context)) && (value.persist === undefined || typeof value.persist === 'boolean') ); @@ -658,11 +707,11 @@ export class AnalyticsController extends BaseController< readonly #isEventFragmentsEnabled: boolean; /** - * In-memory lookup of marketing event names from persisted state. - * Empty until a list is available. A later phase will refresh this from a - * remote source. + * In-memory event-purpose lookup from persisted state. */ - readonly #marketingEventNames: Set; + readonly #eventPurposes: Map; + + readonly #marketingEventsVersion: string | undefined; /** * The in-flight (or settled) initialization promise. Set on the first @@ -730,7 +779,10 @@ export class AnalyticsController extends BaseController< this.#platformAdapter = platformAdapter; this.#initPromise = undefined; this.#locationResolvePromise = undefined; - this.#marketingEventNames = new Set(initialState.marketingEventNames ?? []); + this.#eventPurposes = new Map( + Object.entries(initialState.marketingEventsConfig?.events ?? {}), + ); + this.#marketingEventsVersion = initialState.marketingEventsConfig?.version; this.messenger.registerMethodActionHandlers( this, @@ -801,7 +853,7 @@ export class AnalyticsController extends BaseController< } } - await this.#fetchMarketingEventNames(); + await this.#fetchMarketingEventsConfig(); // 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 @@ -819,7 +871,7 @@ export class AnalyticsController extends BaseController< } this.#replayQueuedEvents(); - this.#reconcilePreConsentEvents(); + this.#replayPreConsentEvents(); this.#reconcileEventFragments(initEventFragmentSnapshot); } @@ -899,106 +951,127 @@ export class AnalyticsController extends BaseController< } /** - * Stamp the capture lane on context as `marketing` for Segment. + * Stamp the purposes currently allowed for a payload using Segment's consent + * context shape. * - * @param lane - Marketing or product. + * @param purposes - Purposes for which the payload is eligible. * @param context - Optional caller-provided context. - * @returns Context with `marketing` set. + * @param version - Config version captured with the payload. + * @returns Context with allowed purpose preferences. */ - #withMarketingContext( - lane: AnalyticsLane, + #withConsentContext( + purposes: AnalyticsPurpose[], context?: AnalyticsContext, + version?: string, ): AnalyticsContext { + const preferences = this.#allowedPurposePreferences(purposes); + const { + consent: existingConsent, + marketingEventsVersion: _ignoredVersion, + ...unmanagedContext + } = context ?? {}; + const consent: Record = isJsonRecord(existingConsent) + ? existingConsent + : {}; + const existingCategoryPreferences = isJsonRecord( + consent.categoryPreferences, + ) + ? consent.categoryPreferences + : {}; + return { - ...context, - marketing: lane === AnalyticsLane.Marketing, + ...unmanagedContext, + consent: { + ...consent, + categoryPreferences: { + ...existingCategoryPreferences, + ...preferences, + }, + }, + ...(version === undefined ? {} : { marketingEventsVersion: version }), }; } /** - * Load marketing event names used to classify named payloads. + * Load event-purpose configuration. * - * Phase 1 stub: there is no remote source yet, so this is a no-op. Any - * persisted {@link AnalyticsControllerState.marketingEventNames} from a - * previous session stay in memory. Otherwise the marketing list stays empty - * and every named event is treated as product. + * Phase 1 stub. Persisted configuration remains authoritative until a remote + * source is wired up. */ - async #fetchMarketingEventNames(): Promise { + async #fetchMarketingEventsConfig(): Promise { // Intentionally empty until a marketing-events source is wired up. } - #laneFromName(name: string): AnalyticsLane { - return this.#marketingEventNames.has(name) - ? AnalyticsLane.Marketing - : AnalyticsLane.Product; + #purposesFromName(name: string): AnalyticsPurpose[] { + return [...(this.#eventPurposes.get(name) ?? [AnalyticsPurpose.Product])]; } - #laneFromContext(context?: AnalyticsContext): AnalyticsLane { - return context?.marketing === true - ? AnalyticsLane.Marketing - : AnalyticsLane.Product; - } - - #laneFromQueuedEvent(queuedEvent: AnalyticsQueuedEvent): AnalyticsLane { - // Identify has no event name and is always product, even if a caller - // supplied `context.marketing`. Check type before trusting the stamp. + #purposesFromQueuedEvent( + queuedEvent: AnalyticsQueuedEvent, + ): AnalyticsPurpose[] { if (queuedEvent.type === 'identify') { - return AnalyticsLane.Product; + return [AnalyticsPurpose.Product]; } - if (typeof queuedEvent.context?.marketing === 'boolean') { - return this.#laneFromContext(queuedEvent.context); + if (queuedEvent.eventPurposes !== undefined) { + return [...queuedEvent.eventPurposes]; } - if (queuedEvent.type === 'view') { - return this.#laneFromName(queuedEvent.name); - } - - return this.#laneFromName(queuedEvent.eventName); + return this.#purposesFromName( + queuedEvent.type === 'view' ? queuedEvent.name : queuedEvent.eventName, + ); } - #laneFromFragmentNames( + #eventNamesFromFragment( fragment: Pick< AnalyticsEventFragment, 'initialEvent' | 'successEvent' | 'failureEvent' >, - ): AnalyticsLane { - const names = [ + ): string[] { + return [ fragment.initialEvent, fragment.successEvent, fragment.failureEvent, ].filter((name): name is string => typeof name === 'string'); + } - return names.some( - (name) => this.#laneFromName(name) === AnalyticsLane.Marketing, - ) - ? AnalyticsLane.Marketing - : AnalyticsLane.Product; + #purposesFromFragmentEvent( + fragment: Pick, + name: string, + ): AnalyticsPurpose[] { + return [ + ...(fragment.eventPurposes?.[name] ?? this.#purposesFromName(name)), + ]; } - #laneFromFragment( + #purposesFromFragment( fragment: Pick< AnalyticsEventFragment, - 'initialEvent' | 'successEvent' | 'failureEvent' | 'context' + 'initialEvent' | 'successEvent' | 'failureEvent' | 'eventPurposes' >, - ): AnalyticsLane { - // Prefer the capture-time stamp so persisted fragments keep their lane - // even if `marketingEventNames` is missing or changed. Fall back to names - // for legacy unstamped fragments. Callers must not pass untrusted context - // into create gating: {@link createEventFragment} classifies from names - // only, and {@link #setEventFragment} stamps from names on write. - if (typeof fragment.context?.marketing === 'boolean') { - return this.#laneFromContext(fragment.context); + ): 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 this.#laneFromFragmentNames(fragment); + return [ + ...new Set( + names.flatMap((name) => + this.#purposesFromFragmentEvent(fragment, name), + ), + ), + ]; } - #consent(lane: AnalyticsLane): { + #consent(purpose: AnalyticsPurpose): { optedIn: boolean; decisionMade: boolean; } { - return lane === AnalyticsLane.Marketing + return purpose === AnalyticsPurpose.Marketing ? { optedIn: this.state.optedInToMarketing === true, decisionMade: this.state.marketingConsentDecisionMade === true, @@ -1009,47 +1082,37 @@ export class AnalyticsController extends BaseController< }; } - #isCaptureAllowed(lane: AnalyticsLane): boolean { - const { optedIn, decisionMade } = this.#consent(lane); - return optedIn || (this.#isPreConsentQueueEnabled && !decisionMade); + #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, + }; } - #filterQueuedEvents( - queue: Record, - laneToClear: AnalyticsLane, - ): Record { - const nextQueue: Record = {}; - - for (const [messageId, queuedEvent] of Object.entries(queue)) { - if ( - !isAnalyticsQueuedEvent(queuedEvent) || - queuedEvent.messageId !== messageId - ) { - continue; - } + #hasAllowedPurpose(purposes: AnalyticsPurpose[]): boolean { + const { product, marketing } = this.#allowedPurposePreferences(purposes); + return product || marketing; + } - if (this.#laneFromQueuedEvent(queuedEvent) !== laneToClear) { - nextQueue[messageId] = queuedEvent as unknown as Json; - } - } + #hasUndecidedPurpose(purposes: AnalyticsPurpose[]): boolean { + return purposes.some((purpose) => !this.#consent(purpose).decisionMade); + } - return nextQueue; + #isCaptureAllowed(purposes: AnalyticsPurpose[]): boolean { + return ( + this.#hasAllowedPurpose(purposes) || + (this.#isPreConsentQueueEnabled && this.#hasUndecidedPurpose(purposes)) + ); } #replaceQueue(field: AnalyticsQueue, nextQueue: Record): void { - const currentQueue = this.state[field] as Record; - const currentKeys = Object.keys(currentQueue); - const nextKeys = Object.keys(nextQueue); - - if ( - currentKeys.length === nextKeys.length && - currentKeys.every((key) => - Object.prototype.hasOwnProperty.call(nextQueue, key), - ) - ) { - return; - } - this.update((state) => { state[field] = nextQueue as never; }); @@ -1061,20 +1124,26 @@ export class AnalyticsController extends BaseController< * @param eventName - The name of the event. * @param properties - Optional event properties. * @param context - Optional platform-specific context. - * @param lane - Capture lane stamped on `context`. + * @param purposes - Capture-time purposes for the event. + * @param version - Capture-time marketing-events config version. */ #sendOrQueueTrackEvent( eventName: string, properties: AnalyticsEventProperties | undefined, context: AnalyticsContext | undefined, - lane: AnalyticsLane, + purposes: AnalyticsPurpose[], + version: string | undefined, ): void { - const { optedIn } = this.#consent(lane); - const contextWithLane = this.#withMarketingContext(lane, context); + const isAllowed = this.#hasAllowedPurpose(purposes); + const contextWithConsent = this.#withConsentContext( + purposes, + context, + version, + ); // Direct delivery: enabled and not persisting. - if (optedIn && !this.#isEventQueuePersistenceEnabled) { - this.#platformAdapter.track(eventName, properties, contextWithLane); + if (isAllowed && !this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.track(eventName, properties, contextWithConsent); return; } @@ -1084,10 +1153,12 @@ export class AnalyticsController extends BaseController< messageId: uuid(), timestamp: new Date().toISOString(), ...(properties === undefined ? {} : { properties }), - context: contextWithLane, + context: contextWithConsent, + eventPurposes: purposes, + ...(version === undefined ? {} : { marketingEventsVersion: version }), }; - if (!optedIn) { + if (!isAllowed) { this.#enqueuePreConsentEvent(queuedEvent); return; } @@ -1107,8 +1178,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; } @@ -1118,7 +1192,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); @@ -1130,19 +1205,25 @@ export class AnalyticsController extends BaseController< * @param name - The view name. * @param properties - Optional view properties. * @param context - Optional platform-specific context. - * @param lane - Capture lane stamped on `context`. + * @param purposes - Capture-time purposes for the view. + * @param version - Capture-time marketing-events config version. */ #sendOrQueueViewEvent( name: string, properties: AnalyticsEventProperties | undefined, context: AnalyticsContext | undefined, - lane: AnalyticsLane, + purposes: AnalyticsPurpose[], + version: string | undefined, ): void { - const { optedIn } = this.#consent(lane); - const contextWithLane = this.#withMarketingContext(lane, context); + const isAllowed = this.#hasAllowedPurpose(purposes); + const contextWithConsent = this.#withConsentContext( + purposes, + context, + version, + ); - if (optedIn && !this.#isEventQueuePersistenceEnabled) { - this.#platformAdapter.view(name, properties, contextWithLane); + if (isAllowed && !this.#isEventQueuePersistenceEnabled) { + this.#platformAdapter.view(name, properties, contextWithConsent); return; } @@ -1152,10 +1233,12 @@ export class AnalyticsController extends BaseController< messageId: uuid(), timestamp: new Date().toISOString(), ...(properties === undefined ? {} : { properties }), - context: contextWithLane, + context: contextWithConsent, + eventPurposes: purposes, + ...(version === undefined ? {} : { marketingEventsVersion: version }), }; - if (!optedIn) { + if (!isAllowed) { this.#enqueuePreConsentEvent(queuedEvent); return; } @@ -1265,9 +1348,9 @@ export class AnalyticsController extends BaseController< continue; } - const { optedIn } = this.#consent(this.#laneFromQueuedEvent(queuedEvent)); + const purposes = this.#purposesFromQueuedEvent(queuedEvent); - if (optedIn) { + if (this.#hasAllowedPurpose(purposes)) { remainingQueue[messageId] = queuedEvent as unknown as Json; eventsToSend.push(queuedEvent); } @@ -1302,16 +1385,79 @@ export class AnalyticsController extends BaseController< }); } - #clearQueuedEventsInLane( - field: AnalyticsQueue, - laneToClear: AnalyticsLane, - ): void { + #refreshQueuedEventConsent( + queuedEvent: AnalyticsQueuedEvent, + ): AnalyticsQueuedEvent { + // Refresh only the consent stamp. Capture-time `eventPurposes` and + // `marketingEventsVersion` 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 ( + isJsonRecord(existingPreferences) && + existingPreferences.product === preferences.product && + existingPreferences.marketing === preferences.marketing + ) { + return queuedEvent; + } + + return { + ...queuedEvent, + context: this.#withConsentContext( + purposes, + queuedEvent.context, + queuedEvent.marketingEventsVersion, + ), + }; + } + + /** + * Prune a queue after a consent change. + * + * 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 only while + * no purpose is opted in and at least one is still undecided. + * + * @param field - The queue to prune. + */ + #pruneQueueForConsent(field: AnalyticsQueue): void { const queue = this.state[field]; if (!queue) { return; } - this.#replaceQueue(field, this.#filterQueuedEvents(queue, laneToClear)); + const nextQueue: Record = {}; + for (const [messageId, queuedEvent] of Object.entries(queue)) { + if ( + !isAnalyticsQueuedEvent(queuedEvent) || + queuedEvent.messageId !== messageId + ) { + continue; + } + + const purposes = this.#purposesFromQueuedEvent(queuedEvent); + const isAllowed = this.#hasAllowedPurpose(purposes); + + 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); } /** @@ -1330,27 +1476,6 @@ export class AnalyticsController extends BaseController< }); } - /** - * Replay queued pre-consent events through the delivery path. - * - * 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. - * - * @param queue - The pre-consent event queue to replay. - */ - #replayPreConsentEvents(queue: Record): void { - for (const queuedEvent of Object.values(queue)) { - const eventToReplay = this.#enrichPreConsentEvent(queuedEvent); - - if (this.#isEventQueuePersistenceEnabled) { - this.#enqueueEvent(eventToReplay); - } else { - this.#sendQueuedEvent(eventToReplay); - } - } - } - /** * Enrich a pre-consent event with the geolocation resolved on opt-in. * @@ -1381,16 +1506,18 @@ export class AnalyticsController extends BaseController< } /** - * Reconcile the pre-consent queue on initialization. + * Replay eligible pre-consent events against current consent. * - * Each queued item is replayed, kept, or dropped according to the consent - * lane stamped at capture. Product and marketing items are independent. + * 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) { @@ -1405,7 +1532,7 @@ export class AnalyticsController extends BaseController< } const keep: Record = {}; - const replay: Record = {}; + const replay: AnalyticsQueuedEvent[] = []; for (const [messageId, queuedEvent] of Object.entries(queue)) { if ( @@ -1415,19 +1542,28 @@ export class AnalyticsController extends BaseController< continue; } - const { optedIn, decisionMade } = this.#consent( - this.#laneFromQueuedEvent(queuedEvent), - ); + const purposes = this.#purposesFromQueuedEvent(queuedEvent); - if (optedIn) { - replay[messageId] = queuedEvent; - } else if (!decisionMade) { + if (this.#hasAllowedPurpose(purposes)) { + replay.push(queuedEvent); + } else if (this.#hasUndecidedPurpose(purposes)) { keep[messageId] = queuedEvent as unknown as Json; } } this.#replaceQueue(AnalyticsQueue.PreConsentEventQueue, keep); - this.#replayPreConsentEvents(replay); + + for (const queuedEvent of replay) { + const eventToReplay = this.#refreshQueuedEventConsent( + this.#enrichPreConsentEvent(queuedEvent), + ); + + if (this.#isEventQueuePersistenceEnabled) { + this.#enqueueEvent(eventToReplay); + } else { + this.#sendQueuedEvent(eventToReplay); + } + } } /** @@ -1440,9 +1576,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 for a fragment's lane (e.g. the - * fragment was written before the user opted out of that lane), those - * fragments are 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 @@ -1480,7 +1615,7 @@ export class AnalyticsController extends BaseController< continue; } - if (!this.#isCaptureAllowed(this.#laneFromFragment(fragment))) { + if (!this.#isCaptureAllowed(this.#purposesFromFragment(fragment))) { continue; } @@ -1523,26 +1658,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 marketing context. + * @returns The stored fragment with capture-time purpose metadata. */ #setEventFragment(fragment: AnalyticsEventFragment): AnalyticsEventFragment { - const fragmentWithMarketingContext: AnalyticsEventFragment = { - ...fragment, - context: this.#withMarketingContext( - this.#laneFromFragmentNames(fragment), - fragment.context, - ), - }; + // 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.#marketingEventsVersion === undefined + ? {} + : { marketingEventsVersion: this.#marketingEventsVersion }), + }; + } + const eventFragments: AnalyticsEventFragments = { ...this.state.eventFragments, - [fragmentWithMarketingContext.id]: fragmentWithMarketingContext, + [fragmentWithPurposeSnapshot.id]: fragmentWithPurposeSnapshot, }; this.update((state) => { state.eventFragments = eventFragments as never; }); - return fragmentWithMarketingContext; + return fragmentWithPurposeSnapshot; } /** @@ -1567,7 +1712,11 @@ export class AnalyticsController extends BaseController< }); } - #clearEventFragmentsInLane(laneToClear: AnalyticsLane): void { + /** + * 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) { @@ -1578,7 +1727,7 @@ export class AnalyticsController extends BaseController< for (const [id, fragment] of Object.entries(fragments)) { if ( isAnalyticsEventFragment(fragment) && - this.#laneFromFragment(fragment) !== laneToClear + this.#isCaptureAllowed(this.#purposesFromFragment(fragment)) ) { eventFragments[id] = fragment; } @@ -1589,6 +1738,16 @@ export class AnalyticsController extends BaseController< }); } + /** + * Drop queued events and fragments that the current consent state no longer + * allows to keep. + */ + #pruneAllEventsForConsent(): void { + this.#pruneQueueForConsent(AnalyticsQueue.EventQueue); + this.#pruneQueueForConsent(AnalyticsQueue.PreConsentEventQueue); + this.#pruneEventFragmentsForConsent(); + } + /** * Clear all event fragments. */ @@ -1622,7 +1781,7 @@ export class AnalyticsController extends BaseController< method: string, fragment?: Pick< AnalyticsEventFragment, - 'initialEvent' | 'successEvent' | 'failureEvent' | 'context' + 'initialEvent' | 'successEvent' | 'failureEvent' | 'eventPurposes' >, ): boolean { if (!this.#isEventFragmentsEnabled) { @@ -1635,9 +1794,8 @@ export class AnalyticsController extends BaseController< } const captureAllowed = fragment - ? this.#isCaptureAllowed(this.#laneFromFragment(fragment)) - : this.#isCaptureAllowed(AnalyticsLane.Product) || - this.#isCaptureAllowed(AnalyticsLane.Marketing); + ? this.#isCaptureAllowed(this.#purposesFromFragment(fragment)) + : this.#isCaptureAllowed(Object.values(AnalyticsPurpose)); if (!captureAllowed) { log( @@ -1670,7 +1828,7 @@ export class AnalyticsController extends BaseController< const properties = { ...fragment.properties }; const sensitiveProperties = { ...fragment.sensitiveProperties }; - this.trackEvent( + this.#trackEvent( { name, properties, @@ -1681,6 +1839,8 @@ export class AnalyticsController extends BaseController< Object.keys(sensitiveProperties).length > 0, }, context, + this.#purposesFromFragmentEvent(fragment, name), + fragment.marketingEventsVersion, ); } @@ -1693,12 +1853,24 @@ export class AnalyticsController extends BaseController< * @param context - Optional platform-specific context forwarded to the platform adapter. */ trackEvent(event: AnalyticsTrackingEvent, context?: AnalyticsContext): void { - const lane = this.#laneFromName(event.name); + this.#trackEvent( + event, + context, + this.#purposesFromName(event.name), + this.#marketingEventsVersion, + ); + } + #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.#isCaptureAllowed(lane)) { + if (!this.#isCaptureAllowed(purposes)) { return; } @@ -1709,7 +1881,8 @@ export class AnalyticsController extends BaseController< event.name, undefined, this.#withLocationContext(context), - lane, + purposes, + version, ); return; } @@ -1724,7 +1897,8 @@ export class AnalyticsController extends BaseController< ...event.properties, }, this.#withLocationContext(context), - lane, + purposes, + version, ); } @@ -1745,7 +1919,8 @@ export class AnalyticsController extends BaseController< this.#isAnonymousEventsFeatureEnabled ? context : this.#withLocationContext(context), - lane, + purposes, + version, ); } } @@ -1780,8 +1955,9 @@ export class AnalyticsController extends BaseController< properties?: AnalyticsEventProperties, context?: AnalyticsContext, ): void { - const lane = this.#laneFromName(name); - if (!this.#isCaptureAllowed(lane)) { + const purposes = this.#purposesFromName(name); + const version = this.#marketingEventsVersion; + if (!this.#isCaptureAllowed(purposes)) { return; } @@ -1790,7 +1966,8 @@ export class AnalyticsController extends BaseController< name, properties, this.#withLocationContext(context), - lane, + purposes, + version, ); } @@ -1821,9 +1998,8 @@ export class AnalyticsController extends BaseController< createEventFragment( options: AnalyticsEventFragmentOptions = {}, ): ReadonlyAnalyticsEventFragment | undefined { - // Classify create from event names only. Do not pass caller `context` into - // the consent gate: a reused `marketing` stamp must not pick the lane. - // `#setEventFragment` stamps from names after the write. + // Classify create from event names only. Caller consent context must not + // affect the event-purpose snapshot. if ( this.#shouldIgnoreEventFragmentCall('createEventFragment', { initialEvent: options.initialEvent, @@ -2026,7 +2202,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(); } /** @@ -2042,15 +2218,7 @@ export class AnalyticsController extends BaseController< state.consentDecisionMade = true; }); - this.#clearQueuedEventsInLane( - AnalyticsQueue.EventQueue, - AnalyticsLane.Product, - ); - this.#clearQueuedEventsInLane( - AnalyticsQueue.PreConsentEventQueue, - AnalyticsLane.Product, - ); - this.#clearEventFragmentsInLane(AnalyticsLane.Product); + this.#pruneAllEventsForConsent(); } /** @@ -2071,13 +2239,7 @@ export class AnalyticsController extends BaseController< state.consentDecisionMade = false; }); - this.#clearQueuedEventsInLane( - AnalyticsQueue.EventQueue, - AnalyticsLane.Product, - ); - if (!this.#isCaptureAllowed(AnalyticsLane.Product)) { - this.#clearEventFragmentsInLane(AnalyticsLane.Product); - } + this.#pruneAllEventsForConsent(); } /** @@ -2094,7 +2256,7 @@ export class AnalyticsController extends BaseController< }); await this.#maybeResolveLocation(); - this.#reconcilePreConsentEvents(); + this.#replayPreConsentEvents(); } /** @@ -2109,15 +2271,7 @@ export class AnalyticsController extends BaseController< state.marketingConsentDecisionMade = true; }); - this.#clearQueuedEventsInLane( - AnalyticsQueue.EventQueue, - AnalyticsLane.Marketing, - ); - this.#clearQueuedEventsInLane( - AnalyticsQueue.PreConsentEventQueue, - AnalyticsLane.Marketing, - ); - this.#clearEventFragmentsInLane(AnalyticsLane.Marketing); + this.#pruneAllEventsForConsent(); } /** @@ -2131,12 +2285,6 @@ export class AnalyticsController extends BaseController< state.marketingConsentDecisionMade = false; }); - this.#clearQueuedEventsInLane( - AnalyticsQueue.EventQueue, - AnalyticsLane.Marketing, - ); - if (!this.#isCaptureAllowed(AnalyticsLane.Marketing)) { - this.#clearEventFragmentsInLane(AnalyticsLane.Marketing); - } + this.#pruneAllEventsForConsent(); } } diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index c67ccfc99ef..b56558d6e7c 100644 --- a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts +++ b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts @@ -56,9 +56,21 @@ 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. `categoryPreferences` is the intersection of the + * event's eligible purposes and the user's current consent. + */ + consent?: { + categoryPreferences: { + product: boolean; + marketing: boolean; + }; + }; + marketingEventsVersion?: 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..09f68c81e75 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; + + /** + * Marketing-events config version used for the capture-time classification. + */ + marketingEventsVersion?: 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>; + marketingEventsVersion?: string; context?: Readonly; persist?: boolean; createdAt: number; diff --git a/packages/analytics-controller/src/index.ts b/packages/analytics-controller/src/index.ts index 19f661e0d01..ba3d7eae948 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, + AnalyticsMarketingEventsConfig, AnalyticsQueuedEvent, AnalyticsQueuedEventType, AnalyticsQueuedTrackEvent, From 473b405dd43fa2c4338217981ddab2e126b08f48 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 17 Sep 2026 07:07:56 +0200 Subject: [PATCH 07/14] fix(analytics-controller): restore coverage and prettier for consent refresh Cover the no-op path in #refreshQueuedEventConsent and reformat the test file so CI lint:misc and package coverage checks pass. Co-authored-by: Cursor --- .../src/AnalyticsController.test.ts | 239 +++++++++++++----- 1 file changed, 180 insertions(+), 59 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 0f4f6e906f6..057e6d5a136 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -1534,7 +1534,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), ); }); @@ -1551,9 +1554,12 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withPurposeConsent({ product: true, marketing: false }, { - location: fullLocationContext, - }), + withPurposeConsent( + { product: true, marketing: false }, + { + location: fullLocationContext, + }, + ), ); }); @@ -1570,7 +1576,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, { trait: 'value' }, - withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), ); }); @@ -1587,9 +1596,12 @@ describe('AnalyticsController', () => { expect(mockAdapter.view).toHaveBeenCalledWith( 'home', undefined, - withPurposeConsent({ product: true, marketing: false }, { - location: fullLocationContext, - }), + withPurposeConsent( + { product: true, marketing: false }, + { + location: fullLocationContext, + }, + ), ); }); @@ -1608,10 +1620,13 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withPurposeConsent({ product: true, marketing: false }, { - app: { name: 'MetaMask' }, - location: fullLocationContext, - }), + withPurposeConsent( + { product: true, marketing: false }, + { + app: { name: 'MetaMask' }, + location: fullLocationContext, + }, + ), ); }); @@ -1630,9 +1645,12 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withPurposeConsent({ product: true, marketing: false }, { - location: { city: 'Seattle', ...fullLocationContext }, - }), + withPurposeConsent( + { product: true, marketing: false }, + { + location: { city: 'Seattle', ...fullLocationContext }, + }, + ), ); }); @@ -1651,9 +1669,12 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withPurposeConsent({ product: true, marketing: false }, { - location: fullLocationContext, - }), + withPurposeConsent( + { product: true, marketing: false }, + { + location: fullLocationContext, + }, + ), ); }); @@ -1672,9 +1693,12 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withPurposeConsent({ product: true, marketing: false }, { - location: fullLocationContext, - }), + withPurposeConsent( + { product: true, marketing: false }, + { + location: fullLocationContext, + }, + ), ); }); @@ -1691,9 +1715,12 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withPurposeConsent({ product: true, marketing: false }, { - location: { country_code: 'FR' }, - }), + withPurposeConsent( + { product: true, marketing: false }, + { + location: { country_code: 'FR' }, + }, + ), ); }); @@ -1711,7 +1738,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withPurposeConsent({ product: true, marketing: false }, { app: { name: 'MetaMask' } }), + withPurposeConsent( + { product: true, marketing: false }, + { app: { name: 'MetaMask' } }, + ), ); }); @@ -1771,7 +1801,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - withPurposeConsent({ product: true, marketing: false }, { location: { city: 'Seattle' } }), + withPurposeConsent( + { product: true, marketing: false }, + { location: { city: 'Seattle' } }, + ), ); }); @@ -1797,7 +1830,10 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1834,7 +1870,10 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), ); }); @@ -1854,9 +1893,12 @@ describe('AnalyticsController', () => { ) as { context?: AnalyticsContext }[]; expect(queuedEvent.context).toStrictEqual({ - ...withPurposeConsent({ product: true, marketing: false }, { - location: fullLocationContext, - }), + ...withPurposeConsent( + { product: true, marketing: false }, + { + location: fullLocationContext, + }, + ), }); }); @@ -1891,7 +1933,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'preconsent_event', undefined, - withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), expect.any(Object), ); @@ -1900,7 +1945,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenLastCalledWith( 'postconsent_event', undefined, - withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), ); }); @@ -1933,7 +1981,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - withPurposeConsent({ product: true, marketing: false }, { location: fullLocationContext }), + withPurposeConsent( + { product: true, marketing: false }, + { location: fullLocationContext }, + ), expect.any(Object), ); // ...but the anonymous payload carries no location. @@ -2256,7 +2307,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.identify).toHaveBeenCalledWith( analyticsId, { trait: 'value' }, - withPurposeConsent({ product: true, marketing: false }, identifyContext), + withPurposeConsent( + { product: true, marketing: false }, + identifyContext, + ), expect.objectContaining({ messageId: identifyOptions.messageId }), ); expect(mockAdapter.view).toHaveBeenCalledWith( @@ -2267,10 +2321,16 @@ describe('AnalyticsController', () => { ); expect(controller.state.eventQueue).toMatchObject({ [identifyOptions.messageId as string]: { - context: withPurposeConsent({ product: true, marketing: false }, identifyContext), + context: withPurposeConsent( + { product: true, marketing: false }, + identifyContext, + ), }, [viewOptions.messageId as string]: { - context: withPurposeConsent({ product: true, marketing: false }, viewContext), + context: withPurposeConsent( + { product: true, marketing: false }, + viewContext, + ), }, }); expect(Object.keys(controller.state.eventQueue ?? {})).toHaveLength(2); @@ -2982,7 +3042,10 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'first_event', { a: 1 }, - withPurposeConsent({ product: true, marketing: false }, { source: 'onboarding' }), + withPurposeConsent( + { product: true, marketing: false }, + { source: 'onboarding' }, + ), expect.objectContaining({ messageId: expect.any(String) }), ); expect(mockAdapter.track).toHaveBeenCalledWith( @@ -3299,9 +3362,12 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Requested', { signature_type: 'personal_sign' }, - withPurposeConsent({ product: true, marketing: false }, { - referrer: { url: 'https://dapp.test' }, - }), + withPurposeConsent( + { product: true, marketing: false }, + { + referrer: { url: 'https://dapp.test' }, + }, + ), ); }); @@ -3665,10 +3731,13 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', undefined, - withPurposeConsent({ product: true, marketing: false }, { - referrer: { url: 'https://other.test' }, - keep: 'me', - }), + withPurposeConsent( + { product: true, marketing: false }, + { + referrer: { url: 'https://other.test' }, + keep: 'me', + }, + ), ); }); @@ -4338,14 +4407,14 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledTimes(1); expect(adapter.track).toHaveBeenCalledWith( dualPurposeEvent, - undefined, + undefined, withPurposeConsent( { product: false, marketing: true }, {}, marketingEventsVersion, ), - ); - }); + ); + }); it('sends immediately when one purpose is opted in and the other is undecided', async () => { const adapter = createMockAdapter(); @@ -4411,6 +4480,44 @@ describe('AnalyticsController', () => { 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, + marketingEventsConfig, + eventQueue: { + queued: { + type: 'track', + eventName: productEvent, + messageId: 'queued', + timestamp: '2026-01-01T00:00:00.000Z', + eventPurposes: [AnalyticsPurpose.Product], + marketingEventsVersion, + context: withPurposeConsent( + { product: true, marketing: false }, + {}, + marketingEventsVersion, + ), + }, + }, + }, + platformAdapter: adapter, + isGeolocationEnabled: false, + isEventQueuePersistenceEnabled: true, + skipInit: true, + }); + + const queuedBefore = controller.state.eventQueue?.queued; + controller.optOutOfMarketing(); + + expect(controller.state.eventQueue?.queued).toBe(queuedBefore); + }); + it('replays a queued event using its capture-time purposes and version', async () => { const adapter = createMockAdapter(); const capturedVersion = 'previous-config'; @@ -4619,12 +4726,18 @@ describe('AnalyticsController', () => { expect(adapter.track).toHaveBeenCalledWith( marketingEvent, undefined, - withConfiguredPurposeConsent({ product: false, marketing: true }, { page: { path: '/home' } }), + withConfiguredPurposeConsent( + { product: false, marketing: true }, + { page: { path: '/home' } }, + ), ); expect(adapter.view).toHaveBeenCalledWith( marketingEvent, undefined, - withConfiguredPurposeConsent({ product: false, marketing: true }, { page: { path: '/home' } }), + withConfiguredPurposeConsent( + { product: false, marketing: true }, + { page: { path: '/home' } }, + ), ); }); @@ -4685,14 +4798,17 @@ describe('AnalyticsController', () => { 1, marketingEvent, { prop: 'value' }, - withConfiguredPurposeConsent({ product: false, marketing: true }, { - page: { path: '/home' }, - location: { - country_code: 'US', - region: 'WA', - timezone: 'America/Los_Angeles', + withConfiguredPurposeConsent( + { product: false, marketing: true }, + { + page: { path: '/home' }, + location: { + country_code: 'US', + region: 'WA', + timezone: 'America/Los_Angeles', + }, }, - }), + ), ); expect(adapter.track).toHaveBeenNthCalledWith( 2, @@ -4702,7 +4818,10 @@ describe('AnalyticsController', () => { sensitive_prop: 'secret', anonymous: true, }, - withConfiguredPurposeConsent({ product: false, marketing: true }, { page: { path: '/home' } }), + withConfiguredPurposeConsent( + { product: false, marketing: true }, + { page: { path: '/home' } }, + ), ); }); @@ -5469,7 +5588,9 @@ describe('AnalyticsController', () => { properties: { step: '1' }, }), ); - expect(controller.state.eventFragments?.bag).not.toHaveProperty('context'); + expect(controller.state.eventFragments?.bag).not.toHaveProperty( + 'context', + ); }); it('drops invalid pre-consent items when replaying marketing events', async () => { From ce57f03d4472f8ea57750214ebbae1b33635479a Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 17 Sep 2026 07:23:16 +0200 Subject: [PATCH 08/14] refactor(analytics-controller): rename events config away from marketing wording Use eventsConfig, eventsConfigVersion, and fetchEventsConfig so classification naming is purpose-agnostic while marketing consent APIs stay unchanged. Co-authored-by: Cursor --- packages/analytics-controller/CHANGELOG.md | 4 +- packages/analytics-controller/README.md | 26 +++--- .../src/AnalyticsController.test.ts | 88 +++++++++---------- .../src/AnalyticsController.ts | 58 ++++++------ .../src/AnalyticsPlatformAdapter.types.ts | 2 +- .../src/EventFragment.types.ts | 6 +- packages/analytics-controller/src/index.ts | 2 +- 7 files changed, 93 insertions(+), 93 deletions(-) diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 67b2076cada..2ec60f7c1ea 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 `marketingEventsConfig` 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.marketingEventsVersion` + - 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 diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index fbe9272c76c..a765fbed7d1 100644 --- a/packages/analytics-controller/README.md +++ b/packages/analytics-controller/README.md @@ -16,16 +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` | 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 | -| `marketingEventsConfig` | `AnalyticsMarketingEventsConfig` | 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 | +| 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 @@ -34,7 +34,7 @@ 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) -`marketingEventsConfig.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. +`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: @@ -47,12 +47,12 @@ Each `track` and `view` payload is emitted once when at least one eligible purpo "marketing": true } }, - "marketingEventsVersion": "a1b2c3d" + "eventsConfigVersion": "a1b2c3d" } } ``` -The booleans are the intersection of event classification and current user consent. `marketingEventsVersion` is included when classification came from a persisted config. `identify` is always product-only. +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. diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 057e6d5a136..6b465f7a75a 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -232,7 +232,7 @@ function createMockAdapter(): MockAnalyticsPlatformAdapter { * @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 marketing-events config version. + * @param version - Optional events config version. * @returns Context including Segment consent category preferences. */ function withPurposeConsent( @@ -245,7 +245,7 @@ function withPurposeConsent( consent: { categoryPreferences: preferences, }, - ...(version === undefined ? {} : { marketingEventsVersion: version }), + ...(version === undefined ? {} : { eventsConfigVersion: version }), }; } @@ -4334,10 +4334,10 @@ describe('AnalyticsController', () => { const marketingEvent = 'Deep Link Used'; const productEvent = 'Button Clicked'; const dualPurposeEvent = 'Perp Trade Completed'; - const marketingEventsVersion = 'a1b2c3d'; - const marketingEventsConfig = { + const eventsConfigVersion = 'a1b2c3d'; + const eventsConfig = { schemaVersion: '1.0.0', - version: marketingEventsVersion, + version: eventsConfigVersion, timestamp: 1_740_000_000_000, events: { [marketingEvent]: [AnalyticsPurpose.Marketing], @@ -4348,13 +4348,13 @@ describe('AnalyticsController', () => { }, }; const withMarketingList = { - marketingEventsConfig, + eventsConfig, }; const withConfiguredPurposeConsent = ( preferences: { product: boolean; marketing: boolean }, context: AnalyticsContext = {}, ): AnalyticsContext => - withPurposeConsent(preferences, context, marketingEventsVersion); + withPurposeConsent(preferences, context, eventsConfigVersion); it('emits a dual-purpose event once with both allowed purposes', async () => { const adapter = createMockAdapter(); @@ -4365,7 +4365,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig, + eventsConfig, }, platformAdapter: adapter, isGeolocationEnabled: false, @@ -4382,7 +4382,7 @@ describe('AnalyticsController', () => { withPurposeConsent( { product: true, marketing: true }, {}, - marketingEventsVersion, + eventsConfigVersion, ), ); }); @@ -4396,7 +4396,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig, + eventsConfig, }, platformAdapter: adapter, isGeolocationEnabled: false, @@ -4411,7 +4411,7 @@ describe('AnalyticsController', () => { withPurposeConsent( { product: false, marketing: true }, {}, - marketingEventsVersion, + eventsConfigVersion, ), ); }); @@ -4425,7 +4425,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: false, marketingConsentDecisionMade: false, - marketingEventsConfig, + eventsConfig, }, platformAdapter: adapter, isGeolocationEnabled: false, @@ -4442,7 +4442,7 @@ describe('AnalyticsController', () => { withPurposeConsent( { product: true, marketing: false }, {}, - marketingEventsVersion, + eventsConfigVersion, ), ); }); @@ -4456,7 +4456,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig, + eventsConfig, }, platformAdapter: adapter, isGeolocationEnabled: false, @@ -4470,11 +4470,11 @@ describe('AnalyticsController', () => { expect(controller.state.eventQueue?.[messageId as string]).toMatchObject({ eventPurposes: [AnalyticsPurpose.Product, AnalyticsPurpose.Marketing], - marketingEventsVersion, + eventsConfigVersion, context: withPurposeConsent( { product: true, marketing: false }, {}, - marketingEventsVersion, + eventsConfigVersion, ), }); expect(adapter.track).toHaveBeenCalledTimes(1); @@ -4489,7 +4489,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: false, marketingConsentDecisionMade: true, - marketingEventsConfig, + eventsConfig, eventQueue: { queued: { type: 'track', @@ -4497,11 +4497,11 @@ describe('AnalyticsController', () => { messageId: 'queued', timestamp: '2026-01-01T00:00:00.000Z', eventPurposes: [AnalyticsPurpose.Product], - marketingEventsVersion, + eventsConfigVersion, context: withPurposeConsent( { product: true, marketing: false }, {}, - marketingEventsVersion, + eventsConfigVersion, ), }, }, @@ -4528,8 +4528,8 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: false, marketingConsentDecisionMade: false, - marketingEventsConfig: { - ...marketingEventsConfig, + eventsConfig: { + ...eventsConfig, events: { [marketingEvent]: [AnalyticsPurpose.Product] }, }, preConsentEventQueue: { @@ -4539,7 +4539,7 @@ describe('AnalyticsController', () => { messageId: 'captured', timestamp: '2026-01-01T00:00:00.000Z', eventPurposes: [AnalyticsPurpose.Marketing], - marketingEventsVersion: capturedVersion, + eventsConfigVersion: capturedVersion, }, }, }, @@ -4573,7 +4573,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig, + eventsConfig, }, platformAdapter: adapter, isGeolocationEnabled: false, @@ -4613,8 +4613,8 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig: { - ...marketingEventsConfig, + eventsConfig: { + ...eventsConfig, events: { [marketingEvent]: [AnalyticsPurpose.Product] }, }, eventFragments: { @@ -4626,7 +4626,7 @@ describe('AnalyticsController', () => { createdAt: now, lastUpdated: now, eventPurposes: { [marketingEvent]: [AnalyticsPurpose.Marketing] }, - marketingEventsVersion: capturedVersion, + eventsConfigVersion: capturedVersion, }, }, }, @@ -4872,7 +4872,7 @@ describe('AnalyticsController', () => { expect(adapter.track).not.toHaveBeenCalled(); }); - it('uses the persisted marketing-events config for classification', async () => { + it('uses the persisted events config for classification', async () => { const adapter = createMockAdapter(); const { controller } = await setupController({ state: { @@ -4881,8 +4881,8 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig: { - ...marketingEventsConfig, + eventsConfig: { + ...eventsConfig, events: { 'Campaign Opened': [AnalyticsPurpose.Marketing] }, }, }, @@ -5094,7 +5094,7 @@ describe('AnalyticsController', () => { eventPurposes: { [marketingEvent]: [AnalyticsPurpose.Marketing], }, - marketingEventsVersion, + eventsConfigVersion, }, }, }, @@ -5111,7 +5111,7 @@ describe('AnalyticsController', () => { eventPurposes: { [marketingEvent]: [AnalyticsPurpose.Marketing], }, - marketingEventsVersion, + eventsConfigVersion, }), }); @@ -5263,7 +5263,7 @@ describe('AnalyticsController', () => { ); }); - it('keeps the persisted marketing-events config across init', async () => { + it('keeps the persisted events config across init', async () => { const { controller } = await setupController({ state: { analyticsId: '550e8400-e29b-41d4-a716-446655440000', @@ -5271,16 +5271,16 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig: { - ...marketingEventsConfig, + eventsConfig: { + ...eventsConfig, events: { 'Campaign Opened': [AnalyticsPurpose.Marketing] }, }, }, isGeolocationEnabled: false, }); - expect(controller.state.marketingEventsConfig).toStrictEqual({ - ...marketingEventsConfig, + expect(controller.state.eventsConfig).toStrictEqual({ + ...eventsConfig, events: { 'Campaign Opened': [AnalyticsPurpose.Marketing] }, }); }); @@ -5301,7 +5301,7 @@ describe('AnalyticsController', () => { controller.trackEvent(createTestEvent(marketingEvent)); - expect(controller.state.marketingEventsConfig).toBeUndefined(); + expect(controller.state.eventsConfig).toBeUndefined(); expect(adapter.track).not.toHaveBeenCalled(); }); @@ -5357,7 +5357,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig, + eventsConfig, eventQueue: { invalid: 'not-an-event', 'keep-me': { @@ -5436,7 +5436,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig, + eventsConfig, eventQueue: { 'legacy-product': { type: 'track', @@ -5474,7 +5474,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig, + eventsConfig, eventQueue: { 'legacy-view': { type: 'view', @@ -5506,7 +5506,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: true, marketingConsentDecisionMade: true, - marketingEventsConfig, + eventsConfig, eventFragments: { legacy: { id: 'legacy', @@ -5602,7 +5602,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: false, marketingConsentDecisionMade: false, - marketingEventsConfig, + eventsConfig, preConsentEventQueue: { invalid: 'not-an-event', 'keep-me': { @@ -5611,7 +5611,7 @@ describe('AnalyticsController', () => { messageId: 'keep-me', timestamp: '2026-01-01T00:00:00.000Z', eventPurposes: [AnalyticsPurpose.Marketing], - marketingEventsVersion, + eventsConfigVersion, }, } as unknown as AnalyticsControllerState['preConsentEventQueue'], }, @@ -5639,7 +5639,7 @@ describe('AnalyticsController', () => { consentDecisionMade: true, optedInToMarketing: false, marketingConsentDecisionMade: false, - marketingEventsConfig, + eventsConfig, preConsentEventQueue: { invalid: 'not-an-event', } as unknown as AnalyticsControllerState['preConsentEventQueue'], diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index a3592d74a08..b4782f427b5 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -70,9 +70,9 @@ export type AnalyticsPurpose = (typeof AnalyticsPurpose)[keyof typeof AnalyticsPurpose]; /** - * Persisted marketing-events classification fetched from config registry. + * Persisted event-purpose classification fetched from config registry. */ -export type AnalyticsMarketingEventsConfig = { +export type AnalyticsEventsConfig = { schemaVersion: string; version: string; timestamp: number; @@ -124,7 +124,7 @@ export type AnalyticsControllerState = { * that up, classification uses the persisted config. Unlisted names are * product-only. */ - marketingEventsConfig?: AnalyticsMarketingEventsConfig; + eventsConfig?: AnalyticsEventsConfig; /** * User's UUIDv4 analytics identifier. @@ -202,9 +202,9 @@ export type AnalyticsQueuedEventBase = { eventPurposes?: AnalyticsPurpose[]; /** - * Marketing-events config version used to classify the payload. + * Events config version used to classify the payload. */ - marketingEventsVersion?: string; + eventsConfigVersion?: string; }; /** @@ -295,7 +295,7 @@ const analyticsControllerMetadata = { includeInDebugSnapshot: true, usedInUi: true, }, - marketingEventsConfig: { + eventsConfig: { includeInStateLogs: true, persist: true, includeInDebugSnapshot: true, @@ -561,8 +561,8 @@ function isAnalyticsQueuedEvent(value: unknown): value is AnalyticsQueuedEvent { (value.eventPurposes !== undefined && (!Array.isArray(value.eventPurposes) || !value.eventPurposes.every(isAnalyticsPurpose))) || - (value.marketingEventsVersion !== undefined && - typeof value.marketingEventsVersion !== 'string') + (value.eventsConfigVersion !== undefined && + typeof value.eventsConfigVersion !== 'string') ) { return false; } @@ -621,8 +621,8 @@ function isAnalyticsEventFragment( typeof value.failureEvent === 'string') && (value.eventPurposes === undefined || isEventPurposesRecord(value.eventPurposes)) && - (value.marketingEventsVersion === undefined || - typeof value.marketingEventsVersion === 'string') && + (value.eventsConfigVersion === undefined || + typeof value.eventsConfigVersion === 'string') && (value.context === undefined || isRecord(value.context)) && (value.persist === undefined || typeof value.persist === 'boolean') ); @@ -711,7 +711,7 @@ export class AnalyticsController extends BaseController< */ readonly #eventPurposes: Map; - readonly #marketingEventsVersion: string | undefined; + readonly #eventsConfigVersion: string | undefined; /** * The in-flight (or settled) initialization promise. Set on the first @@ -780,9 +780,9 @@ export class AnalyticsController extends BaseController< this.#initPromise = undefined; this.#locationResolvePromise = undefined; this.#eventPurposes = new Map( - Object.entries(initialState.marketingEventsConfig?.events ?? {}), + Object.entries(initialState.eventsConfig?.events ?? {}), ); - this.#marketingEventsVersion = initialState.marketingEventsConfig?.version; + this.#eventsConfigVersion = initialState.eventsConfig?.version; this.messenger.registerMethodActionHandlers( this, @@ -853,7 +853,7 @@ export class AnalyticsController extends BaseController< } } - await this.#fetchMarketingEventsConfig(); + 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 @@ -967,7 +967,7 @@ export class AnalyticsController extends BaseController< const preferences = this.#allowedPurposePreferences(purposes); const { consent: existingConsent, - marketingEventsVersion: _ignoredVersion, + eventsConfigVersion: _ignoredVersion, ...unmanagedContext } = context ?? {}; const consent: Record = isJsonRecord(existingConsent) @@ -988,7 +988,7 @@ export class AnalyticsController extends BaseController< ...preferences, }, }, - ...(version === undefined ? {} : { marketingEventsVersion: version }), + ...(version === undefined ? {} : { eventsConfigVersion: version }), }; } @@ -998,8 +998,8 @@ export class AnalyticsController extends BaseController< * Phase 1 stub. Persisted configuration remains authoritative until a remote * source is wired up. */ - async #fetchMarketingEventsConfig(): Promise { - // Intentionally empty until a marketing-events source is wired up. + async #fetchEventsConfig(): Promise { + // Intentionally empty until an events-config source is wired up. } #purposesFromName(name: string): AnalyticsPurpose[] { @@ -1125,7 +1125,7 @@ export class AnalyticsController extends BaseController< * @param properties - Optional event properties. * @param context - Optional platform-specific context. * @param purposes - Capture-time purposes for the event. - * @param version - Capture-time marketing-events config version. + * @param version - Capture-time events config version. */ #sendOrQueueTrackEvent( eventName: string, @@ -1155,7 +1155,7 @@ export class AnalyticsController extends BaseController< ...(properties === undefined ? {} : { properties }), context: contextWithConsent, eventPurposes: purposes, - ...(version === undefined ? {} : { marketingEventsVersion: version }), + ...(version === undefined ? {} : { eventsConfigVersion: version }), }; if (!isAllowed) { @@ -1206,7 +1206,7 @@ export class AnalyticsController extends BaseController< * @param properties - Optional view properties. * @param context - Optional platform-specific context. * @param purposes - Capture-time purposes for the view. - * @param version - Capture-time marketing-events config version. + * @param version - Capture-time events config version. */ #sendOrQueueViewEvent( name: string, @@ -1235,7 +1235,7 @@ export class AnalyticsController extends BaseController< ...(properties === undefined ? {} : { properties }), context: contextWithConsent, eventPurposes: purposes, - ...(version === undefined ? {} : { marketingEventsVersion: version }), + ...(version === undefined ? {} : { eventsConfigVersion: version }), }; if (!isAllowed) { @@ -1389,7 +1389,7 @@ export class AnalyticsController extends BaseController< queuedEvent: AnalyticsQueuedEvent, ): AnalyticsQueuedEvent { // Refresh only the consent stamp. Capture-time `eventPurposes` and - // `marketingEventsVersion` stay as a pair and are never rewritten here. + // `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) @@ -1409,7 +1409,7 @@ export class AnalyticsController extends BaseController< context: this.#withConsentContext( purposes, queuedEvent.context, - queuedEvent.marketingEventsVersion, + queuedEvent.eventsConfigVersion, ), }; } @@ -1672,9 +1672,9 @@ export class AnalyticsController extends BaseController< fragmentWithPurposeSnapshot = { ...fragment, ...(names.length === 0 ? {} : { eventPurposes }), - ...(this.#marketingEventsVersion === undefined + ...(this.#eventsConfigVersion === undefined ? {} - : { marketingEventsVersion: this.#marketingEventsVersion }), + : { eventsConfigVersion: this.#eventsConfigVersion }), }; } @@ -1840,7 +1840,7 @@ export class AnalyticsController extends BaseController< }, context, this.#purposesFromFragmentEvent(fragment, name), - fragment.marketingEventsVersion, + fragment.eventsConfigVersion, ); } @@ -1857,7 +1857,7 @@ export class AnalyticsController extends BaseController< event, context, this.#purposesFromName(event.name), - this.#marketingEventsVersion, + this.#eventsConfigVersion, ); } @@ -1956,7 +1956,7 @@ export class AnalyticsController extends BaseController< context?: AnalyticsContext, ): void { const purposes = this.#purposesFromName(name); - const version = this.#marketingEventsVersion; + const version = this.#eventsConfigVersion; if (!this.#isCaptureAllowed(purposes)) { return; } diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index b56558d6e7c..4077e26cf18 100644 --- a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts +++ b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts @@ -69,7 +69,7 @@ export type AnalyticsContext = Record & { marketing: boolean; }; }; - marketingEventsVersion?: string; + eventsConfigVersion?: string; }; /** diff --git a/packages/analytics-controller/src/EventFragment.types.ts b/packages/analytics-controller/src/EventFragment.types.ts index 09f68c81e75..6be4ca61814 100644 --- a/packages/analytics-controller/src/EventFragment.types.ts +++ b/packages/analytics-controller/src/EventFragment.types.ts @@ -56,9 +56,9 @@ export type AnalyticsEventFragment = { eventPurposes?: Record; /** - * Marketing-events config version used for the capture-time classification. + * Events config version used for the capture-time classification. */ - marketingEventsVersion?: string; + eventsConfigVersion?: string; /** * Platform-specific context forwarded with every event this fragment emits. @@ -102,7 +102,7 @@ export type ReadonlyAnalyticsEventFragment = Readonly<{ successEvent?: string; failureEvent?: string; eventPurposes?: Readonly>; - marketingEventsVersion?: string; + 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 ba3d7eae948..05153a56851 100644 --- a/packages/analytics-controller/src/index.ts +++ b/packages/analytics-controller/src/index.ts @@ -37,7 +37,7 @@ export type { export type { AnalyticsControllerState, AnalyticsEventQueue, - AnalyticsMarketingEventsConfig, + AnalyticsEventsConfig, AnalyticsQueuedEvent, AnalyticsQueuedEventType, AnalyticsQueuedTrackEvent, From 39d64aca34f24237b027c4fab9d9a62b8100957b Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 17 Sep 2026 10:44:37 +0200 Subject: [PATCH 09/14] fix(analytics-controller): keep allowed pre-consent events across opt-in races Retain pre-consent queue entries that are already allowed so overlapping consent changes during the geolocation await cannot drop them before replay. Co-authored-by: Cursor --- .../src/AnalyticsController.test.ts | 47 +++++++++++++++++++ .../src/AnalyticsController.ts | 6 +-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 6b465f7a75a..fcb9cb66495 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -2063,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', () => { diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index b4782f427b5..3a710705ec0 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -1419,8 +1419,8 @@ export class AnalyticsController extends BaseController< * * 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 only while - * no purpose is opted in and at least one is still undecided. + * For {@link AnalyticsQueue.PreConsentEventQueue}, entries are kept while at + * least one purpose is still allowed or undecided. * * @param field - The queue to prune. */ @@ -1451,7 +1451,7 @@ export class AnalyticsController extends BaseController< } if (field === AnalyticsQueue.PreConsentEventQueue) { - if (!isAllowed && this.#hasUndecidedPurpose(purposes)) { + if (isAllowed || this.#hasUndecidedPurpose(purposes)) { nextQueue[messageId] = queuedEvent as unknown as Json; } } From c59200d6bed741c7bac4661d0d8a8911d6410144 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 17 Sep 2026 10:54:18 +0200 Subject: [PATCH 10/14] fix(analytics-controller): refresh delivery-queue consent on init replay Re-stamp queued events with current consent before replaying so adapters do not receive stale categoryPreferences after consent changes. Co-authored-by: Cursor --- .../src/AnalyticsController.test.ts | 53 +++++++++++++++++-- .../src/AnalyticsController.ts | 5 +- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index fcb9cb66495..9ffa73ab942 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -2478,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), @@ -2488,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), @@ -2498,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), @@ -4565,6 +4565,53 @@ describe('AnalyticsController', () => { 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'; diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 3a710705ec0..d0f2c76ba98 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -1351,8 +1351,9 @@ export class AnalyticsController extends BaseController< const purposes = this.#purposesFromQueuedEvent(queuedEvent); if (this.#hasAllowedPurpose(purposes)) { - remainingQueue[messageId] = queuedEvent as unknown as Json; - eventsToSend.push(queuedEvent); + const refreshedEvent = this.#refreshQueuedEventConsent(queuedEvent); + remainingQueue[messageId] = refreshedEvent as unknown as Json; + eventsToSend.push(refreshedEvent); } } From 8be1ded461028a4f59f05a380fb3378af4aa3c99 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 17 Sep 2026 10:58:41 +0200 Subject: [PATCH 11/14] fix(analytics-controller): keep AnalyticsContext.consent open-ended Document product and marketing as optional categoryPreferences fields without narrowing the public context API for other Segment consent data. Co-authored-by: Cursor --- .../src/AnalyticsPlatformAdapter.types.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index 4077e26cf18..8bbfb032050 100644 --- a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts +++ b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts @@ -60,13 +60,15 @@ export type AnalyticsTrackingEvent = { */ export type AnalyticsContext = Record & { /** - * Segment consent context. `categoryPreferences` is the intersection of the - * event's eligible purposes and the user's current consent. + * 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?: { - categoryPreferences: { - product: boolean; - marketing: boolean; + consent?: Record & { + categoryPreferences?: Record & { + product?: boolean; + marketing?: boolean; }; }; eventsConfigVersion?: string; From d6488f30b099f702ceca3e7250deb4346848864a Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Thu, 17 Sep 2026 11:31:28 +0200 Subject: [PATCH 12/14] refactor(analytics-controller): rename pruneAllEventsForConsent to pruneAllForConsent The helper prunes both queued events and fragments after consent changes. Co-authored-by: Cursor --- .../analytics-controller/src/AnalyticsController.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index d0f2c76ba98..9b7d035aca3 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -1743,7 +1743,7 @@ export class AnalyticsController extends BaseController< * Drop queued events and fragments that the current consent state no longer * allows to keep. */ - #pruneAllEventsForConsent(): void { + #pruneAllForConsent(): void { this.#pruneQueueForConsent(AnalyticsQueue.EventQueue); this.#pruneQueueForConsent(AnalyticsQueue.PreConsentEventQueue); this.#pruneEventFragmentsForConsent(); @@ -2219,7 +2219,7 @@ export class AnalyticsController extends BaseController< state.consentDecisionMade = true; }); - this.#pruneAllEventsForConsent(); + this.#pruneAllForConsent(); } /** @@ -2240,7 +2240,7 @@ export class AnalyticsController extends BaseController< state.consentDecisionMade = false; }); - this.#pruneAllEventsForConsent(); + this.#pruneAllForConsent(); } /** @@ -2272,7 +2272,7 @@ export class AnalyticsController extends BaseController< state.marketingConsentDecisionMade = true; }); - this.#pruneAllEventsForConsent(); + this.#pruneAllForConsent(); } /** @@ -2286,6 +2286,6 @@ export class AnalyticsController extends BaseController< state.marketingConsentDecisionMade = false; }); - this.#pruneAllEventsForConsent(); + this.#pruneAllForConsent(); } } From 4520399731ac2d8bf625672262a39de4a87b9505 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Fri, 18 Sep 2026 12:37:26 +0200 Subject: [PATCH 13/14] fix(analytics-controller): validate persisted events config --- .../src/AnalyticsController.test.ts | 30 +++++++++++++++++++ .../src/AnalyticsController.ts | 26 +++++++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 9ffa73ab942..40b1ac4dc5d 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -4403,6 +4403,36 @@ describe('AnalyticsController', () => { ): 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({ diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 9b7d035aca3..8dfe7f2d97b 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -509,6 +509,18 @@ function isEventPurposesRecord( ); } +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. * @@ -758,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, @@ -779,10 +799,8 @@ export class AnalyticsController extends BaseController< this.#platformAdapter = platformAdapter; this.#initPromise = undefined; this.#locationResolvePromise = undefined; - this.#eventPurposes = new Map( - Object.entries(initialState.eventsConfig?.events ?? {}), - ); - this.#eventsConfigVersion = initialState.eventsConfig?.version; + this.#eventPurposes = new Map(Object.entries(eventsConfig?.events ?? {})); + this.#eventsConfigVersion = eventsConfig?.version; this.messenger.registerMethodActionHandlers( this, From 3e86f66f7e4828596c8df7997d4444b43b04006c Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Fri, 18 Sep 2026 12:38:50 +0200 Subject: [PATCH 14/14] docs(analytics-controller): clarify geolocation consent --- .../src/AnalyticsController.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 8dfe7f2d97b..6b887e8916d 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -828,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}). @@ -896,14 +897,14 @@ export class AnalyticsController extends BaseController< /** * 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