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.
@@ -1,7 +1,6 @@
1
1
  import { API_KEY_MAP, DEFAULT_CONFIG, STORAGE_KEYS, } from '../types';
2
2
  import { SessionManager } from './session-manager';
3
- import { Sender } from './sender';
4
- import { StorageQueue } from './storage';
3
+ import { DeliveryQueue } from './delivery-queue';
5
4
  import { collectContext, collectMarketing } from './context-collector';
6
5
  import { initAutoTrack } from './auto-track';
7
6
  import { initErrorMonitor } from './error-monitor';
@@ -17,8 +16,8 @@ export class Analytics {
17
16
  this.userId = null;
18
17
  this.userProps = {};
19
18
  this.initialized = false;
20
- this.retrying = 0;
21
19
  this.unloadHandler = null;
20
+ this.visibilityHandler = null;
22
21
  this.gtm = new GTMManager();
23
22
  this.config = {
24
23
  debug: false,
@@ -37,9 +36,13 @@ export class Analytics {
37
36
  this.batchPageId = this.generateBatchPageId();
38
37
  this.sessionManager = new SessionManager(this.config.sessionTimeout);
39
38
  this.sampled = this.resolveSampling();
40
- if (isBrowser()) {
41
- this.recoverFailedEvents();
42
- }
39
+ this.delivery = new DeliveryQueue({
40
+ serverUrl: this.config.serverUrl,
41
+ apiKey: this.resolveApiKey(),
42
+ appKey,
43
+ maxRetries: this.config.maxRetryCount,
44
+ persistKeyEvents: this.config.persistKeyEvents === true && this.sampled && !this.config.disableServer,
45
+ });
43
46
  this.registerBeforeUnload();
44
47
  if (this.config.autoTrack && isBrowser()) {
45
48
  initAutoTrack(this, this.config.autoTrackSelector);
@@ -131,6 +134,7 @@ export class Analytics {
131
134
  this.userId = null;
132
135
  this.userProps = {};
133
136
  this.buffer = [];
137
+ this.delivery.clear();
134
138
  if (this.bufferTimer) {
135
139
  clearTimeout(this.bufferTimer);
136
140
  this.bufferTimer = null;
@@ -242,11 +246,6 @@ export class Analytics {
242
246
  }
243
247
  if (this.buffer.length === 0)
244
248
  return;
245
- // Wait for inflight retries to settle before sending a new batch
246
- if (this.retrying > 0) {
247
- this.scheduleBufferFlush();
248
- return;
249
- }
250
249
  const events = this.buffer.splice(0, this.buffer.length);
251
250
  events.forEach((e, i) => {
252
251
  e.batch_event_index = i;
@@ -260,42 +259,14 @@ export class Analytics {
260
259
  this.log('Flushing key event:', event.event_name);
261
260
  this.sendBatch([event], 0);
262
261
  }
263
- async sendBatch(events, retryCount) {
264
- if (events.length === 0)
265
- return;
266
- const apiKey = this.resolveApiKey();
267
- if (!apiKey) {
268
- this.log('No API key configured, dropping events');
269
- for (const event of events) {
270
- StorageQueue.enqueue(event);
271
- }
262
+ sendBatch(events, _retryCount) {
263
+ if (!events.length || this.config.disableServer)
272
264
  return;
273
- }
274
- const payload = this.buildPayload(events);
275
265
  try {
276
- await Sender.send(payload, this.config.serverUrl, apiKey);
277
- this.log(`Batch sent successfully: ${events.length} events`);
266
+ this.delivery.enqueue(this.buildPayload(events));
278
267
  }
279
268
  catch (error) {
280
- this.log(`Batch send failed (attempt ${retryCount + 1}/${this.config.maxRetryCount + 1}):`, error);
281
- if (retryCount < this.config.maxRetryCount) {
282
- const delay = 1000 * Math.pow(2, retryCount);
283
- this.log(`Retrying in ${delay}ms...`);
284
- this.retrying += 1;
285
- setTimeout(() => {
286
- this.retrying -= 1;
287
- this.sendBatch(events, retryCount + 1);
288
- if (this.buffer.length > 0) {
289
- this.scheduleBufferFlush();
290
- }
291
- }, delay);
292
- }
293
- else {
294
- this.log('Max retries reached, persisting events to localStorage');
295
- for (const event of events) {
296
- StorageQueue.enqueue(event);
297
- }
298
- }
269
+ this.log('Unable to prepare analytics payload:', error);
299
270
  }
300
271
  }
301
272
  buildPayload(events) {
@@ -309,32 +280,15 @@ export class Analytics {
309
280
  events,
310
281
  };
311
282
  }
312
- recoverFailedEvents() {
313
- if (this.config.disableServer)
314
- return;
315
- if (!this.sampled)
316
- return;
317
- const failedEvents = StorageQueue.recover();
318
- if (failedEvents.length > 0) {
319
- this.log(`Recovered ${failedEvents.length} failed events from storage`);
320
- for (const event of failedEvents) {
321
- event.batch_ordering_id = this.generateBatchOrderingId();
322
- event.batch_page_id = this.batchPageId;
323
- event.batch_event_index = 0;
324
- }
325
- const batchSize = this.config.bufferMaxSize;
326
- for (let i = 0; i < failedEvents.length; i += batchSize) {
327
- const batch = failedEvents.slice(i, i + batchSize);
328
- batch.forEach((e, idx) => {
329
- e.batch_event_index = idx;
330
- });
331
- this.sendBatch(batch, 0);
332
- }
333
- }
334
- }
335
283
  destroy() {
284
+ this.flushBuffer();
285
+ this.delivery.destroy();
286
+ if (this.visibilityHandler) {
287
+ document.removeEventListener('visibilitychange', this.visibilityHandler);
288
+ window.removeEventListener('pageshow', this.visibilityHandler);
289
+ this.visibilityHandler = null;
290
+ }
336
291
  if (this.unloadHandler) {
337
- window.removeEventListener('beforeunload', this.unloadHandler);
338
292
  window.removeEventListener('pagehide', this.unloadHandler);
339
293
  this.unloadHandler = null;
340
294
  }
@@ -346,49 +300,22 @@ export class Analytics {
346
300
  registerBeforeUnload() {
347
301
  if (!isBrowser() || this.config.disableServer)
348
302
  return;
349
- const handler = () => {
350
- if (this.buffer.length === 0)
351
- return;
352
- const events = this.buffer.splice(0, this.buffer.length);
353
- events.forEach((e, i) => {
354
- e.batch_event_index = i;
355
- });
356
- const apiKey = this.resolveApiKey();
357
- if (!apiKey)
358
- return;
359
- const payload = this.buildPayload(events);
360
- try {
361
- if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
362
- const url = `${this.config.serverUrl}${this.config.serverUrl.includes('?') ? '&' : '?'}apiKey=${encodeURIComponent(apiKey)}`;
363
- const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
364
- const success = navigator.sendBeacon(url, blob);
365
- if (!success)
366
- throw new Error('sendBeacon rejected');
367
- }
368
- else {
369
- // XHR sync as fallback — tries to send but doesn't block page unload
370
- try {
371
- const xhr = new XMLHttpRequest();
372
- xhr.open('POST', this.config.serverUrl, false);
373
- xhr.setRequestHeader('Content-Type', 'application/json');
374
- xhr.setRequestHeader('apiKey', apiKey);
375
- xhr.timeout = 2000;
376
- xhr.send(JSON.stringify(payload));
377
- }
378
- catch {
379
- // XHR failed during unload
380
- }
381
- }
382
- }
383
- catch {
384
- for (const event of events) {
385
- StorageQueue.enqueue(event);
386
- }
303
+ this.unloadHandler = () => {
304
+ this.delivery.setHidden(true);
305
+ this.flushBuffer();
306
+ this.delivery.flushHidden();
307
+ };
308
+ this.visibilityHandler = () => {
309
+ const hidden = document.visibilityState === 'hidden';
310
+ this.delivery.setHidden(hidden);
311
+ if (hidden) {
312
+ this.flushBuffer();
313
+ this.delivery.flushHidden();
387
314
  }
388
315
  };
389
- this.unloadHandler = handler;
390
- window.addEventListener('beforeunload', handler);
391
- window.addEventListener('pagehide', handler);
316
+ window.addEventListener('pagehide', this.unloadHandler);
317
+ document.addEventListener('visibilitychange', this.visibilityHandler);
318
+ window.addEventListener('pageshow', this.visibilityHandler);
392
319
  }
393
320
  trackPageView() {
394
321
  if (!isBrowser())
@@ -0,0 +1,41 @@
1
+ import { AnalyticsPayload } from '../types';
2
+ interface Options {
3
+ serverUrl: string;
4
+ apiKey: string;
5
+ appKey: string;
6
+ maxRetries: number;
7
+ persistKeyEvents: boolean;
8
+ }
9
+ /** First attempts are immediate. Only retries/recovery share a two-request pool. */
10
+ export declare class DeliveryQueue {
11
+ private options;
12
+ private jobs;
13
+ private activeRetries;
14
+ private hidden;
15
+ private timer;
16
+ private stopped;
17
+ private storagePrefix;
18
+ private maxAttempts;
19
+ private hiddenSent;
20
+ private hiddenSpent;
21
+ private keepaliveBytes;
22
+ constructor(options: Options);
23
+ enqueue(payload: AnalyticsPayload): void;
24
+ setHidden(hidden: boolean): void;
25
+ /** Submit the whole affordable rescue set synchronously; do not wait for ACKs. */
26
+ flushHidden(): void;
27
+ private bytes;
28
+ private batches;
29
+ private prune;
30
+ private pumpRetries;
31
+ private start;
32
+ private deliver;
33
+ private key;
34
+ private persist;
35
+ private remove;
36
+ private recover;
37
+ clear(): void;
38
+ destroy(): void;
39
+ }
40
+ export {};
41
+ //# sourceMappingURL=delivery-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery-queue.d.ts","sourceRoot":"","sources":["../../src/core/delivery-queue.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAgB5C,UAAU,OAAO;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,OAAO,CAAC;CAC3B;AAED,oFAAoF;AACpF,qBAAa,aAAa;IAYZ,OAAO,CAAC,OAAO;IAX3B,OAAO,CAAC,IAAI,CAAa;IACzB,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,MAAM,CAA4E;IAC1F,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,UAAU,CAAkB;IACpC,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,cAAc,CAAK;gBAEP,OAAO,EAAE,OAAO;IAOpC,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI;IAqBxC,SAAS,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI;IAWhC,kFAAkF;IAClF,WAAW,IAAI,IAAI;IAmBnB,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,OAAO;IA6Bf,OAAO,CAAC,KAAK;IAMb,OAAO,CAAC,WAAW;IAgBnB,OAAO,CAAC,KAAK;YAaC,OAAO;IAyBrB,OAAO,CAAC,GAAG;IAEX,OAAO,CAAC,OAAO;IAOf,OAAO,CAAC,MAAM;IAOd,OAAO,CAAC,OAAO;IAoBf,KAAK,IAAI,IAAI;IAKb,OAAO,IAAI,IAAI;CAIhB"}
@@ -0,0 +1,248 @@
1
+ import { Sender, DeliveryError } from './sender';
2
+ import { safeLocalStorageGet, safeLocalStorageSet, safeLocalStorageRemove } from '../utils';
3
+ const MAX_BYTES = 48 * 1024;
4
+ const MAX_EVENTS = 500;
5
+ const MAX_AGE = 24 * 60 * 60 * 1000;
6
+ /** First attempts are immediate. Only retries/recovery share a two-request pool. */
7
+ export class DeliveryQueue {
8
+ constructor(options) {
9
+ this.options = options;
10
+ this.jobs = [];
11
+ this.activeRetries = 0;
12
+ this.hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden';
13
+ this.timer = null;
14
+ this.stopped = false;
15
+ this.hiddenSent = new Set();
16
+ this.hiddenSpent = 0;
17
+ this.keepaliveBytes = 0;
18
+ this.maxAttempts = 1 + Math.min(5, Math.max(0, options.maxRetries || 0));
19
+ this.storagePrefix = `__analytics_delivery_v1__:${encodeURIComponent(options.appKey)}:${encodeURIComponent(options.serverUrl)}:`;
20
+ if (options.persistKeyEvents && options.apiKey)
21
+ this.recover();
22
+ this.pumpRetries();
23
+ }
24
+ enqueue(payload) {
25
+ if (this.stopped || !this.options.apiKey)
26
+ return;
27
+ const accepted = [];
28
+ for (const event of payload.events) {
29
+ if (this.jobs.length >= MAX_EVENTS)
30
+ break;
31
+ const single = { ...payload, events: [event] };
32
+ if (this.bytes(single) > MAX_BYTES)
33
+ continue;
34
+ const job = { payload: single, attempts: 0, createdAt: Date.now(), nextAt: 0,
35
+ active: 0, hiddenActive: false, terminal: false };
36
+ this.jobs.push(job);
37
+ this.persist(job);
38
+ accepted.push(job);
39
+ }
40
+ if (this.hidden) {
41
+ this.flushHidden();
42
+ }
43
+ else {
44
+ // Preserve normal batch delivery, but never queue a first attempt behind retries.
45
+ for (const batch of this.batches(accepted, MAX_BYTES))
46
+ this.start(batch.payload, batch.jobs, false, false);
47
+ }
48
+ }
49
+ setHidden(hidden) {
50
+ if (hidden && !this.hidden) {
51
+ this.hiddenSent.clear();
52
+ this.hiddenSpent = 0;
53
+ }
54
+ this.hidden = hidden;
55
+ if (this.timer) {
56
+ clearTimeout(this.timer);
57
+ this.timer = null;
58
+ }
59
+ if (!hidden)
60
+ this.pumpRetries();
61
+ // Caller flushes the SDK buffer before flushHidden, so critical jobs take priority.
62
+ }
63
+ /** Submit the whole affordable rescue set synchronously; do not wait for ACKs. */
64
+ flushHidden() {
65
+ if (this.stopped || !this.hidden)
66
+ return;
67
+ const available = Math.min(MAX_BYTES - this.hiddenSpent, MAX_BYTES - this.keepaliveBytes);
68
+ if (available <= 0)
69
+ return;
70
+ this.prune();
71
+ const candidates = this.jobs.filter(job => !job.terminal && !job.hiddenActive &&
72
+ !this.hiddenSent.has(job) && job.attempts < this.maxAttempts)
73
+ .sort((a, b) => Number(b.payload.events[0].key_event) - Number(a.payload.events[0].key_event));
74
+ let remaining = available;
75
+ for (const batch of this.batches(candidates, available, true)) {
76
+ const bytes = this.bytes(batch.payload);
77
+ if (bytes > remaining)
78
+ continue;
79
+ remaining -= bytes;
80
+ this.hiddenSpent += bytes;
81
+ for (const job of batch.jobs)
82
+ this.hiddenSent.add(job);
83
+ this.start(batch.payload, batch.jobs, true, false);
84
+ }
85
+ }
86
+ bytes(payload) {
87
+ return new Blob([JSON.stringify(payload)]).size;
88
+ }
89
+ batches(jobs, budget, totalBudget = false) {
90
+ const result = [];
91
+ let remaining = budget;
92
+ // Context/client/session/marketing/user fields must match before events can share a request.
93
+ const groups = new Map();
94
+ for (const job of jobs) {
95
+ const { events, ...envelope } = job.payload;
96
+ const key = JSON.stringify(envelope);
97
+ let group = groups.get(key);
98
+ const proposed = { ...envelope, events: [...(group?.payload.events || []), ...events]
99
+ .map((event, index) => ({ ...event, batch_event_index: index })) };
100
+ const delta = this.bytes(proposed) - (group ? this.bytes(group.payload) : 0);
101
+ if (totalBudget && delta > remaining)
102
+ continue;
103
+ if (this.bytes(proposed) > budget) {
104
+ group = undefined;
105
+ }
106
+ if (!group) {
107
+ group = { payload: { ...envelope, events: events.map((event, index) => ({ ...event, batch_event_index: index })) }, jobs: [] };
108
+ groups.set(key, group);
109
+ result.push(group);
110
+ }
111
+ else {
112
+ group.payload = proposed;
113
+ }
114
+ group.jobs.push(job);
115
+ if (totalBudget)
116
+ remaining -= delta;
117
+ }
118
+ return result;
119
+ }
120
+ prune() {
121
+ for (const job of [...this.jobs]) {
122
+ if (!job.active && (job.terminal || Date.now() - job.createdAt >= MAX_AGE || job.attempts >= this.maxAttempts))
123
+ this.remove(job);
124
+ }
125
+ }
126
+ pumpRetries() {
127
+ if (this.stopped || this.hidden)
128
+ return;
129
+ if (this.timer) {
130
+ clearTimeout(this.timer);
131
+ this.timer = null;
132
+ }
133
+ this.prune();
134
+ while (this.activeRetries < 2) {
135
+ const job = this.jobs.find(item => !item.active && item.nextAt <= Date.now());
136
+ if (!job)
137
+ break;
138
+ this.start(job.payload, [job], false, true);
139
+ }
140
+ const waiting = this.jobs.filter(job => !job.active);
141
+ if (waiting.length && this.activeRetries < 2) {
142
+ const delay = Math.max(1, Math.min(...waiting.map(job => job.nextAt)) - Date.now());
143
+ this.timer = setTimeout(() => this.pumpRetries(), delay);
144
+ }
145
+ }
146
+ start(payload, jobs, keepalive, retry) {
147
+ const bytes = keepalive ? this.bytes(payload) : 0;
148
+ this.keepaliveBytes += bytes;
149
+ if (retry)
150
+ this.activeRetries++;
151
+ for (const job of jobs) {
152
+ job.active++;
153
+ job.attempts++;
154
+ if (keepalive)
155
+ job.hiddenActive = true;
156
+ this.persist(job);
157
+ }
158
+ void this.deliver(payload, jobs, keepalive, retry, bytes);
159
+ }
160
+ async deliver(payload, jobs, keepalive, retry, bytes) {
161
+ try {
162
+ await Sender.send(payload, this.options.serverUrl, this.options.apiKey, keepalive);
163
+ for (const job of jobs)
164
+ this.remove(job);
165
+ }
166
+ catch (error) {
167
+ for (const job of jobs) {
168
+ if (!this.jobs.includes(job))
169
+ continue; // A concurrent rescue/normal request may have ACKed already.
170
+ if (error instanceof DeliveryError && !error.retryable)
171
+ job.terminal = true;
172
+ job.nextAt = Date.now() + Math.min(30000, 1000 * 2 ** (job.attempts - 1)) + Math.floor(Math.random() * 250);
173
+ }
174
+ }
175
+ finally {
176
+ for (const job of jobs) {
177
+ job.active--;
178
+ if (keepalive)
179
+ job.hiddenActive = false;
180
+ if (!this.jobs.includes(job))
181
+ continue;
182
+ // Wait for both original and rescue outcomes before retiring a failed event.
183
+ if (!job.active && (job.terminal || job.attempts >= this.maxAttempts))
184
+ this.remove(job);
185
+ else
186
+ this.persist(job);
187
+ }
188
+ this.keepaliveBytes -= bytes;
189
+ if (retry)
190
+ this.activeRetries--;
191
+ this.pumpRetries();
192
+ }
193
+ }
194
+ key(eventId) { return this.storagePrefix + eventId; }
195
+ persist(job) {
196
+ if (!this.options.persistKeyEvents || !job.payload.events[0].key_event)
197
+ return;
198
+ safeLocalStorageSet(this.key(job.payload.events[0].event_id), JSON.stringify({
199
+ payload: job.payload, attempts: job.attempts, createdAt: job.createdAt, nextAt: job.nextAt,
200
+ }));
201
+ }
202
+ remove(job) {
203
+ if (!this.jobs.includes(job))
204
+ return;
205
+ this.jobs = this.jobs.filter(item => item !== job);
206
+ this.hiddenSent.delete(job);
207
+ if (this.options.persistKeyEvents)
208
+ safeLocalStorageRemove(this.key(job.payload.events[0].event_id));
209
+ }
210
+ recover() {
211
+ try {
212
+ const keys = Object.keys(localStorage).filter(key => key.startsWith(this.storagePrefix));
213
+ for (const key of keys) {
214
+ try {
215
+ const job = JSON.parse(safeLocalStorageGet(key) || 'null');
216
+ if (!job || !Number.isFinite(job.createdAt) || !Number.isInteger(job.attempts) || job.attempts < 0 ||
217
+ !Number.isFinite(job.nextAt) || Date.now() - job.createdAt >= MAX_AGE || job.attempts >= this.maxAttempts ||
218
+ !Array.isArray(job.payload?.events) || job.payload.events.length !== 1 ||
219
+ !job.payload.events[0].key_event || this.key(job.payload.events[0].event_id) !== key ||
220
+ this.bytes(job.payload) > MAX_BYTES || this.jobs.length >= MAX_EVENTS) {
221
+ safeLocalStorageRemove(key);
222
+ continue;
223
+ }
224
+ this.jobs.push({ ...job, active: 0, hiddenActive: false, terminal: false });
225
+ }
226
+ catch {
227
+ safeLocalStorageRemove(key);
228
+ }
229
+ }
230
+ }
231
+ catch { /* Storage unavailable: delivery continues in memory. */ }
232
+ }
233
+ clear() {
234
+ for (const job of [...this.jobs])
235
+ this.remove(job);
236
+ if (this.timer) {
237
+ clearTimeout(this.timer);
238
+ this.timer = null;
239
+ }
240
+ }
241
+ destroy() {
242
+ this.stopped = true;
243
+ if (this.timer) {
244
+ clearTimeout(this.timer);
245
+ this.timer = null;
246
+ }
247
+ }
248
+ }
@@ -1,10 +1,14 @@
1
1
  import { AnalyticsPayload } from '../types';
2
+ export declare class DeliveryError extends Error {
3
+ retryable: boolean;
4
+ constructor(message: string, retryable: boolean);
5
+ }
2
6
  export declare class Sender {
3
- static send(payload: AnalyticsPayload, serverUrl: string, apiKey: string): Promise<void>;
7
+ static send(payload: AnalyticsPayload, serverUrl: string, apiKey: string, keepalive?: boolean): Promise<void>;
4
8
  static sendBeacon(payload: AnalyticsPayload, serverUrl: string, apiKey: string): Promise<void>;
5
- static fetchSend(payload: AnalyticsPayload, serverUrl: string, apiKey: string): Promise<void>;
9
+ static fetchSend(payload: AnalyticsPayload, serverUrl: string, apiKey: string, keepalive?: boolean): Promise<void>;
10
+ private static httpError;
6
11
  static xhrSend(payload: AnalyticsPayload, serverUrl: string, apiKey: string): Promise<void>;
7
12
  private static supportsKeepalive;
8
- private static buildBeaconUrl;
9
13
  }
10
14
  //# sourceMappingURL=sender.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../src/core/sender.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAE5C,qBAAa,MAAM;WACJ,IAAI,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;WAOjF,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;WAcvF,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;WAgBtF,OAAO,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAuBjG,OAAO,CAAC,MAAM,CAAC,iBAAiB;IAQhC,OAAO,CAAC,MAAM,CAAC,cAAc;CAI9B"}
1
+ {"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../src/core/sender.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAE5C,qBAAa,aAAc,SAAQ,KAAK;IACF,SAAS,EAAE,OAAO;gBAA1C,OAAO,EAAE,MAAM,EAAS,SAAS,EAAE,OAAO;CACvD;AAED,qBAAa,MAAM;WACJ,IAAI,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,UAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAYjH,MAAM,CAAC,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;WAIjF,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,UAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBtH,OAAO,CAAC,MAAM,CAAC,SAAS;IAIxB,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAe3F,OAAO,CAAC,MAAM,CAAC,iBAAiB;CAGjC"}
@@ -1,65 +1,61 @@
1
+ export class DeliveryError extends Error {
2
+ constructor(message, retryable) {
3
+ super(message);
4
+ this.retryable = retryable;
5
+ }
6
+ }
1
7
  export class Sender {
2
- static async send(payload, serverUrl, apiKey) {
3
- if (Sender.supportsKeepalive()) {
4
- return Sender.fetchSend(payload, serverUrl, apiKey);
8
+ static async send(payload, serverUrl, apiKey, keepalive = false) {
9
+ if (typeof fetch === 'function' && typeof AbortController !== 'undefined') {
10
+ if (keepalive && !Sender.supportsKeepalive()) {
11
+ throw new DeliveryError('Keepalive unavailable', true);
12
+ }
13
+ return Sender.fetchSend(payload, serverUrl, apiKey, keepalive);
5
14
  }
15
+ if (keepalive)
16
+ throw new DeliveryError('Unload transport unavailable', true);
6
17
  return Sender.xhrSend(payload, serverUrl, apiKey);
7
18
  }
8
- static async sendBeacon(payload, serverUrl, apiKey) {
9
- if (typeof navigator === 'undefined' || !navigator.sendBeacon) {
10
- throw new Error('sendBeacon not available');
19
+ // Compatibility entry point: authenticated delivery must support custom headers.
20
+ static sendBeacon(payload, serverUrl, apiKey) {
21
+ return Sender.send(payload, serverUrl, apiKey, true);
22
+ }
23
+ static async fetchSend(payload, serverUrl, apiKey, keepalive = false) {
24
+ const controller = new AbortController();
25
+ const timer = setTimeout(() => controller.abort(), 10000);
26
+ try {
27
+ const response = await fetch(serverUrl, {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/json', apiKey },
30
+ body: JSON.stringify(payload),
31
+ keepalive,
32
+ signal: controller.signal,
33
+ });
34
+ if (!response.ok)
35
+ throw Sender.httpError(response.status);
11
36
  }
12
- const url = Sender.buildBeaconUrl(serverUrl, apiKey);
13
- const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
14
- const success = navigator.sendBeacon(url, blob);
15
- if (!success) {
16
- throw new Error('sendBeacon rejected payload');
37
+ finally {
38
+ clearTimeout(timer);
17
39
  }
18
40
  }
19
- static async fetchSend(payload, serverUrl, apiKey) {
20
- const response = await fetch(serverUrl, {
21
- method: 'POST',
22
- headers: {
23
- 'Content-Type': 'application/json',
24
- apiKey,
25
- },
26
- body: JSON.stringify(payload),
27
- keepalive: true,
28
- });
29
- if (!response.ok) {
30
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
31
- }
41
+ static httpError(status) {
42
+ return new DeliveryError(`HTTP ${status}`, status === 408 || status === 429 || status >= 500);
32
43
  }
33
- static async xhrSend(payload, serverUrl, apiKey) {
44
+ static xhrSend(payload, serverUrl, apiKey) {
34
45
  return new Promise((resolve, reject) => {
35
46
  const xhr = new XMLHttpRequest();
36
- xhr.open('POST', serverUrl);
47
+ xhr.open('POST', serverUrl, true);
37
48
  xhr.setRequestHeader('Content-Type', 'application/json');
38
49
  xhr.setRequestHeader('apiKey', apiKey);
39
50
  xhr.timeout = 10000;
40
- xhr.onload = () => {
41
- if (xhr.status >= 200 && xhr.status < 300) {
42
- resolve();
43
- }
44
- else {
45
- reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
46
- }
47
- };
48
- xhr.onerror = () => reject(new Error('XHR network error'));
49
- xhr.ontimeout = () => reject(new Error('XHR timeout'));
51
+ xhr.onload = () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(Sender.httpError(xhr.status));
52
+ xhr.onerror = () => reject(new DeliveryError('XHR network error', true));
53
+ xhr.ontimeout = () => reject(new DeliveryError('XHR timeout', true));
54
+ xhr.onabort = () => reject(new DeliveryError('XHR aborted', true));
50
55
  xhr.send(JSON.stringify(payload));
51
56
  });
52
57
  }
53
58
  static supportsKeepalive() {
54
- try {
55
- return typeof Request !== 'undefined' && 'keepalive' in Request.prototype;
56
- }
57
- catch {
58
- return false;
59
- }
60
- }
61
- static buildBeaconUrl(serverUrl, apiKey) {
62
- const separator = serverUrl.includes('?') ? '&' : '?';
63
- return `${serverUrl}${separator}apiKey=${encodeURIComponent(apiKey)}`;
59
+ return typeof Request !== 'undefined' && 'keepalive' in Request.prototype;
64
60
  }
65
61
  }
@@ -102,6 +102,10 @@ export interface AnalyticsConfig {
102
102
  gtm?: GTMConfig;
103
103
  disableServer?: boolean;
104
104
  sampleRate?: number;
105
+ /** Persist key events until HTTP acknowledgement (24h / 500-event bound).
106
+ * Enable only for applications whose event properties are safe to store locally.
107
+ */
108
+ persistKeyEvents?: boolean;
105
109
  /** @deprecated use appKey instead */
106
110
  appId?: string;
107
111
  }
@@ -135,7 +139,7 @@ export interface RetryEntry {
135
139
  timer: ReturnType<typeof setTimeout> | null;
136
140
  }
137
141
  export declare const API_KEY_MAP: Record<string, string>;
138
- export declare const SDK_VERSION = "1.0.0";
142
+ export declare const SDK_VERSION = "1.3.2";
139
143
  export declare const DEFAULT_CONFIG: {
140
144
  readonly debug: false;
141
145
  readonly autoTrack: true;