@metamask-previews/analytics-controller 3.0.0-preview-f67925e6c → 3.0.0-preview-3e86f66f7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,20 @@ export const controllerName = 'AnalyticsController';
21
21
  * `properties` or `sensitiveProperties` in storage indefinitely.
22
22
  */
23
23
  export const EVENT_FRAGMENT_MAX_AGE = 24 * 60 * 60 * 1000;
24
+ /**
25
+ * Purposes for which an analytics payload can be used.
26
+ */
27
+ export const AnalyticsPurpose = {
28
+ Product: 'product',
29
+ Marketing: 'marketing',
30
+ };
31
+ /**
32
+ * Persisted queues on {@link AnalyticsControllerState}.
33
+ */
34
+ const AnalyticsQueue = {
35
+ EventQueue: 'eventQueue',
36
+ PreConsentEventQueue: 'preConsentEventQueue',
37
+ };
24
38
  /**
25
39
  * Returns default values for AnalyticsController state.
26
40
  *
@@ -33,6 +47,8 @@ export function getDefaultAnalyticsControllerState() {
33
47
  return {
34
48
  optedIn: false,
35
49
  consentDecisionMade: false,
50
+ optedInToMarketing: false,
51
+ marketingConsentDecisionMade: false,
36
52
  };
37
53
  }
38
54
  /**
@@ -48,6 +64,24 @@ const analyticsControllerMetadata = {
48
64
  includeInDebugSnapshot: true,
49
65
  usedInUi: true,
50
66
  },
67
+ optedInToMarketing: {
68
+ includeInStateLogs: true,
69
+ persist: true,
70
+ includeInDebugSnapshot: true,
71
+ usedInUi: true,
72
+ },
73
+ marketingConsentDecisionMade: {
74
+ includeInStateLogs: true,
75
+ persist: true,
76
+ includeInDebugSnapshot: true,
77
+ usedInUi: true,
78
+ },
79
+ eventsConfig: {
80
+ includeInStateLogs: true,
81
+ persist: true,
82
+ includeInDebugSnapshot: true,
83
+ usedInUi: false,
84
+ },
51
85
  analyticsId: {
52
86
  includeInStateLogs: true,
53
87
  persist: true,
@@ -87,6 +121,9 @@ const MESSENGER_EXPOSED_METHODS = [
87
121
  'optIn',
88
122
  'optOut',
89
123
  'resetConsentDecision',
124
+ 'optInToMarketing',
125
+ 'optOutOfMarketing',
126
+ 'resetMarketingConsentDecision',
90
127
  'createEventFragment',
91
128
  'upsertEventFragment',
92
129
  'updateEventFragment',
@@ -103,6 +140,22 @@ const MESSENGER_EXPOSED_METHODS = [
103
140
  function isRecord(value) {
104
141
  return value !== null && typeof value === 'object' && !Array.isArray(value);
105
142
  }
143
+ function isAnalyticsPurpose(value) {
144
+ return (value === AnalyticsPurpose.Product || value === AnalyticsPurpose.Marketing);
145
+ }
146
+ function isEventPurposesRecord(value) {
147
+ return (isRecord(value) &&
148
+ Object.values(value).every((purposes) => Array.isArray(purposes) &&
149
+ purposes.length > 0 &&
150
+ purposes.every(isAnalyticsPurpose)));
151
+ }
152
+ function isAnalyticsEventsConfig(value) {
153
+ return (isRecord(value) &&
154
+ typeof value.schemaVersion === 'string' &&
155
+ typeof value.version === 'string' &&
156
+ typeof value.timestamp === 'number' &&
157
+ isEventPurposesRecord(value.events));
158
+ }
106
159
  /**
107
160
  * Returns whether a JSON value is a non-array object.
108
161
  *
@@ -144,7 +197,12 @@ function isAnalyticsQueuedEvent(value) {
144
197
  return false;
145
198
  }
146
199
  if (typeof value.messageId !== 'string' ||
147
- typeof value.timestamp !== 'string') {
200
+ typeof value.timestamp !== 'string' ||
201
+ (value.eventPurposes !== undefined &&
202
+ (!Array.isArray(value.eventPurposes) ||
203
+ !value.eventPurposes.every(isAnalyticsPurpose))) ||
204
+ (value.eventsConfigVersion !== undefined &&
205
+ typeof value.eventsConfigVersion !== 'string')) {
148
206
  return false;
149
207
  }
150
208
  if (value.type === 'track') {
@@ -185,6 +243,10 @@ function isAnalyticsEventFragment(value) {
185
243
  typeof value.successEvent === 'string') &&
186
244
  (value.failureEvent === undefined ||
187
245
  typeof value.failureEvent === 'string') &&
246
+ (value.eventPurposes === undefined ||
247
+ isEventPurposesRecord(value.eventPurposes)) &&
248
+ (value.eventsConfigVersion === undefined ||
249
+ typeof value.eventsConfigVersion === 'string') &&
188
250
  (value.context === undefined || isRecord(value.context)) &&
189
251
  (value.persist === undefined || typeof value.persist === 'boolean'));
190
252
  }
@@ -214,18 +276,17 @@ function mergeEventFragment(fragment, payload) {
214
276
  };
215
277
  }
216
278
  /**
217
- * Merges two optional analytics contexts, preserving `undefined` when neither
218
- * side has one so an empty context is never sent.
279
+ * Merges two optional analytics contexts.
219
280
  *
220
281
  * @param base - The context to merge into.
221
- * @param override - The context whose fields win.
282
+ * @param override - The context whose fields win. When omitted, `base` is kept.
222
283
  * @returns The merged context, or `undefined` when both sides are unset.
223
284
  */
224
285
  function mergeEventFragmentContext(base, override) {
225
- if (base === undefined && override === undefined) {
226
- return undefined;
286
+ if (override === undefined) {
287
+ return base;
227
288
  }
228
- return { ...(base ?? {}), ...(override ?? {}) };
289
+ return { ...(base ?? {}), ...override };
229
290
  }
230
291
  /**
231
292
  * The AnalyticsController manages analytics tracking across platforms (Mobile/Extension).
@@ -247,6 +308,11 @@ export class AnalyticsController extends BaseController {
247
308
  #isPreConsentQueueEnabled;
248
309
  #isGeolocationEnabled;
249
310
  #isEventFragmentsEnabled;
311
+ /**
312
+ * In-memory event-purpose lookup from persisted state.
313
+ */
314
+ #eventPurposes;
315
+ #eventsConfigVersion;
250
316
  /**
251
317
  * The in-flight (or settled) initialization promise. Set on the first
252
318
  * {@link init} call and returned by subsequent calls so overlapping callers
@@ -280,6 +346,15 @@ export class AnalyticsController extends BaseController {
280
346
  ...getDefaultAnalyticsControllerState(),
281
347
  ...state,
282
348
  };
349
+ const eventsConfig = isAnalyticsEventsConfig(initialState.eventsConfig)
350
+ ? initialState.eventsConfig
351
+ : undefined;
352
+ if (eventsConfig === undefined) {
353
+ delete initialState.eventsConfig;
354
+ }
355
+ else {
356
+ initialState.eventsConfig = eventsConfig;
357
+ }
283
358
  validateAnalyticsControllerState(initialState, platformAdapter.skipUUIDv4Check === true);
284
359
  super({
285
360
  name: controllerName,
@@ -295,10 +370,14 @@ export class AnalyticsController extends BaseController {
295
370
  this.#platformAdapter = platformAdapter;
296
371
  this.#initPromise = undefined;
297
372
  this.#locationResolvePromise = undefined;
373
+ this.#eventPurposes = new Map(Object.entries(eventsConfig?.events ?? {}));
374
+ this.#eventsConfigVersion = eventsConfig?.version;
298
375
  this.messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS);
299
376
  log('AnalyticsController initialized and ready', {
300
377
  enabled: analyticsControllerSelectors.selectEnabled(this.state),
301
378
  optedIn: this.state.optedIn,
379
+ optedInToMarketing: this.state.optedInToMarketing === true,
380
+ marketingConsentDecisionMade: this.state.marketingConsentDecisionMade === true,
302
381
  consentDecisionMade: this.state.consentDecisionMade,
303
382
  analyticsId: this.state.analyticsId,
304
383
  eventQueuePersistenceEnabled: this.#isEventQueuePersistenceEnabled,
@@ -313,10 +392,11 @@ export class AnalyticsController extends BaseController {
313
392
  * method must be called after construction to complete the setup process.
314
393
  *
315
394
  * When geolocation enrichment is enabled (`isGeolocationEnabled`), geolocation
316
- * is resolved only for a user who is already opted in; for undecided or
317
- * opted-out users it is deferred until they opt in (see {@link optIn}), so a
318
- * user's location is never requested before they consent to analytics. In
319
- * either case the `GeolocationController` and its
395
+ * is resolved only for a user who is already opted in to product or marketing
396
+ * analytics. For users undecided or opted out of both purposes, it is
397
+ * deferred until they opt in to either one (see {@link optIn} and
398
+ * {@link optInToMarketing}), so a user's location is never requested before
399
+ * they consent to analytics. In either case the `GeolocationController` and its
320
400
  * `GeolocationController:getGeolocationData` action must be registered before
321
401
  * resolution occurs, or enrichment is skipped for the session (a message is
322
402
  * logged, see {@link #resolveLocationContext}).
@@ -350,9 +430,11 @@ export class AnalyticsController extends BaseController {
350
430
  initEventFragmentSnapshot.set(id, fragment.createdAt);
351
431
  }
352
432
  }
353
- // Resolve geolocation only when the user is already opted in; for undecided
354
- // or opted-out users it is deferred to {@link optIn}. Awaited so that an
355
- // already-opted-in session has location available before events replay.
433
+ await this.#fetchEventsConfig();
434
+ // Resolve geolocation only when the user is already opted in to product or
435
+ // marketing analytics. For undecided or opted-out users it is deferred to
436
+ // {@link optIn} / {@link optInToMarketing}. Awaited so that an already-opted-in
437
+ // session has location available before events replay.
356
438
  await this.#maybeResolveLocation();
357
439
  // Call onSetupCompleted lifecycle hook after initialization
358
440
  // State is already validated, so analyticsId is guaranteed to be a valid UUIDv4
@@ -364,20 +446,20 @@ export class AnalyticsController extends BaseController {
364
446
  log('Error calling platformAdapter.onSetupCompleted', error);
365
447
  }
366
448
  this.#replayQueuedEvents();
367
- this.#reconcilePreConsentEvents();
449
+ this.#replayPreConsentEvents();
368
450
  this.#reconcileEventFragments(initEventFragmentSnapshot);
369
451
  }
370
452
  /**
371
453
  * Start resolving the geolocation context if warranted, and return the
372
454
  * in-flight (or settled) resolution so callers can await it. No-op unless
373
- * enrichment is enabled, the user is opted in, and a resolution has not
374
- * already been started. Deferring resolution until opt-in ensures a user's
375
- * location is never requested before they consent to analytics (for example,
376
- * during onboarding).
455
+ * enrichment is enabled, the user is opted in to product or marketing
456
+ * analytics, and a resolution has not already been started. Deferring
457
+ * resolution until consent ensures a user's location is never requested before
458
+ * they consent to analytics (for example, during onboarding).
377
459
  *
378
460
  * Resolution runs at most once per controller session: the settled promise
379
- * is retained, so the outcome — including a failure (see
380
- * {@link #resolveLocationContext}) — is not retried, and events are delivered
461
+ * is retained, so the outcome, including a failure (see
462
+ * {@link #resolveLocationContext}) is not retried, and events are delivered
381
463
  * without location for the rest of the session.
382
464
  *
383
465
  * @returns The geolocation resolution promise, or `undefined` when no
@@ -386,7 +468,7 @@ export class AnalyticsController extends BaseController {
386
468
  #maybeResolveLocation() {
387
469
  if (this.#isGeolocationEnabled &&
388
470
  this.#locationResolvePromise === undefined &&
389
- analyticsControllerSelectors.selectEnabled(this.state)) {
471
+ (this.state.optedIn || this.state.optedInToMarketing === true)) {
390
472
  this.#locationResolvePromise = this.#resolveLocationContext();
391
473
  }
392
474
  return this.#locationResolvePromise;
@@ -427,18 +509,131 @@ export class AnalyticsController extends BaseController {
427
509
  },
428
510
  };
429
511
  }
512
+ /**
513
+ * Stamp the purposes currently allowed for a payload using Segment's consent
514
+ * context shape.
515
+ *
516
+ * @param purposes - Purposes for which the payload is eligible.
517
+ * @param context - Optional caller-provided context.
518
+ * @param version - Config version captured with the payload.
519
+ * @returns Context with allowed purpose preferences.
520
+ */
521
+ #withConsentContext(purposes, context, version) {
522
+ const preferences = this.#allowedPurposePreferences(purposes);
523
+ const { consent: existingConsent, eventsConfigVersion: _ignoredVersion, ...unmanagedContext } = context ?? {};
524
+ const consent = isJsonRecord(existingConsent)
525
+ ? existingConsent
526
+ : {};
527
+ const existingCategoryPreferences = isJsonRecord(consent.categoryPreferences)
528
+ ? consent.categoryPreferences
529
+ : {};
530
+ return {
531
+ ...unmanagedContext,
532
+ consent: {
533
+ ...consent,
534
+ categoryPreferences: {
535
+ ...existingCategoryPreferences,
536
+ ...preferences,
537
+ },
538
+ },
539
+ ...(version === undefined ? {} : { eventsConfigVersion: version }),
540
+ };
541
+ }
542
+ /**
543
+ * Load event-purpose configuration.
544
+ *
545
+ * Phase 1 stub. Persisted configuration remains authoritative until a remote
546
+ * source is wired up.
547
+ */
548
+ async #fetchEventsConfig() {
549
+ // Intentionally empty until an events-config source is wired up.
550
+ }
551
+ #purposesFromName(name) {
552
+ return [...(this.#eventPurposes.get(name) ?? [AnalyticsPurpose.Product])];
553
+ }
554
+ #purposesFromQueuedEvent(queuedEvent) {
555
+ if (queuedEvent.type === 'identify') {
556
+ return [AnalyticsPurpose.Product];
557
+ }
558
+ if (queuedEvent.eventPurposes !== undefined) {
559
+ return [...queuedEvent.eventPurposes];
560
+ }
561
+ return this.#purposesFromName(queuedEvent.type === 'view' ? queuedEvent.name : queuedEvent.eventName);
562
+ }
563
+ #eventNamesFromFragment(fragment) {
564
+ return [
565
+ fragment.initialEvent,
566
+ fragment.successEvent,
567
+ fragment.failureEvent,
568
+ ].filter((name) => typeof name === 'string');
569
+ }
570
+ #purposesFromFragmentEvent(fragment, name) {
571
+ return [
572
+ ...(fragment.eventPurposes?.[name] ?? this.#purposesFromName(name)),
573
+ ];
574
+ }
575
+ #purposesFromFragment(fragment) {
576
+ const names = this.#eventNamesFromFragment(fragment);
577
+ if (names.length === 0) {
578
+ // Nameless property bags have no event to classify. Treat them as
579
+ // eligible for any purpose so they can accumulate when either consent
580
+ // allows capture.
581
+ return Object.values(AnalyticsPurpose);
582
+ }
583
+ return [
584
+ ...new Set(names.flatMap((name) => this.#purposesFromFragmentEvent(fragment, name))),
585
+ ];
586
+ }
587
+ #consent(purpose) {
588
+ return purpose === AnalyticsPurpose.Marketing
589
+ ? {
590
+ optedIn: this.state.optedInToMarketing === true,
591
+ decisionMade: this.state.marketingConsentDecisionMade === true,
592
+ }
593
+ : {
594
+ optedIn: this.state.optedIn,
595
+ decisionMade: this.state.consentDecisionMade === true,
596
+ };
597
+ }
598
+ #allowedPurposePreferences(purposes) {
599
+ return {
600
+ product: purposes.includes(AnalyticsPurpose.Product) &&
601
+ this.#consent(AnalyticsPurpose.Product).optedIn,
602
+ marketing: purposes.includes(AnalyticsPurpose.Marketing) &&
603
+ this.#consent(AnalyticsPurpose.Marketing).optedIn,
604
+ };
605
+ }
606
+ #hasAllowedPurpose(purposes) {
607
+ const { product, marketing } = this.#allowedPurposePreferences(purposes);
608
+ return product || marketing;
609
+ }
610
+ #hasUndecidedPurpose(purposes) {
611
+ return purposes.some((purpose) => !this.#consent(purpose).decisionMade);
612
+ }
613
+ #isCaptureAllowed(purposes) {
614
+ return (this.#hasAllowedPurpose(purposes) ||
615
+ (this.#isPreConsentQueueEnabled && this.#hasUndecidedPurpose(purposes)));
616
+ }
617
+ #replaceQueue(field, nextQueue) {
618
+ this.update((state) => {
619
+ state[field] = nextQueue;
620
+ });
621
+ }
430
622
  /**
431
623
  * Send final track payload through the platform adapter or queue it if persistence is enabled.
432
624
  *
433
625
  * @param eventName - The name of the event.
434
626
  * @param properties - Optional event properties.
435
627
  * @param context - Optional platform-specific context.
628
+ * @param purposes - Capture-time purposes for the event.
629
+ * @param version - Capture-time events config version.
436
630
  */
437
- #sendOrQueueTrackEvent(eventName, properties, context) {
631
+ #sendOrQueueTrackEvent(eventName, properties, context, purposes, version) {
632
+ const isAllowed = this.#hasAllowedPurpose(purposes);
633
+ const contextWithConsent = this.#withConsentContext(purposes, context, version);
438
634
  // Direct delivery: enabled and not persisting.
439
- if (analyticsControllerSelectors.selectEnabled(this.state) &&
440
- !this.#isEventQueuePersistenceEnabled) {
441
- this.#platformAdapter.track(eventName, properties, context);
635
+ if (isAllowed && !this.#isEventQueuePersistenceEnabled) {
636
+ this.#platformAdapter.track(eventName, properties, contextWithConsent);
442
637
  return;
443
638
  }
444
639
  const queuedEvent = {
@@ -447,11 +642,11 @@ export class AnalyticsController extends BaseController {
447
642
  messageId: uuid(),
448
643
  timestamp: new Date().toISOString(),
449
644
  ...(properties === undefined ? {} : { properties }),
450
- ...(context === undefined ? {} : { context }),
645
+ context: contextWithConsent,
646
+ eventPurposes: purposes,
647
+ ...(version === undefined ? {} : { eventsConfigVersion: version }),
451
648
  };
452
- // Not yet enabled (reached only while undecided with the pre-consent queue
453
- // enabled): hold the event until the user opts in.
454
- if (!analyticsControllerSelectors.selectEnabled(this.state)) {
649
+ if (!isAllowed) {
455
650
  this.#enqueuePreConsentEvent(queuedEvent);
456
651
  return;
457
652
  }
@@ -465,8 +660,10 @@ export class AnalyticsController extends BaseController {
465
660
  * @param context - Optional platform-specific context.
466
661
  */
467
662
  #sendOrQueueIdentifyEvent(userId, traits, context) {
663
+ const purposes = [AnalyticsPurpose.Product];
664
+ const contextWithConsent = this.#withConsentContext(purposes, context);
468
665
  if (!this.#isEventQueuePersistenceEnabled) {
469
- this.#platformAdapter.identify(userId, traits, context);
666
+ this.#platformAdapter.identify(userId, traits, contextWithConsent);
470
667
  return;
471
668
  }
472
669
  const queuedEvent = {
@@ -475,7 +672,8 @@ export class AnalyticsController extends BaseController {
475
672
  messageId: uuid(),
476
673
  timestamp: new Date().toISOString(),
477
674
  ...(traits === undefined ? {} : { traits }),
478
- ...(context === undefined ? {} : { context }),
675
+ context: contextWithConsent,
676
+ eventPurposes: purposes,
479
677
  };
480
678
  this.#enqueueEvent(queuedEvent);
481
679
  }
@@ -485,10 +683,14 @@ export class AnalyticsController extends BaseController {
485
683
  * @param name - The view name.
486
684
  * @param properties - Optional view properties.
487
685
  * @param context - Optional platform-specific context.
686
+ * @param purposes - Capture-time purposes for the view.
687
+ * @param version - Capture-time events config version.
488
688
  */
489
- #sendOrQueueViewEvent(name, properties, context) {
490
- if (!this.#isEventQueuePersistenceEnabled) {
491
- this.#platformAdapter.view(name, properties, context);
689
+ #sendOrQueueViewEvent(name, properties, context, purposes, version) {
690
+ const isAllowed = this.#hasAllowedPurpose(purposes);
691
+ const contextWithConsent = this.#withConsentContext(purposes, context, version);
692
+ if (isAllowed && !this.#isEventQueuePersistenceEnabled) {
693
+ this.#platformAdapter.view(name, properties, contextWithConsent);
492
694
  return;
493
695
  }
494
696
  const queuedEvent = {
@@ -497,8 +699,14 @@ export class AnalyticsController extends BaseController {
497
699
  messageId: uuid(),
498
700
  timestamp: new Date().toISOString(),
499
701
  ...(properties === undefined ? {} : { properties }),
500
- ...(context === undefined ? {} : { context }),
702
+ context: contextWithConsent,
703
+ eventPurposes: purposes,
704
+ ...(version === undefined ? {} : { eventsConfigVersion: version }),
501
705
  };
706
+ if (!isAllowed) {
707
+ this.#enqueuePreConsentEvent(queuedEvent);
708
+ return;
709
+ }
502
710
  this.#enqueueEvent(queuedEvent);
503
711
  }
504
712
  /**
@@ -568,17 +776,23 @@ export class AnalyticsController extends BaseController {
568
776
  if (!this.#isEventQueuePersistenceEnabled || !this.state.eventQueue) {
569
777
  return;
570
778
  }
571
- if (!analyticsControllerSelectors.selectEnabled(this.state)) {
572
- this.#clearQueuedEvents();
573
- return;
574
- }
779
+ const remainingQueue = {};
780
+ const eventsToSend = [];
575
781
  for (const [messageId, queuedEvent] of Object.entries(this.state.eventQueue)) {
576
782
  if (!isAnalyticsQueuedEvent(queuedEvent) ||
577
783
  queuedEvent.messageId !== messageId) {
578
784
  log('Dropping invalid queued analytics event', { messageId });
579
- this.#removeQueuedEvent(messageId);
580
785
  continue;
581
786
  }
787
+ const purposes = this.#purposesFromQueuedEvent(queuedEvent);
788
+ if (this.#hasAllowedPurpose(purposes)) {
789
+ const refreshedEvent = this.#refreshQueuedEventConsent(queuedEvent);
790
+ remainingQueue[messageId] = refreshedEvent;
791
+ eventsToSend.push(refreshedEvent);
792
+ }
793
+ }
794
+ this.#replaceQueue(AnalyticsQueue.EventQueue, remainingQueue);
795
+ for (const queuedEvent of eventsToSend) {
582
796
  this.#sendQueuedEvent(queuedEvent);
583
797
  }
584
798
  }
@@ -598,17 +812,59 @@ export class AnalyticsController extends BaseController {
598
812
  state.eventQueue = eventQueue;
599
813
  });
600
814
  }
815
+ #refreshQueuedEventConsent(queuedEvent) {
816
+ // Refresh only the consent stamp. Capture-time `eventPurposes` and
817
+ // `eventsConfigVersion` stay as a pair and are never rewritten here.
818
+ const purposes = this.#purposesFromQueuedEvent(queuedEvent);
819
+ const preferences = this.#allowedPurposePreferences(purposes);
820
+ const existingPreferences = isJsonRecord(queuedEvent.context?.consent)
821
+ ? queuedEvent.context.consent.categoryPreferences
822
+ : undefined;
823
+ if (isJsonRecord(existingPreferences) &&
824
+ existingPreferences.product === preferences.product &&
825
+ existingPreferences.marketing === preferences.marketing) {
826
+ return queuedEvent;
827
+ }
828
+ return {
829
+ ...queuedEvent,
830
+ context: this.#withConsentContext(purposes, queuedEvent.context, queuedEvent.eventsConfigVersion),
831
+ };
832
+ }
601
833
  /**
602
- * Clear all queued analytics events.
834
+ * Prune a queue after a consent change.
835
+ *
836
+ * For {@link AnalyticsQueue.EventQueue}, entries are kept only while at least
837
+ * one capture-time purpose is opted in, and their consent stamp is refreshed.
838
+ * For {@link AnalyticsQueue.PreConsentEventQueue}, entries are kept while at
839
+ * least one purpose is still allowed or undecided.
840
+ *
841
+ * @param field - The queue to prune.
603
842
  */
604
- #clearQueuedEvents() {
605
- if (!this.state.eventQueue ||
606
- Object.keys(this.state.eventQueue).length === 0) {
843
+ #pruneQueueForConsent(field) {
844
+ const queue = this.state[field];
845
+ if (!queue) {
607
846
  return;
608
847
  }
609
- this.update((state) => {
610
- state.eventQueue = {};
611
- });
848
+ const nextQueue = {};
849
+ for (const [messageId, queuedEvent] of Object.entries(queue)) {
850
+ if (!isAnalyticsQueuedEvent(queuedEvent) ||
851
+ queuedEvent.messageId !== messageId) {
852
+ continue;
853
+ }
854
+ const purposes = this.#purposesFromQueuedEvent(queuedEvent);
855
+ const isAllowed = this.#hasAllowedPurpose(purposes);
856
+ if (field === AnalyticsQueue.EventQueue) {
857
+ if (isAllowed) {
858
+ nextQueue[messageId] = this.#refreshQueuedEventConsent(queuedEvent);
859
+ }
860
+ }
861
+ if (field === AnalyticsQueue.PreConsentEventQueue) {
862
+ if (isAllowed || this.#hasUndecidedPurpose(purposes)) {
863
+ nextQueue[messageId] = queuedEvent;
864
+ }
865
+ }
866
+ }
867
+ this.#replaceQueue(field, nextQueue);
612
868
  }
613
869
  /**
614
870
  * Add an event to the pre-consent queue without delivering it.
@@ -624,34 +880,6 @@ export class AnalyticsController extends BaseController {
624
880
  state.preConsentEventQueue = preConsentEventQueue;
625
881
  });
626
882
  }
627
- /**
628
- * Replay queued pre-consent events through the delivery path.
629
- *
630
- * Only called by {@link #reconcilePreConsentEvents}, which guarantees the
631
- * pre-consent queue is enabled and that the user is opted in. The queue is
632
- * cleared before replaying so events cannot be re-queued or replayed twice.
633
- *
634
- * @param queue - The pre-consent event queue to replay.
635
- */
636
- #replayPreConsentEvents(queue) {
637
- this.#clearPreConsentEvents();
638
- for (const [messageId, queuedEvent] of Object.entries(queue)) {
639
- if (!isAnalyticsQueuedEvent(queuedEvent) ||
640
- queuedEvent.messageId !== messageId) {
641
- log('Dropping invalid queued pre-consent analytics event', {
642
- messageId,
643
- });
644
- continue;
645
- }
646
- const eventToReplay = this.#enrichPreConsentEvent(queuedEvent);
647
- if (this.#isEventQueuePersistenceEnabled) {
648
- this.#enqueueEvent(eventToReplay);
649
- }
650
- else {
651
- this.#sendQueuedEvent(eventToReplay);
652
- }
653
- }
654
- }
655
883
  /**
656
884
  * Enrich a pre-consent event with the geolocation resolved on opt-in.
657
885
  *
@@ -675,42 +903,52 @@ export class AnalyticsController extends BaseController {
675
903
  };
676
904
  }
677
905
  /**
678
- * Clear all queued pre-consent events.
679
- */
680
- #clearPreConsentEvents() {
681
- if (!this.state.preConsentEventQueue) {
682
- return;
683
- }
684
- this.update((state) => {
685
- state.preConsentEventQueue = {};
686
- });
687
- }
688
- /**
689
- * Reconcile the pre-consent queue on initialization.
906
+ * Replay eligible pre-consent events against current consent.
690
907
  *
691
- * The queue should normally be empty unless the user is still undecided. This
692
- * handles the rare cases where a consent decision was persisted but the queue
693
- * was not flushed/cleared (e.g. an interrupted shutdown): replay it if the
694
- * user is opted in, or clear it if they opted out.
908
+ * Allowed entries are replayed, undecided entries are kept for later, and
909
+ * entries with no remaining allowed or undecided purpose are dropped. The
910
+ * keep set is written before replay so events cannot be re-queued or
911
+ * replayed twice.
695
912
  *
696
913
  * If the pre-consent queue is disabled, any stale persisted entries (e.g. from
697
914
  * a previous session where it was enabled) are dropped so they can never be
698
915
  * replayed.
699
916
  */
700
- #reconcilePreConsentEvents() {
917
+ #replayPreConsentEvents() {
701
918
  const queue = this.state.preConsentEventQueue;
702
919
  if (!queue) {
703
920
  return;
704
921
  }
705
922
  if (!this.#isPreConsentQueueEnabled) {
706
- this.#clearPreConsentEvents();
923
+ this.update((state) => {
924
+ state.preConsentEventQueue = {};
925
+ });
707
926
  return;
708
927
  }
709
- if (this.state.optedIn) {
710
- this.#replayPreConsentEvents(queue);
928
+ const keep = {};
929
+ const replay = [];
930
+ for (const [messageId, queuedEvent] of Object.entries(queue)) {
931
+ if (!isAnalyticsQueuedEvent(queuedEvent) ||
932
+ queuedEvent.messageId !== messageId) {
933
+ continue;
934
+ }
935
+ const purposes = this.#purposesFromQueuedEvent(queuedEvent);
936
+ if (this.#hasAllowedPurpose(purposes)) {
937
+ replay.push(queuedEvent);
938
+ }
939
+ else if (this.#hasUndecidedPurpose(purposes)) {
940
+ keep[messageId] = queuedEvent;
941
+ }
711
942
  }
712
- else if (this.state.consentDecisionMade) {
713
- this.#clearPreConsentEvents();
943
+ this.#replaceQueue(AnalyticsQueue.PreConsentEventQueue, keep);
944
+ for (const queuedEvent of replay) {
945
+ const eventToReplay = this.#refreshQueuedEventConsent(this.#enrichPreConsentEvent(queuedEvent));
946
+ if (this.#isEventQueuePersistenceEnabled) {
947
+ this.#enqueueEvent(eventToReplay);
948
+ }
949
+ else {
950
+ this.#sendQueuedEvent(eventToReplay);
951
+ }
714
952
  }
715
953
  }
716
954
  /**
@@ -723,9 +961,8 @@ export class AnalyticsController extends BaseController {
723
961
  * finalization is not a failure, just an unfinished one.
724
962
  *
725
963
  * If the feature is disabled (e.g. a previous session had it enabled), or the
726
- * consent state no longer allows capture (e.g. the fragments were written
727
- * before the user opted out), every persisted fragment is dropped so none of
728
- * them can linger.
964
+ * consent state no longer allows capture for any of a fragment's purposes,
965
+ * those fragments are dropped so none of them can linger.
729
966
  *
730
967
  * Non-persistent fragments are dropped only when their ID and `createdAt`
731
968
  * match a fragment present at the start of {@link init}. Fragments created
@@ -740,28 +977,13 @@ export class AnalyticsController extends BaseController {
740
977
  if (!fragments) {
741
978
  return;
742
979
  }
743
- if (!this.#isEventFragmentsEnabled || !this.#isAnalyticsCaptureAllowed()) {
980
+ if (!this.#isEventFragmentsEnabled) {
744
981
  this.#clearEventFragments();
745
982
  return;
746
983
  }
747
- this.#purgeStaleEventFragments(fragments, initEventFragmentSnapshot);
748
- }
749
- /**
750
- * Drop every persisted fragment that is invalid, expired, did not opt into
751
- * `persist`, or was already present with the same `createdAt` when
752
- * {@link init} began.
753
- *
754
- * Only called by {@link #reconcileEventFragments}, which guarantees the
755
- * fragments exist and that the event fragments feature is enabled.
756
- *
757
- * @param currentEventFragments - The persisted fragments to filter.
758
- * @param initEventFragmentSnapshot - Fragment IDs and `createdAt` values
759
- * present when {@link init} began.
760
- */
761
- #purgeStaleEventFragments(currentEventFragments, initEventFragmentSnapshot) {
762
984
  const eventFragments = {};
763
985
  const now = Date.now();
764
- for (const [id, fragment] of Object.entries(currentEventFragments)) {
986
+ for (const [id, fragment] of Object.entries(fragments)) {
765
987
  if (!isAnalyticsEventFragment(fragment) || fragment.id !== id) {
766
988
  log('Dropping invalid persisted event fragment', { id });
767
989
  continue;
@@ -770,6 +992,9 @@ export class AnalyticsController extends BaseController {
770
992
  log('Dropping expired persisted event fragment', { id });
771
993
  continue;
772
994
  }
995
+ if (!this.#isCaptureAllowed(this.#purposesFromFragment(fragment))) {
996
+ continue;
997
+ }
773
998
  const snapshotCreatedAt = initEventFragmentSnapshot.get(id);
774
999
  if (fragment.persist === true ||
775
1000
  snapshotCreatedAt === undefined ||
@@ -777,8 +1002,11 @@ export class AnalyticsController extends BaseController {
777
1002
  eventFragments[id] = fragment;
778
1003
  }
779
1004
  }
780
- if (Object.keys(eventFragments).length ===
781
- Object.keys(currentEventFragments).length) {
1005
+ if (Object.keys(eventFragments).length === 0) {
1006
+ this.#clearEventFragments();
1007
+ return;
1008
+ }
1009
+ if (Object.keys(eventFragments).length === Object.keys(fragments).length) {
782
1010
  return;
783
1011
  }
784
1012
  this.update((state) => {
@@ -798,15 +1026,31 @@ export class AnalyticsController extends BaseController {
798
1026
  * Write an event fragment to state, replacing any fragment with the same ID.
799
1027
  *
800
1028
  * @param fragment - The fragment to store.
1029
+ * @returns The stored fragment with capture-time purpose metadata.
801
1030
  */
802
1031
  #setEventFragment(fragment) {
1032
+ // Snapshot classification only. Consent is stamped at emit time by
1033
+ // {@link #trackEvent}, so fragment.context stays caller metadata.
1034
+ let fragmentWithPurposeSnapshot = fragment;
1035
+ if (fragment.eventPurposes === undefined) {
1036
+ const names = this.#eventNamesFromFragment(fragment);
1037
+ const eventPurposes = Object.fromEntries(names.map((name) => [name, this.#purposesFromName(name)]));
1038
+ fragmentWithPurposeSnapshot = {
1039
+ ...fragment,
1040
+ ...(names.length === 0 ? {} : { eventPurposes }),
1041
+ ...(this.#eventsConfigVersion === undefined
1042
+ ? {}
1043
+ : { eventsConfigVersion: this.#eventsConfigVersion }),
1044
+ };
1045
+ }
803
1046
  const eventFragments = {
804
1047
  ...this.state.eventFragments,
805
- [fragment.id]: fragment,
1048
+ [fragmentWithPurposeSnapshot.id]: fragmentWithPurposeSnapshot,
806
1049
  };
807
1050
  this.update((state) => {
808
1051
  state.eventFragments = eventFragments;
809
1052
  });
1053
+ return fragmentWithPurposeSnapshot;
810
1054
  }
811
1055
  /**
812
1056
  * Remove an event fragment from state.
@@ -824,6 +1068,35 @@ export class AnalyticsController extends BaseController {
824
1068
  state.eventFragments = eventFragments;
825
1069
  });
826
1070
  }
1071
+ /**
1072
+ * Drop event fragments that the current consent state no longer allows to
1073
+ * accumulate.
1074
+ */
1075
+ #pruneEventFragmentsForConsent() {
1076
+ const fragments = this.state.eventFragments;
1077
+ if (!fragments || Object.keys(fragments).length === 0) {
1078
+ return;
1079
+ }
1080
+ const eventFragments = {};
1081
+ for (const [id, fragment] of Object.entries(fragments)) {
1082
+ if (isAnalyticsEventFragment(fragment) &&
1083
+ this.#isCaptureAllowed(this.#purposesFromFragment(fragment))) {
1084
+ eventFragments[id] = fragment;
1085
+ }
1086
+ }
1087
+ this.update((state) => {
1088
+ state.eventFragments = eventFragments;
1089
+ });
1090
+ }
1091
+ /**
1092
+ * Drop queued events and fragments that the current consent state no longer
1093
+ * allows to keep.
1094
+ */
1095
+ #pruneAllForConsent() {
1096
+ this.#pruneQueueForConsent(AnalyticsQueue.EventQueue);
1097
+ this.#pruneQueueForConsent(AnalyticsQueue.PreConsentEventQueue);
1098
+ this.#pruneEventFragmentsForConsent();
1099
+ }
827
1100
  /**
828
1101
  * Clear all event fragments.
829
1102
  */
@@ -846,14 +1119,18 @@ export class AnalyticsController extends BaseController {
846
1119
  * fragment never accumulates data for an event that could not be delivered.
847
1120
  *
848
1121
  * @param method - The name of the method that was called.
1122
+ * @param fragment - The fragment being read or written, when one is known.
849
1123
  * @returns True when the call should be ignored.
850
1124
  */
851
- #shouldIgnoreEventFragmentCall(method) {
1125
+ #shouldIgnoreEventFragmentCall(method, fragment) {
852
1126
  if (!this.#isEventFragmentsEnabled) {
853
1127
  log('Ignoring event fragment call because the event fragments feature is disabled', { method });
854
1128
  return true;
855
1129
  }
856
- if (!this.#isAnalyticsCaptureAllowed()) {
1130
+ const captureAllowed = fragment
1131
+ ? this.#isCaptureAllowed(this.#purposesFromFragment(fragment))
1132
+ : this.#isCaptureAllowed(Object.values(AnalyticsPurpose));
1133
+ if (!captureAllowed) {
857
1134
  log('Ignoring event fragment call because the consent state does not allow capturing analytics', { method });
858
1135
  return true;
859
1136
  }
@@ -873,32 +1150,14 @@ export class AnalyticsController extends BaseController {
873
1150
  #emitEventFragment(fragment, name, context) {
874
1151
  const properties = { ...fragment.properties };
875
1152
  const sensitiveProperties = { ...fragment.sensitiveProperties };
876
- this.trackEvent({
1153
+ this.#trackEvent({
877
1154
  name,
878
1155
  properties,
879
1156
  sensitiveProperties,
880
1157
  saveDataRecording: false,
881
1158
  hasProperties: Object.keys(properties).length > 0 ||
882
1159
  Object.keys(sensitiveProperties).length > 0,
883
- }, context);
884
- }
885
- /**
886
- * Returns whether the current consent state allows analytics data to be
887
- * captured, either for immediate delivery or to be held until the user
888
- * decides.
889
- *
890
- * Capture is allowed once the user has opted in, and also while they are
891
- * undecided if the pre-consent queue is enabled: what is captured then is
892
- * replayed when they opt in (see {@link optIn}) and discarded if they opt out
893
- * (see {@link optOut}). An explicit opt-out never allows capture.
894
- *
895
- * @returns True when analytics data may be captured.
896
- */
897
- #isAnalyticsCaptureAllowed() {
898
- if (analyticsControllerSelectors.selectEnabled(this.state)) {
899
- return true;
900
- }
901
- return this.#isPreConsentQueueEnabled && !this.state.consentDecisionMade;
1160
+ }, context, this.#purposesFromFragmentEvent(fragment, name), fragment.eventsConfigVersion);
902
1161
  }
903
1162
  /**
904
1163
  * Track an analytics event.
@@ -909,16 +1168,19 @@ export class AnalyticsController extends BaseController {
909
1168
  * @param context - Optional platform-specific context forwarded to the platform adapter.
910
1169
  */
911
1170
  trackEvent(event, context) {
1171
+ this.#trackEvent(event, context, this.#purposesFromName(event.name), this.#eventsConfigVersion);
1172
+ }
1173
+ #trackEvent(event, context, purposes, version) {
912
1174
  // An event captured while the user is still undecided is held in the
913
1175
  // pre-consent queue (see #sendOrQueueTrackEvent) instead of being
914
1176
  // delivered, and replayed if they later opt in.
915
- if (!this.#isAnalyticsCaptureAllowed()) {
1177
+ if (!this.#isCaptureAllowed(purposes)) {
916
1178
  return;
917
1179
  }
918
1180
  // if event does not have properties, send event without properties
919
1181
  // and return to prevent any additional processing
920
1182
  if (!event.hasProperties) {
921
- this.#sendOrQueueTrackEvent(event.name, undefined, this.#withLocationContext(context));
1183
+ this.#sendOrQueueTrackEvent(event.name, undefined, this.#withLocationContext(context), purposes, version);
922
1184
  return;
923
1185
  }
924
1186
  // Track regular properties first if anonymous events feature is enabled
@@ -927,7 +1189,7 @@ export class AnalyticsController extends BaseController {
927
1189
  // an event with user ID is tracked.
928
1190
  this.#sendOrQueueTrackEvent(event.name, {
929
1191
  ...event.properties,
930
- }, this.#withLocationContext(context));
1192
+ }, this.#withLocationContext(context), purposes, version);
931
1193
  }
932
1194
  const hasSensitiveProperties = Object.keys(event.sensitiveProperties).length > 0;
933
1195
  if (!this.#isAnonymousEventsFeatureEnabled || hasSensitiveProperties) {
@@ -941,7 +1203,7 @@ export class AnalyticsController extends BaseController {
941
1203
  // disabled, this is the single identified payload, so it is enriched.
942
1204
  this.#isAnonymousEventsFeatureEnabled
943
1205
  ? context
944
- : this.#withLocationContext(context));
1206
+ : this.#withLocationContext(context), purposes, version);
945
1207
  }
946
1208
  }
947
1209
  /**
@@ -954,7 +1216,6 @@ export class AnalyticsController extends BaseController {
954
1216
  if (!analyticsControllerSelectors.selectEnabled(this.state)) {
955
1217
  return;
956
1218
  }
957
- // Delegate to platform adapter using the current analytics ID
958
1219
  this.#sendOrQueueIdentifyEvent(this.state.analyticsId, traits, this.#withLocationContext(context));
959
1220
  }
960
1221
  /**
@@ -965,11 +1226,13 @@ export class AnalyticsController extends BaseController {
965
1226
  * @param context - Optional platform-specific context forwarded to the platform adapter.
966
1227
  */
967
1228
  trackView(name, properties, context) {
968
- if (!analyticsControllerSelectors.selectEnabled(this.state)) {
1229
+ const purposes = this.#purposesFromName(name);
1230
+ const version = this.#eventsConfigVersion;
1231
+ if (!this.#isCaptureAllowed(purposes)) {
969
1232
  return;
970
1233
  }
971
1234
  // Delegate to platform adapter
972
- this.#sendOrQueueViewEvent(name, properties, this.#withLocationContext(context));
1235
+ this.#sendOrQueueViewEvent(name, properties, this.#withLocationContext(context), purposes, version);
973
1236
  }
974
1237
  /**
975
1238
  * Create an event fragment.
@@ -996,11 +1259,17 @@ export class AnalyticsController extends BaseController {
996
1259
  * Use {@link updateEventFragment} or {@link upsertEventFragment} to write.
997
1260
  */
998
1261
  createEventFragment(options = {}) {
999
- if (this.#shouldIgnoreEventFragmentCall('createEventFragment')) {
1262
+ // Classify create from event names only. Caller consent context must not
1263
+ // affect the event-purpose snapshot.
1264
+ if (this.#shouldIgnoreEventFragmentCall('createEventFragment', {
1265
+ initialEvent: options.initialEvent,
1266
+ successEvent: options.successEvent,
1267
+ failureEvent: options.failureEvent,
1268
+ })) {
1000
1269
  return undefined;
1001
1270
  }
1002
1271
  const now = Date.now();
1003
- const fragment = {
1272
+ const fragment = this.#setEventFragment({
1004
1273
  id: options.id ?? uuid(),
1005
1274
  properties: { ...(options.properties ?? {}) },
1006
1275
  sensitiveProperties: { ...(options.sensitiveProperties ?? {}) },
@@ -1019,8 +1288,7 @@ export class AnalyticsController extends BaseController {
1019
1288
  ? {}
1020
1289
  : { context: { ...options.context } }),
1021
1290
  ...(options.persist === undefined ? {} : { persist: options.persist }),
1022
- };
1023
- this.#setEventFragment(fragment);
1291
+ });
1024
1292
  if (fragment.initialEvent) {
1025
1293
  this.#emitEventFragment(fragment, fragment.initialEvent, fragment.context);
1026
1294
  }
@@ -1037,10 +1305,10 @@ export class AnalyticsController extends BaseController {
1037
1305
  * @param payload - The properties and context to merge in.
1038
1306
  */
1039
1307
  upsertEventFragment(id, payload = {}) {
1040
- if (this.#shouldIgnoreEventFragmentCall('upsertEventFragment')) {
1308
+ const fragment = this.#getEventFragment(id);
1309
+ if (this.#shouldIgnoreEventFragmentCall('upsertEventFragment', fragment ?? {})) {
1041
1310
  return;
1042
1311
  }
1043
- const fragment = this.#getEventFragment(id);
1044
1312
  if (!fragment) {
1045
1313
  this.createEventFragment({ id, ...payload });
1046
1314
  return;
@@ -1058,10 +1326,10 @@ export class AnalyticsController extends BaseController {
1058
1326
  * allow capture, the call is a logged no-op and does not throw.
1059
1327
  */
1060
1328
  updateEventFragment(id, payload = {}) {
1061
- if (this.#shouldIgnoreEventFragmentCall('updateEventFragment')) {
1329
+ const fragment = this.#getEventFragment(id);
1330
+ if (this.#shouldIgnoreEventFragmentCall('updateEventFragment', fragment)) {
1062
1331
  return;
1063
1332
  }
1064
- const fragment = this.#getEventFragment(id);
1065
1333
  if (!fragment) {
1066
1334
  throw new Error(`Event fragment with id ${id} does not exist.`);
1067
1335
  }
@@ -1078,10 +1346,10 @@ export class AnalyticsController extends BaseController {
1078
1346
  * {@link upsertEventFragment} to write.
1079
1347
  */
1080
1348
  getEventFragmentById(id) {
1081
- if (this.#shouldIgnoreEventFragmentCall('getEventFragmentById')) {
1349
+ const fragment = this.#getEventFragment(id);
1350
+ if (this.#shouldIgnoreEventFragmentCall('getEventFragmentById', fragment)) {
1082
1351
  return undefined;
1083
1352
  }
1084
- const fragment = this.#getEventFragment(id);
1085
1353
  return fragment === undefined ? undefined : cloneDeep(fragment);
1086
1354
  }
1087
1355
  /**
@@ -1090,7 +1358,8 @@ export class AnalyticsController extends BaseController {
1090
1358
  * @param id - The fragment ID.
1091
1359
  */
1092
1360
  deleteEventFragment(id) {
1093
- if (this.#shouldIgnoreEventFragmentCall('deleteEventFragment')) {
1361
+ const fragment = this.#getEventFragment(id);
1362
+ if (this.#shouldIgnoreEventFragmentCall('deleteEventFragment', fragment)) {
1094
1363
  return;
1095
1364
  }
1096
1365
  this.#removeEventFragment(id);
@@ -1112,10 +1381,10 @@ export class AnalyticsController extends BaseController {
1112
1381
  * allow capture, the call is a logged no-op and does not throw.
1113
1382
  */
1114
1383
  finalizeEventFragment(id, { abandoned = false, context } = {}) {
1115
- if (this.#shouldIgnoreEventFragmentCall('finalizeEventFragment')) {
1384
+ const fragment = this.#getEventFragment(id);
1385
+ if (this.#shouldIgnoreEventFragmentCall('finalizeEventFragment', fragment)) {
1116
1386
  return;
1117
1387
  }
1118
- const fragment = this.#getEventFragment(id);
1119
1388
  if (!fragment) {
1120
1389
  throw new Error(`Event fragment with id ${id} does not exist.`);
1121
1390
  }
@@ -1149,7 +1418,7 @@ export class AnalyticsController extends BaseController {
1149
1418
  // consent decision may have changed while geolocation was resolving (e.g.
1150
1419
  // resetConsentDecision ran during the await), and preserved pre-consent
1151
1420
  // events must not be delivered once the user is no longer opted in.
1152
- this.#reconcilePreConsentEvents();
1421
+ this.#replayPreConsentEvents();
1153
1422
  }
1154
1423
  /**
1155
1424
  * Opt out of analytics.
@@ -1163,9 +1432,7 @@ export class AnalyticsController extends BaseController {
1163
1432
  state.optedIn = false;
1164
1433
  state.consentDecisionMade = true;
1165
1434
  });
1166
- this.#clearQueuedEvents();
1167
- this.#clearPreConsentEvents();
1168
- this.#clearEventFragments();
1435
+ this.#pruneAllForConsent();
1169
1436
  }
1170
1437
  /**
1171
1438
  * Reset the consent decision back to undecided.
@@ -1184,10 +1451,47 @@ export class AnalyticsController extends BaseController {
1184
1451
  state.optedIn = false;
1185
1452
  state.consentDecisionMade = false;
1186
1453
  });
1187
- this.#clearQueuedEvents();
1188
- if (!this.#isAnalyticsCaptureAllowed()) {
1189
- this.#clearEventFragments();
1190
- }
1454
+ this.#pruneAllForConsent();
1455
+ }
1456
+ /**
1457
+ * Opt in to marketing analytics.
1458
+ *
1459
+ * Independent of {@link optIn}. Replays queued marketing events.
1460
+ *
1461
+ * @returns A promise that resolves once opt-in processing has completed.
1462
+ */
1463
+ async optInToMarketing() {
1464
+ this.update((state) => {
1465
+ state.optedInToMarketing = true;
1466
+ state.marketingConsentDecisionMade = true;
1467
+ });
1468
+ await this.#maybeResolveLocation();
1469
+ this.#replayPreConsentEvents();
1470
+ }
1471
+ /**
1472
+ * Opt out of marketing analytics.
1473
+ *
1474
+ * Independent of {@link optOut}. Discards queued marketing events and
1475
+ * marketing event fragments.
1476
+ */
1477
+ optOutOfMarketing() {
1478
+ this.update((state) => {
1479
+ state.optedInToMarketing = false;
1480
+ state.marketingConsentDecisionMade = true;
1481
+ });
1482
+ this.#pruneAllForConsent();
1483
+ }
1484
+ /**
1485
+ * Reset the marketing consent decision back to undecided.
1486
+ *
1487
+ * Independent of {@link resetConsentDecision}.
1488
+ */
1489
+ resetMarketingConsentDecision() {
1490
+ this.update((state) => {
1491
+ state.optedInToMarketing = false;
1492
+ state.marketingConsentDecisionMade = false;
1493
+ });
1494
+ this.#pruneAllForConsent();
1191
1495
  }
1192
1496
  }
1193
1497
  //# sourceMappingURL=AnalyticsController.js.map