pulse-updates 1.1.0 → 1.2.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 (64) hide show
  1. package/README.md +50 -10
  2. package/SECURITY.md +42 -0
  3. package/android/src/main/java/app/pulse/updates/PulseController.kt +6 -2
  4. package/android/src/main/java/app/pulse/updates/PulseUpdatesModule.kt +25 -0
  5. package/app.plugin.js +47 -0
  6. package/ios/PulseUpdates/PulseController.swift +7 -2
  7. package/ios/PulseUpdates/PulseUpdates.m +7 -0
  8. package/ios/PulseUpdates/PulseUpdates.swift +22 -0
  9. package/lib/commonjs/NativePulseUpdates.js.map +1 -1
  10. package/lib/commonjs/PulseUpdates.js +36 -0
  11. package/lib/commonjs/PulseUpdates.js.map +1 -1
  12. package/lib/commonjs/config.js +175 -21
  13. package/lib/commonjs/config.js.map +1 -1
  14. package/lib/commonjs/decisions.js +128 -0
  15. package/lib/commonjs/decisions.js.map +1 -0
  16. package/lib/commonjs/index.js +12 -0
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/init.js +164 -12
  19. package/lib/commonjs/init.js.map +1 -1
  20. package/lib/commonjs/track.js +154 -12
  21. package/lib/commonjs/track.js.map +1 -1
  22. package/lib/commonjs/usePulseUpdates.js +21 -16
  23. package/lib/commonjs/usePulseUpdates.js.map +1 -1
  24. package/lib/module/NativePulseUpdates.js.map +1 -1
  25. package/lib/module/PulseUpdates.js +33 -0
  26. package/lib/module/PulseUpdates.js.map +1 -1
  27. package/lib/module/config.js +174 -21
  28. package/lib/module/config.js.map +1 -1
  29. package/lib/module/decisions.js +122 -0
  30. package/lib/module/decisions.js.map +1 -0
  31. package/lib/module/index.js +1 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/init.js +165 -14
  34. package/lib/module/init.js.map +1 -1
  35. package/lib/module/track.js +152 -12
  36. package/lib/module/track.js.map +1 -1
  37. package/lib/module/usePulseUpdates.js +21 -16
  38. package/lib/module/usePulseUpdates.js.map +1 -1
  39. package/lib/typescript/NativePulseUpdates.d.ts +1 -0
  40. package/lib/typescript/NativePulseUpdates.d.ts.map +1 -1
  41. package/lib/typescript/PulseUpdates.d.ts +20 -0
  42. package/lib/typescript/PulseUpdates.d.ts.map +1 -1
  43. package/lib/typescript/config.d.ts +22 -0
  44. package/lib/typescript/config.d.ts.map +1 -1
  45. package/lib/typescript/decisions.d.ts +55 -0
  46. package/lib/typescript/decisions.d.ts.map +1 -0
  47. package/lib/typescript/index.d.ts +1 -0
  48. package/lib/typescript/index.d.ts.map +1 -1
  49. package/lib/typescript/init.d.ts +51 -4
  50. package/lib/typescript/init.d.ts.map +1 -1
  51. package/lib/typescript/track.d.ts +29 -1
  52. package/lib/typescript/track.d.ts.map +1 -1
  53. package/lib/typescript/usePulseUpdates.d.ts.map +1 -1
  54. package/logo.png +0 -0
  55. package/package.json +14 -3
  56. package/scripts/publish.mjs +68 -3
  57. package/src/NativePulseUpdates.ts +1 -0
  58. package/src/PulseUpdates.ts +54 -0
  59. package/src/config.ts +234 -21
  60. package/src/decisions.ts +179 -0
  61. package/src/index.ts +1 -0
  62. package/src/init.ts +240 -11
  63. package/src/track.ts +191 -13
  64. package/src/usePulseUpdates.ts +21 -16
package/src/init.ts CHANGED
@@ -30,8 +30,21 @@ import {
30
30
  type ConfigContext,
31
31
  type ConfigStorage,
32
32
  type ConfigValue,
33
+ type ConfigSignature,
34
+ getConfigInfo,
35
+ getConfigBoolean,
36
+ getConfigNumber,
37
+ getConfigString,
38
+ getConfigJson,
33
39
  } from './config';
34
- import { configureTracking } from './track';
40
+ import {
41
+ configureTracking,
42
+ disposeTracking,
43
+ flushEvents,
44
+ getTrackingInfo,
45
+ track,
46
+ type TrackedEventInput,
47
+ } from './track';
35
48
 
36
49
  export interface InitPulseOptions {
37
50
  /** Where Pulse lives, e.g. https://pulse.example.com — no path. */
@@ -53,6 +66,9 @@ export interface InitPulseOptions {
53
66
  /** The store build, e.g. "5.2.0". Needed to target a rollout at a version. */
54
67
  appVersion?: string;
55
68
 
69
+ /** Locale override. When omitted the installed RN locale adapter is detected. */
70
+ language?: string;
71
+
56
72
  /**
57
73
  * A stable id for this install. Left out, one is minted and persisted.
58
74
  *
@@ -68,6 +84,19 @@ export interface InitPulseOptions {
68
84
  /** Attributes a rule can target — a plan, a storefront. Keep them few and stable. */
69
85
  getUserAttributes?: () => Record<string, string> | undefined;
70
86
 
87
+ /** Initial analytics consent or a live consent reader. Defaults to granted for compatibility. */
88
+ analyticsConsent?: boolean | (() => boolean);
89
+
90
+ /** Privacy guardrails applied to every track() call. */
91
+ eventPropertyAllowlist?: readonly string[];
92
+ redactEventProperties?: readonly string[];
93
+
94
+ /** Signed config uses the same Ed25519 public key as OTA updates. */
95
+ signingPublicKey?: string;
96
+ signingKeyId?: string;
97
+ requireConfigSignature?: boolean;
98
+ verifyConfigSignature?: (canonicalPayload: string, signature: ConfigSignature) => boolean | Promise<boolean>;
99
+
71
100
  /** Foreground poll interval for the config. 0 disables it; launch and resume still fetch. */
72
101
  pollIntervalMs?: number;
73
102
 
@@ -85,50 +114,201 @@ export interface InitPulseOptions {
85
114
 
86
115
  const DEVICE_ID_KEY = 'pulse.device-id';
87
116
 
117
+ export interface PulseHealth {
118
+ appSlug: string;
119
+ deviceId: string;
120
+ config: ReturnType<typeof getConfigInfo>;
121
+ events: ReturnType<typeof getTrackingInfo>;
122
+ analyticsConsent: boolean;
123
+ }
124
+
125
+ export interface PulseClient {
126
+ deviceId: string;
127
+ configUrl: string;
128
+ trackUrl: string;
129
+ track: (event: string, props?: TrackedEventInput['props'], time?: Date) => void;
130
+ flush: () => Promise<number>;
131
+ setUser: (userId?: string, attributes?: Record<string, string>) => void;
132
+ clearUser: () => void;
133
+ setAnalyticsConsent: (granted: boolean) => void;
134
+ resetInstallId: () => string;
135
+ health: () => PulseHealth;
136
+ config: {
137
+ boolean: typeof getConfigBoolean;
138
+ number: typeof getConfigNumber;
139
+ string: typeof getConfigString;
140
+ json: typeof getConfigJson;
141
+ refresh: typeof fetchConfig;
142
+ };
143
+ dispose: () => Promise<void>;
144
+ }
145
+
146
+ export interface AsyncPulseStorage {
147
+ getItem(key: string): Promise<string | null>;
148
+ setItem(key: string, value: string): Promise<void>;
149
+ }
150
+
151
+ let activeClient: PulseClient | null = null;
152
+ let stopActiveRefresh: (() => void) | null = null;
153
+
88
154
  /** Wires config and events, and returns the context the two share. */
89
- export function initPulse(opts: InitPulseOptions): { deviceId: string; configUrl: string; trackUrl: string } {
155
+ export function initPulse(opts: InitPulseOptions): PulseClient {
156
+ // Reconfiguration is synchronous. Calling the old async dispose without awaiting
157
+ // it lets its eventual flush tear down the brand-new tracker — exactly the race a
158
+ // fast app switch or test setup triggers. Persist the old outbox, stop its refresh,
159
+ // then configure the new app in one uninterrupted turn.
160
+ stopActiveRefresh?.();
161
+ disposeTracking();
162
+ activeClient = null;
163
+ stopActiveRefresh = null;
90
164
  const base = opts.apiUrl.replace(/\/+$/, '');
91
165
  const slug = opts.appSlug.trim();
92
166
  const configUrl = `${base}/pulse/config/${slug}`;
93
167
  const trackUrl = `${base}/pulse/track/${slug}`;
94
168
 
95
- const deviceId = opts.deviceId?.trim() || resolveDeviceId(opts.storage);
169
+ const deviceStorageKey = `pulse.${slug}.device-id`;
170
+ let deviceId = opts.deviceId?.trim() || resolveDeviceId(opts.storage, deviceStorageKey);
96
171
  const platform = detectPlatform();
97
172
  const osVersion = detectOsVersion();
173
+ const appVersion = opts.appVersion ?? detectAppVersion();
174
+ const language = opts.language ?? detectLanguage();
175
+ let userId: string | undefined;
176
+ let userAttributes: Record<string, string> | undefined;
177
+ let consentOverride: boolean | undefined = typeof opts.analyticsConsent === 'boolean'
178
+ ? opts.analyticsConsent
179
+ : undefined;
180
+
181
+ const hasConsent = (): boolean => consentOverride ??
182
+ (typeof opts.analyticsConsent === 'function' ? opts.analyticsConsent() : true);
98
183
 
99
184
  const getContext = (): ConfigContext => ({
100
185
  platform,
101
186
  osVersion,
102
- appVersion: opts.appVersion,
187
+ appVersion,
188
+ language,
103
189
  deviceId,
104
- userId: opts.getUserId?.(),
105
- userAttributes: opts.getUserAttributes?.(),
190
+ userId: userId ?? opts.getUserId?.(),
191
+ userAttributes: userAttributes ?? opts.getUserAttributes?.(),
106
192
  });
107
193
 
108
194
  configureConfig({
109
195
  url: configUrl,
110
196
  defaults: opts.defaults,
111
197
  storage: opts.storage,
198
+ storageKey: `pulse.${slug}.config.v1`,
112
199
  getContext,
113
200
  pollIntervalMs: opts.pollIntervalMs,
114
201
  onError: opts.onError,
202
+ signingPublicKey: opts.signingPublicKey,
203
+ signingKeyId: opts.signingKeyId,
204
+ requireSignature: opts.requireConfigSignature,
205
+ verifySignature: opts.verifyConfigSignature ??
206
+ (opts.requireConfigSignature && !opts.signingPublicKey ? verifyWithNativeKey : undefined),
115
207
  });
116
208
 
117
209
  configureTracking({
118
210
  url: trackUrl,
119
211
  getContext,
120
212
  flushIntervalMs: opts.flushIntervalMs,
213
+ storage: opts.storage,
214
+ storageKey: `pulse.${slug}.events.v1`,
215
+ hasConsent,
216
+ propertyAllowlist: opts.eventPropertyAllowlist,
217
+ redactProperties: opts.redactEventProperties,
218
+ appState: opts.appState,
121
219
  onIgnored: opts.onIgnoredEvents,
122
220
  onError: opts.onError,
123
221
  });
124
222
 
223
+ // The two fields nothing can derive, said out loud when they are missing. Both
224
+ // fail silently and late: without a version, every rule and slice that names one
225
+ // simply does not match this install — which reads as "the rollout did nothing".
226
+ // Without storage the id is new on every launch, so the arm is re-dealt each time
227
+ // and no experiment can mean anything.
228
+ if (!appVersion) {
229
+ opts.onError?.(new Error(
230
+ 'Pulse: no appVersion given — version targeting and version slices will not match this install'));
231
+ }
232
+ if (!opts.storage && !opts.deviceId) {
233
+ opts.onError?.(new Error(
234
+ 'Pulse: no storage and no deviceId — this install gets a new id every launch, which re-deals its arm'));
235
+ }
236
+
125
237
  // Fetch now rather than on the first read: a launch that reads the config before
126
238
  // the first response is the launch that runs on defaults, and defaults are the
127
239
  // off position of every flag.
128
240
  void fetchConfig();
129
- startConfigAutoRefresh(opts.appState);
241
+ const stopRefresh = startConfigAutoRefresh(opts.appState);
242
+
243
+ const client: PulseClient = {
244
+ get deviceId() { return deviceId; },
245
+ configUrl,
246
+ trackUrl,
247
+ track,
248
+ flush: flushEvents,
249
+ setUser: (nextUserId, attributes) => {
250
+ userId = nextUserId?.trim() || undefined;
251
+ userAttributes = sanitizeAttributes(attributes);
252
+ },
253
+ clearUser: () => {
254
+ userId = undefined;
255
+ userAttributes = undefined;
256
+ },
257
+ setAnalyticsConsent: (granted) => { consentOverride = granted; },
258
+ resetInstallId: () => {
259
+ deviceId = randomId();
260
+ try { opts.storage?.set(deviceStorageKey, deviceId); } catch { /* memory-only fallback */ }
261
+ return deviceId;
262
+ },
263
+ health: () => ({ appSlug: slug, deviceId, config: getConfigInfo(), events: getTrackingInfo(), analyticsConsent: hasConsent() }),
264
+ config: {
265
+ boolean: getConfigBoolean,
266
+ number: getConfigNumber,
267
+ string: getConfigString,
268
+ json: getConfigJson,
269
+ refresh: fetchConfig,
270
+ },
271
+ dispose: async () => {
272
+ stopRefresh();
273
+ if (activeClient !== client) return;
274
+ await flushEvents();
275
+ // Another init may have taken ownership while this network flush was pending.
276
+ if (activeClient === client) {
277
+ disposeTracking();
278
+ activeClient = null;
279
+ stopActiveRefresh = null;
280
+ }
281
+ },
282
+ };
283
+ activeClient = client;
284
+ stopActiveRefresh = stopRefresh;
285
+ return client;
286
+ }
130
287
 
131
- return { deviceId, configUrl, trackUrl };
288
+ /**
289
+ * AsyncStorage-friendly one-call setup. It preloads only Pulse's three app-scoped
290
+ * records, then exposes the same synchronous hot-path API as initPulse.
291
+ */
292
+ export async function initPulseAsync(
293
+ opts: Omit<InitPulseOptions, 'storage'> & { storage: AsyncPulseStorage },
294
+ ): Promise<PulseClient> {
295
+ const slug = opts.appSlug.trim();
296
+ const keys = [
297
+ `pulse.${slug}.device-id`,
298
+ `pulse.${slug}.config.v1`,
299
+ `pulse.${slug}.events.v1`,
300
+ DEVICE_ID_KEY,
301
+ ];
302
+ const loaded = await Promise.all(keys.map(async (key) => [key, await opts.storage.getItem(key)] as const));
303
+ const memory = new Map(loaded.filter((entry): entry is readonly [string, string] => entry[1] !== null));
304
+ const storage: ConfigStorage = {
305
+ getString: (key) => memory.get(key),
306
+ set: (key, value) => {
307
+ memory.set(key, value);
308
+ void opts.storage.setItem(key, value);
309
+ },
310
+ };
311
+ return initPulse({ ...opts, storage });
132
312
  }
133
313
 
134
314
  /**
@@ -138,20 +318,28 @@ export function initPulse(opts: InitPulseOptions): { deviceId: string; configUrl
138
318
  * arm is a hash of it, so every launch would be a fresh coin toss and no experiment
139
319
  * could mean anything. Said out loud through onError rather than hidden.
140
320
  */
141
- function resolveDeviceId(storage?: ConfigStorage): string {
321
+ function resolveDeviceId(storage?: ConfigStorage, key = DEVICE_ID_KEY): string {
142
322
  if (!storage) return randomId();
143
323
 
144
324
  try {
145
- const existing = storage.getString(DEVICE_ID_KEY);
325
+ const existing = storage.getString(key) ?? storage.getString(DEVICE_ID_KEY);
146
326
  if (existing) return existing;
147
327
  const minted = randomId();
148
- storage.set(DEVICE_ID_KEY, minted);
328
+ storage.set(key, minted);
149
329
  return minted;
150
330
  } catch {
151
331
  return randomId();
152
332
  }
153
333
  }
154
334
 
335
+ function sanitizeAttributes(attributes?: Record<string, string>): Record<string, string> | undefined {
336
+ if (!attributes) return undefined;
337
+ const clean = Object.fromEntries(Object.entries(attributes)
338
+ .filter(([key, value]) => key.trim() !== '' && typeof value === 'string')
339
+ .slice(0, 50));
340
+ return Object.keys(clean).length > 0 ? clean : undefined;
341
+ }
342
+
155
343
  function randomId(): string {
156
344
  const bytes = new Uint8Array(16);
157
345
  const crypto = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => void } }).crypto;
@@ -194,3 +382,44 @@ function detectOsVersion(): string | undefined {
194
382
  return undefined;
195
383
  }
196
384
  }
385
+
386
+ function detectAppVersion(): string | undefined {
387
+ try {
388
+ // Optional by design: most RN apps already carry this package, plain JS hosts do not.
389
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
390
+ const loaded = require('react-native-device-info') as { default?: { getVersion?: () => string }; getVersion?: () => string };
391
+ return loaded.default?.getVersion?.() ?? loaded.getVersion?.();
392
+ } catch {
393
+ return undefined;
394
+ }
395
+ }
396
+
397
+ async function verifyWithNativeKey(
398
+ canonicalPayload: string,
399
+ signature: ConfigSignature,
400
+ ): Promise<boolean> {
401
+ try {
402
+ // Kept lazy so init/config remain usable in Node, web and test hosts without RN.
403
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
404
+ const updates = require('./PulseUpdates') as {
405
+ verifyConfigSignatureAsync?: (payload: string, value: ConfigSignature) => Promise<boolean>;
406
+ };
407
+ return await updates.verifyConfigSignatureAsync?.(canonicalPayload, signature) ?? false;
408
+ } catch {
409
+ return false;
410
+ }
411
+ }
412
+
413
+ function detectLanguage(): string | undefined {
414
+ try {
415
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
416
+ const localize = require('react-native-localize') as { getLocales?: () => Array<{ languageTag?: string }> };
417
+ return localize.getLocales?.()[0]?.languageTag;
418
+ } catch {
419
+ try {
420
+ return Intl.DateTimeFormat().resolvedOptions().locale;
421
+ } catch {
422
+ return undefined;
423
+ }
424
+ }
425
+ }
package/src/track.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * everybody sees.
21
21
  */
22
22
 
23
- import type { ConfigContext } from './config';
23
+ import type { ConfigContext, ConfigStorage } from './config';
24
24
 
25
25
  export interface TrackOptions {
26
26
  /**
@@ -43,9 +43,35 @@ export interface TrackOptions {
43
43
  /** Events held before the oldest are dropped. Default 500. */
44
44
  maxQueue?: number;
45
45
 
46
+ /** Persists the outbox across process death. Use the same storage passed to initPulse. */
47
+ storage?: ConfigStorage;
48
+
49
+ /** App-scoped persistence key. initPulse supplies one automatically. */
50
+ storageKey?: string;
51
+
52
+ /** Per-request timeout. Default 10s. */
53
+ timeoutMs?: number;
54
+
55
+ /** Maximum delivery attempts before an event is dropped. Default 10. */
56
+ maxAttempts?: number;
57
+
58
+ /** Return false until analytics consent exists. No event is retained before consent. */
59
+ hasConsent?: () => boolean;
60
+
61
+ /** Only these property keys may leave the process. */
62
+ propertyAllowlist?: readonly string[];
63
+
64
+ /** These allowed properties are replaced with "[REDACTED]". */
65
+ redactProperties?: readonly string[];
66
+
67
+ /** Foreground/background lifecycle used for a best-effort final flush. */
68
+ appState?: { addEventListener: (type: 'change', handler: (state: string) => void) => { remove: () => void } };
69
+
46
70
  /** Called with the event keys the server did not recognise. */
47
71
  onIgnored?: (events: string[]) => void;
48
72
 
73
+ onDropped?: (count: number, reason: 'queue-full' | 'max-attempts' | 'no-consent') => void;
74
+
49
75
  onError?: (error: unknown) => void;
50
76
  }
51
77
 
@@ -56,19 +82,27 @@ export interface TrackedEventInput {
56
82
  }
57
83
 
58
84
  interface QueuedEvent {
85
+ id: string;
59
86
  event: string;
60
87
  time: string;
61
88
  props: Record<string, string>;
89
+ attempts: number;
62
90
  }
63
91
 
64
92
  const DEFAULT_FLUSH_MS = 10_000;
65
93
  const DEFAULT_MAX_BATCH = 100;
66
94
  const DEFAULT_MAX_QUEUE = 500;
95
+ const DEFAULT_TIMEOUT_MS = 10_000;
96
+ const DEFAULT_MAX_ATTEMPTS = 10;
97
+ const DEFAULT_STORAGE_KEY = 'pulse.events.v1';
67
98
 
68
99
  let options: TrackOptions | null = null;
69
100
  let queue: QueuedEvent[] = [];
70
101
  let timer: ReturnType<typeof setInterval> | null = null;
71
102
  let sending = false;
103
+ let nextAttemptAt = 0;
104
+ let lifecycleSubscription: { remove: () => void } | null = null;
105
+ let trackingGeneration = 0;
72
106
 
73
107
  /** Turns a config url into the track url; leaves an explicit track url alone. */
74
108
  function trackUrl(url: string): string {
@@ -79,8 +113,14 @@ function trackUrl(url: string): string {
79
113
  }
80
114
 
81
115
  export function configureTracking(opts: TrackOptions): void {
116
+ disposeTracking();
82
117
  options = opts;
118
+ queue = readQueue(opts);
119
+ nextAttemptAt = 0;
83
120
  startTimer();
121
+ lifecycleSubscription = opts.appState?.addEventListener('change', (state) => {
122
+ if (state !== 'active') void flushEvents();
123
+ }) ?? null;
84
124
  }
85
125
 
86
126
  /**
@@ -90,17 +130,29 @@ export function configureTracking(opts: TrackOptions): void {
90
130
  */
91
131
  export function track(event: string, props?: TrackedEventInput['props'], time?: Date): void {
92
132
  if (!options || !event) return;
133
+ if (options.hasConsent?.() === false) {
134
+ options.onDropped?.(1, 'no-consent');
135
+ return;
136
+ }
93
137
 
94
138
  const flat: Record<string, string> = {};
139
+ const allow = options.propertyAllowlist ? new Set(options.propertyAllowlist) : null;
140
+ const redact = new Set(options.redactProperties ?? []);
95
141
  for (const [key, value] of Object.entries(props ?? {})) {
96
142
  if (value === null || value === undefined) continue;
97
- flat[key] = typeof value === 'string' ? value : String(value);
143
+ if (allow && !allow.has(key)) continue;
144
+ flat[key] = redact.has(key) ? '[REDACTED]' : (typeof value === 'string' ? value : String(value));
98
145
  }
99
146
 
100
- queue.push({ event, time: (time ?? new Date()).toISOString(), props: flat });
147
+ queue.push({ id: randomEventId(), event, time: (time ?? new Date()).toISOString(), props: flat, attempts: 0 });
101
148
 
102
149
  const maxQueue = options.maxQueue ?? DEFAULT_MAX_QUEUE;
103
- if (queue.length > maxQueue) queue = queue.slice(queue.length - maxQueue);
150
+ if (queue.length > maxQueue) {
151
+ const dropped = queue.length - maxQueue;
152
+ queue = queue.slice(dropped);
153
+ options.onDropped?.(dropped, 'queue-full');
154
+ }
155
+ persistQueue();
104
156
 
105
157
  if (queue.length >= (options.maxBatch ?? DEFAULT_MAX_BATCH)) void flushEvents();
106
158
  }
@@ -113,9 +165,10 @@ export function track(event: string, props?: TrackedEventInput['props'], time?:
113
165
  * after launch, when the first events of a session are the ones a funnel needs most.
114
166
  */
115
167
  export async function flushEvents(): Promise<number> {
116
- if (!options || sending || queue.length === 0) return 0;
168
+ if (!options || sending || queue.length === 0 || Date.now() < nextAttemptAt) return 0;
117
169
 
118
170
  const opts = options;
171
+ const requestGeneration = trackingGeneration;
119
172
  const ctx = opts.getContext?.() ?? {};
120
173
 
121
174
  // Without a device id the server cannot put the event in an arm, and an empty id
@@ -125,8 +178,14 @@ export async function flushEvents(): Promise<number> {
125
178
 
126
179
  const batch = queue.slice(0, opts.maxBatch ?? DEFAULT_MAX_BATCH);
127
180
  queue = queue.slice(batch.length);
181
+ persistQueue();
128
182
  sending = true;
129
183
 
184
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
185
+ const timeout = controller
186
+ ? setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
187
+ : null;
188
+
130
189
  try {
131
190
  const response = await fetch(trackUrl(opts.url), {
132
191
  method: 'POST',
@@ -136,26 +195,36 @@ export async function flushEvents(): Promise<number> {
136
195
  userId: ctx.userId,
137
196
  platform: ctx.platform,
138
197
  appVersion: ctx.appVersion,
139
- events: batch.map((e) => ({ event: e.event, time: e.time, props: e.props })),
198
+ events: batch.map((e) => ({ id: e.id, event: e.event, time: e.time, props: e.props })),
140
199
  }),
200
+ signal: controller?.signal,
141
201
  });
142
202
 
143
203
  if (!response.ok) {
144
204
  // Rate limited or briefly down: keep the events, try on the next tick.
145
- queue = [...batch, ...queue];
205
+ if (requestGeneration === trackingGeneration && options === opts) {
206
+ requeueWithBackoff(batch, response.status);
207
+ } else {
208
+ restoreStaleBatch(opts, batch, response.status);
209
+ }
146
210
  if (response.status !== 429) opts.onError?.(new Error(`Pulse track failed: ${response.status}`));
147
211
  return 0;
148
212
  }
149
213
 
150
214
  const body = (await response.json()) as { accepted?: number; ignored?: string[] };
215
+ if (requestGeneration !== trackingGeneration || options !== opts) return body.accepted ?? 0;
151
216
  if (body.ignored && body.ignored.length > 0) opts.onIgnored?.(body.ignored);
217
+ nextAttemptAt = 0;
218
+ persistQueue();
152
219
  return body.accepted ?? 0;
153
220
  } catch (error) {
154
- queue = [...batch, ...queue];
221
+ if (requestGeneration === trackingGeneration && options === opts) requeueWithBackoff(batch);
222
+ else restoreStaleBatch(opts, batch);
155
223
  opts.onError?.(error);
156
224
  return 0;
157
225
  } finally {
158
- sending = false;
226
+ if (timeout) clearTimeout(timeout);
227
+ if (requestGeneration === trackingGeneration) sending = false;
159
228
  }
160
229
  }
161
230
 
@@ -164,14 +233,32 @@ export function pendingEventCount(): number {
164
233
  return queue.length;
165
234
  }
166
235
 
236
+ export function getTrackingInfo(): {
237
+ queueDepth: number;
238
+ sending: boolean;
239
+ nextAttemptAt: number;
240
+ } {
241
+ return { queueDepth: queue.length, sending, nextAttemptAt };
242
+ }
243
+
244
+ /** Stop timers and listeners while preserving the durable outbox. */
245
+ export function disposeTracking(): void {
246
+ trackingGeneration += 1;
247
+ if (timer) clearInterval(timer);
248
+ timer = null;
249
+ lifecycleSubscription?.remove();
250
+ lifecycleSubscription = null;
251
+ persistQueue();
252
+ options = null;
253
+ sending = false;
254
+ }
255
+
167
256
  export function stopTracking(): void {
168
- if (timer) {
169
- clearInterval(timer);
170
- timer = null;
171
- }
257
+ disposeTracking();
172
258
  options = null;
173
259
  queue = [];
174
260
  sending = false;
261
+ nextAttemptAt = 0;
175
262
  }
176
263
 
177
264
  function startTimer(): void {
@@ -182,3 +269,94 @@ function startTimer(): void {
182
269
  // and a CLI importing this should still be able to exit.
183
270
  (timer as unknown as { unref?: () => void }).unref?.();
184
271
  }
272
+
273
+ function requeueWithBackoff(batch: QueuedEvent[], status?: number): void {
274
+ if (!options) return;
275
+ const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
276
+ const retryable: QueuedEvent[] = [];
277
+ let dropped = 0;
278
+ for (const event of batch) {
279
+ const next = { ...event, attempts: event.attempts + 1 };
280
+ if (next.attempts >= maxAttempts && status !== 429) dropped++;
281
+ else retryable.push(next);
282
+ }
283
+ queue = [...retryable, ...queue];
284
+ if (dropped > 0) options.onDropped?.(dropped, 'max-attempts');
285
+ const attempts = retryable.reduce((max, event) => Math.max(max, event.attempts), 1);
286
+ const base = Math.min(60_000, 1_000 * 2 ** Math.min(attempts - 1, 6));
287
+ nextAttemptAt = Date.now() + base + Math.floor(Math.random() * Math.max(1, base / 4));
288
+ persistQueue();
289
+ }
290
+
291
+ /**
292
+ * An old request may fail after another app has been configured. Its rows belong in
293
+ * the old app's outbox, never in the new app's in-memory queue. When the same app was
294
+ * merely reconfigured, merge them back into the live queue so it need not restart to
295
+ * see them; otherwise persist directly under the captured app-scoped key.
296
+ */
297
+ function restoreStaleBatch(opts: TrackOptions, batch: QueuedEvent[], status?: number): void {
298
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
299
+ const retryable: QueuedEvent[] = [];
300
+ let dropped = 0;
301
+ for (const event of batch) {
302
+ const next = { ...event, attempts: event.attempts + 1 };
303
+ if (next.attempts >= maxAttempts && status !== 429) dropped++;
304
+ else retryable.push(next);
305
+ }
306
+ if (dropped > 0) opts.onDropped?.(dropped, 'max-attempts');
307
+ if (retryable.length === 0) return;
308
+
309
+ const currentOptions = options;
310
+ const sameOutbox = currentOptions?.storage === opts.storage &&
311
+ currentOptions !== null && storageKey(currentOptions) === storageKey(opts);
312
+ if (sameOutbox) {
313
+ queue = [...retryable, ...queue].slice(-(options?.maxQueue ?? DEFAULT_MAX_QUEUE));
314
+ persistQueue();
315
+ return;
316
+ }
317
+
318
+ if (!opts.storage) return;
319
+ try {
320
+ const stored = readQueue(opts);
321
+ const restored = [...retryable, ...stored].slice(-(opts.maxQueue ?? DEFAULT_MAX_QUEUE));
322
+ opts.storage.set(storageKey(opts), JSON.stringify(restored));
323
+ } catch {
324
+ // Best effort only; a telemetry recovery must not affect the new app session.
325
+ }
326
+ }
327
+
328
+ function storageKey(opts: TrackOptions): string {
329
+ return opts.storageKey?.trim() || DEFAULT_STORAGE_KEY;
330
+ }
331
+
332
+ function readQueue(opts: TrackOptions): QueuedEvent[] {
333
+ if (!opts.storage) return [];
334
+ try {
335
+ const raw = opts.storage.getString(storageKey(opts));
336
+ if (!raw) return [];
337
+ const parsed = JSON.parse(raw) as unknown;
338
+ if (!Array.isArray(parsed)) return [];
339
+ return parsed.filter((item): item is QueuedEvent => Boolean(
340
+ item && typeof item === 'object' && typeof item.id === 'string' &&
341
+ typeof item.event === 'string' && typeof item.time === 'string' &&
342
+ typeof item.props === 'object' && typeof item.attempts === 'number',
343
+ )).slice(-(opts.maxQueue ?? DEFAULT_MAX_QUEUE));
344
+ } catch {
345
+ return [];
346
+ }
347
+ }
348
+
349
+ function persistQueue(): void {
350
+ if (!options?.storage) return;
351
+ try {
352
+ options.storage.set(storageKey(options), JSON.stringify(queue));
353
+ } catch {
354
+ // Telemetry persistence must never become an app failure.
355
+ }
356
+ }
357
+
358
+ function randomEventId(): string {
359
+ const cryptoLike = globalThis.crypto as { randomUUID?: () => string } | undefined;
360
+ if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
361
+ return `evt-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
362
+ }