@joryio/web-sdk 0.0.0-stage → 1.3.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/CHANGELOG.md +5 -0
- package/LICENSE +54 -0
- package/README.md +497 -2
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/device.d.ts +14 -0
- package/dist/core/device.d.ts.map +1 -0
- package/dist/core/ecommerce.d.ts +67 -0
- package/dist/core/ecommerce.d.ts.map +1 -0
- package/dist/core/identity.d.ts +36 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/inapp.d.ts +169 -0
- package/dist/core/inapp.d.ts.map +1 -0
- package/dist/core/network.d.ts +158 -0
- package/dist/core/network.d.ts.map +1 -0
- package/dist/core/outbox.d.ts +79 -0
- package/dist/core/outbox.d.ts.map +1 -0
- package/dist/core/pending-attributes.d.ts +43 -0
- package/dist/core/pending-attributes.d.ts.map +1 -0
- package/dist/core/push.d.ts +59 -0
- package/dist/core/push.d.ts.map +1 -0
- package/dist/core/queue.d.ts +107 -0
- package/dist/core/queue.d.ts.map +1 -0
- package/dist/core/sdk.d.ts +169 -0
- package/dist/core/sdk.d.ts.map +1 -0
- package/dist/core/session.d.ts +36 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/storage.d.ts +17 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/user.d.ts +28 -0
- package/dist/core/user.d.ts.map +1 -0
- package/dist/core/utm.d.ts +39 -0
- package/dist/core/utm.d.ts.map +1 -0
- package/dist/core/values.d.ts +3 -0
- package/dist/core/values.d.ts.map +1 -0
- package/dist/core/write-retry.d.ts +66 -0
- package/dist/core/write-retry.d.ts.map +1 -0
- package/dist/index.d.mts +711 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/joryio-sw.js +6 -0
- package/dist/joryio.cjs.js +7265 -0
- package/dist/joryio.min.js +7 -0
- package/dist/joryio.min.js.map +1 -0
- package/dist/joryio.mjs +7254 -0
- package/dist/joryio.umd.js +7271 -0
- package/dist/utils/bucket-hash.d.ts +2 -0
- package/dist/utils/bucket-hash.d.ts.map +1 -0
- package/dist/utils/logger.d.ts +14 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/uuid.d.ts +3 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/package.json +90 -4
- package/src/core/config.ts +362 -0
- package/src/core/device.ts +50 -0
- package/src/core/ecommerce.ts +482 -0
- package/src/core/identity.ts +251 -0
- package/src/core/inapp.ts +2419 -0
- package/src/core/network.ts +1261 -0
- package/src/core/outbox.ts +707 -0
- package/src/core/pending-attributes.ts +350 -0
- package/src/core/push.ts +667 -0
- package/src/core/queue.ts +894 -0
- package/src/core/sdk.ts +3108 -0
- package/src/core/session.ts +311 -0
- package/src/core/storage.ts +157 -0
- package/src/core/user.ts +203 -0
- package/src/core/utm.ts +304 -0
- package/src/core/values.ts +85 -0
- package/src/core/write-retry.ts +136 -0
- package/src/index.ts +305 -0
- package/src/snippet/loader.js +134 -0
- package/src/utils/bucket-hash.ts +44 -0
- package/src/utils/logger.ts +58 -0
- package/src/utils/uuid.ts +29 -0
- package/src/worker/joryio-sw.js +218 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event Queue Manager for batching and persisting events
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { StorageManager } from './storage';
|
|
6
|
+
import { Logger } from '../utils/logger';
|
|
7
|
+
import { utf8Length } from './network';
|
|
8
|
+
|
|
9
|
+
export const QUEUE_KEY = 'event_queue';
|
|
10
|
+
export const INFLIGHT_KEY = 'event_queue_inflight';
|
|
11
|
+
export const PENDING_IDENTIFY_KEY = 'pending_identify';
|
|
12
|
+
/**
|
|
13
|
+
* Fallback cap when the SDK passes nothing. The REAL cap is `maxQueueSize` from
|
|
14
|
+
* the init config: this constant used to be the only one consulted, so the
|
|
15
|
+
* documented `maxQueueSize` option was stored and never read (audit
|
|
16
|
+
* 2026-09-26, W4) - iOS and Android honour theirs.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_MAX_QUEUE_SIZE = 1000; // Prevent unbounded growth
|
|
19
|
+
/** Window over which a burst of enqueues collapses into one storage write. */
|
|
20
|
+
const PERSIST_COALESCE_MS = 150;
|
|
21
|
+
|
|
22
|
+
/** The backend's `@ArrayMaxSize(500)` on `/v1/track/batch`. */
|
|
23
|
+
export const MAX_EVENTS_PER_REQUEST = 500;
|
|
24
|
+
/** Stay well under the API's 1 MB body limit. */
|
|
25
|
+
export const MAX_REQUEST_BYTES = 900 * 1024;
|
|
26
|
+
/** Longest the queue waits between retries of a failing batch. */
|
|
27
|
+
const MAX_BACKOFF_MS = 5 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
export interface QueuedEvent {
|
|
30
|
+
eventId: string; // Client-generated ID for idempotency
|
|
31
|
+
type: string;
|
|
32
|
+
properties?: Record<string, any>;
|
|
33
|
+
/**
|
|
34
|
+
* When the event happened: ISO 8601 UTC with milliseconds (rule 55, the
|
|
35
|
+
* same on every SDK). A number (epoch ms) in queues stored by an earlier
|
|
36
|
+
* build; the API accepts both.
|
|
37
|
+
*/
|
|
38
|
+
timestamp: string | number;
|
|
39
|
+
userId?: string | null;
|
|
40
|
+
anonymousId: string;
|
|
41
|
+
sessionId?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PendingIdentify {
|
|
45
|
+
userId: string;
|
|
46
|
+
anonymousId: string;
|
|
47
|
+
attributes?: Record<string, any>;
|
|
48
|
+
/** Failed attempts so far (never sent on the wire). */
|
|
49
|
+
attempts?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The most identifies waiting at once. They form an ordered list rather than
|
|
54
|
+
* one slot: `identify('A')` while offline followed by `reset(); identify('B')`
|
|
55
|
+
* used to overwrite A's identify with B's, so A's anonymous history was never
|
|
56
|
+
* merged into A (scan 3, web #9).
|
|
57
|
+
*/
|
|
58
|
+
const MAX_PENDING_IDENTIFIES = 20;
|
|
59
|
+
/**
|
|
60
|
+
* An identify that keeps being REFUSED is dropped after this many attempts
|
|
61
|
+
* (contract items 28 and 40). Only server answers count (a 2xx with
|
|
62
|
+
* `success: false`, an SDK-authentication 401 for the current user): offline,
|
|
63
|
+
* timeouts, 429 and 5xx never do, so an outage cannot cost a visitor's merge.
|
|
64
|
+
*/
|
|
65
|
+
export const MAX_IDENTIFY_ATTEMPTS = 10;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What became of one batch request. The SDK classifies the HTTP response;
|
|
69
|
+
* the queue decides what to keep.
|
|
70
|
+
*
|
|
71
|
+
* - `ok`: 2xx. `failedIndices` (from a `success: false` body) names the events
|
|
72
|
+
* the server could not store; those are kept for retry, the rest deleted.
|
|
73
|
+
* `identifyFailed`: the events went through but the identify did not - it
|
|
74
|
+
* is kept and retried, never dropped.
|
|
75
|
+
* - `rejected`: 400 / 413 / 422 - the request itself is unacceptable. Retrying
|
|
76
|
+
* it unchanged can never succeed, so the batch is split in half until the
|
|
77
|
+
* bad event is alone, and that one event is dropped.
|
|
78
|
+
* - `retry`: 429 / 5xx / offline / timeout / an SDK-auth 401. Keep everything,
|
|
79
|
+
* back off.
|
|
80
|
+
* - `suspended`: 402 / 403 / an invalid key. Stop sending for this page view;
|
|
81
|
+
* keep everything (bounded by maxQueueSize).
|
|
82
|
+
*/
|
|
83
|
+
export type FlushOutcome =
|
|
84
|
+
| {
|
|
85
|
+
status: 'ok';
|
|
86
|
+
failedIndices?: number[];
|
|
87
|
+
/** Refused for good (property limits, invalid): dropped, never retried (contract item 29). */
|
|
88
|
+
rejectedIndices?: number[];
|
|
89
|
+
identifyFailed?: boolean;
|
|
90
|
+
/** The identify's failure counts toward MAX_IDENTIFY_ATTEMPTS (a server refusal, not an outage). */
|
|
91
|
+
identifyAttemptCounts?: boolean;
|
|
92
|
+
/** The identify was refused for good: dropped, never retried. */
|
|
93
|
+
identifyDropped?: boolean;
|
|
94
|
+
}
|
|
95
|
+
| { status: 'rejected'; httpStatus?: number }
|
|
96
|
+
/** `counts`: a server refusal (SDK-auth 401 for the current user), counted against an identify riding along. */
|
|
97
|
+
| { status: 'retry'; retryAfterMs?: number; counts?: boolean }
|
|
98
|
+
| { status: 'suspended'; reason?: string }
|
|
99
|
+
/**
|
|
100
|
+
* SDK authentication refused the batch because it belongs to a user who is
|
|
101
|
+
* no longer the current one (401 `sub_mismatch` / `missing`): no token for
|
|
102
|
+
* that user will ever be minted again, so the batch is dropped rather than
|
|
103
|
+
* retried at the head of the queue forever (contract item 30).
|
|
104
|
+
*/
|
|
105
|
+
| { status: 'dropped'; reason?: string };
|
|
106
|
+
|
|
107
|
+
/** Returning nothing means `ok`; throwing means `retry`. */
|
|
108
|
+
export type FlushCallback = (
|
|
109
|
+
events: QueuedEvent[],
|
|
110
|
+
identify?: PendingIdentify,
|
|
111
|
+
) => Promise<FlushOutcome | void>;
|
|
112
|
+
|
|
113
|
+
function isOffline(): boolean {
|
|
114
|
+
return typeof navigator !== 'undefined' && (navigator as any).onLine === false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* May this identify ride with this batch? Only with events of the anonymous
|
|
119
|
+
* id it merges, and of no OTHER user: under SDK authentication a request is
|
|
120
|
+
* verified against one user's token.
|
|
121
|
+
*/
|
|
122
|
+
function identifyMatches(identify: PendingIdentify, batch: QueuedEvent[]): boolean {
|
|
123
|
+
return batch.every((e) => e.anonymousId === identify.anonymousId && (!e.userId || e.userId === identify.userId));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class QueueManager {
|
|
127
|
+
private storage: StorageManager;
|
|
128
|
+
private logger: Logger;
|
|
129
|
+
private queue: QueuedEvent[] = [];
|
|
130
|
+
private batchSize: number;
|
|
131
|
+
private flushInterval: number;
|
|
132
|
+
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
133
|
+
private flushCallback: FlushCallback | null = null;
|
|
134
|
+
private persistQueue: boolean;
|
|
135
|
+
private maxQueueSize: number;
|
|
136
|
+
private retryBackoffMs: number;
|
|
137
|
+
/** A coalesced storage write is outstanding — see schedulePersist(). */
|
|
138
|
+
private pendingPersist = false;
|
|
139
|
+
private persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
140
|
+
private isFlushing: boolean = false;
|
|
141
|
+
private flushWaiters: (() => void)[] = [];
|
|
142
|
+
/** Identifies not yet confirmed by the server, oldest first. */
|
|
143
|
+
private pendingIdentifies: PendingIdentify[] = [];
|
|
144
|
+
private inflightEvents: QueuedEvent[] = [];
|
|
145
|
+
/** Consent is pending: collect, never send. */
|
|
146
|
+
private paused = false;
|
|
147
|
+
/** 402/403/invalid key: nothing more is sent this page view. */
|
|
148
|
+
private suspended = false;
|
|
149
|
+
/** Consecutive retryable failures, for exponential backoff. */
|
|
150
|
+
private consecutiveFailures = 0;
|
|
151
|
+
/** Earliest time the next send may be attempted (the SDK's own backoff). */
|
|
152
|
+
private nextAttemptAt = 0;
|
|
153
|
+
/**
|
|
154
|
+
* Earliest time the SERVER allowed (Retry-After). Kept apart from the own
|
|
155
|
+
* backoff because a forced flush skips the backoff but never this
|
|
156
|
+
* (contract item 35).
|
|
157
|
+
*/
|
|
158
|
+
private retryAfterUntil = 0;
|
|
159
|
+
/**
|
|
160
|
+
* Bumped by clear(). A flush that was awaiting the network when the queue
|
|
161
|
+
* was cleared (opt-out, consent denied) must not put its batch back: the
|
|
162
|
+
* visitor withdrew, and restoring it would send it on the next flush.
|
|
163
|
+
*/
|
|
164
|
+
private generation = 0;
|
|
165
|
+
/**
|
|
166
|
+
* Told what a request that was in flight when clear() ran actually
|
|
167
|
+
* delivered (rule 90): the server's answer still settles it, even though
|
|
168
|
+
* nothing of it comes back to the queue.
|
|
169
|
+
*/
|
|
170
|
+
private clearedInflightCallback: ((delivered: QueuedEvent[]) => void) | null = null;
|
|
171
|
+
|
|
172
|
+
constructor(
|
|
173
|
+
storage: StorageManager,
|
|
174
|
+
logger: Logger,
|
|
175
|
+
batchSize: number,
|
|
176
|
+
flushInterval: number,
|
|
177
|
+
persistQueue: boolean = true,
|
|
178
|
+
maxQueueSize: number = DEFAULT_MAX_QUEUE_SIZE,
|
|
179
|
+
retryBackoffMs: number = 1000,
|
|
180
|
+
) {
|
|
181
|
+
this.storage = storage;
|
|
182
|
+
this.logger = logger;
|
|
183
|
+
this.batchSize = batchSize > 0 ? Math.floor(batchSize) : 50;
|
|
184
|
+
this.flushInterval = flushInterval;
|
|
185
|
+
this.persistQueue = persistQueue;
|
|
186
|
+
// A cap below 1 would drop every event on enqueue; treat it as "default".
|
|
187
|
+
this.maxQueueSize = maxQueueSize > 0 ? Math.floor(maxQueueSize) : DEFAULT_MAX_QUEUE_SIZE;
|
|
188
|
+
this.retryBackoffMs = retryBackoffMs > 0 ? retryBackoffMs : 1000;
|
|
189
|
+
|
|
190
|
+
// Load persisted queue and recover any in-flight events from a previous
|
|
191
|
+
// page load that was interrupted (e.g., OAuth redirect canceled the XHR).
|
|
192
|
+
if (this.persistQueue) {
|
|
193
|
+
const inflight = this.storage.get<QueuedEvent[]>(INFLIGHT_KEY) || [];
|
|
194
|
+
const queued = this.storage.get<QueuedEvent[]>(QUEUE_KEY) || [];
|
|
195
|
+
// Put recovered in-flight events first (they were enqueued earlier)
|
|
196
|
+
this.queue = [...inflight, ...queued];
|
|
197
|
+
if (inflight.length > 0) {
|
|
198
|
+
this.storage.remove(INFLIGHT_KEY);
|
|
199
|
+
this.logger.debug(`Recovered ${inflight.length} in-flight events from previous page`);
|
|
200
|
+
}
|
|
201
|
+
if (this.queue.length > this.maxQueueSize) {
|
|
202
|
+
this.queue = this.queue.slice(-this.maxQueueSize);
|
|
203
|
+
}
|
|
204
|
+
if (this.queue.length > 0) {
|
|
205
|
+
this.logger.debug(`Loaded ${this.queue.length} events from storage`);
|
|
206
|
+
this.persistQueueToStorage();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Recover pending identify from a previous page load (e.g., identify flush
|
|
210
|
+
// was interrupted by OAuth redirect or page navigation after signup)
|
|
211
|
+
// An array since 1.3.0; a single object from an earlier version.
|
|
212
|
+
const persisted = this.storage.get<PendingIdentify | PendingIdentify[]>(PENDING_IDENTIFY_KEY);
|
|
213
|
+
const list = Array.isArray(persisted) ? persisted : persisted ? [persisted] : [];
|
|
214
|
+
this.pendingIdentifies = list
|
|
215
|
+
.filter((i) => i && typeof i.userId === 'string' && typeof i.anonymousId === 'string')
|
|
216
|
+
.slice(-MAX_PENDING_IDENTIFIES);
|
|
217
|
+
if (this.pendingIdentifies.length) {
|
|
218
|
+
this.logger.debug(`Recovered ${this.pendingIdentifies.length} pending identify(s) from a previous page`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Set the flush callback
|
|
225
|
+
*/
|
|
226
|
+
setFlushCallback(callback: FlushCallback): void {
|
|
227
|
+
this.flushCallback = callback;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Hold everything locally (consent pending) or release it. */
|
|
231
|
+
setPaused(paused: boolean): void {
|
|
232
|
+
this.paused = paused;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
isPaused(): boolean {
|
|
236
|
+
return this.paused;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** True after a 402/403/invalid key stopped sending for this page view. */
|
|
240
|
+
isSuspended(): boolean {
|
|
241
|
+
return this.suspended;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The last send failed (offline, timeout, 429, 5xx) or the server asked to
|
|
246
|
+
* wait: a keepalive at unload would most likely fail the same way, and the
|
|
247
|
+
* events it carried would be gone - so the unload path keeps them instead.
|
|
248
|
+
*/
|
|
249
|
+
isBackingOff(): boolean {
|
|
250
|
+
return this.consecutiveFailures > 0 || Date.now() < this.retryAfterUntil;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The browser came back online: try again now rather than after the backoff. */
|
|
254
|
+
resetBackoff(): void {
|
|
255
|
+
this.consecutiveFailures = 0;
|
|
256
|
+
this.nextAttemptAt = 0;
|
|
257
|
+
// NOT retryAfterUntil: the server's Retry-After is honoured even when the
|
|
258
|
+
// browser comes back online.
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Record an identify, to be sent with the events that precede it.
|
|
263
|
+
*
|
|
264
|
+
* Identifies queue up in call order. The same user on the same anonymous id
|
|
265
|
+
* replaces the last one (an identical merge); anything else is appended, so
|
|
266
|
+
* `identify('A')` made offline survives a later `reset(); identify('B')`.
|
|
267
|
+
*/
|
|
268
|
+
setPendingIdentify(identify: PendingIdentify): void {
|
|
269
|
+
const last = this.pendingIdentifies[this.pendingIdentifies.length - 1];
|
|
270
|
+
const entry: PendingIdentify = { userId: identify.userId, anonymousId: identify.anonymousId, attributes: identify.attributes || {} };
|
|
271
|
+
if (last && last.userId === entry.userId && last.anonymousId === entry.anonymousId) {
|
|
272
|
+
// Same merge again: nothing new to send (the one in the list may be on
|
|
273
|
+
// the wire right now, and replacing it would orphan its answer).
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.pendingIdentifies.push(entry);
|
|
277
|
+
if (this.pendingIdentifies.length > MAX_PENDING_IDENTIFIES) {
|
|
278
|
+
const dropped = this.pendingIdentifies.shift()!;
|
|
279
|
+
this.logger.warn(`Too many identify calls waiting to be sent; dropped the oldest (${dropped.userId})`);
|
|
280
|
+
}
|
|
281
|
+
this.persistIdentifies();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private persistIdentifies(): void {
|
|
285
|
+
if (!this.persistQueue) return;
|
|
286
|
+
try {
|
|
287
|
+
if (this.pendingIdentifies.length) this.storage.set(PENDING_IDENTIFY_KEY, this.pendingIdentifies);
|
|
288
|
+
else this.storage.remove(PENDING_IDENTIFY_KEY);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
this.logger.error('Failed to persist pending identify:', error);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The oldest identify not yet confirmed, or null. */
|
|
295
|
+
getPendingIdentify(): PendingIdentify | null {
|
|
296
|
+
return this.pendingIdentifies[0] || null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Every identify not yet confirmed, oldest first. */
|
|
300
|
+
getPendingIdentifies(): PendingIdentify[] {
|
|
301
|
+
return [...this.pendingIdentifies];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The identify reached the server, or was refused for good: forget it. */
|
|
305
|
+
private removeIdentify(identify: PendingIdentify): void {
|
|
306
|
+
const i = this.pendingIdentifies.indexOf(identify);
|
|
307
|
+
if (i >= 0) this.pendingIdentifies.splice(i, 1);
|
|
308
|
+
this.persistIdentifies();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** A failed attempt: counted when it was a refusal, and dropped once refused too often. */
|
|
312
|
+
private identifyAttemptFailed(identify: PendingIdentify, counts: boolean): void {
|
|
313
|
+
if (!counts) {
|
|
314
|
+
this.persistIdentifies();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
identify.attempts = (identify.attempts || 0) + 1;
|
|
318
|
+
if (identify.attempts >= MAX_IDENTIFY_ATTEMPTS) {
|
|
319
|
+
this.logger.error(`Identify for ${identify.userId} failed ${identify.attempts} times; dropping it`);
|
|
320
|
+
this.removeIdentify(identify);
|
|
321
|
+
} else {
|
|
322
|
+
this.persistIdentifies();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Start the automatic flush timer
|
|
328
|
+
*/
|
|
329
|
+
start(): void {
|
|
330
|
+
if (this.flushTimer) {
|
|
331
|
+
this.logger.debug('Queue manager already started');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this.flushTimer = setInterval(() => {
|
|
336
|
+
// flush() handles its own errors, but say so explicitly: an unhandled
|
|
337
|
+
// rejection from an SDK fires the HOST page's onunhandledrejection and
|
|
338
|
+
// lands in their monitoring as if their app had failed.
|
|
339
|
+
this.flush().catch((error) => this.logger.error('Scheduled flush failed:', error));
|
|
340
|
+
}, this.flushInterval);
|
|
341
|
+
|
|
342
|
+
this.logger.debug(`Queue manager started (batch: ${this.batchSize}, interval: ${this.flushInterval}ms)`);
|
|
343
|
+
|
|
344
|
+
// Flush any persisted events immediately
|
|
345
|
+
if (this.queue.length > 0 || this.pendingIdentifies.length) {
|
|
346
|
+
this.flush().catch((error) => this.logger.error('Startup flush failed:', error));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
isStarted(): boolean {
|
|
351
|
+
return this.flushTimer !== null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Stop the automatic flush timer
|
|
356
|
+
*/
|
|
357
|
+
stop(): void {
|
|
358
|
+
if (this.flushTimer) {
|
|
359
|
+
clearInterval(this.flushTimer);
|
|
360
|
+
this.flushTimer = null;
|
|
361
|
+
this.logger.debug('Queue manager stopped');
|
|
362
|
+
}
|
|
363
|
+
// Teardown must not strand a coalesced write, or events enqueued in the
|
|
364
|
+
// last 150ms would be lost on reset()/destroy.
|
|
365
|
+
this.persistNow();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Add an event to the queue
|
|
370
|
+
*/
|
|
371
|
+
enqueue(event: QueuedEvent): void {
|
|
372
|
+
this.queue.push(event);
|
|
373
|
+
this.logger.debug(`Event queued: ${event.type} (queue size: ${this.queue.length})`);
|
|
374
|
+
|
|
375
|
+
// Coalesced, NOT immediate: a direct write here re-serialized the whole
|
|
376
|
+
// queue on every event (O(N²) for a burst, on the main thread). Unload and
|
|
377
|
+
// flush still persist synchronously, so nothing that could lose events
|
|
378
|
+
// depends on this timer. See schedulePersist().
|
|
379
|
+
this.schedulePersist();
|
|
380
|
+
|
|
381
|
+
// Prevent unbounded growth. Drop the OLDEST events with an honest warning -
|
|
382
|
+
// this is the ONLY place events are dropped for lack of delivery (a retry
|
|
383
|
+
// count never deletes anything; a device offline for a day keeps its
|
|
384
|
+
// newest `maxQueueSize` events).
|
|
385
|
+
this.trimToMax();
|
|
386
|
+
|
|
387
|
+
// Auto-flush if batch size reached (don't await - fire and forget but safely)
|
|
388
|
+
if (this.queue.length >= this.batchSize && !this.isFlushing) {
|
|
389
|
+
this.logger.debug('Batch size reached, flushing queue');
|
|
390
|
+
void this.flush();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private trimToMax(): void {
|
|
395
|
+
if (this.queue.length > this.maxQueueSize) {
|
|
396
|
+
const droppedCount = this.queue.length - this.maxQueueSize;
|
|
397
|
+
this.queue = this.queue.slice(-this.maxQueueSize);
|
|
398
|
+
this.logger.warn(`Queue exceeded max size (${this.maxQueueSize}), dropped ${droppedCount} oldest event(s)`);
|
|
399
|
+
// Also coalesced. Once the queue sits AT the cap every further event
|
|
400
|
+
// truncates, so an immediate write here would reinstate the per-event
|
|
401
|
+
// full re-serialize in exactly the case it hurts most.
|
|
402
|
+
this.schedulePersist();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Take the next request's worth of events off the front of the queue:
|
|
408
|
+
* at most min(batchSize, 500) events and ~900 KB.
|
|
409
|
+
*
|
|
410
|
+
* The whole queue used to go in ONE request - up to maxQueueSize (1000)
|
|
411
|
+
* events against a backend that accepts 500 and 1 MB. The oversized request
|
|
412
|
+
* was refused, re-queued at the front, refused again: one bad batch blocked
|
|
413
|
+
* every later event forever.
|
|
414
|
+
*/
|
|
415
|
+
private takeBatch(): QueuedEvent[] {
|
|
416
|
+
const limit = Math.min(this.batchSize, MAX_EVENTS_PER_REQUEST);
|
|
417
|
+
const out: QueuedEvent[] = [];
|
|
418
|
+
let bytes = 64; // envelope + identify headroom
|
|
419
|
+
// One identity per request (contract item 30): under SDK authentication a
|
|
420
|
+
// request is verified against ONE user's token, so user A's events still
|
|
421
|
+
// queued after a logout must not share a request with user B's - the
|
|
422
|
+
// whole request was refused and blocked the queue. Cut at a change of
|
|
423
|
+
// anonymous id (a reset) or of user id; events with no user id (before an
|
|
424
|
+
// identify) ride with the user who follows on the same anonymous id.
|
|
425
|
+
let batchUser: string | null = null;
|
|
426
|
+
while (this.queue.length > 0 && out.length < limit) {
|
|
427
|
+
const event = this.queue[0];
|
|
428
|
+
if (out.length > 0) {
|
|
429
|
+
if (event.anonymousId !== out[0].anonymousId) break;
|
|
430
|
+
if (event.userId && batchUser && event.userId !== batchUser) break;
|
|
431
|
+
}
|
|
432
|
+
let size: number;
|
|
433
|
+
try {
|
|
434
|
+
size = utf8Length(JSON.stringify(event)) + 1;
|
|
435
|
+
} catch {
|
|
436
|
+
size = Number.POSITIVE_INFINITY; // circular properties: unsendable
|
|
437
|
+
}
|
|
438
|
+
if (size > MAX_REQUEST_BYTES) {
|
|
439
|
+
// Can never fit in any request. The API's body-size refusal arrives
|
|
440
|
+
// without CORS headers, so the browser would report it as "offline"
|
|
441
|
+
// and the event would be retried forever.
|
|
442
|
+
this.queue.shift();
|
|
443
|
+
this.logger.error(`Dropping event "${event.type}": it is too large to send (${size} bytes)`);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (out.length > 0 && bytes + size > MAX_REQUEST_BYTES) break;
|
|
447
|
+
out.push(this.queue.shift()!);
|
|
448
|
+
if (event.userId) batchUser = event.userId;
|
|
449
|
+
bytes += size;
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Put events back at the FRONT of the queue (they are older than anything queued since). */
|
|
455
|
+
private restoreToFront(events: QueuedEvent[]): void {
|
|
456
|
+
if (!events.length) return;
|
|
457
|
+
const present = new Set(this.queue.map((e) => e.eventId));
|
|
458
|
+
const missing = events.filter((e) => !present.has(e.eventId));
|
|
459
|
+
this.queue.unshift(...missing);
|
|
460
|
+
this.trimToMax();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private backOff(retryAfterMs?: number): void {
|
|
464
|
+
this.consecutiveFailures++;
|
|
465
|
+
const exp = Math.min(
|
|
466
|
+
this.retryBackoffMs * Math.pow(2, Math.min(this.consecutiveFailures - 1, 20)),
|
|
467
|
+
MAX_BACKOFF_MS,
|
|
468
|
+
);
|
|
469
|
+
const jitter = exp * 0.2 * Math.random();
|
|
470
|
+
const delay = Math.max(exp + jitter, Math.min(retryAfterMs ?? 0, MAX_BACKOFF_MS));
|
|
471
|
+
this.nextAttemptAt = Date.now() + delay;
|
|
472
|
+
if (retryAfterMs && retryAfterMs > 0) {
|
|
473
|
+
this.retryAfterUntil = Date.now() + Math.min(retryAfterMs, MAX_BACKOFF_MS);
|
|
474
|
+
}
|
|
475
|
+
this.logger.debug(`Event send failed; next attempt in ${Math.round(delay)}ms`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private async sendBatch(
|
|
479
|
+
batch: QueuedEvent[],
|
|
480
|
+
identify: PendingIdentify | null,
|
|
481
|
+
waiting: QueuedEvent[] = [],
|
|
482
|
+
): Promise<FlushOutcome> {
|
|
483
|
+
// In flight: kept in storage under INFLIGHT_KEY so a navigation that kills
|
|
484
|
+
// the request does not lose them (recovered on the next page load).
|
|
485
|
+
//
|
|
486
|
+
// `waiting` is the rest of a refused batch, split in halves that have not
|
|
487
|
+
// been sent yet. They are off the queue too, so they ride in the in-flight
|
|
488
|
+
// record: they used to live only in a local array, and a page closed in
|
|
489
|
+
// the middle of a split lost every one of them.
|
|
490
|
+
this.inflightEvents = waiting.length ? [...batch, ...waiting] : batch;
|
|
491
|
+
if (this.persistQueue) {
|
|
492
|
+
this.persistQueueToStorage();
|
|
493
|
+
this.persistInflightToStorage();
|
|
494
|
+
}
|
|
495
|
+
let outcome: FlushOutcome;
|
|
496
|
+
try {
|
|
497
|
+
outcome = (await this.flushCallback!(batch, identify ?? undefined)) || { status: 'ok' };
|
|
498
|
+
} catch (error) {
|
|
499
|
+
this.logger.error('Failed to flush events:', error);
|
|
500
|
+
outcome = { status: 'retry' };
|
|
501
|
+
}
|
|
502
|
+
this.inflightEvents = [];
|
|
503
|
+
if (this.persistQueue) this.storage.remove(INFLIGHT_KEY);
|
|
504
|
+
return outcome;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Send what is queued, in requests of at most min(batchSize, 500) events,
|
|
509
|
+
* one identity per request. If a flush is already in progress, waits for it,
|
|
510
|
+
* then sends whatever is still queued - so identify() can rely on flush()
|
|
511
|
+
* covering its events.
|
|
512
|
+
*
|
|
513
|
+
* Never blocks the queue on one bad request: see FlushOutcome.
|
|
514
|
+
*
|
|
515
|
+
* @param throwOnError - reject when events could not be delivered (retry /
|
|
516
|
+
* suspended), so a caller can distinguish success from failure.
|
|
517
|
+
* @param force - an explicit flush (flush(), identify, reset): skips the
|
|
518
|
+
* SDK's own backoff, never the server's Retry-After (contract item 35).
|
|
519
|
+
*/
|
|
520
|
+
async flush(throwOnError: boolean = false, force: boolean = false): Promise<void> {
|
|
521
|
+
// A flush in progress is waited for even when nothing is queued: it may
|
|
522
|
+
// be carrying the only pending identify, and `await flush()` must mean
|
|
523
|
+
// "what was queued has been dealt with".
|
|
524
|
+
if (this.queue.length === 0 && !this.pendingIdentifies.length && !this.isFlushing) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (this.paused || this.suspended) {
|
|
528
|
+
if (throwOnError) throw new Error(this.paused ? 'Sending is paused' : 'Sending is suspended');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (this.isFlushing) {
|
|
533
|
+
// Wait for the in-progress flush to finish
|
|
534
|
+
await new Promise<void>((resolve) => this.flushWaiters.push(resolve));
|
|
535
|
+
// Flush any events that were enqueued during the previous flush
|
|
536
|
+
if (this.queue.length > 0 || this.pendingIdentifies.length) {
|
|
537
|
+
return this.flush(throwOnError, force);
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
if (now < this.retryAfterUntil || (!force && now < this.nextAttemptAt) || isOffline()) {
|
|
544
|
+
if (throwOnError) throw new Error('Waiting to retry');
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!this.flushCallback) {
|
|
549
|
+
this.logger.warn('No flush callback set, cannot flush queue');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
this.isFlushing = true;
|
|
554
|
+
let failure: string | null = null;
|
|
555
|
+
const generation = this.generation;
|
|
556
|
+
try {
|
|
557
|
+
// An identify whose request was refused together with events is
|
|
558
|
+
// retried ALONE after them, so a bad identify cannot make good events
|
|
559
|
+
// look bad (and the reverse).
|
|
560
|
+
let identifyAloneLater: PendingIdentify | null = null;
|
|
561
|
+
// Halves of a refused batch, waiting their turn (ahead of the queue).
|
|
562
|
+
const splits: QueuedEvent[][] = [];
|
|
563
|
+
// Bound the work to what was queued when the flush began, so a page
|
|
564
|
+
// that tracks continuously cannot keep one flush running forever.
|
|
565
|
+
let budget = this.queue.length;
|
|
566
|
+
// Identifies already sent alone in this flush (no second try now).
|
|
567
|
+
const triedAlone = new Set<PendingIdentify>();
|
|
568
|
+
|
|
569
|
+
for (;;) {
|
|
570
|
+
let batch: QueuedEvent[];
|
|
571
|
+
if (splits.length > 0) {
|
|
572
|
+
batch = splits.shift()!;
|
|
573
|
+
} else if (budget > 0) {
|
|
574
|
+
batch = this.takeBatch();
|
|
575
|
+
budget -= batch.length;
|
|
576
|
+
if (batch.length === 0) budget = 0;
|
|
577
|
+
} else if (
|
|
578
|
+
this.pendingIdentifies[0] &&
|
|
579
|
+
!triedAlone.has(this.pendingIdentifies[0]) &&
|
|
580
|
+
this.pendingIdentifies[0] !== identifyAloneLater &&
|
|
581
|
+
this.queue.length > 0 &&
|
|
582
|
+
identifyMatches(this.pendingIdentifies[0], [this.queue[0]])
|
|
583
|
+
) {
|
|
584
|
+
// An identify made while this flush was running: the events of its
|
|
585
|
+
// anonymous id queued before it go WITH it, not after it alone.
|
|
586
|
+
batch = this.takeBatch();
|
|
587
|
+
} else {
|
|
588
|
+
batch = [];
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// The oldest identify rides with the batch it follows - the events
|
|
592
|
+
// of the anonymous id it merges, or its own user's. A batch of
|
|
593
|
+
// another identity goes without it, and an identify with no batch
|
|
594
|
+
// left goes alone at the end.
|
|
595
|
+
const head = this.pendingIdentifies[0] || null;
|
|
596
|
+
let identify: PendingIdentify | null = null;
|
|
597
|
+
if (head && !triedAlone.has(head)) {
|
|
598
|
+
if (batch.length === 0) identify = head;
|
|
599
|
+
else if (head !== identifyAloneLater && identifyMatches(head, batch)) identify = head;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (batch.length === 0 && !identify) break;
|
|
603
|
+
if (batch.length === 0 && identify) triedAlone.add(identify);
|
|
604
|
+
|
|
605
|
+
this.logger.debug(`Flushing ${batch.length} events${identify ? ' + identify' : ''}`);
|
|
606
|
+
const outcome = await this.sendBatch(batch, identify, splits.flat());
|
|
607
|
+
|
|
608
|
+
if (this.generation !== generation) {
|
|
609
|
+
// clear() ran while this request was in flight (opt-out, consent
|
|
610
|
+
// denied): whatever the outcome, nothing of it may come back.
|
|
611
|
+
this.logger.debug('Queue cleared during a flush; the in-flight batch is not restored');
|
|
612
|
+
if (this.clearedInflightCallback) {
|
|
613
|
+
let delivered: QueuedEvent[] = [];
|
|
614
|
+
if (outcome.status === 'ok') {
|
|
615
|
+
const notStored = new Set([...(outcome.failedIndices || []), ...(outcome.rejectedIndices || [])]);
|
|
616
|
+
delivered = batch.filter((_, i) => !notStored.has(i));
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
this.clearedInflightCallback(delivered);
|
|
620
|
+
} catch (error) {
|
|
621
|
+
this.logger.warn('Cleared in-flight callback failed:', error);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (outcome.status === 'dropped') {
|
|
628
|
+
this.logger.error(
|
|
629
|
+
`${batch.length} event(s)${identify ? ' and an identify' : ''} of a user who is no longer signed in were ` +
|
|
630
|
+
`refused by SDK authentication (${outcome.reason || '401'}) and have been dropped`,
|
|
631
|
+
);
|
|
632
|
+
if (identify) this.removeIdentify(identify);
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (outcome.status === 'ok') {
|
|
637
|
+
const inBatch = (list?: number[]) =>
|
|
638
|
+
new Set((list || []).filter((i) => Number.isInteger(i) && i >= 0 && i < batch.length));
|
|
639
|
+
const rejected = inBatch(outcome.rejectedIndices);
|
|
640
|
+
for (const i of rejected) {
|
|
641
|
+
this.logger.error(`Event "${batch[i].type}" was refused by the server for good and has been dropped`);
|
|
642
|
+
}
|
|
643
|
+
const failed = [...inBatch(outcome.failedIndices)].filter((i) => !rejected.has(i)).map((i) => batch[i]);
|
|
644
|
+
|
|
645
|
+
if (identify && outcome.identifyDropped) {
|
|
646
|
+
this.logger.error(`Identify for ${identify.userId} was refused by the server; dropping it`);
|
|
647
|
+
this.removeIdentify(identify);
|
|
648
|
+
identify = null;
|
|
649
|
+
}
|
|
650
|
+
if (identify && outcome.identifyFailed) {
|
|
651
|
+
// The events were stored (bar `failedIndices`); the identify was
|
|
652
|
+
// not. Keep it and come back later rather than drop the merge.
|
|
653
|
+
this.identifyAttemptFailed(identify, outcome.identifyAttemptCounts !== false);
|
|
654
|
+
this.restoreToFront([...failed, ...splits.flat()]);
|
|
655
|
+
this.backOff();
|
|
656
|
+
failure = 'The identify was not confirmed';
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
if (identify) this.removeIdentify(identify);
|
|
660
|
+
this.consecutiveFailures = 0;
|
|
661
|
+
this.nextAttemptAt = 0;
|
|
662
|
+
if (failed.length > 0) {
|
|
663
|
+
// 2xx with `success: false`: the server stored the others. Keep
|
|
664
|
+
// only what it says it could not store, and come back later.
|
|
665
|
+
this.logger.warn(`${failed.length} of ${batch.length} events were not stored by the server; will retry them`);
|
|
666
|
+
this.restoreToFront([...failed, ...splits.flat()]);
|
|
667
|
+
this.backOff();
|
|
668
|
+
failure = 'Some events were not stored';
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (outcome.status === 'rejected') {
|
|
675
|
+
if (identify) {
|
|
676
|
+
if (batch.length === 0) {
|
|
677
|
+
this.logger.error(`Identify for ${identify.userId} was refused by the server (HTTP ${outcome.httpStatus ?? '4xx'}); dropping it`);
|
|
678
|
+
this.removeIdentify(identify);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
// Was it the identify or the events? Send the events without
|
|
682
|
+
// it; the identify goes alone at the end.
|
|
683
|
+
identifyAloneLater = identify;
|
|
684
|
+
splits.unshift(batch);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (batch.length > 1) {
|
|
688
|
+
const mid = Math.ceil(batch.length / 2);
|
|
689
|
+
splits.unshift(batch.slice(0, mid), batch.slice(mid));
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (batch.length === 1) {
|
|
693
|
+
this.logger.error(
|
|
694
|
+
`Event "${batch[0].type}" was refused by the server (HTTP ${outcome.httpStatus ?? '4xx'}) and has been dropped`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// retry / suspended: keep everything, in order.
|
|
701
|
+
this.restoreToFront([...batch, ...splits.flat()]);
|
|
702
|
+
if (identify && outcome.status === 'retry') this.identifyAttemptFailed(identify, outcome.counts === true);
|
|
703
|
+
if (outcome.status === 'suspended') {
|
|
704
|
+
if (!this.suspended) {
|
|
705
|
+
this.suspended = true;
|
|
706
|
+
// eslint-disable-next-line no-console -- the logger is silent by default and the site owner must see this
|
|
707
|
+
console.warn(
|
|
708
|
+
`[Joryio SDK] Event sending stopped for this page view (${outcome.reason || 'refused'}). ` +
|
|
709
|
+
`${this.queue.length} queued event(s) are kept.`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
if (this.flushTimer) {
|
|
713
|
+
clearInterval(this.flushTimer);
|
|
714
|
+
this.flushTimer = null;
|
|
715
|
+
}
|
|
716
|
+
failure = 'Sending is suspended';
|
|
717
|
+
} else {
|
|
718
|
+
this.backOff(outcome.retryAfterMs);
|
|
719
|
+
failure = 'Events could not be delivered; will retry';
|
|
720
|
+
}
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (this.persistQueue) {
|
|
725
|
+
this.persistQueueToStorage();
|
|
726
|
+
}
|
|
727
|
+
} finally {
|
|
728
|
+
this.isFlushing = false;
|
|
729
|
+
// Wake up any callers that were waiting for this flush to complete
|
|
730
|
+
const waiters = this.flushWaiters;
|
|
731
|
+
this.flushWaiters = [];
|
|
732
|
+
waiters.forEach((resolve) => resolve());
|
|
733
|
+
}
|
|
734
|
+
if (failure && throwOnError) {
|
|
735
|
+
throw new Error(failure);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Persist queue to storage
|
|
741
|
+
*/
|
|
742
|
+
private persistQueueToStorage(): void {
|
|
743
|
+
this.pendingPersist = false;
|
|
744
|
+
if (this.persistTimer !== null) {
|
|
745
|
+
clearTimeout(this.persistTimer);
|
|
746
|
+
this.persistTimer = null;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
this.storage.set(QUEUE_KEY, this.queue);
|
|
750
|
+
} catch (error) {
|
|
751
|
+
this.logger.error('Failed to persist queue to storage:', error);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Persist soon, coalescing a burst of events into ONE write.
|
|
757
|
+
*
|
|
758
|
+
* `enqueue` used to call [persistQueueToStorage] directly, so every single
|
|
759
|
+
* event re-serialized the WHOLE queue and wrote it to localStorage — both
|
|
760
|
+
* synchronous, on the main thread. That is O(queue) per event, i.e. O(N²) to
|
|
761
|
+
* fill the queue: measured at ~261ms of JSON.stringify alone to reach the
|
|
762
|
+
* 1000-event cap (vs ~1.5ms if each event were serialized once), before the
|
|
763
|
+
* localStorage write of a ~295KB string is even counted.
|
|
764
|
+
*
|
|
765
|
+
* It matters most in the worst place: the queue only grows when flushes are
|
|
766
|
+
* failing, so the pathological cost lands on a device that is already on a bad
|
|
767
|
+
* connection.
|
|
768
|
+
*
|
|
769
|
+
* Durability is unchanged in every case that can actually lose data — the
|
|
770
|
+
* unload path and flush persist synchronously (see [persistNow]) — so the only
|
|
771
|
+
* exposure is a hard crash inside the coalescing window.
|
|
772
|
+
*/
|
|
773
|
+
private schedulePersist(): void {
|
|
774
|
+
if (!this.persistQueue || this.pendingPersist) return;
|
|
775
|
+
this.pendingPersist = true;
|
|
776
|
+
this.persistTimer = setTimeout(() => {
|
|
777
|
+
this.persistTimer = null;
|
|
778
|
+
if (this.pendingPersist) this.persistQueueToStorage();
|
|
779
|
+
}, PERSIST_COALESCE_MS);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Write immediately if a coalesced write is still outstanding. Call before
|
|
784
|
+
* anything that must not lose events: page unload, flush, teardown.
|
|
785
|
+
*/
|
|
786
|
+
persistNow(): void {
|
|
787
|
+
if (this.persistQueue && this.pendingPersist) {
|
|
788
|
+
this.persistQueueToStorage();
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Persist in-flight events to storage so they survive page navigation
|
|
794
|
+
*/
|
|
795
|
+
private persistInflightToStorage(): void {
|
|
796
|
+
try {
|
|
797
|
+
if (this.inflightEvents.length > 0) {
|
|
798
|
+
this.storage.set(INFLIGHT_KEY, this.inflightEvents);
|
|
799
|
+
} else {
|
|
800
|
+
this.storage.remove(INFLIGHT_KEY);
|
|
801
|
+
}
|
|
802
|
+
} catch (error) {
|
|
803
|
+
this.logger.error('Failed to persist in-flight events to storage:', error);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Get queue size
|
|
809
|
+
*/
|
|
810
|
+
getQueueSize(): number {
|
|
811
|
+
return this.queue.length;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Get a copy of all queued AND in-flight events (for the page-unload send).
|
|
816
|
+
* In-flight events are included because the request may be canceled by navigation.
|
|
817
|
+
*/
|
|
818
|
+
getQueuedEvents(): QueuedEvent[] {
|
|
819
|
+
return [...this.inflightEvents, ...this.queue];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Queued events not yet handed to a request (no in-flight ones). */
|
|
823
|
+
getWaitingEvents(): QueuedEvent[] {
|
|
824
|
+
return [...this.queue];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** In-flight events (a request is carrying them now). */
|
|
828
|
+
getInflightEvents(): QueuedEvent[] {
|
|
829
|
+
return [...this.inflightEvents];
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** See clearedInflightCallback. */
|
|
833
|
+
setClearedInflightCallback(cb: ((delivered: QueuedEvent[]) => void) | null): void {
|
|
834
|
+
this.clearedInflightCallback = cb;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* The page is going away and these events (and identify) were handed to the
|
|
839
|
+
* browser in keepalive requests: stop holding them. ONLY these - events the
|
|
840
|
+
* unload path could not fit stay queued and persisted for the next page.
|
|
841
|
+
*/
|
|
842
|
+
markHandedOff(events: QueuedEvent[], identify: PendingIdentify | null): void {
|
|
843
|
+
const ids = new Set(events.map((e) => e.eventId));
|
|
844
|
+
this.queue = this.queue.filter((e) => !ids.has(e.eventId));
|
|
845
|
+
this.inflightEvents = this.inflightEvents.filter((e) => !ids.has(e.eventId));
|
|
846
|
+
if (identify) this.removeIdentify(identify);
|
|
847
|
+
if (this.persistQueue) {
|
|
848
|
+
this.persistQueueToStorage();
|
|
849
|
+
this.persistInflightToStorage();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* A keepalive hand-off failed and the page is still here (bfcache, a
|
|
855
|
+
* cancelled unload): take the events back.
|
|
856
|
+
*/
|
|
857
|
+
takeBack(events: QueuedEvent[], identify: PendingIdentify | null): void {
|
|
858
|
+
this.restoreToFront(events);
|
|
859
|
+
if (identify && !this.pendingIdentifies.includes(identify)) {
|
|
860
|
+
this.pendingIdentifies.unshift(identify);
|
|
861
|
+
this.persistIdentifies();
|
|
862
|
+
}
|
|
863
|
+
if (this.persistQueue) this.persistQueueToStorage();
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Clear the queue
|
|
868
|
+
*/
|
|
869
|
+
clear(): void {
|
|
870
|
+
this.generation++;
|
|
871
|
+
this.queue = [];
|
|
872
|
+
this.inflightEvents = [];
|
|
873
|
+
if (this.persistQueue) {
|
|
874
|
+
this.storage.remove(QUEUE_KEY);
|
|
875
|
+
this.storage.remove(INFLIGHT_KEY);
|
|
876
|
+
this.storage.remove(PENDING_IDENTIFY_KEY);
|
|
877
|
+
}
|
|
878
|
+
this.pendingIdentifies = [];
|
|
879
|
+
this.logger.debug('Queue cleared');
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Destroy the queue manager
|
|
884
|
+
*/
|
|
885
|
+
async destroy(): Promise<void> {
|
|
886
|
+
this.stop();
|
|
887
|
+
|
|
888
|
+
// Final flush attempt
|
|
889
|
+
if (this.queue.length > 0) {
|
|
890
|
+
this.logger.debug('Flushing remaining events before destroy');
|
|
891
|
+
await this.flush();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|