pulse-updates 1.3.6 → 1.3.8

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/src/track.ts CHANGED
@@ -31,16 +31,28 @@ export interface TrackOptions {
31
31
  */
32
32
  url: string;
33
33
 
34
- /** The same context the config uses — identity travels with the batch, once. */
34
+ /**
35
+ * Request context shared with config. `context` identity mode reads deviceId/userId
36
+ * for backwards-compatible experiment metrics; `anonymous_installation` ignores
37
+ * them and reads only platform/appVersion.
38
+ */
35
39
  getContext?: () => ConfigContext;
36
40
 
41
+ /**
42
+ * `context` preserves the original experiment-event contract and sends the context
43
+ * deviceId/userId. `anonymous_installation` is an explicit privacy mode: it ignores
44
+ * both and sends only an SDK-minted app-scoped random UUID under the legacy wire
45
+ * field `deviceId`. Defaults to `context` for backwards compatibility.
46
+ */
47
+ identityMode?: 'context' | 'anonymous_installation';
48
+
37
49
  /** How often a non-empty queue is sent. Default 10s. */
38
50
  flushIntervalMs?: number;
39
51
 
40
52
  /** Events per request. The server refuses more than 100. */
41
53
  maxBatch?: number;
42
54
 
43
- /** Events held before the oldest are dropped. Default 500. */
55
+ /** Best-effort events held before the oldest are dropped. Durable idempotent rows are never evicted. Default 500. */
44
56
  maxQueue?: number;
45
57
 
46
58
  /** Persists the outbox across process death. Use the same storage passed to initPulse. */
@@ -52,10 +64,13 @@ export interface TrackOptions {
52
64
  /** Per-request timeout. Default 10s. */
53
65
  timeoutMs?: number;
54
66
 
55
- /** Maximum delivery attempts before an event is dropped. Default 10. */
67
+ /** Maximum delivery attempts before a best-effort event is dropped. Durable idempotent events retry until server ACK. Default 10. */
56
68
  maxAttempts?: number;
57
69
 
58
- /** Return false until analytics consent exists. No event is retained before consent. */
70
+ /**
71
+ * Return false until analytics consent exists. No event is retained before consent. Consent is
72
+ * sampled at enqueue; an event already durably accepted remains eligible for later delivery.
73
+ */
59
74
  hasConsent?: () => boolean;
60
75
 
61
76
  /** Only these property keys may leave the process. */
@@ -87,6 +102,8 @@ interface QueuedEvent {
87
102
  time: string;
88
103
  props: Record<string, string>;
89
104
  attempts: number;
105
+ /** Caller-owned events are retained until the server ACKs their stable id. */
106
+ durable?: true;
90
107
  }
91
108
 
92
109
  const DEFAULT_FLUSH_MS = 10_000;
@@ -95,14 +112,18 @@ const DEFAULT_MAX_QUEUE = 500;
95
112
  const DEFAULT_TIMEOUT_MS = 10_000;
96
113
  const DEFAULT_MAX_ATTEMPTS = 10;
97
114
  const DEFAULT_STORAGE_KEY = 'pulse.events.v1';
115
+ const ANALYTICS_INSTALLATION_ID_SUFFIX = '.analytics-installation-id';
116
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
98
117
 
99
118
  let options: TrackOptions | null = null;
100
119
  let queue: QueuedEvent[] = [];
120
+ let analyticsInstallationId = '';
101
121
  let timer: ReturnType<typeof setInterval> | null = null;
102
122
  let sending = false;
103
123
  let nextAttemptAt = 0;
104
124
  let lifecycleSubscription: { remove: () => void } | null = null;
105
125
  let trackingGeneration = 0;
126
+ const asynchronousStorageAdapters = new WeakSet<object>();
106
127
 
107
128
  /** Turns a config url into the track url; leaves an explicit track url alone. */
108
129
  function trackUrl(url: string): string {
@@ -116,6 +137,9 @@ export function configureTracking(opts: TrackOptions): void {
116
137
  disposeTracking();
117
138
  options = opts;
118
139
  queue = readQueue(opts);
140
+ analyticsInstallationId = opts.identityMode === 'anonymous_installation'
141
+ ? resolveAnalyticsInstallationId(opts)
142
+ : '';
119
143
  nextAttemptAt = 0;
120
144
  startTimer();
121
145
  lifecycleSubscription = opts.appState?.addEventListener('change', (state) => {
@@ -129,10 +153,52 @@ export function configureTracking(opts: TrackOptions): void {
129
153
  * caller has to wrap.
130
154
  */
131
155
  export function track(event: string, props?: TrackedEventInput['props'], time?: Date): void {
132
- if (!options || !event) return;
133
- if (options.hasConsent?.() === false) {
156
+ void enqueueEvent(randomEventId(), event, props, time, false);
157
+ }
158
+
159
+ /**
160
+ * Durably enqueue one logical event with a caller-owned UUID. Replaying the same id is safe:
161
+ * Pulse's server/read model deduplicates it, while the local outbox avoids duplicate rows that
162
+ * are still pending. `true` means the event is present in persistent storage, not delivered yet;
163
+ * once acknowledged, Track keeps it in that outbox through retries and process restarts until a
164
+ * successful server response removes it transactionally.
165
+ */
166
+ export function trackIdempotent(
167
+ eventId: string,
168
+ event: string,
169
+ props?: TrackedEventInput['props'],
170
+ time?: Date,
171
+ ): boolean {
172
+ const normalizedId = eventId.trim().toLowerCase();
173
+ if (!UUID_PATTERN.test(normalizedId)) return false;
174
+ return enqueueEvent(normalizedId, event, props, time, true);
175
+ }
176
+
177
+ function enqueueEvent(
178
+ eventId: string,
179
+ event: string,
180
+ props: TrackedEventInput['props'],
181
+ time: Date | undefined,
182
+ requirePersistentAck: boolean,
183
+ ): boolean {
184
+ if (!options || !event) return false;
185
+ try {
186
+ if (options.hasConsent?.() === false) {
187
+ options.onDropped?.(1, 'no-consent');
188
+ return false;
189
+ }
190
+ } catch {
134
191
  options.onDropped?.(1, 'no-consent');
135
- return;
192
+ return false;
193
+ }
194
+
195
+ if (requirePersistentAck) {
196
+ // Probe with the unchanged queue before accepting caller-owned data. This discovers custom
197
+ // adapters that secretly return a Promise without ever placing the terminal event in Track;
198
+ // the upstream Links outbox remains the single source of truth.
199
+ if (!options.storage
200
+ || !supportsDurableSyncWrites(options.storage)
201
+ || !persistQueue()) return false;
136
202
  }
137
203
 
138
204
  const flat: Record<string, string> = {};
@@ -144,25 +210,81 @@ export function track(event: string, props?: TrackedEventInput['props'], time?:
144
210
  flat[key] = redact.has(key) ? '[REDACTED]' : (typeof value === 'string' ? value : String(value));
145
211
  }
146
212
 
147
- queue.push({ id: randomEventId(), event, time: (time ?? new Date()).toISOString(), props: flat, attempts: 0 });
213
+ let occurredAt: string;
214
+ try {
215
+ occurredAt = (time ?? new Date()).toISOString();
216
+ } catch {
217
+ return false;
218
+ }
148
219
 
149
- const maxQueue = options.maxQueue ?? DEFAULT_MAX_QUEUE;
150
- if (queue.length > maxQueue) {
151
- const dropped = queue.length - maxQueue;
152
- queue = queue.slice(dropped);
153
- options.onDropped?.(dropped, 'queue-full');
220
+ const existing = queue.find((queued) => queued.id === eventId);
221
+ if (existing) {
222
+ const sameLogicalEvent = existing.event === event
223
+ && existing.time === occurredAt
224
+ && JSON.stringify(existing.props) === JSON.stringify(flat);
225
+ if (!sameLogicalEvent) return false;
226
+ if (requirePersistentAck && existing.durable !== true) {
227
+ existing.durable = true;
228
+ if (!persistQueue()) {
229
+ delete existing.durable;
230
+ persistQueue();
231
+ return false;
232
+ }
233
+ }
234
+ return true;
154
235
  }
155
- persistQueue();
236
+
237
+ const queueBeforeDurableEnqueue = requirePersistentAck ? [...queue] : null;
238
+ queue.push({
239
+ id: eventId,
240
+ event,
241
+ time: occurredAt,
242
+ props: flat,
243
+ attempts: 0,
244
+ ...(requirePersistentAck ? { durable: true as const } : {}),
245
+ });
246
+
247
+ const maxQueue = Math.max(0, options.maxQueue ?? DEFAULT_MAX_QUEUE);
248
+ let dropped = 0;
249
+ while (queue.length > maxQueue) {
250
+ // Never evict a previously acknowledged durable event to make room. When every queued row
251
+ // is durable, the newest enqueue is refused and its upstream outbox remains authoritative.
252
+ const bestEffortIndex = queue.findIndex((queued) => queued.durable !== true);
253
+ if (bestEffortIndex < 0) {
254
+ // The queue can already exceed a newly lowered max after restart. Refuse only the newest
255
+ // durable enqueue; previously acknowledged records must still reach the server.
256
+ const newestIndex = queue.findIndex((queued) => queued.id === eventId);
257
+ if (newestIndex >= 0) {
258
+ queue.splice(newestIndex, 1);
259
+ dropped += 1;
260
+ }
261
+ break;
262
+ }
263
+ const index = bestEffortIndex;
264
+ queue.splice(index, 1);
265
+ dropped += 1;
266
+ }
267
+ const retained = queue.some((queued) => queued.id === eventId);
268
+ const persisted = persistQueue();
269
+ if (requirePersistentAck && (!retained || !persisted)) {
270
+ queue = queueBeforeDurableEnqueue ?? queue;
271
+ // If an adapter revealed itself as asynchronous only on the second write, schedule a
272
+ // compensating best-effort mirror without the caller-owned event.
273
+ persistQueue();
274
+ return false;
275
+ }
276
+ if (dropped > 0) options.onDropped?.(dropped, 'queue-full');
156
277
 
157
278
  if (queue.length >= (options.maxBatch ?? DEFAULT_MAX_BATCH)) void flushEvents();
279
+ return retained && (!requirePersistentAck || persisted);
158
280
  }
159
281
 
160
282
  /**
161
283
  * Send what is queued. Returns how many events the server accepted.
162
284
  *
163
- * Failures put the batch back at the front of the queue rather than dropping it: the
164
- * usual cause is a network that is about to come back, and the usual moment is right
165
- * after launch, when the first events of a session are the ones a funnel needs most.
285
+ * The batch remains in the durable queue while HTTP is in flight and is removed only after a
286
+ * successful response is also persisted locally. This intentionally permits duplicate replay
287
+ * after a crash, while a caller-owned event id lets the server/read model deduplicate it.
166
288
  */
167
289
  export async function flushEvents(): Promise<number> {
168
290
  if (!options || sending || queue.length === 0 || Date.now() < nextAttemptAt) return 0;
@@ -170,15 +292,14 @@ export async function flushEvents(): Promise<number> {
170
292
  const opts = options;
171
293
  const requestGeneration = trackingGeneration;
172
294
  const ctx = opts.getContext?.() ?? {};
295
+ const anonymousInstallation = opts.identityMode === 'anonymous_installation';
173
296
 
174
- // Without a device id the server cannot put the event in an arm, and an empty id
175
- // would pool every such install into one. Held rather than dropped: the id usually
176
- // arrives a moment after boot.
177
- if (!ctx.deviceId) return 0;
297
+ // The legacy/default mode intentionally preserves the original experiment-event
298
+ // contract. Anonymous campaign events opt into a separate identity explicitly.
299
+ if (!anonymousInstallation && !ctx.deviceId) return 0;
300
+ const requestDeviceId = anonymousInstallation ? analyticsInstallationId : ctx.deviceId;
178
301
 
179
302
  const batch = queue.slice(0, opts.maxBatch ?? DEFAULT_MAX_BATCH);
180
- queue = queue.slice(batch.length);
181
- persistQueue();
182
303
  sending = true;
183
304
 
184
305
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
@@ -191,8 +312,10 @@ export async function flushEvents(): Promise<number> {
191
312
  method: 'POST',
192
313
  headers: { 'Content-Type': 'application/json' },
193
314
  body: JSON.stringify({
194
- deviceId: ctx.deviceId,
195
- userId: ctx.userId,
315
+ deviceId: requestDeviceId,
316
+ // Anonymous mode deliberately omits the property rather than serializing an
317
+ // empty identifier. Context mode remains backwards compatible.
318
+ ...(anonymousInstallation ? {} : { userId: ctx.userId }),
196
319
  platform: ctx.platform,
197
320
  appVersion: ctx.appVersion,
198
321
  events: batch.map((e) => ({ id: e.id, event: e.event, time: e.time, props: e.props })),
@@ -201,11 +324,9 @@ export async function flushEvents(): Promise<number> {
201
324
  });
202
325
 
203
326
  if (!response.ok) {
204
- // Rate limited or briefly down: keep the events, try on the next tick.
327
+ // Rate limited or briefly down: the batch never left the durable queue.
205
328
  if (requestGeneration === trackingGeneration && options === opts) {
206
- requeueWithBackoff(batch, response.status);
207
- } else {
208
- restoreStaleBatch(opts, batch, response.status);
329
+ markBatchForRetry(batch, response.status);
209
330
  }
210
331
  if (response.status !== 429) opts.onError?.(new Error(`Pulse track failed: ${response.status}`));
211
332
  return 0;
@@ -215,11 +336,15 @@ export async function flushEvents(): Promise<number> {
215
336
  if (requestGeneration !== trackingGeneration || options !== opts) return body.accepted ?? 0;
216
337
  if (body.ignored && body.ignored.length > 0) opts.onIgnored?.(body.ignored);
217
338
  nextAttemptAt = 0;
218
- persistQueue();
339
+ if (!acknowledgeBatch(opts, batch)) {
340
+ // The server may already have accepted the ids. Keep and replay them rather than creating
341
+ // the loss window between remote ACK and local deletion.
342
+ nextAttemptAt = Date.now() + retryDelayFor(batch);
343
+ opts.onError?.(new Error('Pulse track acknowledgement was not persisted'));
344
+ }
219
345
  return body.accepted ?? 0;
220
346
  } catch (error) {
221
- if (requestGeneration === trackingGeneration && options === opts) requeueWithBackoff(batch);
222
- else restoreStaleBatch(opts, batch);
347
+ if (requestGeneration === trackingGeneration && options === opts) markBatchForRetry(batch);
223
348
  opts.onError?.(error);
224
349
  return 0;
225
350
  } finally {
@@ -257,6 +382,7 @@ export function stopTracking(): void {
257
382
  disposeTracking();
258
383
  options = null;
259
384
  queue = [];
385
+ analyticsInstallationId = '';
260
386
  sending = false;
261
387
  nextAttemptAt = 0;
262
388
  }
@@ -270,65 +396,128 @@ function startTimer(): void {
270
396
  (timer as unknown as { unref?: () => void }).unref?.();
271
397
  }
272
398
 
273
- function requeueWithBackoff(batch: QueuedEvent[], status?: number): void {
399
+ function markBatchForRetry(batch: QueuedEvent[], status?: number): void {
274
400
  if (!options) return;
275
401
  const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
276
- const retryable: QueuedEvent[] = [];
402
+ const batchIds = new Set(batch.map((event) => event.id));
277
403
  let dropped = 0;
278
- for (const event of batch) {
279
- const next = { ...event, attempts: event.attempts + 1 };
280
- if (next.attempts >= maxAttempts && status !== 429) dropped++;
281
- else retryable.push(next);
282
- }
283
- queue = [...retryable, ...queue];
404
+ queue = queue.flatMap((event) => {
405
+ if (!batchIds.has(event.id)) return [event];
406
+ const next = { ...event, attempts: Math.min(event.attempts + 1, 100_000) };
407
+ if (next.durable !== true && next.attempts >= maxAttempts && status !== 429) {
408
+ dropped += 1;
409
+ return [];
410
+ }
411
+ return [next];
412
+ });
284
413
  if (dropped > 0) options.onDropped?.(dropped, 'max-attempts');
285
- const attempts = retryable.reduce((max, event) => Math.max(max, event.attempts), 1);
286
- const base = Math.min(60_000, 1_000 * 2 ** Math.min(attempts - 1, 6));
287
- nextAttemptAt = Date.now() + base + Math.floor(Math.random() * Math.max(1, base / 4));
414
+ nextAttemptAt = Date.now() + retryDelayFor(batch.map((event) => ({
415
+ ...event,
416
+ attempts: Math.min(event.attempts + 1, 100_000),
417
+ })));
288
418
  persistQueue();
289
419
  }
290
420
 
291
- /**
292
- * An old request may fail after another app has been configured. Its rows belong in
293
- * the old app's outbox, never in the new app's in-memory queue. When the same app was
294
- * merely reconfigured, merge them back into the live queue so it need not restart to
295
- * see them; otherwise persist directly under the captured app-scoped key.
296
- */
297
- function restoreStaleBatch(opts: TrackOptions, batch: QueuedEvent[], status?: number): void {
298
- const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
299
- const retryable: QueuedEvent[] = [];
300
- let dropped = 0;
301
- for (const event of batch) {
302
- const next = { ...event, attempts: event.attempts + 1 };
303
- if (next.attempts >= maxAttempts && status !== 429) dropped++;
304
- else retryable.push(next);
421
+ function acknowledgeBatch(opts: TrackOptions, batch: QueuedEvent[]): boolean {
422
+ const acknowledgedIds = new Set(batch.map((event) => event.id));
423
+ const remaining = queue.filter((event) => !acknowledgedIds.has(event.id));
424
+ if (!opts.storage) {
425
+ queue = remaining;
426
+ return true;
305
427
  }
306
- if (dropped > 0) opts.onDropped?.(dropped, 'max-attempts');
307
- if (retryable.length === 0) return;
308
-
309
- const currentOptions = options;
310
- const sameOutbox = currentOptions?.storage === opts.storage &&
311
- currentOptions !== null && storageKey(currentOptions) === storageKey(opts);
312
- if (sameOutbox) {
313
- queue = [...retryable, ...queue].slice(-(options?.maxQueue ?? DEFAULT_MAX_QUEUE));
314
- persistQueue();
315
- return;
428
+ const includesDurable = batch.some((event) => event.durable === true);
429
+ // Never schedule a destructive outbox write through an adapter already known to be async.
430
+ // The server may have accepted this batch, but retaining it for a deduplicated replay is safer
431
+ // than losing it during the gap before an asynchronous deletion reaches disk.
432
+ if (!supportsDurableSyncWrites(opts.storage)) {
433
+ if (includesDurable) return false;
434
+ queue = remaining;
435
+ // Best-effort events keep the historical AsyncStorage behavior: the in-memory batch drains
436
+ // after 2xx and the mirror may replay after a crash, but it cannot block or resend forever.
437
+ writeStorageValue(
438
+ opts.storage,
439
+ storageKey(opts),
440
+ JSON.stringify(remaining),
441
+ opts.onError,
442
+ true,
443
+ );
444
+ return true;
316
445
  }
317
-
318
- if (!opts.storage) return;
319
- try {
320
- const stored = readQueue(opts);
321
- const restored = [...retryable, ...stored].slice(-(opts.maxQueue ?? DEFAULT_MAX_QUEUE));
322
- opts.storage.set(storageKey(opts), JSON.stringify(restored));
323
- } catch {
324
- // Best effort only; a telemetry recovery must not affect the new app session.
446
+ const persisted = writeStorageValue(
447
+ opts.storage,
448
+ storageKey(opts),
449
+ JSON.stringify(remaining),
450
+ opts.onError,
451
+ false,
452
+ );
453
+ if (!persisted) {
454
+ if (!includesDurable && !supportsDurableSyncWrites(opts.storage)) {
455
+ queue = remaining;
456
+ writeStorageValue(
457
+ opts.storage,
458
+ storageKey(opts),
459
+ JSON.stringify(remaining),
460
+ opts.onError,
461
+ true,
462
+ );
463
+ return true;
464
+ }
465
+ if (!supportsDurableSyncWrites(opts.storage)) {
466
+ // A conditional thenable may already have scheduled deletion. Immediately schedule the
467
+ // unchanged durable outbox behind it so a normal ordered async adapter restores the record.
468
+ writeStorageValue(
469
+ opts.storage,
470
+ storageKey(opts),
471
+ JSON.stringify(queue),
472
+ opts.onError,
473
+ true,
474
+ );
475
+ }
476
+ return false;
325
477
  }
478
+ queue = remaining;
479
+ return true;
480
+ }
481
+
482
+ function retryDelayFor(batch: QueuedEvent[]): number {
483
+ const attempts = batch.reduce((max, event) => Math.max(max, event.attempts), 1);
484
+ const base = Math.min(60_000, 1_000 * 2 ** Math.min(Math.max(attempts - 1, 0), 6));
485
+ return base + Math.floor(Math.random() * Math.max(1, base / 4));
326
486
  }
327
487
 
328
488
  function storageKey(opts: TrackOptions): string {
329
489
  return opts.storageKey?.trim() || DEFAULT_STORAGE_KEY;
330
490
  }
331
491
 
492
+ function analyticsInstallationIdStorageKey(opts: TrackOptions): string {
493
+ return `${storageKey(opts)}${ANALYTICS_INSTALLATION_ID_SUFFIX}`;
494
+ }
495
+
496
+ function resolveAnalyticsInstallationId(opts: TrackOptions): string {
497
+ if (opts.storage) {
498
+ try {
499
+ const stored = opts.storage.getString(analyticsInstallationIdStorageKey(opts))?.trim();
500
+ if (stored && UUID_PATTERN.test(stored)) return stored.toLowerCase();
501
+ } catch {
502
+ // A telemetry identifier can stay memory-only when storage is unavailable.
503
+ }
504
+ }
505
+
506
+ const generated = randomAnalyticsInstallationId();
507
+ if (opts.storage) {
508
+ // This identifier does not acknowledge delivery, so asynchronous best-effort persistence is
509
+ // acceptable. The helper still observes rejections and marks accidental thenable adapters.
510
+ writeStorageValue(
511
+ opts.storage,
512
+ analyticsInstallationIdStorageKey(opts),
513
+ generated,
514
+ opts.onError,
515
+ true,
516
+ );
517
+ }
518
+ return generated;
519
+ }
520
+
332
521
  function readQueue(opts: TrackOptions): QueuedEvent[] {
333
522
  if (!opts.storage) return [];
334
523
  try {
@@ -336,27 +525,103 @@ function readQueue(opts: TrackOptions): QueuedEvent[] {
336
525
  if (!raw) return [];
337
526
  const parsed = JSON.parse(raw) as unknown;
338
527
  if (!Array.isArray(parsed)) return [];
339
- return parsed.filter((item): item is QueuedEvent => Boolean(
528
+ const restored = parsed.filter((item): item is QueuedEvent => Boolean(
340
529
  item && typeof item === 'object' && typeof item.id === 'string' &&
341
530
  typeof item.event === 'string' && typeof item.time === 'string' &&
342
- typeof item.props === 'object' && typeof item.attempts === 'number',
343
- )).slice(-(opts.maxQueue ?? DEFAULT_MAX_QUEUE));
531
+ item.props && typeof item.props === 'object' && !Array.isArray(item.props) &&
532
+ typeof item.attempts === 'number' && Number.isSafeInteger(item.attempts) &&
533
+ item.attempts >= 0 && item.attempts <= 100_000 &&
534
+ ((item as QueuedEvent).durable === undefined || (item as QueuedEvent).durable === true),
535
+ ));
536
+ const maxQueue = Math.max(0, opts.maxQueue ?? DEFAULT_MAX_QUEUE);
537
+ if (restored.length <= maxQueue) return restored;
538
+ const selected = new Set<number>();
539
+ restored.forEach((event, index) => {
540
+ if (event.durable === true) selected.add(index);
541
+ });
542
+ // A lower max in a later app version may not erase already-ACKed durable rows. Fill any
543
+ // remaining capacity with the newest best-effort events, retaining original send order.
544
+ for (let index = restored.length - 1; index >= 0 && selected.size < maxQueue; index -= 1) {
545
+ if (restored[index]?.durable !== true) selected.add(index);
546
+ }
547
+ return restored.filter((_event, index) => selected.has(index));
344
548
  } catch {
345
549
  return [];
346
550
  }
347
551
  }
348
552
 
349
- function persistQueue(): void {
350
- if (!options?.storage) return;
553
+ function persistQueue(): boolean {
554
+ if (!options?.storage) return false;
555
+ // Best-effort track() still mirrors through AsyncStorage, but the return value stays false so
556
+ // trackIdempotent() cannot claim synchronous durability that the adapter has not provided.
557
+ return writeStorageValue(
558
+ options.storage,
559
+ storageKey(options),
560
+ JSON.stringify(queue),
561
+ options.onError,
562
+ true,
563
+ );
564
+ }
565
+
566
+ function supportsDurableSyncWrites(storage: ConfigStorage): boolean {
567
+ return storage.supportsDurableSyncWrites !== false
568
+ && !asynchronousStorageAdapters.has(storage as object);
569
+ }
570
+
571
+ function writeStorageValue(
572
+ storage: ConfigStorage,
573
+ key: string,
574
+ value: string,
575
+ onError: ((error: unknown) => void) | undefined,
576
+ allowKnownAsyncBestEffort: boolean,
577
+ ): boolean {
578
+ const declaredOrObservedAsync = !supportsDurableSyncWrites(storage);
579
+ if (declaredOrObservedAsync && !allowKnownAsyncBestEffort) return false;
351
580
  try {
352
- options.storage.set(storageKey(options), JSON.stringify(queue));
353
- } catch {
354
- // Telemetry persistence must never become an app failure.
581
+ const result = (storage.set as unknown as (storageKey: string, stored: string) => unknown)(key, value);
582
+ if (isThenable(result)) {
583
+ asynchronousStorageAdapters.add(storage as object);
584
+ void Promise.resolve(result).catch((error) => reportStorageError(onError, error));
585
+ return false;
586
+ }
587
+ return !declaredOrObservedAsync;
588
+ } catch (error) {
589
+ reportStorageError(onError, error);
590
+ return false;
355
591
  }
356
592
  }
357
593
 
594
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
595
+ return (typeof value === 'object' && value !== null) || typeof value === 'function'
596
+ ? typeof (value as { then?: unknown }).then === 'function'
597
+ : false;
598
+ }
599
+
600
+ function reportStorageError(
601
+ onError: ((error: unknown) => void) | undefined,
602
+ error: unknown,
603
+ ): void {
604
+ try { onError?.(error); } catch { /* telemetry diagnostics never become an app failure */ }
605
+ }
606
+
358
607
  function randomEventId(): string {
359
608
  const cryptoLike = globalThis.crypto as { randomUUID?: () => string } | undefined;
360
609
  if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
361
610
  return `evt-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
362
611
  }
612
+
613
+ function randomAnalyticsInstallationId(): string {
614
+ const cryptoLike = globalThis.crypto as {
615
+ randomUUID?: () => string;
616
+ getRandomValues?: (values: Uint8Array) => Uint8Array;
617
+ } | undefined;
618
+ if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID().toLowerCase();
619
+
620
+ const bytes = new Uint8Array(16);
621
+ if (typeof cryptoLike?.getRandomValues === 'function') cryptoLike.getRandomValues(bytes);
622
+ else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
623
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
624
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
625
+ const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
626
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
627
+ }