@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.
- package/CHANGELOG.md +8 -1
- package/README.md +36 -8
- package/dist/AnalyticsController-method-action-types.d.ts +31 -1
- package/dist/AnalyticsController-method-action-types.d.ts.map +1 -1
- package/dist/AnalyticsController-method-action-types.js.map +1 -1
- package/dist/AnalyticsController.d.ts +77 -4
- package/dist/AnalyticsController.d.ts.map +1 -1
- package/dist/AnalyticsController.js +475 -171
- package/dist/AnalyticsController.js.map +1 -1
- package/dist/AnalyticsPlatformAdapter.types.d.ts +16 -2
- package/dist/AnalyticsPlatformAdapter.types.d.ts.map +1 -1
- package/dist/AnalyticsPlatformAdapter.types.js.map +1 -1
- package/dist/EventFragment.types.d.ts +11 -0
- package/dist/EventFragment.types.d.ts.map +1 -1
- package/dist/EventFragment.types.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/selectors.d.ts +16 -0
- package/dist/selectors.d.ts.map +1 -1
- package/dist/selectors.js +16 -0
- package/dist/selectors.js.map +1 -1
- package/package.json +3 -2
|
@@ -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
|
|
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 (
|
|
226
|
-
return
|
|
286
|
+
if (override === undefined) {
|
|
287
|
+
return base;
|
|
227
288
|
}
|
|
228
|
-
return { ...(base ?? {}), ...
|
|
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
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
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
|
-
|
|
354
|
-
//
|
|
355
|
-
//
|
|
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.#
|
|
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
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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
|
|
380
|
-
* {@link #resolveLocationContext})
|
|
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
|
-
|
|
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 (
|
|
440
|
-
|
|
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
|
-
|
|
645
|
+
context: contextWithConsent,
|
|
646
|
+
eventPurposes: purposes,
|
|
647
|
+
...(version === undefined ? {} : { eventsConfigVersion: version }),
|
|
451
648
|
};
|
|
452
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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
|
-
*
|
|
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
|
-
#
|
|
605
|
-
|
|
606
|
-
|
|
843
|
+
#pruneQueueForConsent(field) {
|
|
844
|
+
const queue = this.state[field];
|
|
845
|
+
if (!queue) {
|
|
607
846
|
return;
|
|
608
847
|
}
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
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
|
-
#
|
|
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
|
|
923
|
+
this.update((state) => {
|
|
924
|
+
state.preConsentEventQueue = {};
|
|
925
|
+
});
|
|
707
926
|
return;
|
|
708
927
|
}
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
713
|
-
|
|
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
|
|
727
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
1188
|
-
|
|
1189
|
-
|
|
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
|