@formo/analytics-react-native 1.0.1 → 1.1.0

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 (96) hide show
  1. package/lib/commonjs/FormoAnalytics.js +15 -3
  2. package/lib/commonjs/FormoAnalytics.js.map +1 -1
  3. package/lib/commonjs/lib/crash/index.js +23 -7
  4. package/lib/commonjs/lib/crash/index.js.map +1 -1
  5. package/lib/commonjs/lib/event/EventFactory.js +11 -1
  6. package/lib/commonjs/lib/event/EventFactory.js.map +1 -1
  7. package/lib/commonjs/lib/event/EventQueue.js +367 -57
  8. package/lib/commonjs/lib/event/EventQueue.js.map +1 -1
  9. package/lib/commonjs/utils/sanitize.js +260 -0
  10. package/lib/commonjs/utils/sanitize.js.map +1 -0
  11. package/lib/commonjs/utils/trafficSource.js +10 -1
  12. package/lib/commonjs/utils/trafficSource.js.map +1 -1
  13. package/lib/commonjs/version.js +1 -1
  14. package/lib/module/FormoAnalytics.js +15 -3
  15. package/lib/module/FormoAnalytics.js.map +1 -1
  16. package/lib/module/lib/crash/index.js +23 -7
  17. package/lib/module/lib/crash/index.js.map +1 -1
  18. package/lib/module/lib/event/EventFactory.js +11 -1
  19. package/lib/module/lib/event/EventFactory.js.map +1 -1
  20. package/lib/module/lib/event/EventQueue.js +368 -57
  21. package/lib/module/lib/event/EventQueue.js.map +1 -1
  22. package/lib/module/utils/sanitize.js +253 -0
  23. package/lib/module/utils/sanitize.js.map +1 -0
  24. package/lib/module/utils/trafficSource.js +10 -1
  25. package/lib/module/utils/trafficSource.js.map +1 -1
  26. package/lib/module/version.js +1 -1
  27. package/lib/typescript/commonjs/FormoAnalytics.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/lib/crash/index.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/lib/event/EventFactory.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/lib/event/EventQueue.d.ts +55 -0
  31. package/lib/typescript/commonjs/lib/event/EventQueue.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/utils/sanitize.d.ts +31 -0
  33. package/lib/typescript/commonjs/utils/sanitize.d.ts.map +1 -0
  34. package/lib/typescript/commonjs/utils/trafficSource.d.ts +3 -0
  35. package/lib/typescript/commonjs/utils/trafficSource.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/version.d.ts +1 -1
  37. package/lib/typescript/module/FormoAnalytics.d.ts +2 -2
  38. package/lib/typescript/module/FormoAnalytics.d.ts.map +1 -1
  39. package/lib/typescript/module/FormoAnalyticsProvider.d.ts +1 -1
  40. package/lib/typescript/module/FormoAnalyticsProvider.d.ts.map +1 -1
  41. package/lib/typescript/module/constants/index.d.ts +3 -3
  42. package/lib/typescript/module/constants/index.d.ts.map +1 -1
  43. package/lib/typescript/module/index.d.ts +5 -5
  44. package/lib/typescript/module/index.d.ts.map +1 -1
  45. package/lib/typescript/module/lib/crash/index.d.ts.map +1 -1
  46. package/lib/typescript/module/lib/event/EventFactory.d.ts +2 -2
  47. package/lib/typescript/module/lib/event/EventFactory.d.ts.map +1 -1
  48. package/lib/typescript/module/lib/event/EventManager.d.ts +2 -2
  49. package/lib/typescript/module/lib/event/EventManager.d.ts.map +1 -1
  50. package/lib/typescript/module/lib/event/EventQueue.d.ts +57 -2
  51. package/lib/typescript/module/lib/event/EventQueue.d.ts.map +1 -1
  52. package/lib/typescript/module/lib/event/index.d.ts +4 -4
  53. package/lib/typescript/module/lib/event/index.d.ts.map +1 -1
  54. package/lib/typescript/module/lib/event/types.d.ts +1 -1
  55. package/lib/typescript/module/lib/event/types.d.ts.map +1 -1
  56. package/lib/typescript/module/lib/session/index.d.ts +1 -1
  57. package/lib/typescript/module/lib/session/index.d.ts.map +1 -1
  58. package/lib/typescript/module/lib/storage/AsyncStorageAdapter.d.ts +2 -2
  59. package/lib/typescript/module/lib/storage/AsyncStorageAdapter.d.ts.map +1 -1
  60. package/lib/typescript/module/lib/storage/MemoryStorage.d.ts +1 -1
  61. package/lib/typescript/module/lib/storage/MemoryStorage.d.ts.map +1 -1
  62. package/lib/typescript/module/lib/storage/StorageBlueprint.d.ts +1 -1
  63. package/lib/typescript/module/lib/storage/StorageBlueprint.d.ts.map +1 -1
  64. package/lib/typescript/module/lib/storage/StorageManager.d.ts +1 -1
  65. package/lib/typescript/module/lib/storage/StorageManager.d.ts.map +1 -1
  66. package/lib/typescript/module/lib/storage/index.d.ts +4 -4
  67. package/lib/typescript/module/lib/storage/index.d.ts.map +1 -1
  68. package/lib/typescript/module/lib/wagmi/WagmiEventHandler.d.ts +2 -2
  69. package/lib/typescript/module/lib/wagmi/WagmiEventHandler.d.ts.map +1 -1
  70. package/lib/typescript/module/lib/wagmi/index.d.ts +2 -2
  71. package/lib/typescript/module/lib/wagmi/index.d.ts.map +1 -1
  72. package/lib/typescript/module/solana/address.d.ts +1 -1
  73. package/lib/typescript/module/solana/address.d.ts.map +1 -1
  74. package/lib/typescript/module/solana/index.d.ts +2 -2
  75. package/lib/typescript/module/solana/index.d.ts.map +1 -1
  76. package/lib/typescript/module/types/base.d.ts +3 -3
  77. package/lib/typescript/module/types/base.d.ts.map +1 -1
  78. package/lib/typescript/module/types/events.d.ts +2 -2
  79. package/lib/typescript/module/types/events.d.ts.map +1 -1
  80. package/lib/typescript/module/types/index.d.ts +2 -2
  81. package/lib/typescript/module/types/index.d.ts.map +1 -1
  82. package/lib/typescript/module/utils/index.d.ts +5 -5
  83. package/lib/typescript/module/utils/index.d.ts.map +1 -1
  84. package/lib/typescript/module/utils/sanitize.d.ts +31 -0
  85. package/lib/typescript/module/utils/sanitize.d.ts.map +1 -0
  86. package/lib/typescript/module/utils/trafficSource.d.ts +4 -1
  87. package/lib/typescript/module/utils/trafficSource.d.ts.map +1 -1
  88. package/lib/typescript/module/version.d.ts +1 -1
  89. package/package.json +2 -2
  90. package/src/FormoAnalytics.ts +18 -3
  91. package/src/lib/crash/index.ts +21 -7
  92. package/src/lib/event/EventFactory.ts +14 -1
  93. package/src/lib/event/EventQueue.ts +402 -61
  94. package/src/utils/sanitize.ts +274 -0
  95. package/src/utils/trafficSource.ts +19 -1
  96. package/src/version.ts +1 -1
@@ -22,6 +22,15 @@ type IFormoEventFlushPayload = IFormoEventPayload & {
22
22
  sent_at: string;
23
23
  };
24
24
 
25
+ /**
26
+ * A send failure tagged with whether another attempt could ever succeed.
27
+ * `retryable: false` means the API rejected the payload itself (4xx other
28
+ * than 429) — re-posting the identical batch will fail identically forever.
29
+ * Left undefined for unexpected errors, which are treated as retryable so an
30
+ * unrecognised fault never silently discards events.
31
+ */
32
+ type SendError = Error & { retryable?: boolean };
33
+
25
34
  interface Options {
26
35
  apiHost: string;
27
36
  flushAt?: number;
@@ -42,12 +51,37 @@ const DEFAULT_QUEUE_SIZE = 1_024 * 500; // 500kB
42
51
  const MAX_QUEUE_SIZE = 1_024 * 500; // 500kB
43
52
  const MIN_QUEUE_SIZE = 200; // 200 bytes
44
53
 
54
+ // How long cleanup() waits for an already-in-flight send before abandoning it.
55
+ // Teardown must finish promptly: the provider blocks re-initialization on the
56
+ // pending cleanup, so an unbounded wait would strand the SDK.
57
+ const CLEANUP_FLUSH_WAIT = 1_000 * 5; // 5 seconds
58
+
45
59
  const DEFAULT_FLUSH_INTERVAL = 1_000 * 30; // 30 seconds
46
60
  const MAX_FLUSH_INTERVAL = 1_000 * 300; // 5 minutes
47
61
  const MIN_FLUSH_INTERVAL = 1_000 * 10; // 10 seconds
48
62
 
49
63
  const noop = () => {};
50
64
 
65
+ /**
66
+ * Invoke a consumer-supplied callback without letting it escape the SDK.
67
+ * These callbacks are arbitrary app code; a throw from one must not surface
68
+ * as an unhandled rejection from flush() or abort the remaining callbacks.
69
+ */
70
+ const safeCall = (fn: (...args: unknown[]) => unknown, ...args: unknown[]) => {
71
+ try {
72
+ const result = fn(...args);
73
+ // An `async` callback signals failure by returning a rejected promise
74
+ // rather than throwing, which the catch below would never see.
75
+ if (result && typeof (result as PromiseLike<unknown>).then === "function") {
76
+ Promise.resolve(result).catch((error) => {
77
+ logger.error("EventQueue: Async callback rejected, ignoring", error);
78
+ });
79
+ }
80
+ } catch (error) {
81
+ logger.error("EventQueue: Callback threw, ignoring", error);
82
+ }
83
+ };
84
+
51
85
  /**
52
86
  * Event queue for React Native
53
87
  * Handles batching, flushing, and retries with app lifecycle awareness
@@ -64,6 +98,35 @@ export class EventQueue implements IEventQueue {
64
98
  private payloadHashes: Set<string> = new Set();
65
99
  private flushMutex: Promise<void> = Promise.resolve();
66
100
  private appStateSubscription: { remove: () => void } | null = null;
101
+ /**
102
+ * Whether anything has been flushed yet this app session. Starts false so
103
+ * the first event is sent immediately (see enqueue). A cold start opened
104
+ * from an ad click or deep link produces its attribution events right away,
105
+ * and those are exactly the events lost if the process is killed before the
106
+ * batch timer fires or AppState reports background — a force-quit from the
107
+ * app switcher, an OS memory kill, or a crash never gives us that chance.
108
+ */
109
+ private flushed = false;
110
+ /**
111
+ * Set once cleanup starts. A flush already in flight can fail and re-queue
112
+ * its items after teardown has begun; without this it would arm a timer on
113
+ * an instance that is going away, firing network calls post-cleanup.
114
+ */
115
+ private closed = false;
116
+ /**
117
+ * Bumped by clear(). flush() splices its batch out of the queue before
118
+ * sending, so a clear() during opt-out cannot see those items; without this
119
+ * a later send failure would unshift them back and they would be delivered
120
+ * after consent was withdrawn.
121
+ */
122
+ private generation = 0;
123
+ /**
124
+ * The in-progress teardown, if any. cleanup() is public and the provider can
125
+ * call it more than once; a second run would start its own drain flush that
126
+ * is exempt from the closed check, splice the events the first run had just
127
+ * re-queued, and send them after the first cleanup() had already resolved.
128
+ */
129
+ private cleanupPromise: Promise<void> | null = null;
67
130
 
68
131
  constructor(writeKey: string, options: Options) {
69
132
  this.writeKey = writeKey;
@@ -107,6 +170,11 @@ export class EventQueue implements IEventQueue {
107
170
  * Handle app state changes
108
171
  */
109
172
  private handleAppStateChange(nextAppState: AppStateStatus): void {
173
+ // Teardown is already draining the queue. A flush queued here would wait
174
+ // on the mutex and could run — and re-queue on failure — after cleanup()
175
+ // has returned, leaving network work with no owner.
176
+ if (this.closed) return;
177
+
110
178
  // Flush when app goes to background or becomes inactive
111
179
  if (nextAppState === "background" || nextAppState === "inactive") {
112
180
  logger.debug("EventQueue: App going to background, flushing events");
@@ -144,10 +212,33 @@ export class EventQueue implements IEventQueue {
144
212
  event: IFormoEvent,
145
213
  callback?: (...args: unknown[]) => void
146
214
  ): Promise<void> {
215
+ if (this.closed) {
216
+ logger.debug("EventQueue: Ignoring event enqueued after cleanup");
217
+ return;
218
+ }
219
+
147
220
  callback = callback || noop;
148
221
 
222
+ const generation = this.generation;
149
223
  const message_id = await this.generateMessageId(event);
150
224
 
225
+ // Hashing is async, so cleanup() can complete while this call is suspended
226
+ // above. Re-check, or a caller that did not await enqueue() would resume
227
+ // after teardown, push onto a queue nobody will drain, and — if this is the
228
+ // session's first event — fire a network request on a torn-down instance.
229
+ if (this.closed) {
230
+ logger.debug("EventQueue: Ignoring event enqueued after cleanup");
231
+ return;
232
+ }
233
+
234
+ // Same window, but for opt-out: clear() cannot see an event that has not
235
+ // reached the queue yet, so an enqueue suspended across it would land
236
+ // afterwards and be delivered despite consent having been withdrawn.
237
+ if (this.generation !== generation) {
238
+ logger.debug("EventQueue: Ignoring event enqueued before opt-out");
239
+ return;
240
+ }
241
+
151
242
  // Check for duplicate
152
243
  if (this.isDuplicate(message_id)) {
153
244
  logger.warn(
@@ -189,22 +280,48 @@ export class EventQueue implements IEventQueue {
189
280
  0
190
281
  ) >= this.maxQueueSize;
191
282
 
192
- if (hasReachedFlushAt || hasReachedQueueSize) {
283
+ // Ship the first event of the app session as a batch of one rather than
284
+ // holding it for flushAt/flushInterval; subsequent events keep batching.
285
+ if (hasReachedFlushAt || hasReachedQueueSize || !this.flushed) {
193
286
  // Clear timer to prevent double flush
194
287
  if (this.timer) {
195
288
  clearTimeout(this.timer);
196
289
  this.timer = null;
197
290
  }
291
+ this.flushed = true;
198
292
  // Flush uses internal mutex to serialize operations
293
+ // A failed flush re-queues its items and re-arms the interval itself, so
294
+ // a cold start whose immediate flush fails — the likeliest case, since
295
+ // the radio may still be waking — still retries the attribution event.
199
296
  this.flush().catch((error) => {
200
297
  logger.error("EventQueue: Failed to flush on threshold", error);
201
298
  });
202
299
  return;
203
300
  }
204
301
 
205
- if (this.flushIntervalMs && !this.timer) {
206
- this.timer = setTimeout(this.flush.bind(this), this.flushIntervalMs);
207
- }
302
+ this.scheduleFlush();
303
+ }
304
+
305
+ /**
306
+ * Arm the batch-interval timer, if there is queued work and nothing is
307
+ * already scheduled. Safe to call repeatedly; it never stacks timers.
308
+ */
309
+ private scheduleFlush(): void {
310
+ if (this.closed) return;
311
+ if (!this.flushIntervalMs || this.timer || !this.queue.length) return;
312
+
313
+ this.timer = setTimeout(() => {
314
+ // flush() rethrows once sendWithRetry is exhausted. Passing it to
315
+ // setTimeout bare left that rejection unhandled, surfacing in the host
316
+ // app as an "Uncaught (in promise)" on every failed interval flush —
317
+ // observed against a 4xx from the events API. The threshold and
318
+ // background paths already log and swallow; this one has to as well.
319
+ // flush() re-arms on failure, so a queue that outlives a transient
320
+ // outage keeps retrying and drains once connectivity returns.
321
+ this.flush().catch((error) => {
322
+ logger.error("EventQueue: Failed to flush on interval", error);
323
+ });
324
+ }, this.flushIntervalMs);
208
325
  }
209
326
 
210
327
  /**
@@ -213,6 +330,21 @@ export class EventQueue implements IEventQueue {
213
330
  * preventing race conditions with re-queued items on failure.
214
331
  */
215
332
  async flush(callback?: (...args: unknown[]) => void): Promise<void> {
333
+ return this.runFlush(callback, false);
334
+ }
335
+
336
+ /**
337
+ * The flush body. `duringCleanup` marks the calls teardown makes itself, so
338
+ * they are exempt from the closed check below — every other flush must be
339
+ * abandoned once teardown has begun. It has to identify the specific call
340
+ * rather than a window of time, because cleanup spends most of its drain
341
+ * loop awaiting one of these, and a concurrent flush arriving then would
342
+ * look internal.
343
+ */
344
+ private async runFlush(
345
+ callback: ((...args: unknown[]) => void) | undefined,
346
+ duringCleanup: boolean
347
+ ): Promise<void> {
216
348
  callback = callback || noop;
217
349
 
218
350
  if (this.timer) {
@@ -231,12 +363,28 @@ export class EventQueue implements IEventQueue {
231
363
  // Wait for any previous flush to complete
232
364
  await previousMutex;
233
365
 
366
+ // Teardown began while this flush was waiting its turn. The instant the
367
+ // flush ahead released the mutex it would otherwise splice and send —
368
+ // before cleanup() had even observed that flush finishing — delivering
369
+ // events after teardown resolved.
370
+ if (this.closed && !duringCleanup) {
371
+ // Deliberately no callback: this resumes only once the flush ahead
372
+ // settles, which can be long after cleanup() resolved, and nothing may
373
+ // call back into a torn-down instance. The returned promise still
374
+ // resolves, so an awaiting caller is never left hanging.
375
+ logger.debug("EventQueue: Abandoning flush that outlived cleanup");
376
+ return;
377
+ }
378
+
234
379
  if (!this.queue.length) {
235
- callback();
380
+ safeCall(callback);
236
381
  return;
237
382
  }
238
383
 
239
384
  const items = this.queue.splice(0, this.flushAt);
385
+ // Snapshot after the splice: from here on these items live only in this
386
+ // closure, so a clear() cannot reach them and we have to detect it.
387
+ const generation = this.generation;
240
388
 
241
389
  const sentAt = new Date().toISOString();
242
390
  const data: IFormoEventFlushPayload[] = items.map((item) => ({
@@ -245,25 +393,69 @@ export class EventQueue implements IEventQueue {
245
393
  }));
246
394
 
247
395
  const done = (err?: Error) => {
396
+ // A batch cleanup() abandoned must not call back into an app that has
397
+ // already torn the SDK down: its request has no timeout, so it can
398
+ // settle long after cleanup() resolved and the provider moved on to a
399
+ // replacement instance. A plain clear() (opt-out) still notifies, so
400
+ // the consumer does learn those events were dropped.
401
+ if (this.closed && this.generation !== generation) return;
402
+
248
403
  items.forEach(({ message, callback: itemCallback }) =>
249
- itemCallback(err, message, data)
404
+ safeCall(itemCallback, err, message, data)
250
405
  );
251
- callback!(err, data);
406
+ safeCall(callback!, err, data);
252
407
  };
253
408
 
254
409
  try {
255
- await this.sendWithRetry(data);
256
- // Only remove hashes after successful send
257
- items.forEach((item) => this.payloadHashes.delete(item.hash));
410
+ await this.sendWithRetry(data, generation);
411
+ // Only remove hashes after successful send, and only if clear() has
412
+ // not run meanwhile: it already emptied the set, so an identical event
413
+ // may have been enqueued since and now owns that hash. Deleting it
414
+ // here would strip the new item's dedup entry and let a duplicate
415
+ // through.
416
+ if (this.generation === generation) {
417
+ items.forEach((item) => this.payloadHashes.delete(item.hash));
418
+ }
258
419
  done();
259
420
  logger.info(`Events sent successfully: ${data.length} events`);
260
421
  } catch (err) {
261
- // Re-add items to the front of the queue for retry on next flush
262
- // Note: We intentionally keep hashes in payloadHashes to prevent duplicate
263
- // events from being enqueued while these items are pending retry.
264
- this.queue.unshift(...items);
265
- done(err as Error);
266
- logger.error("Error sending events, re-queued for retry:", err);
422
+ if (this.generation !== generation) {
423
+ // clear() ran while this batch was in flight the consumer opted
424
+ // out. Putting these back would deliver events after consent was
425
+ // withdrawn, and clear() already emptied payloadHashes, so a
426
+ // resurrected item would also no longer be deduped.
427
+ done(err as Error);
428
+ logger.debug(
429
+ `EventQueue: Discarding ${items.length} in-flight event(s) cleared mid-flush`
430
+ );
431
+ } else if ((err as SendError)?.retryable === false) {
432
+ // The API rejected this payload itself, so the identical batch can
433
+ // never succeed. Keeping it queued would re-post it every interval
434
+ // forever, re-invoking callbacks and burning the user's battery and
435
+ // data. Drop it, and release the hashes so equivalent events are not
436
+ // blocked from being enqueued again later. The web SDK likewise does
437
+ // not re-queue a failed batch.
438
+ items.forEach((item) => this.payloadHashes.delete(item.hash));
439
+ done(err as Error);
440
+ logger.error(
441
+ `Dropping ${items.length} event(s), permanently rejected by the API:`,
442
+ err
443
+ );
444
+ } else {
445
+ // Re-add items to the front of the queue for retry on next flush
446
+ // Note: We intentionally keep hashes in payloadHashes to prevent duplicate
447
+ // events from being enqueued while these items are pending retry.
448
+ this.queue.unshift(...items);
449
+ done(err as Error);
450
+ logger.error("Error sending events, re-queued for retry:", err);
451
+ }
452
+
453
+ // Re-arm here rather than in each caller's catch, so EVERY entry point
454
+ // is covered — the background AppState flush and a consumer's manual
455
+ // flush() included. flush() clears the timer on entry, so without this
456
+ // a failed background flush would re-queue its items and leave nothing
457
+ // scheduled to retry them. No-ops when the queue is empty or closed.
458
+ this.scheduleFlush();
267
459
  throw err;
268
460
  }
269
461
  } finally {
@@ -271,11 +463,28 @@ export class EventQueue implements IEventQueue {
271
463
  }
272
464
  }
273
465
 
466
+ /**
467
+ * Abort a batch whose events were cleared while it sat in retry backoff.
468
+ * Retries span seconds, so a consumer can opt out between attempts; posting
469
+ * the next one would deliver events after consent was withdrawn.
470
+ */
471
+ private assertNotCleared(generation: number): void {
472
+ if (this.generation !== generation) {
473
+ const error: SendError = new Error(
474
+ "EventQueue: batch cleared during retry backoff"
475
+ );
476
+ // Never re-queue: these events were explicitly discarded.
477
+ error.retryable = false;
478
+ throw error;
479
+ }
480
+ }
481
+
274
482
  /**
275
483
  * Send events with retry logic
276
484
  */
277
485
  private async sendWithRetry(
278
486
  data: IFormoEventFlushPayload[],
487
+ generation: number,
279
488
  attempt = 0
280
489
  ): Promise<void> {
281
490
  try {
@@ -290,16 +499,29 @@ export class EventQueue implements IEventQueue {
290
499
  if (shouldRetry && attempt < this.retryCount) {
291
500
  const delay = Math.pow(2, attempt) * 1000;
292
501
  await new Promise<void>((resolve) => setTimeout(() => resolve(), delay));
293
- return this.sendWithRetry(data, attempt + 1);
502
+ this.assertNotCleared(generation);
503
+ return this.sendWithRetry(data, generation, attempt + 1);
294
504
  }
295
- throw new Error(`HTTP error! status: ${response.status}`);
505
+ const error: SendError = new Error(
506
+ `HTTP error! status: ${response.status}`
507
+ );
508
+ // A 4xx that is not 429 rejects this payload permanently — an invalid
509
+ // write key or a malformed batch. Tag it so flush() drops the batch
510
+ // instead of re-posting it on every interval for the process lifetime.
511
+ error.retryable = shouldRetry;
512
+ throw error;
296
513
  }
297
514
  } catch (error) {
298
- if (isNetworkError(error) && attempt < this.retryCount) {
299
- const delay = Math.pow(2, attempt) * 1000;
300
- logger.warn(`Network error, retrying in ${delay}ms...`);
301
- await new Promise<void>((resolve) => setTimeout(() => resolve(), delay));
302
- return this.sendWithRetry(data, attempt + 1);
515
+ if (isNetworkError(error)) {
516
+ if (attempt < this.retryCount) {
517
+ const delay = Math.pow(2, attempt) * 1000;
518
+ logger.warn(`Network error, retrying in ${delay}ms...`);
519
+ await new Promise<void>((resolve) => setTimeout(() => resolve(), delay));
520
+ this.assertNotCleared(generation);
521
+ return this.sendWithRetry(data, generation, attempt + 1);
522
+ }
523
+ // Connectivity comes back; keep these for a later attempt.
524
+ (error as SendError).retryable = true;
303
525
  }
304
526
  throw error;
305
527
  }
@@ -309,8 +531,11 @@ export class EventQueue implements IEventQueue {
309
531
  * Check if error should be retried
310
532
  */
311
533
  private shouldRetry(status: number): boolean {
312
- // Retry on server errors (5xx) and rate limiting (429)
313
- return (status >= 500 && status <= 599) || status === 429;
534
+ // Retry on server errors (5xx), rate limiting (429) and request timeout
535
+ // (408). 408 matters now that a non-retryable status drops the batch: a
536
+ // proxy or server timing out a request is transient, and treating it as
537
+ // permanent would silently lose those events.
538
+ return (status >= 500 && status <= 599) || status === 429 || status === 408;
314
539
  }
315
540
 
316
541
  /**
@@ -319,6 +544,9 @@ export class EventQueue implements IEventQueue {
319
544
  * from being sent after consent is revoked.
320
545
  */
321
546
  public clear(): void {
547
+ // Invalidate any batch already in flight so a later send failure cannot
548
+ // unshift it back onto the queue we are emptying here.
549
+ this.generation++;
322
550
  this.queue = [];
323
551
  this.payloadHashes.clear();
324
552
 
@@ -330,56 +558,169 @@ export class EventQueue implements IEventQueue {
330
558
  logger.debug("EventQueue: Cleared all pending events");
331
559
  }
332
560
 
561
+ /**
562
+ * Give up on the events still queued at teardown, and make sure a send that
563
+ * is still holding a batch cannot put them back.
564
+ */
565
+ private abandonQueuedEvents(reason: string): void {
566
+ logger.warn(
567
+ `EventQueue: ${reason}, abandoning ${this.queue.length} event(s)`
568
+ );
569
+ // clear() rather than emptying the queue by hand: it also bumps the
570
+ // generation, which is what actually invalidates a batch a stalled flush
571
+ // is still holding. Without the bump that flush would later fail, unshift
572
+ // its items back onto the queue we just emptied, and a flush chained
573
+ // behind it would send them and invoke their callbacks a second time —
574
+ // after teardown had already returned.
575
+ this.clear();
576
+ }
577
+
333
578
  /**
334
579
  * Clean up resources, flushing any pending events first
335
580
  */
336
581
  public async cleanup(): Promise<void> {
337
- // Flush all remaining queued events before teardown
338
- // Loop until queue is empty since flush() only sends flushAt events per call
339
- // Safety limit prevents infinite loops if flush silently fails
340
- const maxAttempts = Math.ceil(this.queue.length / this.flushAt) + 3;
341
- let attempts = 0;
342
- const initialQueueLength = this.queue.length;
343
-
344
- while (this.queue.length > 0 && attempts < maxAttempts) {
345
- const queueLengthBefore = this.queue.length;
582
+ // Teardown is idempotent: a caller that asks twice joins the run already
583
+ // under way rather than starting a competing one.
584
+ if (!this.cleanupPromise) {
585
+ // Teardown is best-effort and must always settle: a rejection here would
586
+ // be memoised, so every later cleanup() would return the same rejected
587
+ // promise and never retry — and the provider, which awaits the pending
588
+ // cleanup before building a replacement, would reject with it forever.
589
+ this.cleanupPromise = this.runCleanup().catch((error) => {
590
+ logger.error("EventQueue: Cleanup failed", error);
591
+ // Whatever failed, the instance is going away: make sure nothing is
592
+ // left queued for a straggling flush to pick up.
593
+ this.clear();
594
+ });
595
+ }
596
+ return this.cleanupPromise;
597
+ }
598
+
599
+ private async runCleanup(): Promise<void> {
600
+ // Stop anything from arming a new timer for the rest of teardown.
601
+ this.closed = true;
602
+ if (this.timer) {
603
+ clearTimeout(this.timer);
604
+ this.timer = null;
605
+ }
606
+
607
+ // Detach up front rather than at the end: while the drain loop below is
608
+ // awaiting a send, a backgrounding app would otherwise queue a flush that
609
+ // outlives cleanup(). The closed check in the handler covers the same
610
+ // window; removing the listener means the event never reaches it at all.
611
+ if (this.appStateSubscription) {
346
612
  try {
347
- await this.flush();
613
+ this.appStateSubscription.remove();
348
614
  } catch (error) {
349
- logger.error("EventQueue: Failed to flush during cleanup", error);
350
- // Break on error to avoid infinite loop if flush keeps failing
351
- break;
615
+ // A native-module failure here must not abort teardown before the
616
+ // queue has been drained or invalidated below.
617
+ logger.error("EventQueue: Failed to remove AppState listener", error);
352
618
  }
619
+ this.appStateSubscription = null;
620
+ }
621
+
622
+ // One deadline for the whole of teardown, not per wait.
623
+ //
624
+ // `fetch` here has no request timeout, so any send this method waits on —
625
+ // a flush already in flight, or one the drain loop starts itself — can
626
+ // stall forever. Bounding only the first would leave the ordinary case
627
+ // unbounded: with nothing in flight the first wait returns immediately and
628
+ // the drain loop then opens a fresh request. A single deadline also keeps
629
+ // total teardown bounded rather than 5s per flush attempt.
630
+ //
631
+ // This matters beyond a stuck cleanup(): FormoAnalyticsProvider awaits the
632
+ // pending cleanup before constructing a replacement instance, so a hang
633
+ // here means the SDK can never be reconfigured again.
634
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
635
+ const deadline = new Promise<"timeout">((resolve) => {
636
+ deadlineTimer = setTimeout(() => resolve("timeout"), CLEANUP_FLUSH_WAIT);
637
+ });
638
+ const withinDeadline = <T>(work: Promise<T>) =>
639
+ Promise.race([work, deadline]);
640
+ const timedOut = `Teardown exceeded ${millisecondsToSecond(
641
+ CLEANUP_FLUSH_WAIT
642
+ )}s`;
353
643
 
354
- // If queue length didn't decrease, flush is silently failing
355
- if (this.queue.length >= queueLengthBefore) {
356
- logger.warn("EventQueue: Flush did not reduce queue size, aborting cleanup");
357
- break;
644
+ try {
645
+ // A flush may already be in flight with its items spliced out of the
646
+ // queue, which would make the drain loop below see an empty queue and
647
+ // return before delivery finished. Wait for it to settle first — on a
648
+ // transient failure it puts those items back, and the loop then retries
649
+ // them.
650
+ if (
651
+ (await withinDeadline(this.flushMutex.then(() => "settled" as const))) ===
652
+ "timeout"
653
+ ) {
654
+ this.abandonQueuedEvents(timedOut);
655
+ return;
358
656
  }
359
657
 
360
- attempts++;
361
- }
658
+ // Flush all remaining queued events before teardown
659
+ // Loop until queue is empty since flush() only sends flushAt events per call
660
+ // Safety limit prevents infinite loops if flush silently fails
661
+ const maxAttempts = Math.ceil(this.queue.length / this.flushAt) + 3;
662
+ let attempts = 0;
663
+ const initialQueueLength = this.queue.length;
664
+
665
+ while (this.queue.length > 0 && attempts < maxAttempts) {
666
+ const queueLengthBefore = this.queue.length;
667
+
668
+ const outcome = await withinDeadline(
669
+ // Settle rather than reject, so only the deadline can win the race
670
+ // on an error and a rejection cannot escape unhandled.
671
+ this.runFlush(undefined, true).then(
672
+ () => "flushed" as const,
673
+ (error) => {
674
+ logger.error("EventQueue: Failed to flush during cleanup", error);
675
+ return "failed" as const;
676
+ }
677
+ )
678
+ );
362
679
 
363
- if (attempts >= maxAttempts && this.queue.length > 0) {
364
- logger.warn(
365
- `EventQueue: Cleanup safety limit reached. Discarding ${this.queue.length} events.`
366
- );
367
- this.queue = [];
368
- this.payloadHashes.clear();
369
- }
680
+ if (outcome === "timeout") {
681
+ this.abandonQueuedEvents(timedOut);
682
+ return;
683
+ }
684
+ // Break on error to avoid infinite loop if flush keeps failing
685
+ if (outcome === "failed") break;
370
686
 
371
- if (initialQueueLength > 0) {
372
- logger.debug(`EventQueue: Cleanup completed, flushed ${initialQueueLength - this.queue.length} events`);
373
- }
687
+ // If queue length didn't decrease, flush is silently failing
688
+ if (this.queue.length >= queueLengthBefore) {
689
+ logger.warn("EventQueue: Flush did not reduce queue size, aborting cleanup");
690
+ break;
691
+ }
374
692
 
375
- if (this.timer) {
376
- clearTimeout(this.timer);
377
- this.timer = null;
378
- }
693
+ attempts++;
694
+ }
379
695
 
380
- if (this.appStateSubscription) {
381
- this.appStateSubscription.remove();
382
- this.appStateSubscription = null;
696
+ if (initialQueueLength > 0) {
697
+ logger.debug(`EventQueue: Cleanup completed, flushed ${initialQueueLength - this.queue.length} events`);
698
+ }
699
+
700
+ // Teardown always ends with an empty, invalidated queue — not only when
701
+ // the safety limit is hit. Each `break` above (a failed flush, a flush
702
+ // that did not shrink the queue) otherwise left events behind with the
703
+ // generation unchanged, and a flush queued behind cleanup's own — a
704
+ // concurrent public flush(), or a second cleanup() — would then send
705
+ // them and invoke their callbacks after this call had resolved.
706
+ if (this.queue.length > 0) {
707
+ this.abandonQueuedEvents(
708
+ attempts >= maxAttempts
709
+ ? "Cleanup safety limit reached"
710
+ : "Teardown finished with events still queued"
711
+ );
712
+ }
713
+ } finally {
714
+ if (deadlineTimer) clearTimeout(deadlineTimer);
715
+
716
+ // The AppState listener was already detached at the top of cleanup, and
717
+ // `closed` stops anything arming a timer, so nothing can have been
718
+ // scheduled since. This is the last-resort clear for a timer that a flush
719
+ // in the drain loop above might have left behind.
720
+ if (this.timer) {
721
+ clearTimeout(this.timer);
722
+ this.timer = null;
723
+ }
383
724
  }
384
725
  }
385
726
  }