@corbado/observe 1.7.2 → 1.8.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 (65) hide show
  1. package/dist/analysis/client.mjs +1851 -0
  2. package/dist/analysis/config-manager.mjs +83 -0
  3. package/dist/analysis/config.mjs +216 -0
  4. package/dist/analysis/device-info-manager.mjs +153 -0
  5. package/dist/analysis/device.mjs +562 -0
  6. package/dist/analysis/index.mjs +118 -0
  7. package/dist/analysis/logger.mjs +50 -0
  8. package/dist/analysis/low-events/capture-sequence.mjs +11 -0
  9. package/dist/analysis/low-events/ceremony-initiator.mjs +26 -0
  10. package/dist/analysis/low-events/form-input-binding.mjs +46 -0
  11. package/dist/analysis/low-events/grouped-input-tracker.mjs +133 -0
  12. package/dist/analysis/low-events/input-change-recorder.mjs +200 -0
  13. package/dist/analysis/low-events/input-paste-observer.mjs +91 -0
  14. package/dist/analysis/low-events/input-tracker.mjs +223 -0
  15. package/dist/analysis/low-events/otp-input-binding.mjs +67 -0
  16. package/dist/analysis/low-events/shared-window-observer.mjs +225 -0
  17. package/dist/analysis/low-events/sink.mjs +0 -0
  18. package/dist/analysis/low-events/viewport-batcher.mjs +42 -0
  19. package/dist/analysis/low-events/window-tracker.mjs +40 -0
  20. package/dist/analysis/queue.mjs +887 -0
  21. package/dist/analysis/readers/device.mjs +73 -0
  22. package/dist/analysis/readers/meta.mjs +18 -0
  23. package/dist/analysis/readers/password-manager-dom.mjs +112 -0
  24. package/dist/analysis/readers/password-manager-extensions.mjs +247 -0
  25. package/dist/analysis/readers/private-mode.mjs +9 -0
  26. package/dist/analysis/storage/index.mjs +197 -0
  27. package/dist/analysis/storage/migration.mjs +56 -0
  28. package/dist/analysis/store/event-store.mjs +114 -0
  29. package/dist/analysis/transport.mjs +0 -0
  30. package/dist/analysis/transports/fetch.mjs +108 -0
  31. package/dist/analysis/types.mjs +25 -0
  32. package/dist/analysis/utils/appconfirmation-operation-full.mjs +19 -0
  33. package/dist/analysis/utils/blocked-user-agents.mjs +37 -0
  34. package/dist/analysis/utils/captcha-operation-full.mjs +17 -0
  35. package/dist/analysis/utils/email-link-continuity.mjs +50 -0
  36. package/dist/analysis/utils/email-link-operation-full.mjs +142 -0
  37. package/dist/analysis/utils/email-otp-operation-full.mjs +39 -0
  38. package/dist/analysis/utils/form-operation-full.mjs +81 -0
  39. package/dist/analysis/utils/key-operation-full.mjs +38 -0
  40. package/dist/analysis/utils/normalize-error.mjs +29 -0
  41. package/dist/analysis/utils/operation-full-provide-identifier-with-cui.mjs +46 -0
  42. package/dist/analysis/utils/operation-full.mjs +235 -0
  43. package/dist/analysis/utils/passkey-deletion-operation-full.mjs +17 -0
  44. package/dist/analysis/utils/passkey-enrollment-operation-full.mjs +32 -0
  45. package/dist/analysis/utils/passkey-login-operation-full.mjs +34 -0
  46. package/dist/analysis/utils/password-enrollment-operation-full.mjs +27 -0
  47. package/dist/analysis/utils/password-login-operation-full.mjs +61 -0
  48. package/dist/analysis/utils/password-manager-sampler.mjs +130 -0
  49. package/dist/analysis/utils/provide-data-operation-full.mjs +28 -0
  50. package/dist/analysis/utils/serialize-raw-error.mjs +205 -0
  51. package/dist/analysis/utils/sms-otp-operation-full.mjs +39 -0
  52. package/dist/analysis/utils/social-operation-login.mjs +17 -0
  53. package/dist/analysis/utils/source-path.mjs +39 -0
  54. package/dist/analysis/utils/sso-operation-login.mjs +37 -0
  55. package/dist/analysis/utils/totp-operation-full.mjs +35 -0
  56. package/dist/analysis/utils/trusted-device-operation-full.mjs +100 -0
  57. package/dist/analysis/utils/web-lock.mjs +52 -0
  58. package/dist/analysis/utils/webauthn-payloads.mjs +101 -0
  59. package/dist/analysis/version.mjs +7 -0
  60. package/dist/cdn/script.global.js +4 -4
  61. package/dist/index.d.mts +156 -4
  62. package/dist/index.d.ts +156 -4
  63. package/dist/index.js +4 -4
  64. package/dist/index.mjs +4 -4
  65. package/package.json +7 -2
@@ -0,0 +1,887 @@
1
+ // src/queue.ts
2
+ import { v7 as uuidv7 } from "uuid";
3
+ import { AuthEventName } from "./types.mjs";
4
+ import { CONFIG_REQUEST_HEADER, DEFAULT_RELIABILITY_CONFIG } from "./config.mjs";
5
+ import { EventStore } from "./store/event-store.mjs";
6
+ var PROBE_QUEUE_MAX_ITEMS = 500;
7
+ var PROBE_QUEUE_MAX_BYTES = 512 * 1024;
8
+ var PROBE_BATCH_MAX_ITEMS = 200;
9
+ var PROBE_BATCH_MAX_BYTES = 200 * 1024;
10
+ var PROBE_PAYLOAD_MAX_BYTES = 16 * 1024;
11
+ var UNLOAD_BUDGET_BYTES = 6e4;
12
+ var UNLOAD_PROBE_MAX_BYTES = 32e3;
13
+ function utf8Length(text) {
14
+ let bytes = 0;
15
+ for (let i = 0; i < text.length; i++) {
16
+ const code = text.charCodeAt(i);
17
+ if (code < 128) bytes += 1;
18
+ else if (code < 2048) bytes += 2;
19
+ else if (code >= 55296 && code <= 56319 && i + 1 < text.length) {
20
+ bytes += 4;
21
+ i++;
22
+ } else bytes += 3;
23
+ }
24
+ return bytes;
25
+ }
26
+ var RequestQueue = class {
27
+ constructor(logger, transport, sessionId, sdk, options = {}, storage) {
28
+ this.logger = logger;
29
+ this.transport = transport;
30
+ this.sessionId = sessionId;
31
+ this.sdk = sdk;
32
+ this.storage = storage;
33
+ this.batchSize = options.batchSize || 50;
34
+ this.flushIntervalOverride = options.flushInterval;
35
+ this.debug = options.debug ?? false;
36
+ this.requestConfig = options.requestConfig ?? true;
37
+ this.onConfigReceived = options.onConfigReceived;
38
+ this.getDataPolicy = options.getDataPolicy;
39
+ this.fixedData = options.fixedData === true;
40
+ this.beforeEventBatchSend = options.beforeEventBatchSend;
41
+ this.standaloneDeviceInfo = options.standaloneDeviceInfo;
42
+ this.config = options.config ?? DEFAULT_RELIABILITY_CONFIG;
43
+ this.priorityFlowTypes = new Set(this.config.flushOnFlowTypeFinished);
44
+ this.outboxLockName = options.outboxLockName;
45
+ if (this.config.durableOutbox && this.storage) {
46
+ this.store = new EventStore(this.storage, this.logger, void 0, void 0, this.outboxLockName);
47
+ this.pending = this.store.all();
48
+ }
49
+ if (this.storage) {
50
+ this.lowStore = new EventStore(
51
+ this.storage,
52
+ this.logger,
53
+ 1e3,
54
+ 256 * 1024,
55
+ `${this.outboxLockName ?? "cbo_outbox"}:lows`,
56
+ "cbo_low_outbox_v1"
57
+ );
58
+ const recovered = this.lowStore.all();
59
+ this.lowPending = recovered.filter((entry) => this.validPendingLow(entry));
60
+ void this.lowStore.remove(recovered.filter((entry) => !this.validPendingLow(entry)).map((entry) => entry.id));
61
+ }
62
+ this.setupLifecycleHooks();
63
+ if (this.pending.length > 0 || this.lowPending.length > 0) {
64
+ this.dirtySinceSyncFlush = true;
65
+ this.scheduleFlush(0, "recovery");
66
+ }
67
+ }
68
+ pending = [];
69
+ lowsQueue = [];
70
+ lowPending = [];
71
+ lowStore;
72
+ /**
73
+ * Diagnostic telemetry buffered for the current session. Best-effort and never persisted. Attached to the next outgoing batch for this session; a telemetry entry alone never arms
74
+ * the flush timer (it piggybacks), unless `config.flushOnTelemetry` escalates to an immediate flush.
75
+ */
76
+ telemetryQueue = [];
77
+ /**
78
+ * Probe items (`tracker.probe()`): in memory only — never the durable outbox — capped by
79
+ * {@link PROBE_QUEUE_MAX_ITEMS} / {@link PROBE_QUEUE_MAX_BYTES}. They arm the flush timer, ride
80
+ * the regular batches (probe-only batches allowed), retry under `config.retry`, and are the lowest
81
+ * priority at unload (a separate request after the event request, within the remaining budget).
82
+ */
83
+ probeQueue = [];
84
+ probeBytes = 0;
85
+ /** Probe items dropped on this load (caps, permanent rejection, exhausted retries, unload budget). */
86
+ probesDropped = 0;
87
+ timer = null;
88
+ isFlushing = false;
89
+ flushingDone;
90
+ destroyed = false;
91
+ /**
92
+ * True when something was enqueued (or recovered) since the last sync flush. visibilitychange,
93
+ * pagehide and per-input flushKeepalive calls all funnel into flushSync; this flag makes the
94
+ * duplicate calls no-ops. The async flush path ignores it.
95
+ */
96
+ dirtySinceSyncFlush = false;
97
+ onVisibilityChange = null;
98
+ onPageHide = null;
99
+ batchSize;
100
+ flushIntervalOverride;
101
+ debug;
102
+ requestConfig;
103
+ onConfigReceived;
104
+ getDataPolicy;
105
+ fixedData;
106
+ beforeEventBatchSend;
107
+ standaloneDeviceInfo;
108
+ /** Current delivery policy, including live updates. */
109
+ config;
110
+ /**
111
+ * Flow types whose completion triggers an immediate flush at enqueue time
112
+ * (config.flushOnFlowTypeFinished).
113
+ */
114
+ priorityFlowTypes;
115
+ /** Scoped Web Lock name for the durable outbox; undefined = EventStore's shared default. */
116
+ outboxLockName;
117
+ store;
118
+ /**
119
+ * Once the server has answered the config request with a 2xx (200 = fresh config cached for the
120
+ * next load, 204 = cached version still current) we stop sending the header for this load. A
121
+ * transport-level failure or error status keeps the request pending for the next flush.
122
+ */
123
+ configResolved = false;
124
+ setSessionId(sessionId) {
125
+ this.sessionId = sessionId;
126
+ }
127
+ flushInterval() {
128
+ return this.flushIntervalOverride ?? this.config.flushIntervalMs;
129
+ }
130
+ /**
131
+ * Error situations:
132
+ * - Any error while enqueueing or scheduling the flush timer: log "Failed to enqueue event"
133
+ */
134
+ enqueue(event, capturedSessionId = this.sessionId) {
135
+ try {
136
+ const entry = {
137
+ id: event.id,
138
+ sessionId: capturedSessionId,
139
+ flushAfter: 0,
140
+ attempts: 0,
141
+ event
142
+ };
143
+ this.pending.push(entry);
144
+ void this.store?.add(entry);
145
+ this.scheduleAfterEnqueue(event);
146
+ } catch (error) {
147
+ this.logger.error("Failed to enqueue event", error);
148
+ }
149
+ }
150
+ /**
151
+ * Error situations:
152
+ * - Any error while enqueueing or scheduling the flush timer: log "Failed to enqueue low event"
153
+ */
154
+ enqueueLow(low, capturedSessionId = this.sessionId) {
155
+ try {
156
+ const event = { ...low, id: low.id ?? uuidv7() };
157
+ if (this.config.durableLowOutbox) {
158
+ const entry = {
159
+ id: event.id,
160
+ sessionId: capturedSessionId,
161
+ event,
162
+ attempts: 0,
163
+ flushAfter: 0
164
+ };
165
+ this.lowPending.push(entry);
166
+ this.lowPending = this.lowPending.slice(-1e3);
167
+ void this.lowStore?.add(entry);
168
+ } else {
169
+ this.lowsQueue.push({ sessionId: capturedSessionId, event });
170
+ }
171
+ this.scheduleAfterEnqueue();
172
+ } catch (error) {
173
+ this.logger.error("Failed to enqueue low event", error);
174
+ }
175
+ }
176
+ /**
177
+ * A device info snapshot that no event carried is waiting ({@link QueueOptions.standaloneDeviceInfo}): arm the
178
+ * ordinary flush timer so it goes out even when no event follows, and let the next unload flush pick it up.
179
+ * An event tracked before the timer fires carries it instead.
180
+ *
181
+ * Error situations:
182
+ * - Any error while scheduling the flush: log "Failed to schedule device info flush"
183
+ */
184
+ notifyDeviceInfo() {
185
+ try {
186
+ this.dirtySinceSyncFlush = true;
187
+ this.scheduleFlush(this.flushInterval(), "timer");
188
+ } catch (error) {
189
+ this.logger.error("Failed to schedule device info flush", error);
190
+ }
191
+ }
192
+ /**
193
+ * Buffer a diagnostic telemetry entry for delivery with the next event batch.
194
+ *
195
+ * @remarks
196
+ * The switch `config.telemetry` gates collection. Entries buffered while on remain eligible for delivery.
197
+ * By default a telemetry entry does NOT arm the flush timer — it piggybacks the next flush caused
198
+ * by events or a lifecycle signal (it is marked dirty so the unload flush picks it up). When
199
+ * `config.flushOnTelemetry` is on it escalates to an immediate flush.
200
+ *
201
+ * Error situations:
202
+ * - Any error while enqueueing or triggering the flush: log "Failed to enqueue telemetry"
203
+ */
204
+ enqueueTelemetry(entry) {
205
+ try {
206
+ if (!this.config.telemetry) {
207
+ return;
208
+ }
209
+ this.telemetryQueue.push(entry);
210
+ this.dirtySinceSyncFlush = true;
211
+ if (this.config.flushOnTelemetry) {
212
+ void this.flush("telemetry");
213
+ }
214
+ } catch (error) {
215
+ this.logger.error("Failed to enqueue telemetry", error);
216
+ }
217
+ }
218
+ /**
219
+ * Queue a probe item (in memory only). The payload is snapshotted (serialized once) so later host
220
+ * mutations cannot change it; items over {@link PROBE_PAYLOAD_MAX_BYTES} are dropped, and the
221
+ * oldest items are dropped beyond the queue caps. Arms the normal flush timer.
222
+ *
223
+ * Error situations:
224
+ * - Any error while enqueueing or scheduling the flush timer: log "Failed to enqueue probe"
225
+ */
226
+ enqueueProbe(entry, capturedSessionId = this.sessionId) {
227
+ try {
228
+ if (this.destroyed) return;
229
+ const payloadJson = JSON.stringify(entry.payload);
230
+ if (typeof payloadJson !== "string" || utf8Length(payloadJson) > PROBE_PAYLOAD_MAX_BYTES) {
231
+ this.probesDropped++;
232
+ return;
233
+ }
234
+ const snapshot = { ...entry, payload: JSON.parse(payloadJson) };
235
+ const bytes = utf8Length(JSON.stringify(snapshot));
236
+ this.probeQueue.push({ sessionId: capturedSessionId, entry: snapshot, bytes, attempts: 0, flushAfter: 0 });
237
+ this.probeBytes += bytes;
238
+ while (this.probeQueue.length > PROBE_QUEUE_MAX_ITEMS || this.probeBytes > PROBE_QUEUE_MAX_BYTES && this.probeQueue.length > 1) {
239
+ const dropped = this.probeQueue.shift();
240
+ this.probeBytes -= dropped.bytes;
241
+ this.probesDropped++;
242
+ }
243
+ this.scheduleFlush(this.flushInterval(), "timer");
244
+ } catch (error) {
245
+ this.logger.error("Failed to enqueue probe", error);
246
+ }
247
+ }
248
+ /** Number of probe items dropped on this load (diagnostics / tests). */
249
+ droppedProbeCount() {
250
+ return this.probesDropped;
251
+ }
252
+ dueProbes(now) {
253
+ return this.probeQueue.filter((item) => item.flushAfter <= now);
254
+ }
255
+ /** Oldest-first selection for one session, within an item and byte cap (at least one item when any fits the item cap). */
256
+ selectProbes(candidates, sessionId, maxItems, maxBytes) {
257
+ const selected = [];
258
+ let bytes = 0;
259
+ for (const item of candidates) {
260
+ if (selected.length >= maxItems) break;
261
+ if (item.sessionId !== sessionId) continue;
262
+ if (bytes + item.bytes > maxBytes) {
263
+ if (selected.length === 0 && maxBytes >= PROBE_BATCH_MAX_BYTES) selected.push(item);
264
+ break;
265
+ }
266
+ bytes += item.bytes;
267
+ selected.push(item);
268
+ }
269
+ return selected;
270
+ }
271
+ removeProbes(items) {
272
+ if (items.length === 0) return;
273
+ const removed = new Set(items);
274
+ this.probeQueue = this.probeQueue.filter((item) => !removed.has(item));
275
+ for (const item of items) this.probeBytes -= item.bytes;
276
+ }
277
+ dropProbes(items) {
278
+ this.removeProbes(items);
279
+ this.probesDropped += items.length;
280
+ }
281
+ /** Same policy as events: 2xx delivered, non-retryable dropped, retryable backed off up to `config.retry.maxAttempts`. */
282
+ handleProbeSendResult(response, items, now) {
283
+ if (items.length === 0) return;
284
+ const status = response.statusCode;
285
+ if (typeof status === "number" && status >= 200 && status < 300) {
286
+ this.removeProbes(items);
287
+ return;
288
+ }
289
+ if (!this.isRetryable(status)) {
290
+ this.dropProbes(items);
291
+ return;
292
+ }
293
+ const maxAttempts = Math.max(1, this.config.retry.maxAttempts);
294
+ const retryAfterMs = this.parseRetryAfter(response);
295
+ const exhausted = [];
296
+ for (const item of items) {
297
+ item.attempts += 1;
298
+ if (item.attempts >= maxAttempts) exhausted.push(item);
299
+ else item.flushAfter = now + this.backoffForAttempt(item.attempts, retryAfterMs);
300
+ }
301
+ this.dropProbes(exhausted);
302
+ }
303
+ /**
304
+ * Unload / final teardown: one probe-only request for the current session within `budget` bytes,
305
+ * sent after the event request so the events' delivery is unaffected. Leftovers are dropped when
306
+ * `dropRest` (pagehide, destroy); otherwise they stay queued.
307
+ */
308
+ sendUnloadProbes(budget, reason, dropRest, useBeacon) {
309
+ const sessionId = this.sessionId;
310
+ const envelope = { sessionId, events: [], sdk: this.sdk, probes: [] };
311
+ this.attachBatchMeta(envelope, [], reason);
312
+ const overhead = utf8Length(JSON.stringify(envelope)) + 120;
313
+ const items = budget > overhead ? this.selectProbes(this.probeQueue, sessionId, PROBE_BATCH_MAX_ITEMS, budget - overhead) : [];
314
+ if (items.length > 0) {
315
+ envelope.probes = items.map((item) => item.entry);
316
+ this.attachBatchMeta(envelope, items, reason);
317
+ if (useBeacon && this.config.beaconKeepalive && this.transport.sendBeacon) {
318
+ this.transport.sendBeacon(envelope);
319
+ } else {
320
+ void Promise.resolve(this.transport.send(envelope)).catch(() => void 0);
321
+ }
322
+ this.removeProbes(items);
323
+ }
324
+ if (dropRest) this.dropProbes(this.probeQueue.slice());
325
+ }
326
+ scheduleAfterEnqueue(event) {
327
+ this.dirtySinceSyncFlush = true;
328
+ if (event !== void 0 && this.isPriorityEvent(event)) {
329
+ void this.flush("flow-finished");
330
+ return;
331
+ }
332
+ this.scheduleFlush(this.flushInterval(), "timer");
333
+ }
334
+ /**
335
+ * Whether this event completes one of the configured priority flow types. Only the two flow
336
+ * completion events qualify — `flow_finished` and `flow_auto_finished`, both carrying the
337
+ * finished flow in `data.flowName`. Custom events never match, even when named like one of them.
338
+ */
339
+ isPriorityEvent(event) {
340
+ if (event.type !== "predefined") {
341
+ return false;
342
+ }
343
+ if (event.name !== AuthEventName.FlowFinished && event.name !== AuthEventName.FlowAutoFinished) {
344
+ return false;
345
+ }
346
+ const flowName = event.data?.flowName;
347
+ return typeof flowName === "string" && this.priorityFlowTypes.has(flowName);
348
+ }
349
+ /**
350
+ * Arm the flush timer. The `reason` travels with the timer into the flush it triggers; an
351
+ * already-armed timer keeps the reason it was armed with (the flush it fires is that flush, and
352
+ * it takes whatever accumulated in the meantime with it).
353
+ */
354
+ scheduleFlush(delay, reason) {
355
+ if (this.destroyed) {
356
+ return;
357
+ }
358
+ if (this.timer) {
359
+ return;
360
+ }
361
+ this.timer = setTimeout(() => {
362
+ this.timer = null;
363
+ void this.flush(reason);
364
+ }, delay);
365
+ }
366
+ clearTimer() {
367
+ if (this.timer) {
368
+ clearTimeout(this.timer);
369
+ this.timer = null;
370
+ }
371
+ }
372
+ /**
373
+ * @param reason What triggered this flush; stamped onto every batch it sends as
374
+ * `meta.flushReason`. Defaults to the ordinary interval cadence.
375
+ *
376
+ * Error situations:
377
+ * - Queue is empty: return without calling transport.send
378
+ * - A flush is already in progress: return without calling transport.send again
379
+ * - transport.send rejects or throws, or any other error in the try block: log "Unexpected error in queue flush"
380
+ */
381
+ async flush(reason = "timer") {
382
+ if (this.destroyed) return;
383
+ if (this.isFlushing) {
384
+ if (reason === "destroy") {
385
+ await this.flushingDone;
386
+ try {
387
+ this.sendUnloadProbes(UNLOAD_PROBE_MAX_BYTES, reason, true, false);
388
+ } catch (error) {
389
+ this.logger.error("Unexpected error in queue flush", error);
390
+ }
391
+ }
392
+ return;
393
+ }
394
+ this.isFlushing = true;
395
+ let finish;
396
+ this.flushingDone = new Promise((resolve) => {
397
+ finish = resolve;
398
+ });
399
+ try {
400
+ this.clearTimer();
401
+ for (; ; ) {
402
+ const now = Date.now();
403
+ const due = this.getDuePending(now);
404
+ const dueLows = this.lowPending.filter((entry) => entry.flushAfter <= now);
405
+ const dueProbes = reason === "destroy" ? [] : this.dueProbes(now);
406
+ const onlyDeviceInfo = due.length === 0 && dueLows.length === 0 && this.lowsQueue.length === 0 && this.telemetryQueue.length === 0 && dueProbes.length === 0;
407
+ if (onlyDeviceInfo && !this.hasStandaloneDeviceInfo()) {
408
+ break;
409
+ }
410
+ const sessionId = due[0]?.sessionId ?? dueLows[0]?.sessionId ?? this.lowsQueue[0]?.sessionId ?? dueProbes[0]?.sessionId ?? this.sessionId;
411
+ const batchEntries = due.filter((entry) => entry.sessionId === sessionId).slice(0, this.batchSize);
412
+ const lowEntries = dueLows.filter((entry) => entry.sessionId === sessionId).slice(0, 100);
413
+ const lows = [...lowEntries.map((entry) => entry.event), ...this.takeLows(sessionId, 100 - lowEntries.length)];
414
+ this.prepareEvents(batchEntries, reason);
415
+ const telemetry = sessionId === this.sessionId ? this.takeTelemetry() : [];
416
+ const deviceInfo = sessionId === this.sessionId ? this.takeStandaloneDeviceInfo() : void 0;
417
+ const probeItems = this.selectProbes(dueProbes, sessionId, PROBE_BATCH_MAX_ITEMS, PROBE_BATCH_MAX_BYTES);
418
+ const batch = {
419
+ sessionId,
420
+ events: batchEntries.map((entry) => entry.event),
421
+ sdk: this.sdk
422
+ };
423
+ if (lows.length > 0) {
424
+ batch.lows = lows;
425
+ }
426
+ if (telemetry.length > 0) {
427
+ batch.telemetry = telemetry;
428
+ }
429
+ if (probeItems.length > 0) {
430
+ batch.probes = probeItems.map((item) => item.entry);
431
+ }
432
+ if (deviceInfo) {
433
+ batch.deviceInfo = deviceInfo;
434
+ }
435
+ if (onlyDeviceInfo && !deviceInfo) {
436
+ break;
437
+ }
438
+ this.attachBatchMeta(batch, [...batchEntries, ...lowEntries, ...probeItems], reason);
439
+ if (lowEntries.length) batch.deliveryAck = true;
440
+ const extraHeaders = this.shouldRequestConfig() ? { [CONFIG_REQUEST_HEADER]: this.config.version || "1" } : void 0;
441
+ let response = await this.transport.send(batch, extraHeaders).catch((error) => {
442
+ this.logger.error("Unexpected error in queue flush", error);
443
+ return {};
444
+ });
445
+ if (batch.deliveryAck && response.statusCode && response.statusCode >= 200 && response.statusCode < 300 && !response.deliveryAccepted) {
446
+ response = { ...response, statusCode: 503 };
447
+ }
448
+ if (this.requestConfig) this.handleConfigResponse(response);
449
+ this.handleLowSendResult(response, lowEntries, now);
450
+ this.handleProbeSendResult(response, probeItems, now);
451
+ if (!this.handleSendResult(response, batchEntries, now)) {
452
+ break;
453
+ }
454
+ }
455
+ if (reason === "destroy") {
456
+ this.sendUnloadProbes(UNLOAD_PROBE_MAX_BYTES, reason, true, false);
457
+ }
458
+ } catch (error) {
459
+ this.logger.error("Unexpected error in queue flush", error);
460
+ } finally {
461
+ this.isFlushing = false;
462
+ finish();
463
+ this.flushingDone = void 0;
464
+ this.rescheduleIfPending();
465
+ }
466
+ }
467
+ /**
468
+ * Stamps delivery metadata onto the batch: the trigger that caused this flush, the config version
469
+ * this load runs under (omitted on built-in defaults) and the number of prior failed delivery
470
+ * attempts (the max across its entries, only when at least one entry is a retry). `entry.attempts`
471
+ * is incremented on each failed flush, so it equals the retry count at send time. The data policy
472
+ * code (when one has ever been set) is read live, so it reflects the tracker state at send time.
473
+ * The transport later merges in `sent`/`transport`.
474
+ */
475
+ attachBatchMeta(batch, entries, reason) {
476
+ batch.meta = { ...batch.meta, flushReason: reason };
477
+ const dataPolicy = this.readDataPolicy();
478
+ if (dataPolicy !== void 0) {
479
+ batch.meta = { ...batch.meta, dataPolicy };
480
+ }
481
+ if (this.config.version) {
482
+ batch.meta = { ...batch.meta, configVersion: this.config.version };
483
+ }
484
+ if (this.fixedData) {
485
+ batch.meta = { ...batch.meta, fixedData: true };
486
+ }
487
+ const retryCount = entries.reduce((max, entry) => Math.max(max, entry.attempts), 0);
488
+ if (retryCount > 0) {
489
+ batch.meta = { ...batch.meta, retryCount };
490
+ }
491
+ }
492
+ /** Run the beforeEventBatchSend hook; a throwing hook must never break a flush. */
493
+ prepareEvents(entries, reason) {
494
+ if (!this.beforeEventBatchSend || entries.length === 0) {
495
+ return;
496
+ }
497
+ try {
498
+ this.beforeEventBatchSend(
499
+ entries.map((entry) => entry.event),
500
+ reason
501
+ );
502
+ } catch (error) {
503
+ this.logger.error("Failed to prepare events for sending", error);
504
+ }
505
+ }
506
+ /** The data policy code from the tracker, or undefined; a throwing getter must never break a flush. */
507
+ readDataPolicy() {
508
+ try {
509
+ return this.getDataPolicy?.();
510
+ } catch (error) {
511
+ this.logger.debug("Failed to read data policy for batch meta", error);
512
+ return void 0;
513
+ }
514
+ }
515
+ getDuePending(now) {
516
+ return this.pending.filter((entry) => entry.flushAfter <= now);
517
+ }
518
+ takeLows(sessionId = this.sessionId, limit = 100) {
519
+ const selected = this.lowsQueue.filter((entry) => entry.sessionId === sessionId).slice(0, limit);
520
+ const taken = new Set(selected);
521
+ this.lowsQueue = this.lowsQueue.filter((entry) => !taken.has(entry));
522
+ return selected.map((entry) => entry.event);
523
+ }
524
+ validPendingLow(entry) {
525
+ return typeof entry.event?.lowType === "string" && entry.event.id === entry.id && Number.isFinite(entry.event.ts) && entry.event.ts > Date.now() - 7 * 864e5;
526
+ }
527
+ removeLowEntries(ids) {
528
+ const removed = new Set(ids);
529
+ this.lowPending = this.lowPending.filter((entry) => !removed.has(entry.id));
530
+ void this.lowStore?.remove(ids);
531
+ }
532
+ handleLowSendResult(response, entries, now) {
533
+ const status = response.statusCode;
534
+ if (status !== void 0 && status >= 200 && status < 300) {
535
+ if (response.deliveryAccepted) this.removeLowEntries(entries.map((entry) => entry.id));
536
+ else this.deferLowEntries(entries, now);
537
+ return;
538
+ }
539
+ if (!this.isRetryable(status)) {
540
+ this.removeLowEntries(entries.map((entry) => entry.id));
541
+ this.logger.reportTelemetry?.("error", "low events rejected by ingestion");
542
+ return;
543
+ }
544
+ this.deferLowEntries(entries, now, this.parseRetryAfter(response));
545
+ }
546
+ deferLowEntries(entries, now, retryAfter = null) {
547
+ const expired = [];
548
+ for (const entry of entries) {
549
+ if (!this.validPendingLow(entry)) {
550
+ expired.push(entry.id);
551
+ continue;
552
+ }
553
+ entry.attempts = Math.min(entry.attempts + 1, 16);
554
+ entry.flushAfter = now + Math.max(
555
+ Math.min(6e4, 1e3 * 2 ** Math.min(entry.attempts, 6)),
556
+ this.backoffForAttempt(entry.attempts, retryAfter)
557
+ );
558
+ }
559
+ this.removeLowEntries(expired);
560
+ void this.lowStore?.update(entries.filter((entry) => !expired.includes(entry.id)));
561
+ }
562
+ hasStandaloneDeviceInfo() {
563
+ try {
564
+ return this.standaloneDeviceInfo?.has() ?? false;
565
+ } catch (error) {
566
+ this.logger.error("Failed to read device info for batch", error);
567
+ return false;
568
+ }
569
+ }
570
+ /** The snapshot for the request-level `deviceInfo`; a throwing source must never break a flush. */
571
+ takeStandaloneDeviceInfo() {
572
+ try {
573
+ return this.standaloneDeviceInfo?.take();
574
+ } catch (error) {
575
+ this.logger.error("Failed to read device info for batch", error);
576
+ return void 0;
577
+ }
578
+ }
579
+ takeTelemetry() {
580
+ const telemetry = this.telemetryQueue;
581
+ this.telemetryQueue = [];
582
+ return telemetry;
583
+ }
584
+ shouldRequestConfig() {
585
+ return this.requestConfig && !this.configResolved;
586
+ }
587
+ /**
588
+ * Handle the server's answer to the config request. With a cached boot config the boot-snapshot
589
+ * model applies: a fresh config is ONLY handed to `onConfigReceived` for caching and takes effect
590
+ * on the next load. A load that booted on the built-in defaults (nothing cached) has no prior
591
+ * behavior to stay coherent with — there the first server config is additionally applied live,
592
+ * so cold-storage visits (first visit, cleared storage) don't run their whole load without the
593
+ * reliability features.
594
+ */
595
+ handleConfigResponse(response) {
596
+ const status = response.statusCode;
597
+ if (typeof status === "number" && status >= 200 && status < 300) {
598
+ this.configResolved = true;
599
+ }
600
+ if (response.config) {
601
+ if (!this.config.version) {
602
+ this.updateConfig(response.config);
603
+ }
604
+ this.onConfigReceived?.(response.config);
605
+ }
606
+ }
607
+ /** Applies live delivery policy, retaining pending events, retry deadlines, and activated persistence. */
608
+ updateConfig(config) {
609
+ if (this.destroyed) return;
610
+ const intervalChanged = this.config.flushIntervalMs !== config.flushIntervalMs;
611
+ this.config = { ...config, durableOutbox: this.config.durableOutbox || config.durableOutbox };
612
+ this.priorityFlowTypes = new Set(config.flushOnFlowTypeFinished);
613
+ if (config.durableLowOutbox && this.lowsQueue.length) {
614
+ const queued = this.lowsQueue;
615
+ this.lowsQueue = [];
616
+ for (const { event, sessionId } of queued) this.enqueueLow(event, sessionId);
617
+ }
618
+ let recovered = false;
619
+ if (this.config.durableOutbox && this.storage && !this.store) {
620
+ this.store = new EventStore(this.storage, this.logger, void 0, void 0, this.outboxLockName);
621
+ const pendingIds = new Set(this.pending.map((entry) => entry.id));
622
+ const orphans = this.store.all().filter((entry) => !pendingIds.has(entry.id));
623
+ for (const entry of this.pending) void this.store.add(entry);
624
+ this.pending = [...orphans, ...this.pending];
625
+ recovered = orphans.length > 0;
626
+ }
627
+ if (recovered) {
628
+ this.dirtySinceSyncFlush = true;
629
+ this.clearTimer();
630
+ this.scheduleFlush(0, "recovery");
631
+ } else if (intervalChanged) {
632
+ this.clearTimer();
633
+ this.rescheduleIfPending();
634
+ }
635
+ }
636
+ handleSendResult(response, batchEntries, now) {
637
+ const status = response.statusCode;
638
+ const success = typeof status === "number" && status >= 200 && status < 300;
639
+ if (batchEntries.length === 0) {
640
+ return success;
641
+ }
642
+ if (success) {
643
+ this.removeEntries(batchEntries.map((entry) => entry.id));
644
+ return true;
645
+ }
646
+ if (!this.isRetryable(status)) {
647
+ this.removeEntries(batchEntries.map((entry) => entry.id));
648
+ return false;
649
+ }
650
+ const maxAttempts = Math.max(1, this.config.retry.maxAttempts);
651
+ const retryAfterMs = this.parseRetryAfter(response);
652
+ const exhausted = [];
653
+ const retried = [];
654
+ for (const entry of batchEntries) {
655
+ entry.attempts += 1;
656
+ if (entry.attempts >= maxAttempts) {
657
+ exhausted.push(entry.id);
658
+ } else {
659
+ entry.flushAfter = now + this.backoffForAttempt(entry.attempts, retryAfterMs);
660
+ retried.push(entry);
661
+ }
662
+ }
663
+ this.removeEntries(exhausted);
664
+ void this.store?.update(retried);
665
+ return false;
666
+ }
667
+ isRetryable(status) {
668
+ if (status === void 0) {
669
+ return true;
670
+ }
671
+ if (status === 408 || status === 429) {
672
+ return true;
673
+ }
674
+ return status >= 500;
675
+ }
676
+ /**
677
+ * Exponential backoff with equal jitter. The exponential ceiling is `baseDelayMs * 2^(attempt-1)`
678
+ * (capped at `maxDelayMs`); the actual delay is half that ceiling plus a random portion of the other
679
+ * half. The jitter spreads retries from many clients to avoid synchronized retry storms and needs no
680
+ * extra config. A `Retry-After` header, when present, acts as a lower bound.
681
+ */
682
+ backoffForAttempt(attempt, retryAfterMs) {
683
+ const retry = this.config.retry;
684
+ let ceiling = retry.baseDelayMs * Math.pow(2, Math.max(0, attempt - 1));
685
+ if (retry.maxDelayMs > 0) {
686
+ ceiling = Math.min(ceiling, retry.maxDelayMs);
687
+ }
688
+ let delay = Math.round(ceiling / 2 + Math.random() * (ceiling / 2));
689
+ if (retryAfterMs !== null) {
690
+ delay = Math.max(delay, retryAfterMs);
691
+ }
692
+ return delay;
693
+ }
694
+ parseRetryAfter(response) {
695
+ const raw = response.headers?.["retry-after"];
696
+ if (!raw) {
697
+ return null;
698
+ }
699
+ const seconds = Number(raw);
700
+ if (!Number.isNaN(seconds)) {
701
+ return seconds * 1e3;
702
+ }
703
+ const dateMs = Date.parse(raw);
704
+ if (!Number.isNaN(dateMs)) {
705
+ return Math.max(0, dateMs - Date.now());
706
+ }
707
+ return null;
708
+ }
709
+ removeEntries(ids) {
710
+ if (ids.length === 0) {
711
+ return;
712
+ }
713
+ const idSet = new Set(ids);
714
+ this.pending = this.pending.filter((entry) => !idSet.has(entry.id));
715
+ void this.store?.remove(ids);
716
+ }
717
+ rescheduleIfPending() {
718
+ try {
719
+ if (this.destroyed) {
720
+ return;
721
+ }
722
+ if (this.timer) {
723
+ return;
724
+ }
725
+ if (this.pending.length === 0 && this.lowPending.length === 0 && this.lowsQueue.length === 0 && this.probeQueue.length === 0) {
726
+ return;
727
+ }
728
+ const now = Date.now();
729
+ if (this.getDuePending(now).length > 0 || this.lowPending.some((entry) => entry.flushAfter <= now) || this.lowsQueue.length > 0 || this.probeQueue.some((item) => item.flushAfter <= now)) {
730
+ this.scheduleFlush(this.flushInterval(), "timer");
731
+ return;
732
+ }
733
+ const nextDue = Math.min(
734
+ ...[...this.pending, ...this.lowPending, ...this.probeQueue].map((entry) => entry.flushAfter)
735
+ );
736
+ this.scheduleFlush(Math.max(0, nextDue - now), "backoff");
737
+ } catch (error) {
738
+ this.logger.error("Failed to reschedule queue flush", error);
739
+ }
740
+ }
741
+ setupLifecycleHooks() {
742
+ if (typeof window === "undefined") return;
743
+ this.onVisibilityChange = () => {
744
+ try {
745
+ this.writeLifecycleDebugOutput("cbo_visibility_change_debug");
746
+ if (document.visibilityState === "hidden" && this.config.tvc) {
747
+ this.flushSync("visibility-change");
748
+ }
749
+ } catch (error) {
750
+ this.logger.error("Unexpected error in queue flushSync", error);
751
+ }
752
+ };
753
+ this.onPageHide = () => {
754
+ try {
755
+ this.writeLifecycleDebugOutput("cbo_pagehide_debug");
756
+ if (this.config.tph) {
757
+ this.flushSync("pagehide");
758
+ }
759
+ } catch (error) {
760
+ this.logger.error("Unexpected error in queue flushSync", error);
761
+ }
762
+ };
763
+ window.addEventListener("visibilitychange", this.onVisibilityChange);
764
+ window.addEventListener("pagehide", this.onPageHide);
765
+ }
766
+ writeLifecycleDebugOutput(key) {
767
+ if (!this.debug || !this.storage) {
768
+ return;
769
+ }
770
+ const output = {
771
+ last_executed: Date.now(),
772
+ url: window.location.href
773
+ };
774
+ this.storage.setItem(key, output);
775
+ }
776
+ /**
777
+ * Synchronously flush pending events (and best-effort lows) during page unload.
778
+ *
779
+ * @remarks
780
+ * When `config.beaconKeepalive` is on, uses `sendBeacon` (falling back to fetch keepalive when
781
+ * the UA refuses the beacon); otherwise fires a fire-and-forget `fetch` keepalive request. The
782
+ * send is capped at one `batchSize` chunk per call (unload transports share an in-flight budget
783
+ * with the host app). With `config.durableOutbox` enabled, entries are NOT removed (they are
784
+ * resent on the next load and deduped server-side); otherwise the sent chunk is removed
785
+ * best-effort and a remainder keeps the next lifecycle signal flush-worthy.
786
+ * Deduplicated: a call with nothing newly enqueued since the last sync flush is a no-op — so of
787
+ * several unload signals in one navigation only the first one that finds something to send is
788
+ * recorded as the flush reason.
789
+ *
790
+ * @param reason What triggered the unload flush; defaults to a direct call by the host app.
791
+ */
792
+ flushKeepalive(reason = "manual") {
793
+ this.flushSync(reason);
794
+ }
795
+ flushSync(reason) {
796
+ const eventBytes = this.flushSyncEvents(reason);
797
+ if (reason === "pagehide" && this.probeQueue.length > 0) {
798
+ this.sendUnloadProbes(Math.min(UNLOAD_BUDGET_BYTES - eventBytes, UNLOAD_PROBE_MAX_BYTES), reason, true, true);
799
+ }
800
+ }
801
+ /** The event part of the unload flush; returns the serialized size of what it sent (0 = nothing). */
802
+ flushSyncEvents(reason) {
803
+ if (!this.dirtySinceSyncFlush) {
804
+ return 0;
805
+ }
806
+ const sessionId = this.sessionId;
807
+ const sessionEntries = this.pending.filter((entry) => entry.sessionId === sessionId);
808
+ const entries = sessionEntries.slice(0, this.batchSize);
809
+ const lowEntries = this.lowPending.filter((entry) => entry.sessionId === sessionId).slice(0, 100);
810
+ const lows = [...lowEntries.map((entry) => entry.event), ...this.takeLows(sessionId, 100 - lowEntries.length)];
811
+ this.prepareEvents(entries, reason);
812
+ const telemetry = this.takeTelemetry();
813
+ const deviceInfo = this.takeStandaloneDeviceInfo();
814
+ if (entries.length === 0 && lows.length === 0 && telemetry.length === 0 && !deviceInfo) {
815
+ this.dirtySinceSyncFlush = false;
816
+ return 0;
817
+ }
818
+ const batch = {
819
+ sessionId,
820
+ events: entries.map((entry) => entry.event),
821
+ sdk: this.sdk
822
+ };
823
+ if (lows.length > 0) {
824
+ batch.lows = lows;
825
+ }
826
+ if (telemetry.length > 0) {
827
+ batch.telemetry = telemetry;
828
+ }
829
+ if (deviceInfo) {
830
+ batch.deviceInfo = deviceInfo;
831
+ }
832
+ this.attachBatchMeta(batch, [...entries, ...lowEntries], reason);
833
+ if (lowEntries.length) batch.deliveryAck = true;
834
+ if (this.config.beaconKeepalive && this.transport.sendBeacon) {
835
+ if (!this.transport.sendBeacon(batch)) {
836
+ void this.transport.send(batch);
837
+ }
838
+ } else {
839
+ void this.transport.send(batch);
840
+ }
841
+ if (!this.config.durableOutbox) {
842
+ this.removeEntries(entries.map((entry) => entry.id));
843
+ }
844
+ this.dirtySinceSyncFlush = !this.config.durableOutbox && sessionEntries.length > entries.length;
845
+ return this.approximateSize(batch);
846
+ }
847
+ /** Serialized batch size for the unload budget; on failure assume the whole budget was used. */
848
+ approximateSize(batch) {
849
+ try {
850
+ return utf8Length(JSON.stringify(batch));
851
+ } catch {
852
+ return UNLOAD_BUDGET_BYTES;
853
+ }
854
+ }
855
+ /**
856
+ * Error situations:
857
+ * - Timer clearance or event listener removal throws: log "Failed to destroy queue lifecycle hooks"
858
+ */
859
+ destroy() {
860
+ try {
861
+ this.destroyed = true;
862
+ this.clearTimer();
863
+ this.dropProbes(this.probeQueue.slice());
864
+ if (typeof window !== "undefined") {
865
+ if (this.onVisibilityChange) {
866
+ window.removeEventListener("visibilitychange", this.onVisibilityChange);
867
+ }
868
+ if (this.onPageHide) {
869
+ window.removeEventListener("pagehide", this.onPageHide);
870
+ }
871
+ }
872
+ } catch (error) {
873
+ this.logger.error("Failed to destroy queue lifecycle hooks", error);
874
+ }
875
+ }
876
+ };
877
+ export {
878
+ PROBE_BATCH_MAX_BYTES,
879
+ PROBE_BATCH_MAX_ITEMS,
880
+ PROBE_PAYLOAD_MAX_BYTES,
881
+ PROBE_QUEUE_MAX_BYTES,
882
+ PROBE_QUEUE_MAX_ITEMS,
883
+ RequestQueue,
884
+ UNLOAD_BUDGET_BYTES,
885
+ UNLOAD_PROBE_MAX_BYTES,
886
+ utf8Length
887
+ };