onerway-analytics 1.3.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
  };
@@ -999,15 +1164,15 @@ var OnrwayAnalytics = (() => {
999
1164
 
1000
1165
  // src/core/analytics.ts
1001
1166
  var globalBatchCounter = 0;
1002
- var Analytics = class _Analytics {
1167
+ var Analytics = class {
1003
1168
  constructor(config) {
1004
1169
  this.buffer = [];
1005
1170
  this.bufferTimer = null;
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);
@@ -1053,14 +1222,6 @@ var OnrwayAnalytics = (() => {
1053
1222
  this.trackPageView();
1054
1223
  }
1055
1224
  }
1056
- static {
1057
- // HOTFIX (production): retry / recovery / localStorage persistence DISABLED.
1058
- // Reason: retry loop + parallel recoverFailedEvents + fire-and-forget
1059
- // setTimeout caused 70+ concurrent in-flight requests on network errors.
1060
- // Flip this flag to true (and uncomment the StorageQueue.enqueue blocks)
1061
- // to restore the original behavior once the retry mechanism is rewritten.
1062
- this.RETRY_ENABLED = false;
1063
- }
1064
1225
  track(eventName, properties, options) {
1065
1226
  if (!eventName || typeof eventName !== "string") {
1066
1227
  this.log("Invalid event name");
@@ -1125,6 +1286,7 @@ var OnrwayAnalytics = (() => {
1125
1286
  this.userId = null;
1126
1287
  this.userProps = {};
1127
1288
  this.buffer = [];
1289
+ this.delivery.clear();
1128
1290
  if (this.bufferTimer) {
1129
1291
  clearTimeout(this.bufferTimer);
1130
1292
  this.bufferTimer = null;
@@ -1227,10 +1389,6 @@ var OnrwayAnalytics = (() => {
1227
1389
  this.bufferTimer = null;
1228
1390
  }
1229
1391
  if (this.buffer.length === 0) return;
1230
- if (this.retrying > 0) {
1231
- this.scheduleBufferFlush();
1232
- return;
1233
- }
1234
1392
  const events = this.buffer.splice(0, this.buffer.length);
1235
1393
  events.forEach((e2, i2) => {
1236
1394
  e2.batch_event_index = i2;
@@ -1243,34 +1401,12 @@ var OnrwayAnalytics = (() => {
1243
1401
  this.log("Flushing key event:", event.event_name);
1244
1402
  this.sendBatch([event], 0);
1245
1403
  }
1246
- async sendBatch(events, retryCount) {
1247
- if (events.length === 0) return;
1248
- const apiKey = this.resolveApiKey();
1249
- if (!apiKey) {
1250
- this.log("No API key configured, dropping events (retry disabled)");
1251
- return;
1252
- }
1253
- const payload = this.buildPayload(events);
1404
+ sendBatch(events, _retryCount) {
1405
+ if (!events.length || this.config.disableServer) return;
1254
1406
  try {
1255
- await Sender.send(payload, this.config.serverUrl, apiKey);
1256
- this.log(`Batch sent successfully: ${events.length} events`);
1407
+ this.delivery.enqueue(this.buildPayload(events));
1257
1408
  } catch (error) {
1258
- this.log(`Batch send failed (attempt ${retryCount + 1}/${this.config.maxRetryCount + 1}):`, error);
1259
- const retryEnabled = _Analytics.RETRY_ENABLED;
1260
- if (retryEnabled && retryCount < this.config.maxRetryCount) {
1261
- const delay = 1e3 * Math.pow(2, retryCount);
1262
- this.log(`Retrying in ${delay}ms...`);
1263
- this.retrying += 1;
1264
- setTimeout(() => {
1265
- this.retrying -= 1;
1266
- this.sendBatch(events, retryCount + 1);
1267
- if (this.buffer.length > 0) {
1268
- this.scheduleBufferFlush();
1269
- }
1270
- }, delay);
1271
- } else {
1272
- this.log("Max retries reached / retry disabled, dropping events");
1273
- }
1409
+ this.log("Unable to prepare analytics payload:", error);
1274
1410
  }
1275
1411
  }
1276
1412
  buildPayload(events) {
@@ -1284,34 +1420,15 @@ var OnrwayAnalytics = (() => {
1284
1420
  events
1285
1421
  };
1286
1422
  }
1287
- recoverFailedEvents() {
1288
- if (this.config.disableServer) return;
1289
- if (!this.sampled) return;
1290
- if (!_Analytics.RETRY_ENABLED) {
1291
- StorageQueue.clear();
1292
- return;
1293
- }
1294
- const failedEvents = StorageQueue.recover();
1295
- if (failedEvents.length > 0) {
1296
- this.log(`Recovered ${failedEvents.length} failed events from storage`);
1297
- for (const event of failedEvents) {
1298
- event.batch_ordering_id = this.generateBatchOrderingId();
1299
- event.batch_page_id = this.batchPageId;
1300
- event.batch_event_index = 0;
1301
- }
1302
- const batchSize = this.config.bufferMaxSize;
1303
- for (let i2 = 0; i2 < failedEvents.length; i2 += batchSize) {
1304
- const batch = failedEvents.slice(i2, i2 + batchSize);
1305
- batch.forEach((e2, idx) => {
1306
- e2.batch_event_index = idx;
1307
- });
1308
- this.sendBatch(batch, 0);
1309
- }
1310
- }
1311
- }
1312
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
+ }
1313
1431
  if (this.unloadHandler) {
1314
- window.removeEventListener("beforeunload", this.unloadHandler);
1315
1432
  window.removeEventListener("pagehide", this.unloadHandler);
1316
1433
  this.unloadHandler = null;
1317
1434
  }
@@ -1322,39 +1439,22 @@ var OnrwayAnalytics = (() => {
1322
1439
  }
1323
1440
  registerBeforeUnload() {
1324
1441
  if (!isBrowser() || this.config.disableServer) return;
1325
- const handler = () => {
1326
- if (this.buffer.length === 0) return;
1327
- const events = this.buffer.splice(0, this.buffer.length);
1328
- events.forEach((e2, i2) => {
1329
- e2.batch_event_index = i2;
1330
- });
1331
- const apiKey = this.resolveApiKey();
1332
- if (!apiKey) return;
1333
- const payload = this.buildPayload(events);
1334
- try {
1335
- if (typeof navigator !== "undefined" && navigator.sendBeacon) {
1336
- const url = `${this.config.serverUrl}${this.config.serverUrl.includes("?") ? "&" : "?"}apiKey=${encodeURIComponent(apiKey)}`;
1337
- const blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
1338
- const success = navigator.sendBeacon(url, blob);
1339
- if (!success) throw new Error("sendBeacon rejected");
1340
- } else {
1341
- try {
1342
- const xhr = new XMLHttpRequest();
1343
- xhr.open("POST", this.config.serverUrl, false);
1344
- xhr.setRequestHeader("Content-Type", "application/json");
1345
- xhr.setRequestHeader("apiKey", apiKey);
1346
- xhr.timeout = 2e3;
1347
- xhr.send(JSON.stringify(payload));
1348
- } catch {
1349
- }
1350
- }
1351
- } catch {
1352
- this.log("Failed to flush buffer on unload, dropping events (retry disabled)");
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();
1353
1453
  }
1354
1454
  };
1355
- this.unloadHandler = handler;
1356
- window.addEventListener("beforeunload", handler);
1357
- window.addEventListener("pagehide", handler);
1455
+ window.addEventListener("pagehide", this.unloadHandler);
1456
+ document.addEventListener("visibilitychange", this.visibilityHandler);
1457
+ window.addEventListener("pageshow", this.visibilityHandler);
1358
1458
  }
1359
1459
  trackPageView() {
1360
1460
  if (!isBrowser()) return;
@@ -1369,6 +1469,50 @@ var OnrwayAnalytics = (() => {
1369
1469
  function createAnalytics(config) {
1370
1470
  return new Analytics(config);
1371
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
+ };
1372
1516
  return __toCommonJS(index_exports);
1373
1517
  })();
1374
1518
  //# sourceMappingURL=index.js.map