@metamask-previews/analytics-controller 2.1.0-preview-abca9bcea → 3.0.0-preview-97d6b86c0

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/dist/{AnalyticsController-method-action-types.d.mts → AnalyticsController-method-action-types.d.ts} +2 -2
  3. package/dist/AnalyticsController-method-action-types.d.ts.map +1 -0
  4. package/dist/{AnalyticsController-method-action-types.mjs → AnalyticsController-method-action-types.js} +1 -1
  5. package/dist/AnalyticsController-method-action-types.js.map +1 -0
  6. package/dist/{AnalyticsController.d.cts → AnalyticsController.d.ts} +9 -9
  7. package/dist/AnalyticsController.d.ts.map +1 -0
  8. package/dist/AnalyticsController.js +1193 -0
  9. package/dist/AnalyticsController.js.map +1 -0
  10. package/dist/AnalyticsLogger.d.ts +4 -0
  11. package/dist/AnalyticsLogger.d.ts.map +1 -0
  12. package/dist/{AnalyticsLogger.mjs → AnalyticsLogger.js} +2 -2
  13. package/dist/AnalyticsLogger.js.map +1 -0
  14. package/dist/{AnalyticsPlatformAdapter.types.d.mts → AnalyticsPlatformAdapter.types.d.ts} +2 -2
  15. package/dist/AnalyticsPlatformAdapter.types.d.ts.map +1 -0
  16. package/dist/AnalyticsPlatformAdapter.types.js +2 -0
  17. package/dist/AnalyticsPlatformAdapter.types.js.map +1 -0
  18. package/dist/{AnalyticsPlatformAdapterSetupError.d.cts → AnalyticsPlatformAdapterSetupError.d.ts} +1 -1
  19. package/dist/AnalyticsPlatformAdapterSetupError.d.ts.map +1 -0
  20. package/dist/{AnalyticsPlatformAdapterSetupError.mjs → AnalyticsPlatformAdapterSetupError.js} +2 -1
  21. package/dist/AnalyticsPlatformAdapterSetupError.js.map +1 -0
  22. package/dist/{EventFragment.types.d.cts → EventFragment.types.d.ts} +2 -2
  23. package/dist/EventFragment.types.d.ts.map +1 -0
  24. package/dist/EventFragment.types.js +2 -0
  25. package/dist/EventFragment.types.js.map +1 -0
  26. package/dist/{analyticsControllerStateValidator.d.mts → analyticsControllerStateValidator.d.ts} +2 -2
  27. package/dist/analyticsControllerStateValidator.d.ts.map +1 -0
  28. package/dist/{analyticsControllerStateValidator.mjs → analyticsControllerStateValidator.js} +1 -1
  29. package/dist/analyticsControllerStateValidator.js.map +1 -0
  30. package/dist/{index.d.cts → index.d.ts} +11 -11
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +7 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/{selectors.cjs → selectors.d.ts} +17 -18
  35. package/dist/selectors.d.ts.map +1 -0
  36. package/dist/{selectors.mjs → selectors.js} +1 -1
  37. package/dist/selectors.js.map +1 -0
  38. package/package.json +18 -22
  39. package/dist/AnalyticsController-method-action-types.cjs +0 -7
  40. package/dist/AnalyticsController-method-action-types.cjs.map +0 -1
  41. package/dist/AnalyticsController-method-action-types.d.cts +0 -185
  42. package/dist/AnalyticsController-method-action-types.d.cts.map +0 -1
  43. package/dist/AnalyticsController-method-action-types.d.mts.map +0 -1
  44. package/dist/AnalyticsController-method-action-types.mjs.map +0 -1
  45. package/dist/AnalyticsController.cjs +0 -991
  46. package/dist/AnalyticsController.cjs.map +0 -1
  47. package/dist/AnalyticsController.d.cts.map +0 -1
  48. package/dist/AnalyticsController.d.mts +0 -431
  49. package/dist/AnalyticsController.d.mts.map +0 -1
  50. package/dist/AnalyticsController.mjs +0 -987
  51. package/dist/AnalyticsController.mjs.map +0 -1
  52. package/dist/AnalyticsLogger.cjs +0 -8
  53. package/dist/AnalyticsLogger.cjs.map +0 -1
  54. package/dist/AnalyticsLogger.d.cts +0 -5
  55. package/dist/AnalyticsLogger.d.cts.map +0 -1
  56. package/dist/AnalyticsLogger.d.mts +0 -5
  57. package/dist/AnalyticsLogger.d.mts.map +0 -1
  58. package/dist/AnalyticsLogger.mjs.map +0 -1
  59. package/dist/AnalyticsPlatformAdapter.types.cjs +0 -3
  60. package/dist/AnalyticsPlatformAdapter.types.cjs.map +0 -1
  61. package/dist/AnalyticsPlatformAdapter.types.d.cts +0 -144
  62. package/dist/AnalyticsPlatformAdapter.types.d.cts.map +0 -1
  63. package/dist/AnalyticsPlatformAdapter.types.d.mts.map +0 -1
  64. package/dist/AnalyticsPlatformAdapter.types.mjs +0 -2
  65. package/dist/AnalyticsPlatformAdapter.types.mjs.map +0 -1
  66. package/dist/AnalyticsPlatformAdapterSetupError.cjs +0 -17
  67. package/dist/AnalyticsPlatformAdapterSetupError.cjs.map +0 -1
  68. package/dist/AnalyticsPlatformAdapterSetupError.d.cts.map +0 -1
  69. package/dist/AnalyticsPlatformAdapterSetupError.d.mts +0 -8
  70. package/dist/AnalyticsPlatformAdapterSetupError.d.mts.map +0 -1
  71. package/dist/AnalyticsPlatformAdapterSetupError.mjs.map +0 -1
  72. package/dist/EventFragment.types.cjs +0 -3
  73. package/dist/EventFragment.types.cjs.map +0 -1
  74. package/dist/EventFragment.types.d.cts.map +0 -1
  75. package/dist/EventFragment.types.d.mts +0 -111
  76. package/dist/EventFragment.types.d.mts.map +0 -1
  77. package/dist/EventFragment.types.mjs +0 -2
  78. package/dist/EventFragment.types.mjs.map +0 -1
  79. package/dist/analyticsControllerStateValidator.cjs +0 -36
  80. package/dist/analyticsControllerStateValidator.cjs.map +0 -1
  81. package/dist/analyticsControllerStateValidator.d.cts +0 -17
  82. package/dist/analyticsControllerStateValidator.d.cts.map +0 -1
  83. package/dist/analyticsControllerStateValidator.d.mts.map +0 -1
  84. package/dist/analyticsControllerStateValidator.mjs.map +0 -1
  85. package/dist/index.cjs +0 -15
  86. package/dist/index.cjs.map +0 -1
  87. package/dist/index.d.cts.map +0 -1
  88. package/dist/index.d.mts +0 -11
  89. package/dist/index.d.mts.map +0 -1
  90. package/dist/index.mjs +0 -7
  91. package/dist/index.mjs.map +0 -1
  92. package/dist/selectors.cjs.map +0 -1
  93. package/dist/selectors.d.cts +0 -15
  94. package/dist/selectors.d.cts.map +0 -1
  95. package/dist/selectors.d.mts +0 -15
  96. package/dist/selectors.d.mts.map +0 -1
  97. package/dist/selectors.mjs.map +0 -1
@@ -0,0 +1,1193 @@
1
+ import { BaseController } from '@metamask/base-controller';
2
+ import { cloneDeep } from 'lodash-es';
3
+ import { v4 as uuid } from 'uuid';
4
+ import { validateAnalyticsControllerState } from './analyticsControllerStateValidator.js';
5
+ import { projectLogger as log } from './AnalyticsLogger.js';
6
+ import { analyticsControllerSelectors } from './selectors.js';
7
+ // === GENERAL ===
8
+ /**
9
+ * The name of the {@link AnalyticsController}, used to namespace the
10
+ * controller's actions and events and to namespace the controller's state data
11
+ * when composed with other controllers.
12
+ */
13
+ export const controllerName = 'AnalyticsController';
14
+ /**
15
+ * Maximum age of a persisted event fragment, measured from
16
+ * {@link AnalyticsEventFragment.lastUpdated}.
17
+ *
18
+ * Fragments older than this are discarded during {@link AnalyticsController.init}
19
+ * without emitting a success or failure event. Confirmation journeys that span a
20
+ * restart are expected to resume within this window; abandoned ones must not keep
21
+ * `properties` or `sensitiveProperties` in storage indefinitely.
22
+ */
23
+ export const EVENT_FRAGMENT_MAX_AGE = 24 * 60 * 60 * 1000;
24
+ /**
25
+ * Returns default values for AnalyticsController state.
26
+ *
27
+ * Note: analyticsId is NOT included - it's an identity that must be
28
+ * provided by the platform (generated once on first run, then persisted).
29
+ *
30
+ * @returns Default state without analyticsId
31
+ */
32
+ export function getDefaultAnalyticsControllerState() {
33
+ return {
34
+ optedIn: false,
35
+ consentDecisionMade: false,
36
+ };
37
+ }
38
+ /**
39
+ * The metadata for each property in {@link AnalyticsControllerState}.
40
+ *
41
+ * Both `optedIn` and `analyticsId` are persisted (`persist: true`).
42
+ * The platform must supply a valid UUIDv4 `analyticsId` on first run.
43
+ */
44
+ const analyticsControllerMetadata = {
45
+ optedIn: {
46
+ includeInStateLogs: true,
47
+ persist: true,
48
+ includeInDebugSnapshot: true,
49
+ usedInUi: true,
50
+ },
51
+ analyticsId: {
52
+ includeInStateLogs: true,
53
+ persist: true,
54
+ includeInDebugSnapshot: true,
55
+ usedInUi: false,
56
+ },
57
+ eventQueue: {
58
+ includeInStateLogs: false,
59
+ persist: true,
60
+ includeInDebugSnapshot: false,
61
+ usedInUi: false,
62
+ },
63
+ consentDecisionMade: {
64
+ includeInStateLogs: true,
65
+ persist: true,
66
+ includeInDebugSnapshot: true,
67
+ usedInUi: true,
68
+ },
69
+ preConsentEventQueue: {
70
+ includeInStateLogs: false,
71
+ persist: true,
72
+ includeInDebugSnapshot: false,
73
+ usedInUi: false,
74
+ },
75
+ eventFragments: {
76
+ includeInStateLogs: false,
77
+ persist: true,
78
+ includeInDebugSnapshot: false,
79
+ usedInUi: false,
80
+ },
81
+ };
82
+ // === MESSENGER ===
83
+ const MESSENGER_EXPOSED_METHODS = [
84
+ 'trackEvent',
85
+ 'identify',
86
+ 'trackView',
87
+ 'optIn',
88
+ 'optOut',
89
+ 'resetConsentDecision',
90
+ 'createEventFragment',
91
+ 'upsertEventFragment',
92
+ 'updateEventFragment',
93
+ 'getEventFragmentById',
94
+ 'deleteEventFragment',
95
+ 'finalizeEventFragment',
96
+ ];
97
+ /**
98
+ * Returns whether a value is a non-array object.
99
+ *
100
+ * @param value - The value to check.
101
+ * @returns True if the value is a record.
102
+ */
103
+ function isRecord(value) {
104
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
105
+ }
106
+ /**
107
+ * Returns whether a JSON value is a non-array object.
108
+ *
109
+ * @param value - The value to check.
110
+ * @returns True if the value is a JSON record.
111
+ */
112
+ function isJsonRecord(value) {
113
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
114
+ }
115
+ /**
116
+ * Builds the analytics location context from geolocation data, keeping only
117
+ * the fields the geolocation API was able to determine.
118
+ *
119
+ * @param geolocation - The geolocation data to convert.
120
+ * @returns The location context, or `undefined` when no field is known.
121
+ */
122
+ function buildLocationContext(geolocation) {
123
+ const locationContext = {
124
+ ...(geolocation.country === null
125
+ ? {}
126
+ : { country_code: geolocation.country }),
127
+ ...(geolocation.region === null ? {} : { region: geolocation.region }),
128
+ ...(geolocation.timezone === null
129
+ ? {}
130
+ : { timezone: geolocation.timezone }),
131
+ };
132
+ return Object.keys(locationContext).length === 0
133
+ ? undefined
134
+ : locationContext;
135
+ }
136
+ /**
137
+ * Returns whether a value is a valid persisted analytics event.
138
+ *
139
+ * @param value - The value to check.
140
+ * @returns True if the value is a queued analytics event.
141
+ */
142
+ function isAnalyticsQueuedEvent(value) {
143
+ if (!isRecord(value)) {
144
+ return false;
145
+ }
146
+ if (typeof value.messageId !== 'string' ||
147
+ typeof value.timestamp !== 'string') {
148
+ return false;
149
+ }
150
+ if (value.type === 'track') {
151
+ return (typeof value.eventName === 'string' &&
152
+ (value.properties === undefined || isRecord(value.properties)) &&
153
+ (value.context === undefined || isRecord(value.context)));
154
+ }
155
+ if (value.type === 'identify') {
156
+ return (typeof value.userId === 'string' &&
157
+ (value.traits === undefined || isRecord(value.traits)) &&
158
+ (value.context === undefined || isRecord(value.context)));
159
+ }
160
+ if (value.type === 'view') {
161
+ return (typeof value.name === 'string' &&
162
+ (value.properties === undefined || isRecord(value.properties)) &&
163
+ (value.context === undefined || isRecord(value.context)));
164
+ }
165
+ return false;
166
+ }
167
+ /**
168
+ * Returns whether a value is a valid persisted event fragment.
169
+ *
170
+ * @param value - The value to check.
171
+ * @returns True if the value is an event fragment.
172
+ */
173
+ function isAnalyticsEventFragment(value) {
174
+ if (!isRecord(value)) {
175
+ return false;
176
+ }
177
+ return (typeof value.id === 'string' &&
178
+ typeof value.createdAt === 'number' &&
179
+ typeof value.lastUpdated === 'number' &&
180
+ isRecord(value.properties) &&
181
+ isRecord(value.sensitiveProperties) &&
182
+ (value.initialEvent === undefined ||
183
+ typeof value.initialEvent === 'string') &&
184
+ (value.successEvent === undefined ||
185
+ typeof value.successEvent === 'string') &&
186
+ (value.failureEvent === undefined ||
187
+ typeof value.failureEvent === 'string') &&
188
+ (value.context === undefined || isRecord(value.context)) &&
189
+ (value.persist === undefined || typeof value.persist === 'boolean'));
190
+ }
191
+ /**
192
+ * Merges a payload into an event fragment.
193
+ *
194
+ * `properties`, `sensitiveProperties` and `context` are merged one level deep,
195
+ * so a key written twice is replaced rather than combined. This keeps array
196
+ * values predictable: writing a shorter array replaces the longer one instead
197
+ * of leaving stale trailing entries behind.
198
+ *
199
+ * @param fragment - The fragment to merge into.
200
+ * @param payload - The payload to merge.
201
+ * @returns A new fragment with the payload applied.
202
+ */
203
+ function mergeEventFragment(fragment, payload) {
204
+ const context = mergeEventFragmentContext(fragment.context, payload.context);
205
+ return {
206
+ ...fragment,
207
+ properties: { ...fragment.properties, ...(payload.properties ?? {}) },
208
+ sensitiveProperties: {
209
+ ...fragment.sensitiveProperties,
210
+ ...(payload.sensitiveProperties ?? {}),
211
+ },
212
+ ...(context === undefined ? {} : { context }),
213
+ lastUpdated: Date.now(),
214
+ };
215
+ }
216
+ /**
217
+ * Merges two optional analytics contexts, preserving `undefined` when neither
218
+ * side has one so an empty context is never sent.
219
+ *
220
+ * @param base - The context to merge into.
221
+ * @param override - The context whose fields win.
222
+ * @returns The merged context, or `undefined` when both sides are unset.
223
+ */
224
+ function mergeEventFragmentContext(base, override) {
225
+ if (base === undefined && override === undefined) {
226
+ return undefined;
227
+ }
228
+ return { ...(base ?? {}), ...(override ?? {}) };
229
+ }
230
+ /**
231
+ * The AnalyticsController manages analytics tracking across platforms (Mobile/Extension).
232
+ * It provides a unified interface for tracking events, identifying users, and managing
233
+ * analytics preferences while delegating platform-specific implementation to an
234
+ * {@link AnalyticsPlatformAdapter}.
235
+ *
236
+ * This controller follows the MetaMask controller pattern and integrates with the
237
+ * messenger system to allow other controllers and components to track analytics events.
238
+ * It delegates platform-specific implementation to an {@link AnalyticsPlatformAdapter}.
239
+ *
240
+ * The controller persists `optedIn` and `analyticsId` when composed with a persisted
241
+ * store. The platform must supply a valid `analyticsId` on first launch.
242
+ */
243
+ export class AnalyticsController extends BaseController {
244
+ #platformAdapter;
245
+ #isAnonymousEventsFeatureEnabled;
246
+ #isEventQueuePersistenceEnabled;
247
+ #isPreConsentQueueEnabled;
248
+ #isGeolocationEnabled;
249
+ #isEventFragmentsEnabled;
250
+ /**
251
+ * The in-flight (or settled) initialization promise. Set on the first
252
+ * {@link init} call and returned by subsequent calls so overlapping callers
253
+ * await the same work rather than observing a premature completion.
254
+ */
255
+ #initPromise;
256
+ /**
257
+ * The in-flight (or settled) geolocation resolution, if any. Its presence
258
+ * marks that resolution has been started, so it runs at most once.
259
+ */
260
+ #locationResolvePromise;
261
+ #locationContext;
262
+ /**
263
+ * Constructs an AnalyticsController instance.
264
+ *
265
+ * @param options - Controller options
266
+ * @param options.state - Initial controller state. Must include a valid UUIDv4 `analyticsId`.
267
+ * Use `getDefaultAnalyticsControllerState()` for default opt-in preferences.
268
+ * @param options.messenger - Messenger used to communicate with BaseController
269
+ * @param options.platformAdapter - Platform adapter implementation for tracking
270
+ * @param options.isAnonymousEventsFeatureEnabled - Whether the anonymous events feature is enabled
271
+ * @param options.isEventQueuePersistenceEnabled - Whether analytics event queue persistence is enabled
272
+ * @param options.isPreConsentQueueEnabled - Whether the pre-consent event queue is enabled
273
+ * @param options.isGeolocationEnabled - Whether geolocation enrichment is enabled
274
+ * @param options.isEventFragmentsEnabled - Whether the event fragments feature is enabled
275
+ * @throws Error if state.analyticsId is missing or not a valid UUIDv4
276
+ * @remarks After construction, call {@link AnalyticsController.init} to complete initialization.
277
+ */
278
+ constructor({ state, messenger, platformAdapter, isAnonymousEventsFeatureEnabled = false, isEventQueuePersistenceEnabled = false, isPreConsentQueueEnabled = false, isGeolocationEnabled = false, isEventFragmentsEnabled = false, }) {
279
+ const initialState = {
280
+ ...getDefaultAnalyticsControllerState(),
281
+ ...state,
282
+ };
283
+ validateAnalyticsControllerState(initialState, platformAdapter.skipUUIDv4Check === true);
284
+ super({
285
+ name: controllerName,
286
+ metadata: analyticsControllerMetadata,
287
+ state: initialState,
288
+ messenger,
289
+ });
290
+ this.#isAnonymousEventsFeatureEnabled = isAnonymousEventsFeatureEnabled;
291
+ this.#isEventQueuePersistenceEnabled = isEventQueuePersistenceEnabled;
292
+ this.#isPreConsentQueueEnabled = isPreConsentQueueEnabled;
293
+ this.#isGeolocationEnabled = isGeolocationEnabled;
294
+ this.#isEventFragmentsEnabled = isEventFragmentsEnabled;
295
+ this.#platformAdapter = platformAdapter;
296
+ this.#initPromise = undefined;
297
+ this.#locationResolvePromise = undefined;
298
+ this.messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS);
299
+ log('AnalyticsController initialized and ready', {
300
+ enabled: analyticsControllerSelectors.selectEnabled(this.state),
301
+ optedIn: this.state.optedIn,
302
+ consentDecisionMade: this.state.consentDecisionMade,
303
+ analyticsId: this.state.analyticsId,
304
+ eventQueuePersistenceEnabled: this.#isEventQueuePersistenceEnabled,
305
+ preConsentQueueEnabled: this.#isPreConsentQueueEnabled,
306
+ geolocationEnabled: this.#isGeolocationEnabled,
307
+ eventFragmentsEnabled: this.#isEventFragmentsEnabled,
308
+ });
309
+ }
310
+ /**
311
+ * Initialize the controller by calling the platform adapter's
312
+ * onSetupCompleted lifecycle hook and replaying any queued events. This
313
+ * method must be called after construction to complete the setup process.
314
+ *
315
+ * 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
320
+ * `GeolocationController:getGeolocationData` action must be registered before
321
+ * resolution occurs, or enrichment is skipped for the session (a message is
322
+ * logged, see {@link #resolveLocationContext}).
323
+ *
324
+ * Safe to call more than once: the first call performs initialization and
325
+ * subsequent calls return the same in-flight (or settled) promise.
326
+ *
327
+ * @returns A promise that resolves once initialization has completed.
328
+ */
329
+ init() {
330
+ // Cache the in-flight promise so repeated or overlapping calls share a
331
+ // single initialization and all await the same completion (rather than an
332
+ // early call observing a finished init while work is still pending).
333
+ this.#initPromise ??= this.#performInit();
334
+ return this.#initPromise;
335
+ }
336
+ /**
337
+ * Performs the one-time initialization work: resolve geolocation, run the
338
+ * platform adapter's onSetupCompleted lifecycle hook, then replay any queued
339
+ * and pre-consent events.
340
+ */
341
+ async #performInit() {
342
+ // Snapshot fragment IDs and createdAt before any awaited init work so
343
+ // reconciliation can tell previous-session leftovers from fragments
344
+ // created or replaced while init runs.
345
+ const initEventFragmentSnapshot = new Map();
346
+ for (const [id, fragment] of Object.entries(this.state.eventFragments ?? {})) {
347
+ if (isAnalyticsEventFragment(fragment) &&
348
+ fragment.id === id &&
349
+ typeof fragment.createdAt === 'number') {
350
+ initEventFragmentSnapshot.set(id, fragment.createdAt);
351
+ }
352
+ }
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.
356
+ await this.#maybeResolveLocation();
357
+ // Call onSetupCompleted lifecycle hook after initialization
358
+ // State is already validated, so analyticsId is guaranteed to be a valid UUIDv4
359
+ try {
360
+ this.#platformAdapter.onSetupCompleted(this.state.analyticsId);
361
+ }
362
+ catch (error) {
363
+ // Log error but don't throw - adapter setup failure shouldn't break controller
364
+ log('Error calling platformAdapter.onSetupCompleted', error);
365
+ }
366
+ this.#replayQueuedEvents();
367
+ this.#reconcilePreConsentEvents();
368
+ this.#reconcileEventFragments(initEventFragmentSnapshot);
369
+ }
370
+ /**
371
+ * Start resolving the geolocation context if warranted, and return the
372
+ * 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).
377
+ *
378
+ * 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
381
+ * without location for the rest of the session.
382
+ *
383
+ * @returns The geolocation resolution promise, or `undefined` when no
384
+ * resolution is warranted.
385
+ */
386
+ #maybeResolveLocation() {
387
+ if (this.#isGeolocationEnabled &&
388
+ this.#locationResolvePromise === undefined &&
389
+ analyticsControllerSelectors.selectEnabled(this.state)) {
390
+ this.#locationResolvePromise = this.#resolveLocationContext();
391
+ }
392
+ return this.#locationResolvePromise;
393
+ }
394
+ async #resolveLocationContext() {
395
+ try {
396
+ const geolocation = await this.messenger.call('GeolocationController:getGeolocationData');
397
+ this.#locationContext = buildLocationContext(geolocation);
398
+ }
399
+ catch (error) {
400
+ // A common cause is the GeolocationController not being registered before
401
+ // resolution runs (at init for an opted-in user, otherwise at opt-in).
402
+ // Name it here so the failure is diagnosable, since enrichment is
403
+ // otherwise skipped silently for the session.
404
+ log('Failed to resolve geolocation for analytics enrichment; events will be sent without location. Ensure the GeolocationController is registered and initialized before the user opts in when geolocation is enabled.', error);
405
+ }
406
+ }
407
+ /**
408
+ * Merge the resolved location context into a caller-provided context.
409
+ *
410
+ * Caller-provided `location` fields are preserved, but the fields the
411
+ * controller resolves take precedence over them.
412
+ *
413
+ * @param context - Optional caller-provided context.
414
+ * @returns The context enriched with location, or the original context when
415
+ * no location is known.
416
+ */
417
+ #withLocationContext(context) {
418
+ if (!this.#locationContext) {
419
+ return context;
420
+ }
421
+ const callerLocation = context?.location;
422
+ return {
423
+ ...context,
424
+ location: {
425
+ ...(isJsonRecord(callerLocation) ? callerLocation : {}),
426
+ ...this.#locationContext,
427
+ },
428
+ };
429
+ }
430
+ /**
431
+ * Send final track payload through the platform adapter or queue it if persistence is enabled.
432
+ *
433
+ * @param eventName - The name of the event.
434
+ * @param properties - Optional event properties.
435
+ * @param context - Optional platform-specific context.
436
+ */
437
+ #sendOrQueueTrackEvent(eventName, properties, context) {
438
+ // Direct delivery: enabled and not persisting.
439
+ if (analyticsControllerSelectors.selectEnabled(this.state) &&
440
+ !this.#isEventQueuePersistenceEnabled) {
441
+ this.#platformAdapter.track(eventName, properties, context);
442
+ return;
443
+ }
444
+ const queuedEvent = {
445
+ type: 'track',
446
+ eventName,
447
+ messageId: uuid(),
448
+ timestamp: new Date().toISOString(),
449
+ ...(properties === undefined ? {} : { properties }),
450
+ ...(context === undefined ? {} : { context }),
451
+ };
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)) {
455
+ this.#enqueuePreConsentEvent(queuedEvent);
456
+ return;
457
+ }
458
+ this.#enqueueEvent(queuedEvent);
459
+ }
460
+ /**
461
+ * Send final identify payload through the platform adapter or queue it if persistence is enabled.
462
+ *
463
+ * @param userId - The user ID.
464
+ * @param traits - Optional user traits.
465
+ * @param context - Optional platform-specific context.
466
+ */
467
+ #sendOrQueueIdentifyEvent(userId, traits, context) {
468
+ if (!this.#isEventQueuePersistenceEnabled) {
469
+ this.#platformAdapter.identify(userId, traits, context);
470
+ return;
471
+ }
472
+ const queuedEvent = {
473
+ type: 'identify',
474
+ userId,
475
+ messageId: uuid(),
476
+ timestamp: new Date().toISOString(),
477
+ ...(traits === undefined ? {} : { traits }),
478
+ ...(context === undefined ? {} : { context }),
479
+ };
480
+ this.#enqueueEvent(queuedEvent);
481
+ }
482
+ /**
483
+ * Send final view payload through the platform adapter or queue it if persistence is enabled.
484
+ *
485
+ * @param name - The view name.
486
+ * @param properties - Optional view properties.
487
+ * @param context - Optional platform-specific context.
488
+ */
489
+ #sendOrQueueViewEvent(name, properties, context) {
490
+ if (!this.#isEventQueuePersistenceEnabled) {
491
+ this.#platformAdapter.view(name, properties, context);
492
+ return;
493
+ }
494
+ const queuedEvent = {
495
+ type: 'view',
496
+ name,
497
+ messageId: uuid(),
498
+ timestamp: new Date().toISOString(),
499
+ ...(properties === undefined ? {} : { properties }),
500
+ ...(context === undefined ? {} : { context }),
501
+ };
502
+ this.#enqueueEvent(queuedEvent);
503
+ }
504
+ /**
505
+ * Add an analytics event to the queue and send it.
506
+ *
507
+ * @param queuedEvent - The event to enqueue and deliver.
508
+ */
509
+ #enqueueEvent(queuedEvent) {
510
+ const eventQueue = {
511
+ ...(this.state.eventQueue ?? {}),
512
+ [queuedEvent.messageId]: queuedEvent,
513
+ };
514
+ this.update((state) => {
515
+ state.eventQueue = eventQueue;
516
+ });
517
+ this.#sendQueuedEvent(queuedEvent);
518
+ }
519
+ /**
520
+ * Send a queued event through the platform adapter.
521
+ *
522
+ * @param queuedEvent - The queued event to deliver.
523
+ */
524
+ #sendQueuedEvent(queuedEvent) {
525
+ const timestamp = new Date(queuedEvent.timestamp);
526
+ if (Number.isNaN(timestamp.getTime())) {
527
+ log('Dropping queued analytics event with invalid timestamp', {
528
+ messageId: queuedEvent.messageId,
529
+ });
530
+ this.#removeQueuedEvent(queuedEvent.messageId);
531
+ return;
532
+ }
533
+ const options = {
534
+ messageId: queuedEvent.messageId,
535
+ timestamp,
536
+ callback: (error) => {
537
+ if (error) {
538
+ log('Queued analytics event delivery failed', {
539
+ messageId: queuedEvent.messageId,
540
+ error,
541
+ });
542
+ }
543
+ this.#removeQueuedEvent(queuedEvent.messageId);
544
+ },
545
+ };
546
+ try {
547
+ if (queuedEvent.type === 'track') {
548
+ this.#platformAdapter.track(queuedEvent.eventName, cloneDeep(queuedEvent.properties), cloneDeep(queuedEvent.context), options);
549
+ }
550
+ else if (queuedEvent.type === 'identify') {
551
+ this.#platformAdapter.identify(queuedEvent.userId, cloneDeep(queuedEvent.traits), cloneDeep(queuedEvent.context), options);
552
+ }
553
+ else {
554
+ this.#platformAdapter.view(queuedEvent.name, cloneDeep(queuedEvent.properties), cloneDeep(queuedEvent.context), options);
555
+ }
556
+ }
557
+ catch (error) {
558
+ log('Error sending queued analytics event', {
559
+ messageId: queuedEvent.messageId,
560
+ error,
561
+ });
562
+ }
563
+ }
564
+ /**
565
+ * Replay persisted analytics events.
566
+ */
567
+ #replayQueuedEvents() {
568
+ if (!this.#isEventQueuePersistenceEnabled || !this.state.eventQueue) {
569
+ return;
570
+ }
571
+ if (!analyticsControllerSelectors.selectEnabled(this.state)) {
572
+ this.#clearQueuedEvents();
573
+ return;
574
+ }
575
+ for (const [messageId, queuedEvent] of Object.entries(this.state.eventQueue)) {
576
+ if (!isAnalyticsQueuedEvent(queuedEvent) ||
577
+ queuedEvent.messageId !== messageId) {
578
+ log('Dropping invalid queued analytics event', { messageId });
579
+ this.#removeQueuedEvent(messageId);
580
+ continue;
581
+ }
582
+ this.#sendQueuedEvent(queuedEvent);
583
+ }
584
+ }
585
+ /**
586
+ * Remove a queued analytics event.
587
+ *
588
+ * @param messageId - The queued event message ID.
589
+ */
590
+ #removeQueuedEvent(messageId) {
591
+ const currentEventQueue = this.state.eventQueue;
592
+ if (!currentEventQueue ||
593
+ !Object.prototype.hasOwnProperty.call(currentEventQueue, messageId)) {
594
+ return;
595
+ }
596
+ const { [messageId]: _deletedEvent, ...eventQueue } = currentEventQueue;
597
+ this.update((state) => {
598
+ state.eventQueue = eventQueue;
599
+ });
600
+ }
601
+ /**
602
+ * Clear all queued analytics events.
603
+ */
604
+ #clearQueuedEvents() {
605
+ if (!this.state.eventQueue ||
606
+ Object.keys(this.state.eventQueue).length === 0) {
607
+ return;
608
+ }
609
+ this.update((state) => {
610
+ state.eventQueue = {};
611
+ });
612
+ }
613
+ /**
614
+ * Add an event to the pre-consent queue without delivering it.
615
+ *
616
+ * @param queuedEvent - The event to hold until the user opts in.
617
+ */
618
+ #enqueuePreConsentEvent(queuedEvent) {
619
+ const preConsentEventQueue = {
620
+ ...(this.state.preConsentEventQueue ?? {}),
621
+ [queuedEvent.messageId]: queuedEvent,
622
+ };
623
+ this.update((state) => {
624
+ state.preConsentEventQueue = preConsentEventQueue;
625
+ });
626
+ }
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
+ /**
656
+ * Enrich a pre-consent event with the geolocation resolved on opt-in.
657
+ *
658
+ * Pre-consent events are captured while geolocation is not yet resolved, so
659
+ * they are re-enriched here as they replay. Anonymous track payloads are left
660
+ * untouched, since they must never carry location.
661
+ *
662
+ * @param queuedEvent - The queued pre-consent event.
663
+ * @returns The event with its context enriched, or the event unchanged when
664
+ * enrichment does not apply.
665
+ */
666
+ #enrichPreConsentEvent(queuedEvent) {
667
+ if (queuedEvent.type === 'track' &&
668
+ queuedEvent.properties?.anonymous === true) {
669
+ return queuedEvent;
670
+ }
671
+ const context = this.#withLocationContext(queuedEvent.context);
672
+ return {
673
+ ...queuedEvent,
674
+ ...(context === undefined ? {} : { context }),
675
+ };
676
+ }
677
+ /**
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.
690
+ *
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.
695
+ *
696
+ * If the pre-consent queue is disabled, any stale persisted entries (e.g. from
697
+ * a previous session where it was enabled) are dropped so they can never be
698
+ * replayed.
699
+ */
700
+ #reconcilePreConsentEvents() {
701
+ const queue = this.state.preConsentEventQueue;
702
+ if (!queue) {
703
+ return;
704
+ }
705
+ if (!this.#isPreConsentQueueEnabled) {
706
+ this.#clearPreConsentEvents();
707
+ return;
708
+ }
709
+ if (this.state.optedIn) {
710
+ this.#replayPreConsentEvents(queue);
711
+ }
712
+ else if (this.state.consentDecisionMade) {
713
+ this.#clearPreConsentEvents();
714
+ }
715
+ }
716
+ /**
717
+ * Reconcile persisted event fragments on initialization.
718
+ *
719
+ * A fragment describes a journey that was in progress when the previous
720
+ * session ended. Only fragments that opted into `persist` and are younger
721
+ * than {@link EVENT_FRAGMENT_MAX_AGE} can be resumed, so the rest are
722
+ * discarded. Nothing is emitted: a journey that never reached its own
723
+ * finalization is not a failure, just an unfinished one.
724
+ *
725
+ * 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.
729
+ *
730
+ * Non-persistent fragments are dropped only when their ID and `createdAt`
731
+ * match a fragment present at the start of {@link init}. Fragments created
732
+ * or replaced while init is in flight are kept so a slow startup path cannot
733
+ * discard an in-progress journey, as long as they have not expired.
734
+ *
735
+ * @param initEventFragmentSnapshot - Fragment IDs and `createdAt` values
736
+ * present when {@link init} began.
737
+ */
738
+ #reconcileEventFragments(initEventFragmentSnapshot) {
739
+ const fragments = this.state.eventFragments;
740
+ if (!fragments) {
741
+ return;
742
+ }
743
+ if (!this.#isEventFragmentsEnabled || !this.#isAnalyticsCaptureAllowed()) {
744
+ this.#clearEventFragments();
745
+ return;
746
+ }
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
+ const eventFragments = {};
763
+ const now = Date.now();
764
+ for (const [id, fragment] of Object.entries(currentEventFragments)) {
765
+ if (!isAnalyticsEventFragment(fragment) || fragment.id !== id) {
766
+ log('Dropping invalid persisted event fragment', { id });
767
+ continue;
768
+ }
769
+ if (now - fragment.lastUpdated > EVENT_FRAGMENT_MAX_AGE) {
770
+ log('Dropping expired persisted event fragment', { id });
771
+ continue;
772
+ }
773
+ const snapshotCreatedAt = initEventFragmentSnapshot.get(id);
774
+ if (fragment.persist === true ||
775
+ snapshotCreatedAt === undefined ||
776
+ fragment.createdAt !== snapshotCreatedAt) {
777
+ eventFragments[id] = fragment;
778
+ }
779
+ }
780
+ if (Object.keys(eventFragments).length ===
781
+ Object.keys(currentEventFragments).length) {
782
+ return;
783
+ }
784
+ this.update((state) => {
785
+ state.eventFragments = eventFragments;
786
+ });
787
+ }
788
+ /**
789
+ * Read an event fragment from state without the feature guard.
790
+ *
791
+ * @param id - The fragment ID.
792
+ * @returns The fragment, or `undefined` when no fragment has that ID.
793
+ */
794
+ #getEventFragment(id) {
795
+ return this.state.eventFragments?.[id];
796
+ }
797
+ /**
798
+ * Write an event fragment to state, replacing any fragment with the same ID.
799
+ *
800
+ * @param fragment - The fragment to store.
801
+ */
802
+ #setEventFragment(fragment) {
803
+ const eventFragments = {
804
+ ...this.state.eventFragments,
805
+ [fragment.id]: fragment,
806
+ };
807
+ this.update((state) => {
808
+ state.eventFragments = eventFragments;
809
+ });
810
+ }
811
+ /**
812
+ * Remove an event fragment from state.
813
+ *
814
+ * @param id - The fragment ID.
815
+ */
816
+ #removeEventFragment(id) {
817
+ const currentEventFragments = this.state.eventFragments;
818
+ if (!currentEventFragments ||
819
+ !Object.prototype.hasOwnProperty.call(currentEventFragments, id)) {
820
+ return;
821
+ }
822
+ const { [id]: _deletedFragment, ...eventFragments } = currentEventFragments;
823
+ this.update((state) => {
824
+ state.eventFragments = eventFragments;
825
+ });
826
+ }
827
+ /**
828
+ * Clear all event fragments.
829
+ */
830
+ #clearEventFragments() {
831
+ if (!this.state.eventFragments ||
832
+ Object.keys(this.state.eventFragments).length === 0) {
833
+ return;
834
+ }
835
+ this.update((state) => {
836
+ state.eventFragments = {};
837
+ });
838
+ }
839
+ /**
840
+ * Returns whether an event fragment call should be ignored, either because
841
+ * the feature is disabled or because the consent state does not allow
842
+ * capture. The ignored call is logged so a missing `isEventFragmentsEnabled`
843
+ * or an unexpected consent state is diagnosable rather than silent.
844
+ *
845
+ * Consent is checked on every call, not just on the ones that emit, so a
846
+ * fragment never accumulates data for an event that could not be delivered.
847
+ *
848
+ * @param method - The name of the method that was called.
849
+ * @returns True when the call should be ignored.
850
+ */
851
+ #shouldIgnoreEventFragmentCall(method) {
852
+ if (!this.#isEventFragmentsEnabled) {
853
+ log('Ignoring event fragment call because the event fragments feature is disabled', { method });
854
+ return true;
855
+ }
856
+ if (!this.#isAnalyticsCaptureAllowed()) {
857
+ log('Ignoring event fragment call because the consent state does not allow capturing analytics', { method });
858
+ return true;
859
+ }
860
+ return false;
861
+ }
862
+ /**
863
+ * Emit one of an event fragment's events, carrying the properties the
864
+ * fragment has accumulated.
865
+ *
866
+ * Delivery goes through {@link trackEvent}, so consent gating, the anonymous
867
+ * payload split, the pre-consent queue and geolocation enrichment all apply.
868
+ *
869
+ * @param fragment - The fragment supplying the properties.
870
+ * @param name - The name of the event to emit.
871
+ * @param context - The context to send with the event.
872
+ */
873
+ #emitEventFragment(fragment, name, context) {
874
+ const properties = { ...fragment.properties };
875
+ const sensitiveProperties = { ...fragment.sensitiveProperties };
876
+ this.trackEvent({
877
+ name,
878
+ properties,
879
+ sensitiveProperties,
880
+ saveDataRecording: false,
881
+ hasProperties: Object.keys(properties).length > 0 ||
882
+ 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;
902
+ }
903
+ /**
904
+ * Track an analytics event.
905
+ *
906
+ * Events are only tracked if analytics is enabled.
907
+ *
908
+ * @param event - Analytics event with properties and sensitive properties
909
+ * @param context - Optional platform-specific context forwarded to the platform adapter.
910
+ */
911
+ trackEvent(event, context) {
912
+ // An event captured while the user is still undecided is held in the
913
+ // pre-consent queue (see #sendOrQueueTrackEvent) instead of being
914
+ // delivered, and replayed if they later opt in.
915
+ if (!this.#isAnalyticsCaptureAllowed()) {
916
+ return;
917
+ }
918
+ // if event does not have properties, send event without properties
919
+ // and return to prevent any additional processing
920
+ if (!event.hasProperties) {
921
+ this.#sendOrQueueTrackEvent(event.name, undefined, this.#withLocationContext(context));
922
+ return;
923
+ }
924
+ // Track regular properties first if anonymous events feature is enabled
925
+ if (this.#isAnonymousEventsFeatureEnabled) {
926
+ // Note: Even if regular properties object is empty, we still send it to ensure
927
+ // an event with user ID is tracked.
928
+ this.#sendOrQueueTrackEvent(event.name, {
929
+ ...event.properties,
930
+ }, this.#withLocationContext(context));
931
+ }
932
+ const hasSensitiveProperties = Object.keys(event.sensitiveProperties).length > 0;
933
+ if (!this.#isAnonymousEventsFeatureEnabled || hasSensitiveProperties) {
934
+ this.#sendOrQueueTrackEvent(event.name, {
935
+ ...event.properties,
936
+ ...event.sensitiveProperties,
937
+ ...(hasSensitiveProperties && { anonymous: true }),
938
+ },
939
+ // When the anonymous events feature is enabled, this payload is the
940
+ // anonymous one and must carry no geolocation. When the feature is
941
+ // disabled, this is the single identified payload, so it is enriched.
942
+ this.#isAnonymousEventsFeatureEnabled
943
+ ? context
944
+ : this.#withLocationContext(context));
945
+ }
946
+ }
947
+ /**
948
+ * Identify a user for analytics.
949
+ *
950
+ * @param traits - User traits/properties
951
+ * @param context - Optional platform-specific context forwarded to the platform adapter.
952
+ */
953
+ identify(traits, context) {
954
+ if (!analyticsControllerSelectors.selectEnabled(this.state)) {
955
+ return;
956
+ }
957
+ // Delegate to platform adapter using the current analytics ID
958
+ this.#sendOrQueueIdentifyEvent(this.state.analyticsId, traits, this.#withLocationContext(context));
959
+ }
960
+ /**
961
+ * Track a page or screen view.
962
+ *
963
+ * @param name - The identifier/name of the page or screen being viewed (e.g., "home", "settings", "wallet")
964
+ * @param properties - Optional properties associated with the view
965
+ * @param context - Optional platform-specific context forwarded to the platform adapter.
966
+ */
967
+ trackView(name, properties, context) {
968
+ if (!analyticsControllerSelectors.selectEnabled(this.state)) {
969
+ return;
970
+ }
971
+ // Delegate to platform adapter
972
+ this.#sendOrQueueViewEvent(name, properties, this.#withLocationContext(context));
973
+ }
974
+ /**
975
+ * Create an event fragment.
976
+ *
977
+ * A fragment accumulates properties across a user journey so that several
978
+ * parts of a client can contribute to the same set of events without
979
+ * re-deriving them. Declaring `successEvent` and `failureEvent` turns the
980
+ * fragment into a funnel that {@link finalizeEventFragment} closes. Declaring
981
+ * none of the event names makes it a pure property bag that the client reads
982
+ * back with {@link getEventFragmentById} when it emits its own events.
983
+ *
984
+ * Any existing fragment with the same ID is replaced, so a new journey never
985
+ * inherits properties from a stale one.
986
+ *
987
+ * Nothing is created unless the user is opted in, or undecided with the
988
+ * pre-consent queue enabled, so an opted-out user accumulates no fragment
989
+ * data.
990
+ *
991
+ * @param options - The fragment definition. An ID is generated when one is
992
+ * not supplied.
993
+ * @returns A read-only copy of the created fragment, or `undefined` when the
994
+ * event fragments feature is disabled or the consent state does not allow
995
+ * capture. Mutating the returned object does not change controller state.
996
+ * Use {@link updateEventFragment} or {@link upsertEventFragment} to write.
997
+ */
998
+ createEventFragment(options = {}) {
999
+ if (this.#shouldIgnoreEventFragmentCall('createEventFragment')) {
1000
+ return undefined;
1001
+ }
1002
+ const now = Date.now();
1003
+ const fragment = {
1004
+ id: options.id ?? uuid(),
1005
+ properties: { ...(options.properties ?? {}) },
1006
+ sensitiveProperties: { ...(options.sensitiveProperties ?? {}) },
1007
+ createdAt: now,
1008
+ lastUpdated: now,
1009
+ ...(options.initialEvent === undefined
1010
+ ? {}
1011
+ : { initialEvent: options.initialEvent }),
1012
+ ...(options.successEvent === undefined
1013
+ ? {}
1014
+ : { successEvent: options.successEvent }),
1015
+ ...(options.failureEvent === undefined
1016
+ ? {}
1017
+ : { failureEvent: options.failureEvent }),
1018
+ ...(options.context === undefined
1019
+ ? {}
1020
+ : { context: { ...options.context } }),
1021
+ ...(options.persist === undefined ? {} : { persist: options.persist }),
1022
+ };
1023
+ this.#setEventFragment(fragment);
1024
+ if (fragment.initialEvent) {
1025
+ this.#emitEventFragment(fragment, fragment.initialEvent, fragment.context);
1026
+ }
1027
+ return cloneDeep(fragment);
1028
+ }
1029
+ /**
1030
+ * Write to an event fragment, creating a property bag if none exists.
1031
+ *
1032
+ * This is the ergonomic entry point for contributors that do not know
1033
+ * whether the journey has been started yet, and it avoids the read then
1034
+ * write race a caller would otherwise have to implement itself.
1035
+ *
1036
+ * @param id - The fragment ID.
1037
+ * @param payload - The properties and context to merge in.
1038
+ */
1039
+ upsertEventFragment(id, payload = {}) {
1040
+ if (this.#shouldIgnoreEventFragmentCall('upsertEventFragment')) {
1041
+ return;
1042
+ }
1043
+ const fragment = this.#getEventFragment(id);
1044
+ if (!fragment) {
1045
+ this.createEventFragment({ id, ...payload });
1046
+ return;
1047
+ }
1048
+ this.#setEventFragment(mergeEventFragment(fragment, payload));
1049
+ }
1050
+ /**
1051
+ * Write to an existing event fragment.
1052
+ *
1053
+ * @param id - The fragment ID.
1054
+ * @param payload - The properties and context to merge in.
1055
+ * @throws Error if no fragment has that ID when the call is not ignored.
1056
+ * Use {@link upsertEventFragment} when the fragment may not exist yet.
1057
+ * When the event fragments feature is disabled or the consent state does not
1058
+ * allow capture, the call is a logged no-op and does not throw.
1059
+ */
1060
+ updateEventFragment(id, payload = {}) {
1061
+ if (this.#shouldIgnoreEventFragmentCall('updateEventFragment')) {
1062
+ return;
1063
+ }
1064
+ const fragment = this.#getEventFragment(id);
1065
+ if (!fragment) {
1066
+ throw new Error(`Event fragment with id ${id} does not exist.`);
1067
+ }
1068
+ this.#setEventFragment(mergeEventFragment(fragment, payload));
1069
+ }
1070
+ /**
1071
+ * Read an event fragment.
1072
+ *
1073
+ * @param id - The fragment ID.
1074
+ * @returns A read-only copy of the fragment, or `undefined` when no fragment
1075
+ * has that ID, the event fragments feature is disabled, or the consent state
1076
+ * does not allow capture. Mutating the returned object does not change
1077
+ * controller state. Use {@link updateEventFragment} or
1078
+ * {@link upsertEventFragment} to write.
1079
+ */
1080
+ getEventFragmentById(id) {
1081
+ if (this.#shouldIgnoreEventFragmentCall('getEventFragmentById')) {
1082
+ return undefined;
1083
+ }
1084
+ const fragment = this.#getEventFragment(id);
1085
+ return fragment === undefined ? undefined : cloneDeep(fragment);
1086
+ }
1087
+ /**
1088
+ * Discard an event fragment without emitting anything.
1089
+ *
1090
+ * @param id - The fragment ID.
1091
+ */
1092
+ deleteEventFragment(id) {
1093
+ if (this.#shouldIgnoreEventFragmentCall('deleteEventFragment')) {
1094
+ return;
1095
+ }
1096
+ this.#removeEventFragment(id);
1097
+ }
1098
+ /**
1099
+ * Close an event fragment, emitting its closing event and discarding it.
1100
+ *
1101
+ * The event emitted is `failureEvent` when the journey was abandoned and
1102
+ * `successEvent` otherwise. A fragment that does not declare the relevant
1103
+ * event name is discarded silently, which is what makes a pure property bag
1104
+ * possible.
1105
+ *
1106
+ * @param id - The fragment ID.
1107
+ * @param options - Finalization options.
1108
+ * @param options.abandoned - Whether the journey was abandoned.
1109
+ * @param options.context - Context merged over the fragment's own context.
1110
+ * @throws Error if no fragment has that ID when the call is not ignored.
1111
+ * When the event fragments feature is disabled or the consent state does not
1112
+ * allow capture, the call is a logged no-op and does not throw.
1113
+ */
1114
+ finalizeEventFragment(id, { abandoned = false, context } = {}) {
1115
+ if (this.#shouldIgnoreEventFragmentCall('finalizeEventFragment')) {
1116
+ return;
1117
+ }
1118
+ const fragment = this.#getEventFragment(id);
1119
+ if (!fragment) {
1120
+ throw new Error(`Event fragment with id ${id} does not exist.`);
1121
+ }
1122
+ const eventName = abandoned ? fragment.failureEvent : fragment.successEvent;
1123
+ if (eventName) {
1124
+ this.#emitEventFragment(fragment, eventName, mergeEventFragmentContext(fragment.context, context));
1125
+ }
1126
+ this.#removeEventFragment(id);
1127
+ }
1128
+ /**
1129
+ * Opt in to analytics.
1130
+ *
1131
+ * Records that a consent decision has been made and replays any events that
1132
+ * were queued while the user was undecided.
1133
+ *
1134
+ * When geolocation enrichment is enabled, geolocation is resolved here (once
1135
+ * the user has consented) and awaited before the queued events are replayed,
1136
+ * so those events are enriched with the resolved location as they are sent.
1137
+ *
1138
+ * @returns A promise that resolves once opt-in processing has completed.
1139
+ */
1140
+ async optIn() {
1141
+ this.update((state) => {
1142
+ state.optedIn = true;
1143
+ state.consentDecisionMade = true;
1144
+ });
1145
+ // Now that the user has consented, resolve geolocation (once) and wait for
1146
+ // it so the queued pre-consent events can be enriched as they replay.
1147
+ await this.#maybeResolveLocation();
1148
+ // Reconcile against the current state rather than replaying blindly: the
1149
+ // consent decision may have changed while geolocation was resolving (e.g.
1150
+ // resetConsentDecision ran during the await), and preserved pre-consent
1151
+ // events must not be delivered once the user is no longer opted in.
1152
+ this.#reconcilePreConsentEvents();
1153
+ }
1154
+ /**
1155
+ * Opt out of analytics.
1156
+ *
1157
+ * Records that a consent decision has been made and discards any persisted
1158
+ * events and in-progress event fragments so nothing captured before the
1159
+ * decision is ever delivered.
1160
+ */
1161
+ optOut() {
1162
+ this.update((state) => {
1163
+ state.optedIn = false;
1164
+ state.consentDecisionMade = true;
1165
+ });
1166
+ this.#clearQueuedEvents();
1167
+ this.#clearPreConsentEvents();
1168
+ this.#clearEventFragments();
1169
+ }
1170
+ /**
1171
+ * Reset the consent decision back to undecided.
1172
+ *
1173
+ * Intended for client flows that restart onboarding. Clears the opt-in
1174
+ * preference and discards the delivery queue, but preserves any pre-consent
1175
+ * events so they can still be replayed if the user opts in again. The user is
1176
+ * treated as undecided again.
1177
+ *
1178
+ * In-progress event fragments are kept only while the undecided user can
1179
+ * still accumulate them, and discarded otherwise, so no fragment outlives the
1180
+ * consent state that allowed it.
1181
+ */
1182
+ resetConsentDecision() {
1183
+ this.update((state) => {
1184
+ state.optedIn = false;
1185
+ state.consentDecisionMade = false;
1186
+ });
1187
+ this.#clearQueuedEvents();
1188
+ if (!this.#isAnalyticsCaptureAllowed()) {
1189
+ this.#clearEventFragments();
1190
+ }
1191
+ }
1192
+ }
1193
+ //# sourceMappingURL=AnalyticsController.js.map