onerway-analytics 1.2.0 → 1.5.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/README.md CHANGED
@@ -214,7 +214,8 @@ const analytics = createAnalytics({
214
214
  | `keyEvents` | `string[]` | — | 关键事件名单,命中的事件立即发送 |
215
215
  | `bufferFlushInterval` | `number` | `5000` | 缓冲区定时刷新间隔(ms) |
216
216
  | `bufferMaxSize` | `number` | `25` | 缓冲区达到此数量立即刷新 |
217
- | `maxRetryCount` | `number` | `3` | 发送失败最大重试次数(指数退避) |
217
+ | `maxRetryCount` | `number` | `3` | 同一事件重发预算(含离页补发),上限 5;预算跨页面保留 |
218
+ | `persistKeyEvents` | `boolean` | `false` | 将关键事件保存到本地直到 HTTP 确认;仅对属性可安全持久化的应用启用 |
218
219
  | `sessionTimeout` | `number` | `1800000` | 会话超时(ms),默认 30 分钟 |
219
220
  | `performance.enabled` | `boolean` | `true` | Core Web Vitals 采集开关 |
220
221
  | `gtm.enabled` | `boolean` | — | GTM 双发开关 |
@@ -314,9 +315,13 @@ const analytics = createAnalytics({
314
315
  ## 可靠性机制
315
316
 
316
317
  - **缓冲区 + 批量发送**:事件先入缓冲区,满 `bufferMaxSize` 或到 `bufferFlushInterval` 后批量发送
317
- - **指数退避重试**:发送失败最多重试 `maxRetryCount` 次,间隔 1s / 2s / 4s…
318
- - **本地持久化**:超出重试次数的事件写入 localStorage,下次页面加载时自动恢复补发(24 小时内有效)
319
- - **页面关闭兜底**:`beforeunload` / `pagehide` 时用 `sendBeacon` 发送缓冲区剩余事件
318
+ - **有界重试**:网络错误、408、429、5xx 最多重试 `maxRetryCount` 次(上限 5),指数退避加随机抖动;其他 4xx 终止。fetch/XHR 均有 10 秒超时。
319
+ - **可选关键事件持久化**:`persistKeyEvents: true` 时,关键事件发送前保存完整 payload,HTTP 2xx 后移除;重启保留 event_id 和原页面归属,按 appKey/serverUrl 隔离。自动事件不落盘;队列最多 500 条、24 小时有效,超出重试预算或不可重试错误终止。存储不可用时降为内存发送。
320
+ - **页面生命周期**:visibilitychange(hidden) / pagehide 提前刷新,pageshow/visible 恢复正常发送;原请求与离页副本任一 HTTP 2xx 即确认,另一个副本后续失败不会恢复已确认事件。全部请求使用 apiKey header;正常使用普通 fetch,隐藏时使用 keepalive,缺少 fetch 时正常页面降级异步 XHR。无 URI apiKey 或同步 XHR。
321
+ - **首次即时发送**:正常页面的首次请求直接发送,不受两个并发的队列限制;只有失败重试和历史恢复共用两个并发。浏览器仍决定实际网络调度。
322
+ - **离页合批**:收集所有未确认事件(包括正常请求在途的副本),关键事件优先,按相同 client/session/context/marketing/user 等请求上下文合并。一次回调内直接提交全部预算内批次,不等待前一批响应。
323
+ - **总字节控制**:单批及同一隐藏周期离页提交总量限制为 48 KiB UTF-8 字节,同时扣除尚未完成的 keepalive 在途字节;不同生命周期回调不重复提交同一隐藏周期已提交的事件。单事件超大或待确认队列满时丢弃新增事件。SDK 无法控制其他库占用的 keepalive 额度。
324
+ - **送达边界**:服务端必须按 event_id 去重;HTTP 2xx 仅作为传输确认。离页后后续排队请求不保证继续执行,已持久化的关键事件等下次同源访问恢复;用户不再访问、存储不可用、超过上限/有效期/重试预算时仍可能丢失。自动事件不保证跨页面补发。
320
325
  - **采样控制**:`sampleRate` 在客户端决定是否上报,降低高流量场景的服务器压力
321
326
 
322
327
  ---
@@ -335,3 +340,11 @@ analytics = createAnalytics({ ... });
335
340
  analytics = createAnalytics({ ..., autoTrackPageView: false });
336
341
  analytics.track('page_view', { page_url: location.href });
337
342
  ```
343
+
344
+ ## 发送可靠性验证(1.3.2)
345
+
346
+ - `npm ci`,`npm run typecheck`,`npm test -- --runInBand`。
347
+ - `npm run test:browser`:Playwright + 本地 HTTP/CORS 接收端,覆盖密集请求、断网恢复、刷新、原生 pagehide、关闭重开、401。默认使用 macOS 已安装 Chrome;其他环境可设置 `CHROME_PATH` 或先安装 Playwright Chromium。
348
+ - `npm pack` 会重新构建模块及浏览器包,排除测试产物。生产 APISIX 鉴权、真实入库和统计去重需要部署后单独验收;本地测试不调用生产接口。
349
+
350
+ - `npm run test:browser:comparison`:使用相邻收银台实际安装的 Axios 对照 V1 传输(可用 `CHECKOUT_DIR` 指定仓库路径)。每场景双写 24 个事件,覆盖正常立即跳转、已有请求积压的慢网跳转、慢网关闭;离开后不再访问或初始化 SDK,接收端比较 V1 已收到的事件是否都包含在 V2 中。V1 省略指纹等待,使其发送时机更有利;这不是完整支付业务 E2E,也不代表生产零漏报。
@@ -67,7 +67,7 @@ var OnrwayAnalytics = (() => {
67
67
  prod: "HUk5Kogyuv9zH5qDA2qSCjVEnhanSkf8",
68
68
  production: "HUk5Kogyuv9zH5qDA2qSCjVEnhanSkf8"
69
69
  };
70
- var SDK_VERSION = "1.0.0";
70
+ var SDK_VERSION = "1.3.2";
71
71
  var DEFAULT_CONFIG = {
72
72
  debug: false,
73
73
  autoTrack: true,
@@ -322,110 +322,275 @@ var OnrwayAnalytics = (() => {
322
322
  };
323
323
 
324
324
  // src/core/sender.ts
325
+ var DeliveryError = class extends Error {
326
+ constructor(message, retryable) {
327
+ super(message);
328
+ this.retryable = retryable;
329
+ }
330
+ };
325
331
  var Sender = class _Sender {
326
- static async send(payload, serverUrl, apiKey) {
327
- if (_Sender.supportsKeepalive()) {
328
- return _Sender.fetchSend(payload, serverUrl, apiKey);
332
+ static async send(payload, serverUrl, apiKey, keepalive = false) {
333
+ if (typeof fetch === "function" && typeof AbortController !== "undefined") {
334
+ if (keepalive && !_Sender.supportsKeepalive()) {
335
+ throw new DeliveryError("Keepalive unavailable", true);
336
+ }
337
+ return _Sender.fetchSend(payload, serverUrl, apiKey, keepalive);
329
338
  }
339
+ if (keepalive) throw new DeliveryError("Unload transport unavailable", true);
330
340
  return _Sender.xhrSend(payload, serverUrl, apiKey);
331
341
  }
332
- static async sendBeacon(payload, serverUrl, apiKey) {
333
- if (typeof navigator === "undefined" || !navigator.sendBeacon) {
334
- throw new Error("sendBeacon not available");
335
- }
336
- const url = _Sender.buildBeaconUrl(serverUrl, apiKey);
337
- const blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
338
- const success = navigator.sendBeacon(url, blob);
339
- if (!success) {
340
- throw new Error("sendBeacon rejected payload");
341
- }
342
+ // Compatibility entry point: authenticated delivery must support custom headers.
343
+ static sendBeacon(payload, serverUrl, apiKey) {
344
+ return _Sender.send(payload, serverUrl, apiKey, true);
342
345
  }
343
- static async fetchSend(payload, serverUrl, apiKey) {
344
- const response = await fetch(serverUrl, {
345
- method: "POST",
346
- headers: {
347
- "Content-Type": "application/json",
348
- apiKey
349
- },
350
- body: JSON.stringify(payload),
351
- keepalive: true
352
- });
353
- if (!response.ok) {
354
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
346
+ static async fetchSend(payload, serverUrl, apiKey, keepalive = false) {
347
+ const controller = new AbortController();
348
+ const timer = setTimeout(() => controller.abort(), 1e4);
349
+ try {
350
+ const response = await fetch(serverUrl, {
351
+ method: "POST",
352
+ headers: { "Content-Type": "application/json", apiKey },
353
+ body: JSON.stringify(payload),
354
+ keepalive,
355
+ signal: controller.signal
356
+ });
357
+ if (!response.ok) throw _Sender.httpError(response.status);
358
+ } finally {
359
+ clearTimeout(timer);
355
360
  }
356
361
  }
357
- static async xhrSend(payload, serverUrl, apiKey) {
362
+ static httpError(status) {
363
+ return new DeliveryError(`HTTP ${status}`, status === 408 || status === 429 || status >= 500);
364
+ }
365
+ static xhrSend(payload, serverUrl, apiKey) {
358
366
  return new Promise((resolve, reject) => {
359
367
  const xhr = new XMLHttpRequest();
360
- xhr.open("POST", serverUrl);
368
+ xhr.open("POST", serverUrl, true);
361
369
  xhr.setRequestHeader("Content-Type", "application/json");
362
370
  xhr.setRequestHeader("apiKey", apiKey);
363
371
  xhr.timeout = 1e4;
364
- xhr.onload = () => {
365
- if (xhr.status >= 200 && xhr.status < 300) {
366
- resolve();
367
- } else {
368
- reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
369
- }
370
- };
371
- xhr.onerror = () => reject(new Error("XHR network error"));
372
- xhr.ontimeout = () => reject(new Error("XHR timeout"));
372
+ xhr.onload = () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(_Sender.httpError(xhr.status));
373
+ xhr.onerror = () => reject(new DeliveryError("XHR network error", true));
374
+ xhr.ontimeout = () => reject(new DeliveryError("XHR timeout", true));
375
+ xhr.onabort = () => reject(new DeliveryError("XHR aborted", true));
373
376
  xhr.send(JSON.stringify(payload));
374
377
  });
375
378
  }
376
379
  static supportsKeepalive() {
377
- try {
378
- return typeof Request !== "undefined" && "keepalive" in Request.prototype;
379
- } catch {
380
- return false;
381
- }
382
- }
383
- static buildBeaconUrl(serverUrl, apiKey) {
384
- const separator = serverUrl.includes("?") ? "&" : "?";
385
- return `${serverUrl}${separator}apiKey=${encodeURIComponent(apiKey)}`;
380
+ return typeof Request !== "undefined" && "keepalive" in Request.prototype;
386
381
  }
387
382
  };
388
383
 
389
- // src/core/storage.ts
390
- var MAX_EVENT_AGE_MS = 24 * 60 * 60 * 1e3;
391
- var StorageQueue = class _StorageQueue {
392
- static enqueue(event) {
393
- try {
394
- const queue = _StorageQueue.readQueue();
395
- queue.push({ event, storedAt: Date.now() });
396
- while (queue.length > MAX_STORAGE_ITEMS) {
397
- queue.shift();
384
+ // src/core/delivery-queue.ts
385
+ var MAX_BYTES = 48 * 1024;
386
+ var MAX_EVENTS = 500;
387
+ var MAX_AGE = 24 * 60 * 60 * 1e3;
388
+ var DeliveryQueue = class {
389
+ constructor(options) {
390
+ this.options = options;
391
+ this.jobs = [];
392
+ this.activeRetries = 0;
393
+ this.hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
394
+ this.timer = null;
395
+ this.stopped = false;
396
+ this.hiddenSent = /* @__PURE__ */ new Set();
397
+ this.hiddenSpent = 0;
398
+ this.keepaliveBytes = 0;
399
+ this.maxAttempts = 1 + Math.min(5, Math.max(0, options.maxRetries || 0));
400
+ this.storagePrefix = `__analytics_delivery_v1__:${encodeURIComponent(options.appKey)}:${encodeURIComponent(options.serverUrl)}:`;
401
+ if (options.persistKeyEvents && options.apiKey) this.recover();
402
+ this.pumpRetries();
403
+ }
404
+ enqueue(payload) {
405
+ if (this.stopped || !this.options.apiKey) return;
406
+ const accepted = [];
407
+ for (const event of payload.events) {
408
+ if (this.jobs.length >= MAX_EVENTS) break;
409
+ const single = { ...payload, events: [event] };
410
+ if (this.bytes(single) > MAX_BYTES) continue;
411
+ const job = {
412
+ payload: single,
413
+ attempts: 0,
414
+ createdAt: Date.now(),
415
+ nextAt: 0,
416
+ active: 0,
417
+ hiddenActive: false,
418
+ terminal: false
419
+ };
420
+ this.jobs.push(job);
421
+ this.persist(job);
422
+ accepted.push(job);
423
+ }
424
+ if (this.hidden) {
425
+ this.flushHidden();
426
+ } else {
427
+ for (const batch of this.batches(accepted, MAX_BYTES)) this.start(batch.payload, batch.jobs, false, false);
428
+ }
429
+ }
430
+ setHidden(hidden) {
431
+ if (hidden && !this.hidden) {
432
+ this.hiddenSent.clear();
433
+ this.hiddenSpent = 0;
434
+ }
435
+ this.hidden = hidden;
436
+ if (this.timer) {
437
+ clearTimeout(this.timer);
438
+ this.timer = null;
439
+ }
440
+ if (!hidden) this.pumpRetries();
441
+ }
442
+ /** Submit the whole affordable rescue set synchronously; do not wait for ACKs. */
443
+ flushHidden() {
444
+ if (this.stopped || !this.hidden) return;
445
+ const available = Math.min(MAX_BYTES - this.hiddenSpent, MAX_BYTES - this.keepaliveBytes);
446
+ if (available <= 0) return;
447
+ this.prune();
448
+ const candidates = this.jobs.filter((job) => !job.terminal && !job.hiddenActive && !this.hiddenSent.has(job) && job.attempts < this.maxAttempts).sort((a2, b2) => Number(b2.payload.events[0].key_event) - Number(a2.payload.events[0].key_event));
449
+ let remaining = available;
450
+ for (const batch of this.batches(candidates, available, true)) {
451
+ const bytes = this.bytes(batch.payload);
452
+ if (bytes > remaining) continue;
453
+ remaining -= bytes;
454
+ this.hiddenSpent += bytes;
455
+ for (const job of batch.jobs) this.hiddenSent.add(job);
456
+ this.start(batch.payload, batch.jobs, true, false);
457
+ }
458
+ }
459
+ bytes(payload) {
460
+ return new Blob([JSON.stringify(payload)]).size;
461
+ }
462
+ batches(jobs, budget, totalBudget = false) {
463
+ const result = [];
464
+ let remaining = budget;
465
+ const groups = /* @__PURE__ */ new Map();
466
+ for (const job of jobs) {
467
+ const { events, ...envelope } = job.payload;
468
+ const key = JSON.stringify(envelope);
469
+ let group = groups.get(key);
470
+ const proposed = { ...envelope, events: [...group?.payload.events || [], ...events].map((event, index) => ({ ...event, batch_event_index: index })) };
471
+ const delta = this.bytes(proposed) - (group ? this.bytes(group.payload) : 0);
472
+ if (totalBudget && delta > remaining) continue;
473
+ if (this.bytes(proposed) > budget) {
474
+ group = void 0;
398
475
  }
399
- safeLocalStorageSet(STORAGE_KEYS.FAILED_QUEUE, JSON.stringify(queue));
400
- } catch {
476
+ if (!group) {
477
+ group = { payload: { ...envelope, events: events.map((event, index) => ({ ...event, batch_event_index: index })) }, jobs: [] };
478
+ groups.set(key, group);
479
+ result.push(group);
480
+ } else {
481
+ group.payload = proposed;
482
+ }
483
+ group.jobs.push(job);
484
+ if (totalBudget) remaining -= delta;
401
485
  }
486
+ return result;
402
487
  }
403
- static recover() {
488
+ prune() {
489
+ for (const job of [...this.jobs]) {
490
+ if (!job.active && (job.terminal || Date.now() - job.createdAt >= MAX_AGE || job.attempts >= this.maxAttempts)) this.remove(job);
491
+ }
492
+ }
493
+ pumpRetries() {
494
+ if (this.stopped || this.hidden) return;
495
+ if (this.timer) {
496
+ clearTimeout(this.timer);
497
+ this.timer = null;
498
+ }
499
+ this.prune();
500
+ while (this.activeRetries < 2) {
501
+ const job = this.jobs.find((item) => !item.active && item.nextAt <= Date.now());
502
+ if (!job) break;
503
+ this.start(job.payload, [job], false, true);
504
+ }
505
+ const waiting = this.jobs.filter((job) => !job.active);
506
+ if (waiting.length && this.activeRetries < 2) {
507
+ const delay = Math.max(1, Math.min(...waiting.map((job) => job.nextAt)) - Date.now());
508
+ this.timer = setTimeout(() => this.pumpRetries(), delay);
509
+ }
510
+ }
511
+ start(payload, jobs, keepalive, retry) {
512
+ const bytes = keepalive ? this.bytes(payload) : 0;
513
+ this.keepaliveBytes += bytes;
514
+ if (retry) this.activeRetries++;
515
+ for (const job of jobs) {
516
+ job.active++;
517
+ job.attempts++;
518
+ if (keepalive) job.hiddenActive = true;
519
+ this.persist(job);
520
+ }
521
+ void this.deliver(payload, jobs, keepalive, retry, bytes);
522
+ }
523
+ async deliver(payload, jobs, keepalive, retry, bytes) {
404
524
  try {
405
- const queue = _StorageQueue.readQueue();
406
- _StorageQueue.clear();
407
- const now = Date.now();
408
- const events = queue.filter((item) => now - item.storedAt < MAX_EVENT_AGE_MS).map((item) => item.event);
409
- return events;
410
- } catch {
411
- return [];
525
+ await Sender.send(payload, this.options.serverUrl, this.options.apiKey, keepalive);
526
+ for (const job of jobs) this.remove(job);
527
+ } catch (error) {
528
+ for (const job of jobs) {
529
+ if (!this.jobs.includes(job)) continue;
530
+ if (error instanceof DeliveryError && !error.retryable) job.terminal = true;
531
+ job.nextAt = Date.now() + Math.min(3e4, 1e3 * 2 ** (job.attempts - 1)) + Math.floor(Math.random() * 250);
532
+ }
533
+ } finally {
534
+ for (const job of jobs) {
535
+ job.active--;
536
+ if (keepalive) job.hiddenActive = false;
537
+ if (!this.jobs.includes(job)) continue;
538
+ if (!job.active && (job.terminal || job.attempts >= this.maxAttempts)) this.remove(job);
539
+ else this.persist(job);
540
+ }
541
+ this.keepaliveBytes -= bytes;
542
+ if (retry) this.activeRetries--;
543
+ this.pumpRetries();
412
544
  }
413
545
  }
414
- static clear() {
415
- safeLocalStorageRemove(STORAGE_KEYS.FAILED_QUEUE);
546
+ key(eventId) {
547
+ return this.storagePrefix + eventId;
416
548
  }
417
- static getQueueSize() {
418
- return _StorageQueue.readQueue().length;
549
+ persist(job) {
550
+ if (!this.options.persistKeyEvents || !job.payload.events[0].key_event) return;
551
+ safeLocalStorageSet(this.key(job.payload.events[0].event_id), JSON.stringify({
552
+ payload: job.payload,
553
+ attempts: job.attempts,
554
+ createdAt: job.createdAt,
555
+ nextAt: job.nextAt
556
+ }));
419
557
  }
420
- static readQueue() {
558
+ remove(job) {
559
+ if (!this.jobs.includes(job)) return;
560
+ this.jobs = this.jobs.filter((item) => item !== job);
561
+ this.hiddenSent.delete(job);
562
+ if (this.options.persistKeyEvents) safeLocalStorageRemove(this.key(job.payload.events[0].event_id));
563
+ }
564
+ recover() {
421
565
  try {
422
- const raw = safeLocalStorageGet(STORAGE_KEYS.FAILED_QUEUE);
423
- if (!raw) return [];
424
- const parsed = JSON.parse(raw);
425
- if (!Array.isArray(parsed)) return [];
426
- return parsed;
566
+ const keys = Object.keys(localStorage).filter((key) => key.startsWith(this.storagePrefix));
567
+ for (const key of keys) {
568
+ try {
569
+ const job = JSON.parse(safeLocalStorageGet(key) || "null");
570
+ if (!job || !Number.isFinite(job.createdAt) || !Number.isInteger(job.attempts) || job.attempts < 0 || !Number.isFinite(job.nextAt) || Date.now() - job.createdAt >= MAX_AGE || job.attempts >= this.maxAttempts || !Array.isArray(job.payload?.events) || job.payload.events.length !== 1 || !job.payload.events[0].key_event || this.key(job.payload.events[0].event_id) !== key || this.bytes(job.payload) > MAX_BYTES || this.jobs.length >= MAX_EVENTS) {
571
+ safeLocalStorageRemove(key);
572
+ continue;
573
+ }
574
+ this.jobs.push({ ...job, active: 0, hiddenActive: false, terminal: false });
575
+ } catch {
576
+ safeLocalStorageRemove(key);
577
+ }
578
+ }
427
579
  } catch {
428
- return [];
580
+ }
581
+ }
582
+ clear() {
583
+ for (const job of [...this.jobs]) this.remove(job);
584
+ if (this.timer) {
585
+ clearTimeout(this.timer);
586
+ this.timer = null;
587
+ }
588
+ }
589
+ destroy() {
590
+ this.stopped = true;
591
+ if (this.timer) {
592
+ clearTimeout(this.timer);
593
+ this.timer = null;
429
594
  }
430
595
  }
431
596
  };
@@ -1006,8 +1171,8 @@ var OnrwayAnalytics = (() => {
1006
1171
  this.userId = null;
1007
1172
  this.userProps = {};
1008
1173
  this.initialized = false;
1009
- this.retrying = 0;
1010
1174
  this.unloadHandler = null;
1175
+ this.visibilityHandler = null;
1011
1176
  this.gtm = new GTMManager();
1012
1177
  this.config = {
1013
1178
  debug: false,
@@ -1025,9 +1190,13 @@ var OnrwayAnalytics = (() => {
1025
1190
  this.batchPageId = this.generateBatchPageId();
1026
1191
  this.sessionManager = new SessionManager(this.config.sessionTimeout);
1027
1192
  this.sampled = this.resolveSampling();
1028
- if (isBrowser()) {
1029
- this.recoverFailedEvents();
1030
- }
1193
+ this.delivery = new DeliveryQueue({
1194
+ serverUrl: this.config.serverUrl,
1195
+ apiKey: this.resolveApiKey(),
1196
+ appKey,
1197
+ maxRetries: this.config.maxRetryCount,
1198
+ persistKeyEvents: this.config.persistKeyEvents === true && this.sampled && !this.config.disableServer
1199
+ });
1031
1200
  this.registerBeforeUnload();
1032
1201
  if (this.config.autoTrack && isBrowser()) {
1033
1202
  initAutoTrack(this, this.config.autoTrackSelector);
@@ -1117,6 +1286,7 @@ var OnrwayAnalytics = (() => {
1117
1286
  this.userId = null;
1118
1287
  this.userProps = {};
1119
1288
  this.buffer = [];
1289
+ this.delivery.clear();
1120
1290
  if (this.bufferTimer) {
1121
1291
  clearTimeout(this.bufferTimer);
1122
1292
  this.bufferTimer = null;
@@ -1219,10 +1389,6 @@ var OnrwayAnalytics = (() => {
1219
1389
  this.bufferTimer = null;
1220
1390
  }
1221
1391
  if (this.buffer.length === 0) return;
1222
- if (this.retrying > 0) {
1223
- this.scheduleBufferFlush();
1224
- return;
1225
- }
1226
1392
  const events = this.buffer.splice(0, this.buffer.length);
1227
1393
  events.forEach((e2, i2) => {
1228
1394
  e2.batch_event_index = i2;
@@ -1235,39 +1401,12 @@ var OnrwayAnalytics = (() => {
1235
1401
  this.log("Flushing key event:", event.event_name);
1236
1402
  this.sendBatch([event], 0);
1237
1403
  }
1238
- async sendBatch(events, retryCount) {
1239
- if (events.length === 0) return;
1240
- const apiKey = this.resolveApiKey();
1241
- if (!apiKey) {
1242
- this.log("No API key configured, dropping events");
1243
- for (const event of events) {
1244
- StorageQueue.enqueue(event);
1245
- }
1246
- return;
1247
- }
1248
- const payload = this.buildPayload(events);
1404
+ sendBatch(events, _retryCount) {
1405
+ if (!events.length || this.config.disableServer) return;
1249
1406
  try {
1250
- await Sender.send(payload, this.config.serverUrl, apiKey);
1251
- this.log(`Batch sent successfully: ${events.length} events`);
1407
+ this.delivery.enqueue(this.buildPayload(events));
1252
1408
  } catch (error) {
1253
- this.log(`Batch send failed (attempt ${retryCount + 1}/${this.config.maxRetryCount + 1}):`, error);
1254
- if (retryCount < this.config.maxRetryCount) {
1255
- const delay = 1e3 * Math.pow(2, retryCount);
1256
- this.log(`Retrying in ${delay}ms...`);
1257
- this.retrying += 1;
1258
- setTimeout(() => {
1259
- this.retrying -= 1;
1260
- this.sendBatch(events, retryCount + 1);
1261
- if (this.buffer.length > 0) {
1262
- this.scheduleBufferFlush();
1263
- }
1264
- }, delay);
1265
- } else {
1266
- this.log("Max retries reached, persisting events to localStorage");
1267
- for (const event of events) {
1268
- StorageQueue.enqueue(event);
1269
- }
1270
- }
1409
+ this.log("Unable to prepare analytics payload:", error);
1271
1410
  }
1272
1411
  }
1273
1412
  buildPayload(events) {
@@ -1281,30 +1420,15 @@ var OnrwayAnalytics = (() => {
1281
1420
  events
1282
1421
  };
1283
1422
  }
1284
- recoverFailedEvents() {
1285
- if (this.config.disableServer) return;
1286
- if (!this.sampled) return;
1287
- const failedEvents = StorageQueue.recover();
1288
- if (failedEvents.length > 0) {
1289
- this.log(`Recovered ${failedEvents.length} failed events from storage`);
1290
- for (const event of failedEvents) {
1291
- event.batch_ordering_id = this.generateBatchOrderingId();
1292
- event.batch_page_id = this.batchPageId;
1293
- event.batch_event_index = 0;
1294
- }
1295
- const batchSize = this.config.bufferMaxSize;
1296
- for (let i2 = 0; i2 < failedEvents.length; i2 += batchSize) {
1297
- const batch = failedEvents.slice(i2, i2 + batchSize);
1298
- batch.forEach((e2, idx) => {
1299
- e2.batch_event_index = idx;
1300
- });
1301
- this.sendBatch(batch, 0);
1302
- }
1303
- }
1304
- }
1305
1423
  destroy() {
1424
+ this.flushBuffer();
1425
+ this.delivery.destroy();
1426
+ if (this.visibilityHandler) {
1427
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
1428
+ window.removeEventListener("pageshow", this.visibilityHandler);
1429
+ this.visibilityHandler = null;
1430
+ }
1306
1431
  if (this.unloadHandler) {
1307
- window.removeEventListener("beforeunload", this.unloadHandler);
1308
1432
  window.removeEventListener("pagehide", this.unloadHandler);
1309
1433
  this.unloadHandler = null;
1310
1434
  }
@@ -1315,41 +1439,22 @@ var OnrwayAnalytics = (() => {
1315
1439
  }
1316
1440
  registerBeforeUnload() {
1317
1441
  if (!isBrowser() || this.config.disableServer) return;
1318
- const handler = () => {
1319
- if (this.buffer.length === 0) return;
1320
- const events = this.buffer.splice(0, this.buffer.length);
1321
- events.forEach((e2, i2) => {
1322
- e2.batch_event_index = i2;
1323
- });
1324
- const apiKey = this.resolveApiKey();
1325
- if (!apiKey) return;
1326
- const payload = this.buildPayload(events);
1327
- try {
1328
- if (typeof navigator !== "undefined" && navigator.sendBeacon) {
1329
- const url = `${this.config.serverUrl}${this.config.serverUrl.includes("?") ? "&" : "?"}apiKey=${encodeURIComponent(apiKey)}`;
1330
- const blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
1331
- const success = navigator.sendBeacon(url, blob);
1332
- if (!success) throw new Error("sendBeacon rejected");
1333
- } else {
1334
- try {
1335
- const xhr = new XMLHttpRequest();
1336
- xhr.open("POST", this.config.serverUrl, false);
1337
- xhr.setRequestHeader("Content-Type", "application/json");
1338
- xhr.setRequestHeader("apiKey", apiKey);
1339
- xhr.timeout = 2e3;
1340
- xhr.send(JSON.stringify(payload));
1341
- } catch {
1342
- }
1343
- }
1344
- } catch {
1345
- for (const event of events) {
1346
- StorageQueue.enqueue(event);
1347
- }
1442
+ this.unloadHandler = () => {
1443
+ this.delivery.setHidden(true);
1444
+ this.flushBuffer();
1445
+ this.delivery.flushHidden();
1446
+ };
1447
+ this.visibilityHandler = () => {
1448
+ const hidden = document.visibilityState === "hidden";
1449
+ this.delivery.setHidden(hidden);
1450
+ if (hidden) {
1451
+ this.flushBuffer();
1452
+ this.delivery.flushHidden();
1348
1453
  }
1349
1454
  };
1350
- this.unloadHandler = handler;
1351
- window.addEventListener("beforeunload", handler);
1352
- window.addEventListener("pagehide", handler);
1455
+ window.addEventListener("pagehide", this.unloadHandler);
1456
+ document.addEventListener("visibilitychange", this.visibilityHandler);
1457
+ window.addEventListener("pageshow", this.visibilityHandler);
1353
1458
  }
1354
1459
  trackPageView() {
1355
1460
  if (!isBrowser()) return;
@@ -1364,6 +1469,50 @@ var OnrwayAnalytics = (() => {
1364
1469
  function createAnalytics(config) {
1365
1470
  return new Analytics(config);
1366
1471
  }
1472
+
1473
+ // src/core/storage.ts
1474
+ var MAX_EVENT_AGE_MS = 24 * 60 * 60 * 1e3;
1475
+ var StorageQueue = class _StorageQueue {
1476
+ static enqueue(event) {
1477
+ try {
1478
+ const queue = _StorageQueue.readQueue();
1479
+ queue.push({ event, storedAt: Date.now() });
1480
+ while (queue.length > MAX_STORAGE_ITEMS) {
1481
+ queue.shift();
1482
+ }
1483
+ safeLocalStorageSet(STORAGE_KEYS.FAILED_QUEUE, JSON.stringify(queue));
1484
+ } catch {
1485
+ }
1486
+ }
1487
+ static recover() {
1488
+ try {
1489
+ const queue = _StorageQueue.readQueue();
1490
+ _StorageQueue.clear();
1491
+ const now = Date.now();
1492
+ const events = queue.filter((item) => now - item.storedAt < MAX_EVENT_AGE_MS).map((item) => item.event);
1493
+ return events;
1494
+ } catch {
1495
+ return [];
1496
+ }
1497
+ }
1498
+ static clear() {
1499
+ safeLocalStorageRemove(STORAGE_KEYS.FAILED_QUEUE);
1500
+ }
1501
+ static getQueueSize() {
1502
+ return _StorageQueue.readQueue().length;
1503
+ }
1504
+ static readQueue() {
1505
+ try {
1506
+ const raw = safeLocalStorageGet(STORAGE_KEYS.FAILED_QUEUE);
1507
+ if (!raw) return [];
1508
+ const parsed = JSON.parse(raw);
1509
+ if (!Array.isArray(parsed)) return [];
1510
+ return parsed;
1511
+ } catch {
1512
+ return [];
1513
+ }
1514
+ }
1515
+ };
1367
1516
  return __toCommonJS(index_exports);
1368
1517
  })();
1369
1518
  //# sourceMappingURL=index.js.map