@formo/analytics-react-native 1.0.2 → 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.
- package/lib/commonjs/lib/event/EventQueue.js +367 -57
- package/lib/commonjs/lib/event/EventQueue.js.map +1 -1
- package/lib/commonjs/utils/sanitize.js +260 -0
- package/lib/commonjs/utils/sanitize.js.map +1 -0
- package/lib/commonjs/utils/trafficSource.js +10 -1
- package/lib/commonjs/utils/trafficSource.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/lib/event/EventQueue.js +368 -57
- package/lib/module/lib/event/EventQueue.js.map +1 -1
- package/lib/module/utils/sanitize.js +253 -0
- package/lib/module/utils/sanitize.js.map +1 -0
- package/lib/module/utils/trafficSource.js +10 -1
- package/lib/module/utils/trafficSource.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/commonjs/lib/event/EventQueue.d.ts +55 -0
- package/lib/typescript/commonjs/lib/event/EventQueue.d.ts.map +1 -1
- package/lib/typescript/commonjs/utils/sanitize.d.ts +31 -0
- package/lib/typescript/commonjs/utils/sanitize.d.ts.map +1 -0
- package/lib/typescript/commonjs/utils/trafficSource.d.ts +3 -0
- package/lib/typescript/commonjs/utils/trafficSource.d.ts.map +1 -1
- package/lib/typescript/commonjs/version.d.ts +1 -1
- package/lib/typescript/module/FormoAnalytics.d.ts +2 -2
- package/lib/typescript/module/FormoAnalytics.d.ts.map +1 -1
- package/lib/typescript/module/FormoAnalyticsProvider.d.ts +1 -1
- package/lib/typescript/module/FormoAnalyticsProvider.d.ts.map +1 -1
- package/lib/typescript/module/constants/index.d.ts +3 -3
- package/lib/typescript/module/constants/index.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +5 -5
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/lib/event/EventFactory.d.ts +2 -2
- package/lib/typescript/module/lib/event/EventFactory.d.ts.map +1 -1
- package/lib/typescript/module/lib/event/EventManager.d.ts +2 -2
- package/lib/typescript/module/lib/event/EventManager.d.ts.map +1 -1
- package/lib/typescript/module/lib/event/EventQueue.d.ts +57 -2
- package/lib/typescript/module/lib/event/EventQueue.d.ts.map +1 -1
- package/lib/typescript/module/lib/event/index.d.ts +4 -4
- package/lib/typescript/module/lib/event/index.d.ts.map +1 -1
- package/lib/typescript/module/lib/event/types.d.ts +1 -1
- package/lib/typescript/module/lib/event/types.d.ts.map +1 -1
- package/lib/typescript/module/lib/session/index.d.ts +1 -1
- package/lib/typescript/module/lib/session/index.d.ts.map +1 -1
- package/lib/typescript/module/lib/storage/AsyncStorageAdapter.d.ts +2 -2
- package/lib/typescript/module/lib/storage/AsyncStorageAdapter.d.ts.map +1 -1
- package/lib/typescript/module/lib/storage/MemoryStorage.d.ts +1 -1
- package/lib/typescript/module/lib/storage/MemoryStorage.d.ts.map +1 -1
- package/lib/typescript/module/lib/storage/StorageBlueprint.d.ts +1 -1
- package/lib/typescript/module/lib/storage/StorageBlueprint.d.ts.map +1 -1
- package/lib/typescript/module/lib/storage/StorageManager.d.ts +1 -1
- package/lib/typescript/module/lib/storage/StorageManager.d.ts.map +1 -1
- package/lib/typescript/module/lib/storage/index.d.ts +4 -4
- package/lib/typescript/module/lib/storage/index.d.ts.map +1 -1
- package/lib/typescript/module/lib/wagmi/WagmiEventHandler.d.ts +2 -2
- package/lib/typescript/module/lib/wagmi/WagmiEventHandler.d.ts.map +1 -1
- package/lib/typescript/module/lib/wagmi/index.d.ts +2 -2
- package/lib/typescript/module/lib/wagmi/index.d.ts.map +1 -1
- package/lib/typescript/module/solana/address.d.ts +1 -1
- package/lib/typescript/module/solana/address.d.ts.map +1 -1
- package/lib/typescript/module/solana/index.d.ts +2 -2
- package/lib/typescript/module/solana/index.d.ts.map +1 -1
- package/lib/typescript/module/types/base.d.ts +3 -3
- package/lib/typescript/module/types/base.d.ts.map +1 -1
- package/lib/typescript/module/types/events.d.ts +2 -2
- package/lib/typescript/module/types/events.d.ts.map +1 -1
- package/lib/typescript/module/types/index.d.ts +2 -2
- package/lib/typescript/module/types/index.d.ts.map +1 -1
- package/lib/typescript/module/utils/index.d.ts +5 -5
- package/lib/typescript/module/utils/index.d.ts.map +1 -1
- package/lib/typescript/module/utils/sanitize.d.ts +31 -0
- package/lib/typescript/module/utils/sanitize.d.ts.map +1 -0
- package/lib/typescript/module/utils/trafficSource.d.ts +4 -1
- package/lib/typescript/module/utils/trafficSource.d.ts.map +1 -1
- package/lib/typescript/module/version.d.ts +1 -1
- package/package.json +2 -2
- package/src/lib/event/EventQueue.ts +402 -61
- package/src/utils/sanitize.ts +274 -0
- package/src/utils/trafficSource.ts +19 -1
- 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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
|
404
|
+
safeCall(itemCallback, err, message, data)
|
|
250
405
|
);
|
|
251
|
-
callback
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
502
|
+
this.assertNotCleared(generation);
|
|
503
|
+
return this.sendWithRetry(data, generation, attempt + 1);
|
|
294
504
|
}
|
|
295
|
-
|
|
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)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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)
|
|
313
|
-
|
|
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
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
613
|
+
this.appStateSubscription.remove();
|
|
348
614
|
} catch (error) {
|
|
349
|
-
|
|
350
|
-
//
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
this.timer = null;
|
|
378
|
-
}
|
|
693
|
+
attempts++;
|
|
694
|
+
}
|
|
379
695
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
}
|