diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 017f7dbc01c..73941122832 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 ([#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 + ### 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..ba92a9210f1 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..a35ef325630 100644 --- a/packages/analytics-controller/src/AnalyticsController-method-action-types.ts +++ b/packages/analytics-controller/src/AnalyticsController-method-action-types.ts @@ -192,6 +192,39 @@ export type AnalyticsControllerResetConsentDecisionAction = { handler: AnalyticsController['resetConsentDecision']; }; +/** + * Opt in to marketing analytics. + * + * Independent of {@link optIn}. Replays queued marketing events. + * + * @returns A promise that resolves once opt-in processing has completed. + */ +export type AnalyticsControllerOptInToMarketingAction = { + type: `AnalyticsController:optInToMarketing`; + handler: AnalyticsController['optInToMarketing']; +}; + +/** + * Opt out of marketing analytics. + * + * Independent of {@link optOut}. Discards queued marketing events and + * marketing event fragments. + */ +export type AnalyticsControllerOptOutOfMarketingAction = { + type: `AnalyticsController:optOutOfMarketing`; + handler: AnalyticsController['optOutOfMarketing']; +}; + +/** + * Reset the marketing consent decision back to undecided. + * + * Independent of {@link resetConsentDecision}. + */ +export type AnalyticsControllerResetMarketingConsentDecisionAction = { + type: `AnalyticsController:resetMarketingConsentDecision`; + handler: AnalyticsController['resetMarketingConsentDecision']; +}; + /** * Union of all AnalyticsController action types. */ @@ -207,4 +240,7 @@ export type AnalyticsControllerMethodActions = | AnalyticsControllerFinalizeEventFragmentAction | AnalyticsControllerOptInAction | AnalyticsControllerOptOutAction - | AnalyticsControllerResetConsentDecisionAction; + | AnalyticsControllerResetConsentDecisionAction + | AnalyticsControllerOptInToMarketingAction + | AnalyticsControllerOptOutOfMarketingAction + | AnalyticsControllerResetMarketingConsentDecisionAction; diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index 4ef837a5942..48eae1dbed6 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,13 @@ describe('AnalyticsController', () => { controller.trackEvent(createTestEvent('test_event')); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - location: fullLocationContext, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withMarketingFlag(false, { + location: fullLocationContext, + }), + ); }); it('adds location to identify events', async () => { @@ -1545,9 +1573,13 @@ describe('AnalyticsController', () => { controller.trackView('home'); - expect(mockAdapter.view).toHaveBeenCalledWith('home', undefined, { - location: fullLocationContext, - }); + expect(mockAdapter.view).toHaveBeenCalledWith( + 'home', + undefined, + withMarketingFlag(false, { + location: fullLocationContext, + }), + ); }); it('preserves unrelated caller context', async () => { @@ -1562,10 +1594,14 @@ describe('AnalyticsController', () => { app: { name: 'MetaMask' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - 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 () => { @@ -1582,6 +1618,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { location: { city: 'Seattle', ...fullLocationContext }, + marketing: false, }); }); @@ -1597,9 +1634,13 @@ describe('AnalyticsController', () => { location: { country_code: 'FR' }, }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - location: fullLocationContext, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withMarketingFlag(false, { + location: fullLocationContext, + }), + ); }); it('replaces a non-record caller location', async () => { @@ -1614,9 +1655,13 @@ describe('AnalyticsController', () => { location: 'Seattle', }); - expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { - location: fullLocationContext, - }); + expect(mockAdapter.track).toHaveBeenCalledWith( + 'test_event', + undefined, + withMarketingFlag(false, { + location: fullLocationContext, + }), + ); }); it('omits fields the geolocation API could not determine', async () => { @@ -1631,6 +1676,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { location: { country_code: 'FR' }, + marketing: false, }); }); @@ -1647,6 +1693,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { app: { name: 'MetaMask' }, + marketing: false, }); }); @@ -1664,7 +1711,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - undefined, + withMarketingFlag(false), ); }); @@ -1686,7 +1733,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', undefined, - undefined, + withMarketingFlag(false), ); }); @@ -1705,6 +1752,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith('test_event', undefined, { location: { city: 'Seattle' }, + marketing: false, }); }); @@ -1730,7 +1778,7 @@ describe('AnalyticsController', () => { 1, 'test_event', { prop: 'value' }, - { location: fullLocationContext }, + withMarketingFlag(false, { location: fullLocationContext }), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -1740,7 +1788,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withMarketingFlag(false), ); }); @@ -1767,7 +1815,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - { location: fullLocationContext }, + withMarketingFlag(false, { location: fullLocationContext }), ); }); @@ -1788,6 +1836,7 @@ describe('AnalyticsController', () => { expect(queuedEvent.context).toStrictEqual({ location: fullLocationContext, + marketing: false, }); }); @@ -1822,7 +1871,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'preconsent_event', undefined, - { location: fullLocationContext }, + withMarketingFlag(false, { location: fullLocationContext }), expect.any(Object), ); @@ -1831,7 +1880,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenLastCalledWith( 'postconsent_event', undefined, - { location: fullLocationContext }, + withMarketingFlag(false, { location: fullLocationContext }), ); }); @@ -1864,7 +1913,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 +1924,7 @@ describe('AnalyticsController', () => { sensitive_prop: 'sensitive value', anonymous: true, }, - undefined, + withMarketingFlag(false), expect.any(Object), ); }); @@ -1962,7 +2011,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'test_event', { prop: 'value' }, - undefined, + withMarketingFlag(false), ); expect(mockAdapter.track.mock.calls[0]).toHaveLength(3); }); @@ -1993,6 +2042,7 @@ describe('AnalyticsController', () => { messageId: deliveryOptions.messageId, timestamp: deliveryOptions.timestamp?.toISOString(), properties: { prop: 'value' }, + context: withMarketingFlag(false), }, }); @@ -2191,7 +2241,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 +2249,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 +2286,7 @@ describe('AnalyticsController', () => { eventName: 'test_event', messageId: trackOptions.messageId, timestamp: trackOptions.timestamp?.toISOString(), + context: withMarketingFlag(false), }, [identifyOptions.messageId as string]: { type: 'identify', @@ -2248,6 +2299,7 @@ describe('AnalyticsController', () => { name: 'home', messageId: viewOptions.messageId, timestamp: viewOptions.timestamp?.toISOString(), + context: withMarketingFlag(false), }, }); }); @@ -2746,7 +2798,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - undefined, + withMarketingFlag(false), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -2777,7 +2829,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'queued_event', { foo: 'bar' }, - undefined, + withMarketingFlag(false), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -2853,7 +2905,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 +2957,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 +3002,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 +3074,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 +3184,7 @@ describe('AnalyticsController', () => { id: 'bag-1', properties: {}, sensitiveProperties: {}, + context: withMarketingFlag(false), createdAt: now, lastUpdated: now, }); @@ -3108,7 +3195,9 @@ 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 +3211,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 +3228,9 @@ 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 +3241,9 @@ 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 +3261,16 @@ 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 +3323,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 +3363,7 @@ describe('AnalyticsController', () => { gas_edit_attempted: 'basic', }, sensitiveProperties: { sending_value: '0x1' }, + context: withMarketingFlag(false), createdAt: expect.any(Number), lastUpdated: expect.any(Number), }); @@ -3317,17 +3416,21 @@ 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 +3439,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 +3453,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 +3497,9 @@ 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 +3521,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 +3579,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 +3597,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Rejected', undefined, - undefined, + withMarketingFlag(false), ); }); @@ -3528,7 +3635,10 @@ 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 +3660,7 @@ describe('AnalyticsController', () => { 1, 'Signature Approved', { signature_type: 'personal_sign' }, - undefined, + withMarketingFlag(false), ); expect(mockAdapter.track).toHaveBeenNthCalledWith( 2, @@ -3560,7 +3670,7 @@ describe('AnalyticsController', () => { eip712_primary_type: 'Permit', anonymous: true, }, - undefined, + withMarketingFlag(false), ); }); @@ -3643,7 +3753,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Requested', undefined, - undefined, + withMarketingFlag(false), expect.objectContaining({ messageId: expect.any(String) }), ); }); @@ -3815,13 +3925,8 @@ describe('AnalyticsController', () => { }); it('keeps a fragment replaced during init even when the leftover ID was expired', async () => { - let resolveGeolocation!: (value: GeolocationData) => void; - const geolocationHandler = jest.fn( - () => - new Promise((resolve) => { - resolveGeolocation = resolve; - }), - ); + const { geolocationHandler, geolocationRequested, resolveGeolocation } = + createBlockingGeolocationHandler(); const mockAdapter = createMockAdapter(); const analyticsId = '11111111-2222-4333-8444-555555555555'; const now = 1_800_000_000_000; @@ -3850,6 +3955,7 @@ describe('AnalyticsController', () => { }); const initPromise = controller.init(); + await geolocationRequested; controller.createEventFragment({ id: 'signature-123', @@ -3871,13 +3977,8 @@ describe('AnalyticsController', () => { }); it('keeps fragments created while init is in flight and still drops stale non-persistent ones', async () => { - let resolveGeolocation!: (value: GeolocationData) => void; - const geolocationHandler = jest.fn( - () => - new Promise((resolve) => { - resolveGeolocation = resolve; - }), - ); + const { geolocationHandler, geolocationRequested, resolveGeolocation } = + createBlockingGeolocationHandler(); const mockAdapter = createMockAdapter(); const analyticsId = '11111111-2222-4333-8444-555555555555'; @@ -3898,6 +3999,7 @@ describe('AnalyticsController', () => { }); const initPromise = controller.init(); + await geolocationRequested; controller.createEventFragment({ id: 'signature-1', @@ -3921,19 +4023,14 @@ 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 +4056,7 @@ describe('AnalyticsController', () => { }); const initPromise = controller.init(); + await geolocationRequested; controller.createEventFragment({ id: 'signature-123', @@ -3986,7 +4084,7 @@ describe('AnalyticsController', () => { expect(mockAdapter.track).toHaveBeenCalledWith( 'Signature Approved', { signature_type: 'personal_sign' }, - undefined, + withMarketingFlag(false), ); expect(controller.state.eventFragments).toStrictEqual({}); }); @@ -4127,11 +4225,985 @@ 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('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('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: { + 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({ + 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('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: { + 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..1d0711495e6 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,7 @@ 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 +740,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 +801,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 +843,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 +898,183 @@ 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 { + // 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); + } + + if (queuedEvent.type === 'view') { + return this.#laneFromName(queuedEvent.name); + } + + return this.#laneFromName(queuedEvent.eventName); + } + + #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 { + // 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; + } { + 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 +1084,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 +1130,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 +1152,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 +1251,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 +1262,20 @@ 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,8 @@ 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 +1380,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 +1398,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 +1440,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 +1461,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 +1480,10 @@ export class AnalyticsController extends BaseController< continue; } + if (!this.#isCaptureAllowed(this.#laneFromFragment(fragment))) { + continue; + } + const snapshotCreatedAt = initEventFragmentSnapshot.get(id); if ( @@ -1259,10 +1495,12 @@ export class AnalyticsController extends BaseController< } } - if ( - Object.keys(eventFragments).length === - Object.keys(currentEventFragments).length - ) { + if (Object.keys(eventFragments).length === 0) { + this.#clearEventFragments(); + return; + } + + if (Object.keys(eventFragments).length === Object.keys(fragments).length) { return; } @@ -1285,16 +1523,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 +1567,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 +1615,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' | 'context' + >, + ): boolean { if (!this.#isEventFragmentsEnabled) { log( 'Ignoring event fragment call because the event fragments feature is disabled', @@ -1357,7 +1634,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 +1684,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 +1693,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 +1709,7 @@ export class AnalyticsController extends BaseController< event.name, undefined, this.#withLocationContext(context), + lane, ); return; } @@ -1459,6 +1724,7 @@ export class AnalyticsController extends BaseController< ...event.properties, }, this.#withLocationContext(context), + lane, ); } @@ -1479,6 +1745,7 @@ export class AnalyticsController extends BaseController< this.#isAnonymousEventsFeatureEnabled ? context : this.#withLocationContext(context), + lane, ); } } @@ -1494,7 +1761,6 @@ export class AnalyticsController extends BaseController< return; } - // Delegate to platform adapter using the current analytics ID this.#sendOrQueueIdentifyEvent( this.state.analyticsId, traits, @@ -1514,7 +1780,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 +1790,7 @@ export class AnalyticsController extends BaseController< name, properties, this.#withLocationContext(context), + lane, ); } @@ -1553,13 +1821,22 @@ export class AnalyticsController extends BaseController< createEventFragment( options: AnalyticsEventFragmentOptions = {}, ): ReadonlyAnalyticsEventFragment | undefined { - if (this.#shouldIgnoreEventFragmentCall('createEventFragment')) { + // 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; } const now = Date.now(); - const fragment: AnalyticsEventFragment = { + const fragment = this.#setEventFragment({ id: options.id ?? uuid(), properties: { ...(options.properties ?? {}) }, sensitiveProperties: { ...(options.sensitiveProperties ?? {}) }, @@ -1578,9 +1855,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 +1882,13 @@ export class AnalyticsController extends BaseController< id: string, payload: AnalyticsEventFragmentPayload = {}, ): void { - if (this.#shouldIgnoreEventFragmentCall('upsertEventFragment')) { + const fragment = this.#getEventFragment(id); + if ( + this.#shouldIgnoreEventFragmentCall('upsertEventFragment', fragment ?? {}) + ) { return; } - const fragment = this.#getEventFragment(id); - if (!fragment) { this.createEventFragment({ id, ...payload }); return; @@ -1635,12 +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,13 @@ export class AnalyticsController extends BaseController< id: string, { abandoned = false, context }: AnalyticsEventFragmentFinalizeOptions = {}, ): void { - if (this.#shouldIgnoreEventFragmentCall('finalizeEventFragment')) { + const fragment = this.#getEventFragment(id); + if ( + this.#shouldIgnoreEventFragmentCall('finalizeEventFragment', fragment) + ) { return; } - const fragment = this.#getEventFragment(id); - if (!fragment) { throw new Error(`Event fragment with id ${id} does not exist.`); } @@ -1766,9 +2042,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 +2071,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..9a400859457 100644 --- a/packages/analytics-controller/src/selectors.test.ts +++ b/packages/analytics-controller/src/selectors.test.ts @@ -31,6 +31,35 @@ describe('analyticsControllerSelectors', () => { }); }); + describe('selectOptedInToMarketing', () => { + it.each([[true], [false]])( + 'returns %s when optedInToMarketing is %s', + (optedInToMarketing) => { + const state: AnalyticsControllerState = { + optedIn: false, + optedInToMarketing, + analyticsId: defaultAnalyticsId, + }; + + const result = + analyticsControllerSelectors.selectOptedInToMarketing(state); + + expect(result).toBe(optedInToMarketing); + }, + ); + + it('defaults to false when the field is absent', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: defaultAnalyticsId, + }; + + expect(analyticsControllerSelectors.selectOptedInToMarketing(state)).toBe( + false, + ); + }); + }); + describe('selectEnabled', () => { it.each([ [false, false], @@ -92,6 +121,36 @@ describe('analyticsControllerSelectors', () => { }); }); + describe('selectMarketingConsentDecisionMade', () => { + it.each([[true], [false]])( + 'returns %s when marketingConsentDecisionMade is %s', + (marketingConsentDecisionMade) => { + const state: AnalyticsControllerState = { + optedIn: false, + marketingConsentDecisionMade, + analyticsId: defaultAnalyticsId, + }; + + expect( + analyticsControllerSelectors.selectMarketingConsentDecisionMade( + state, + ), + ).toBe(marketingConsentDecisionMade); + }, + ); + + it('defaults to false when the field is absent', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: defaultAnalyticsId, + }; + + expect( + analyticsControllerSelectors.selectMarketingConsentDecisionMade(state), + ).toBe(false); + }); + }); + describe('event fragment selectors', () => { const fragment: AnalyticsEventFragment = { id: 'signature-1', diff --git a/packages/analytics-controller/src/selectors.ts b/packages/analytics-controller/src/selectors.ts index 79591012ffb..c3dfb586626 100644 --- a/packages/analytics-controller/src/selectors.ts +++ b/packages/analytics-controller/src/selectors.ts @@ -25,6 +25,15 @@ const selectAnalyticsId = (state: AnalyticsControllerState): string => const selectOptedIn = (state: AnalyticsControllerState): boolean => state.optedIn; +/** + * Selects the marketing opt-in status from the controller state. + * + * @param state - The controller state + * @returns Whether the user has opted in to marketing analytics + */ +const selectOptedInToMarketing = (state: AnalyticsControllerState): boolean => + state.optedInToMarketing === true; + /** * Selects whether analytics tracking is enabled. * Use this selector to determine if tracking should occur (e.g., in controller methods). @@ -46,6 +55,16 @@ const selectEnabled = (state: AnalyticsControllerState): boolean => const selectConsentDecisionMade = (state: AnalyticsControllerState): boolean => state.consentDecisionMade ?? false; +/** + * Selects whether the user has made a marketing consent decision. + * + * @param state - The controller state + * @returns Whether the user has made a marketing consent decision + */ +const selectMarketingConsentDecisionMade = ( + state: AnalyticsControllerState, +): boolean => state.marketingConsentDecisionMade ?? false; + /** * Selects the in-progress event fragments from the controller state. * @@ -76,8 +95,10 @@ const selectEventFragmentById = ( export const analyticsControllerSelectors = { selectAnalyticsId, selectOptedIn, + selectOptedInToMarketing, selectEnabled, selectConsentDecisionMade, + selectMarketingConsentDecisionMade, selectEventFragments, selectEventFragmentById, };