pulse-updates 1.3.6 → 1.3.8
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 +50 -2
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/init.js +23 -8
- package/lib/commonjs/init.js.map +1 -1
- package/lib/commonjs/links.js +561 -61
- package/lib/commonjs/links.js.map +1 -1
- package/lib/commonjs/track.js +241 -76
- package/lib/commonjs/track.js.map +1 -1
- package/lib/module/config.js.map +1 -1
- package/lib/module/init.js +24 -9
- package/lib/module/init.js.map +1 -1
- package/lib/module/links.js +561 -61
- package/lib/module/links.js.map +1 -1
- package/lib/module/track.js +240 -76
- package/lib/module/track.js.map +1 -1
- package/lib/typescript/config.d.ts +6 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +15 -6
- package/lib/typescript/init.d.ts.map +1 -1
- package/lib/typescript/links.d.ts +49 -0
- package/lib/typescript/links.d.ts.map +1 -1
- package/lib/typescript/track.d.ts +29 -7
- package/lib/typescript/track.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +6 -0
- package/src/init.ts +33 -7
- package/src/links.ts +810 -40
- package/src/track.ts +348 -83
package/src/track.ts
CHANGED
|
@@ -31,16 +31,28 @@ export interface TrackOptions {
|
|
|
31
31
|
*/
|
|
32
32
|
url: string;
|
|
33
33
|
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Request context shared with config. `context` identity mode reads deviceId/userId
|
|
36
|
+
* for backwards-compatible experiment metrics; `anonymous_installation` ignores
|
|
37
|
+
* them and reads only platform/appVersion.
|
|
38
|
+
*/
|
|
35
39
|
getContext?: () => ConfigContext;
|
|
36
40
|
|
|
41
|
+
/**
|
|
42
|
+
* `context` preserves the original experiment-event contract and sends the context
|
|
43
|
+
* deviceId/userId. `anonymous_installation` is an explicit privacy mode: it ignores
|
|
44
|
+
* both and sends only an SDK-minted app-scoped random UUID under the legacy wire
|
|
45
|
+
* field `deviceId`. Defaults to `context` for backwards compatibility.
|
|
46
|
+
*/
|
|
47
|
+
identityMode?: 'context' | 'anonymous_installation';
|
|
48
|
+
|
|
37
49
|
/** How often a non-empty queue is sent. Default 10s. */
|
|
38
50
|
flushIntervalMs?: number;
|
|
39
51
|
|
|
40
52
|
/** Events per request. The server refuses more than 100. */
|
|
41
53
|
maxBatch?: number;
|
|
42
54
|
|
|
43
|
-
/**
|
|
55
|
+
/** Best-effort events held before the oldest are dropped. Durable idempotent rows are never evicted. Default 500. */
|
|
44
56
|
maxQueue?: number;
|
|
45
57
|
|
|
46
58
|
/** Persists the outbox across process death. Use the same storage passed to initPulse. */
|
|
@@ -52,10 +64,13 @@ export interface TrackOptions {
|
|
|
52
64
|
/** Per-request timeout. Default 10s. */
|
|
53
65
|
timeoutMs?: number;
|
|
54
66
|
|
|
55
|
-
/** Maximum delivery attempts before
|
|
67
|
+
/** Maximum delivery attempts before a best-effort event is dropped. Durable idempotent events retry until server ACK. Default 10. */
|
|
56
68
|
maxAttempts?: number;
|
|
57
69
|
|
|
58
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Return false until analytics consent exists. No event is retained before consent. Consent is
|
|
72
|
+
* sampled at enqueue; an event already durably accepted remains eligible for later delivery.
|
|
73
|
+
*/
|
|
59
74
|
hasConsent?: () => boolean;
|
|
60
75
|
|
|
61
76
|
/** Only these property keys may leave the process. */
|
|
@@ -87,6 +102,8 @@ interface QueuedEvent {
|
|
|
87
102
|
time: string;
|
|
88
103
|
props: Record<string, string>;
|
|
89
104
|
attempts: number;
|
|
105
|
+
/** Caller-owned events are retained until the server ACKs their stable id. */
|
|
106
|
+
durable?: true;
|
|
90
107
|
}
|
|
91
108
|
|
|
92
109
|
const DEFAULT_FLUSH_MS = 10_000;
|
|
@@ -95,14 +112,18 @@ const DEFAULT_MAX_QUEUE = 500;
|
|
|
95
112
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
96
113
|
const DEFAULT_MAX_ATTEMPTS = 10;
|
|
97
114
|
const DEFAULT_STORAGE_KEY = 'pulse.events.v1';
|
|
115
|
+
const ANALYTICS_INSTALLATION_ID_SUFFIX = '.analytics-installation-id';
|
|
116
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
98
117
|
|
|
99
118
|
let options: TrackOptions | null = null;
|
|
100
119
|
let queue: QueuedEvent[] = [];
|
|
120
|
+
let analyticsInstallationId = '';
|
|
101
121
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
102
122
|
let sending = false;
|
|
103
123
|
let nextAttemptAt = 0;
|
|
104
124
|
let lifecycleSubscription: { remove: () => void } | null = null;
|
|
105
125
|
let trackingGeneration = 0;
|
|
126
|
+
const asynchronousStorageAdapters = new WeakSet<object>();
|
|
106
127
|
|
|
107
128
|
/** Turns a config url into the track url; leaves an explicit track url alone. */
|
|
108
129
|
function trackUrl(url: string): string {
|
|
@@ -116,6 +137,9 @@ export function configureTracking(opts: TrackOptions): void {
|
|
|
116
137
|
disposeTracking();
|
|
117
138
|
options = opts;
|
|
118
139
|
queue = readQueue(opts);
|
|
140
|
+
analyticsInstallationId = opts.identityMode === 'anonymous_installation'
|
|
141
|
+
? resolveAnalyticsInstallationId(opts)
|
|
142
|
+
: '';
|
|
119
143
|
nextAttemptAt = 0;
|
|
120
144
|
startTimer();
|
|
121
145
|
lifecycleSubscription = opts.appState?.addEventListener('change', (state) => {
|
|
@@ -129,10 +153,52 @@ export function configureTracking(opts: TrackOptions): void {
|
|
|
129
153
|
* caller has to wrap.
|
|
130
154
|
*/
|
|
131
155
|
export function track(event: string, props?: TrackedEventInput['props'], time?: Date): void {
|
|
132
|
-
|
|
133
|
-
|
|
156
|
+
void enqueueEvent(randomEventId(), event, props, time, false);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Durably enqueue one logical event with a caller-owned UUID. Replaying the same id is safe:
|
|
161
|
+
* Pulse's server/read model deduplicates it, while the local outbox avoids duplicate rows that
|
|
162
|
+
* are still pending. `true` means the event is present in persistent storage, not delivered yet;
|
|
163
|
+
* once acknowledged, Track keeps it in that outbox through retries and process restarts until a
|
|
164
|
+
* successful server response removes it transactionally.
|
|
165
|
+
*/
|
|
166
|
+
export function trackIdempotent(
|
|
167
|
+
eventId: string,
|
|
168
|
+
event: string,
|
|
169
|
+
props?: TrackedEventInput['props'],
|
|
170
|
+
time?: Date,
|
|
171
|
+
): boolean {
|
|
172
|
+
const normalizedId = eventId.trim().toLowerCase();
|
|
173
|
+
if (!UUID_PATTERN.test(normalizedId)) return false;
|
|
174
|
+
return enqueueEvent(normalizedId, event, props, time, true);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function enqueueEvent(
|
|
178
|
+
eventId: string,
|
|
179
|
+
event: string,
|
|
180
|
+
props: TrackedEventInput['props'],
|
|
181
|
+
time: Date | undefined,
|
|
182
|
+
requirePersistentAck: boolean,
|
|
183
|
+
): boolean {
|
|
184
|
+
if (!options || !event) return false;
|
|
185
|
+
try {
|
|
186
|
+
if (options.hasConsent?.() === false) {
|
|
187
|
+
options.onDropped?.(1, 'no-consent');
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
134
191
|
options.onDropped?.(1, 'no-consent');
|
|
135
|
-
return;
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (requirePersistentAck) {
|
|
196
|
+
// Probe with the unchanged queue before accepting caller-owned data. This discovers custom
|
|
197
|
+
// adapters that secretly return a Promise without ever placing the terminal event in Track;
|
|
198
|
+
// the upstream Links outbox remains the single source of truth.
|
|
199
|
+
if (!options.storage
|
|
200
|
+
|| !supportsDurableSyncWrites(options.storage)
|
|
201
|
+
|| !persistQueue()) return false;
|
|
136
202
|
}
|
|
137
203
|
|
|
138
204
|
const flat: Record<string, string> = {};
|
|
@@ -144,25 +210,81 @@ export function track(event: string, props?: TrackedEventInput['props'], time?:
|
|
|
144
210
|
flat[key] = redact.has(key) ? '[REDACTED]' : (typeof value === 'string' ? value : String(value));
|
|
145
211
|
}
|
|
146
212
|
|
|
147
|
-
|
|
213
|
+
let occurredAt: string;
|
|
214
|
+
try {
|
|
215
|
+
occurredAt = (time ?? new Date()).toISOString();
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
148
219
|
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
220
|
+
const existing = queue.find((queued) => queued.id === eventId);
|
|
221
|
+
if (existing) {
|
|
222
|
+
const sameLogicalEvent = existing.event === event
|
|
223
|
+
&& existing.time === occurredAt
|
|
224
|
+
&& JSON.stringify(existing.props) === JSON.stringify(flat);
|
|
225
|
+
if (!sameLogicalEvent) return false;
|
|
226
|
+
if (requirePersistentAck && existing.durable !== true) {
|
|
227
|
+
existing.durable = true;
|
|
228
|
+
if (!persistQueue()) {
|
|
229
|
+
delete existing.durable;
|
|
230
|
+
persistQueue();
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
154
235
|
}
|
|
155
|
-
|
|
236
|
+
|
|
237
|
+
const queueBeforeDurableEnqueue = requirePersistentAck ? [...queue] : null;
|
|
238
|
+
queue.push({
|
|
239
|
+
id: eventId,
|
|
240
|
+
event,
|
|
241
|
+
time: occurredAt,
|
|
242
|
+
props: flat,
|
|
243
|
+
attempts: 0,
|
|
244
|
+
...(requirePersistentAck ? { durable: true as const } : {}),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const maxQueue = Math.max(0, options.maxQueue ?? DEFAULT_MAX_QUEUE);
|
|
248
|
+
let dropped = 0;
|
|
249
|
+
while (queue.length > maxQueue) {
|
|
250
|
+
// Never evict a previously acknowledged durable event to make room. When every queued row
|
|
251
|
+
// is durable, the newest enqueue is refused and its upstream outbox remains authoritative.
|
|
252
|
+
const bestEffortIndex = queue.findIndex((queued) => queued.durable !== true);
|
|
253
|
+
if (bestEffortIndex < 0) {
|
|
254
|
+
// The queue can already exceed a newly lowered max after restart. Refuse only the newest
|
|
255
|
+
// durable enqueue; previously acknowledged records must still reach the server.
|
|
256
|
+
const newestIndex = queue.findIndex((queued) => queued.id === eventId);
|
|
257
|
+
if (newestIndex >= 0) {
|
|
258
|
+
queue.splice(newestIndex, 1);
|
|
259
|
+
dropped += 1;
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
const index = bestEffortIndex;
|
|
264
|
+
queue.splice(index, 1);
|
|
265
|
+
dropped += 1;
|
|
266
|
+
}
|
|
267
|
+
const retained = queue.some((queued) => queued.id === eventId);
|
|
268
|
+
const persisted = persistQueue();
|
|
269
|
+
if (requirePersistentAck && (!retained || !persisted)) {
|
|
270
|
+
queue = queueBeforeDurableEnqueue ?? queue;
|
|
271
|
+
// If an adapter revealed itself as asynchronous only on the second write, schedule a
|
|
272
|
+
// compensating best-effort mirror without the caller-owned event.
|
|
273
|
+
persistQueue();
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
if (dropped > 0) options.onDropped?.(dropped, 'queue-full');
|
|
156
277
|
|
|
157
278
|
if (queue.length >= (options.maxBatch ?? DEFAULT_MAX_BATCH)) void flushEvents();
|
|
279
|
+
return retained && (!requirePersistentAck || persisted);
|
|
158
280
|
}
|
|
159
281
|
|
|
160
282
|
/**
|
|
161
283
|
* Send what is queued. Returns how many events the server accepted.
|
|
162
284
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* after
|
|
285
|
+
* The batch remains in the durable queue while HTTP is in flight and is removed only after a
|
|
286
|
+
* successful response is also persisted locally. This intentionally permits duplicate replay
|
|
287
|
+
* after a crash, while a caller-owned event id lets the server/read model deduplicate it.
|
|
166
288
|
*/
|
|
167
289
|
export async function flushEvents(): Promise<number> {
|
|
168
290
|
if (!options || sending || queue.length === 0 || Date.now() < nextAttemptAt) return 0;
|
|
@@ -170,15 +292,14 @@ export async function flushEvents(): Promise<number> {
|
|
|
170
292
|
const opts = options;
|
|
171
293
|
const requestGeneration = trackingGeneration;
|
|
172
294
|
const ctx = opts.getContext?.() ?? {};
|
|
295
|
+
const anonymousInstallation = opts.identityMode === 'anonymous_installation';
|
|
173
296
|
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
297
|
+
// The legacy/default mode intentionally preserves the original experiment-event
|
|
298
|
+
// contract. Anonymous campaign events opt into a separate identity explicitly.
|
|
299
|
+
if (!anonymousInstallation && !ctx.deviceId) return 0;
|
|
300
|
+
const requestDeviceId = anonymousInstallation ? analyticsInstallationId : ctx.deviceId;
|
|
178
301
|
|
|
179
302
|
const batch = queue.slice(0, opts.maxBatch ?? DEFAULT_MAX_BATCH);
|
|
180
|
-
queue = queue.slice(batch.length);
|
|
181
|
-
persistQueue();
|
|
182
303
|
sending = true;
|
|
183
304
|
|
|
184
305
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
@@ -191,8 +312,10 @@ export async function flushEvents(): Promise<number> {
|
|
|
191
312
|
method: 'POST',
|
|
192
313
|
headers: { 'Content-Type': 'application/json' },
|
|
193
314
|
body: JSON.stringify({
|
|
194
|
-
deviceId:
|
|
195
|
-
|
|
315
|
+
deviceId: requestDeviceId,
|
|
316
|
+
// Anonymous mode deliberately omits the property rather than serializing an
|
|
317
|
+
// empty identifier. Context mode remains backwards compatible.
|
|
318
|
+
...(anonymousInstallation ? {} : { userId: ctx.userId }),
|
|
196
319
|
platform: ctx.platform,
|
|
197
320
|
appVersion: ctx.appVersion,
|
|
198
321
|
events: batch.map((e) => ({ id: e.id, event: e.event, time: e.time, props: e.props })),
|
|
@@ -201,11 +324,9 @@ export async function flushEvents(): Promise<number> {
|
|
|
201
324
|
});
|
|
202
325
|
|
|
203
326
|
if (!response.ok) {
|
|
204
|
-
// Rate limited or briefly down:
|
|
327
|
+
// Rate limited or briefly down: the batch never left the durable queue.
|
|
205
328
|
if (requestGeneration === trackingGeneration && options === opts) {
|
|
206
|
-
|
|
207
|
-
} else {
|
|
208
|
-
restoreStaleBatch(opts, batch, response.status);
|
|
329
|
+
markBatchForRetry(batch, response.status);
|
|
209
330
|
}
|
|
210
331
|
if (response.status !== 429) opts.onError?.(new Error(`Pulse track failed: ${response.status}`));
|
|
211
332
|
return 0;
|
|
@@ -215,11 +336,15 @@ export async function flushEvents(): Promise<number> {
|
|
|
215
336
|
if (requestGeneration !== trackingGeneration || options !== opts) return body.accepted ?? 0;
|
|
216
337
|
if (body.ignored && body.ignored.length > 0) opts.onIgnored?.(body.ignored);
|
|
217
338
|
nextAttemptAt = 0;
|
|
218
|
-
|
|
339
|
+
if (!acknowledgeBatch(opts, batch)) {
|
|
340
|
+
// The server may already have accepted the ids. Keep and replay them rather than creating
|
|
341
|
+
// the loss window between remote ACK and local deletion.
|
|
342
|
+
nextAttemptAt = Date.now() + retryDelayFor(batch);
|
|
343
|
+
opts.onError?.(new Error('Pulse track acknowledgement was not persisted'));
|
|
344
|
+
}
|
|
219
345
|
return body.accepted ?? 0;
|
|
220
346
|
} catch (error) {
|
|
221
|
-
if (requestGeneration === trackingGeneration && options === opts)
|
|
222
|
-
else restoreStaleBatch(opts, batch);
|
|
347
|
+
if (requestGeneration === trackingGeneration && options === opts) markBatchForRetry(batch);
|
|
223
348
|
opts.onError?.(error);
|
|
224
349
|
return 0;
|
|
225
350
|
} finally {
|
|
@@ -257,6 +382,7 @@ export function stopTracking(): void {
|
|
|
257
382
|
disposeTracking();
|
|
258
383
|
options = null;
|
|
259
384
|
queue = [];
|
|
385
|
+
analyticsInstallationId = '';
|
|
260
386
|
sending = false;
|
|
261
387
|
nextAttemptAt = 0;
|
|
262
388
|
}
|
|
@@ -270,65 +396,128 @@ function startTimer(): void {
|
|
|
270
396
|
(timer as unknown as { unref?: () => void }).unref?.();
|
|
271
397
|
}
|
|
272
398
|
|
|
273
|
-
function
|
|
399
|
+
function markBatchForRetry(batch: QueuedEvent[], status?: number): void {
|
|
274
400
|
if (!options) return;
|
|
275
401
|
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
276
|
-
const
|
|
402
|
+
const batchIds = new Set(batch.map((event) => event.id));
|
|
277
403
|
let dropped = 0;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
404
|
+
queue = queue.flatMap((event) => {
|
|
405
|
+
if (!batchIds.has(event.id)) return [event];
|
|
406
|
+
const next = { ...event, attempts: Math.min(event.attempts + 1, 100_000) };
|
|
407
|
+
if (next.durable !== true && next.attempts >= maxAttempts && status !== 429) {
|
|
408
|
+
dropped += 1;
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
return [next];
|
|
412
|
+
});
|
|
284
413
|
if (dropped > 0) options.onDropped?.(dropped, 'max-attempts');
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
414
|
+
nextAttemptAt = Date.now() + retryDelayFor(batch.map((event) => ({
|
|
415
|
+
...event,
|
|
416
|
+
attempts: Math.min(event.attempts + 1, 100_000),
|
|
417
|
+
})));
|
|
288
418
|
persistQueue();
|
|
289
419
|
}
|
|
290
420
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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);
|
|
421
|
+
function acknowledgeBatch(opts: TrackOptions, batch: QueuedEvent[]): boolean {
|
|
422
|
+
const acknowledgedIds = new Set(batch.map((event) => event.id));
|
|
423
|
+
const remaining = queue.filter((event) => !acknowledgedIds.has(event.id));
|
|
424
|
+
if (!opts.storage) {
|
|
425
|
+
queue = remaining;
|
|
426
|
+
return true;
|
|
305
427
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
428
|
+
const includesDurable = batch.some((event) => event.durable === true);
|
|
429
|
+
// Never schedule a destructive outbox write through an adapter already known to be async.
|
|
430
|
+
// The server may have accepted this batch, but retaining it for a deduplicated replay is safer
|
|
431
|
+
// than losing it during the gap before an asynchronous deletion reaches disk.
|
|
432
|
+
if (!supportsDurableSyncWrites(opts.storage)) {
|
|
433
|
+
if (includesDurable) return false;
|
|
434
|
+
queue = remaining;
|
|
435
|
+
// Best-effort events keep the historical AsyncStorage behavior: the in-memory batch drains
|
|
436
|
+
// after 2xx and the mirror may replay after a crash, but it cannot block or resend forever.
|
|
437
|
+
writeStorageValue(
|
|
438
|
+
opts.storage,
|
|
439
|
+
storageKey(opts),
|
|
440
|
+
JSON.stringify(remaining),
|
|
441
|
+
opts.onError,
|
|
442
|
+
true,
|
|
443
|
+
);
|
|
444
|
+
return true;
|
|
316
445
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
446
|
+
const persisted = writeStorageValue(
|
|
447
|
+
opts.storage,
|
|
448
|
+
storageKey(opts),
|
|
449
|
+
JSON.stringify(remaining),
|
|
450
|
+
opts.onError,
|
|
451
|
+
false,
|
|
452
|
+
);
|
|
453
|
+
if (!persisted) {
|
|
454
|
+
if (!includesDurable && !supportsDurableSyncWrites(opts.storage)) {
|
|
455
|
+
queue = remaining;
|
|
456
|
+
writeStorageValue(
|
|
457
|
+
opts.storage,
|
|
458
|
+
storageKey(opts),
|
|
459
|
+
JSON.stringify(remaining),
|
|
460
|
+
opts.onError,
|
|
461
|
+
true,
|
|
462
|
+
);
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
if (!supportsDurableSyncWrites(opts.storage)) {
|
|
466
|
+
// A conditional thenable may already have scheduled deletion. Immediately schedule the
|
|
467
|
+
// unchanged durable outbox behind it so a normal ordered async adapter restores the record.
|
|
468
|
+
writeStorageValue(
|
|
469
|
+
opts.storage,
|
|
470
|
+
storageKey(opts),
|
|
471
|
+
JSON.stringify(queue),
|
|
472
|
+
opts.onError,
|
|
473
|
+
true,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return false;
|
|
325
477
|
}
|
|
478
|
+
queue = remaining;
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function retryDelayFor(batch: QueuedEvent[]): number {
|
|
483
|
+
const attempts = batch.reduce((max, event) => Math.max(max, event.attempts), 1);
|
|
484
|
+
const base = Math.min(60_000, 1_000 * 2 ** Math.min(Math.max(attempts - 1, 0), 6));
|
|
485
|
+
return base + Math.floor(Math.random() * Math.max(1, base / 4));
|
|
326
486
|
}
|
|
327
487
|
|
|
328
488
|
function storageKey(opts: TrackOptions): string {
|
|
329
489
|
return opts.storageKey?.trim() || DEFAULT_STORAGE_KEY;
|
|
330
490
|
}
|
|
331
491
|
|
|
492
|
+
function analyticsInstallationIdStorageKey(opts: TrackOptions): string {
|
|
493
|
+
return `${storageKey(opts)}${ANALYTICS_INSTALLATION_ID_SUFFIX}`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function resolveAnalyticsInstallationId(opts: TrackOptions): string {
|
|
497
|
+
if (opts.storage) {
|
|
498
|
+
try {
|
|
499
|
+
const stored = opts.storage.getString(analyticsInstallationIdStorageKey(opts))?.trim();
|
|
500
|
+
if (stored && UUID_PATTERN.test(stored)) return stored.toLowerCase();
|
|
501
|
+
} catch {
|
|
502
|
+
// A telemetry identifier can stay memory-only when storage is unavailable.
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const generated = randomAnalyticsInstallationId();
|
|
507
|
+
if (opts.storage) {
|
|
508
|
+
// This identifier does not acknowledge delivery, so asynchronous best-effort persistence is
|
|
509
|
+
// acceptable. The helper still observes rejections and marks accidental thenable adapters.
|
|
510
|
+
writeStorageValue(
|
|
511
|
+
opts.storage,
|
|
512
|
+
analyticsInstallationIdStorageKey(opts),
|
|
513
|
+
generated,
|
|
514
|
+
opts.onError,
|
|
515
|
+
true,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return generated;
|
|
519
|
+
}
|
|
520
|
+
|
|
332
521
|
function readQueue(opts: TrackOptions): QueuedEvent[] {
|
|
333
522
|
if (!opts.storage) return [];
|
|
334
523
|
try {
|
|
@@ -336,27 +525,103 @@ function readQueue(opts: TrackOptions): QueuedEvent[] {
|
|
|
336
525
|
if (!raw) return [];
|
|
337
526
|
const parsed = JSON.parse(raw) as unknown;
|
|
338
527
|
if (!Array.isArray(parsed)) return [];
|
|
339
|
-
|
|
528
|
+
const restored = parsed.filter((item): item is QueuedEvent => Boolean(
|
|
340
529
|
item && typeof item === 'object' && typeof item.id === 'string' &&
|
|
341
530
|
typeof item.event === 'string' && typeof item.time === 'string' &&
|
|
342
|
-
typeof item.props === 'object' &&
|
|
343
|
-
|
|
531
|
+
item.props && typeof item.props === 'object' && !Array.isArray(item.props) &&
|
|
532
|
+
typeof item.attempts === 'number' && Number.isSafeInteger(item.attempts) &&
|
|
533
|
+
item.attempts >= 0 && item.attempts <= 100_000 &&
|
|
534
|
+
((item as QueuedEvent).durable === undefined || (item as QueuedEvent).durable === true),
|
|
535
|
+
));
|
|
536
|
+
const maxQueue = Math.max(0, opts.maxQueue ?? DEFAULT_MAX_QUEUE);
|
|
537
|
+
if (restored.length <= maxQueue) return restored;
|
|
538
|
+
const selected = new Set<number>();
|
|
539
|
+
restored.forEach((event, index) => {
|
|
540
|
+
if (event.durable === true) selected.add(index);
|
|
541
|
+
});
|
|
542
|
+
// A lower max in a later app version may not erase already-ACKed durable rows. Fill any
|
|
543
|
+
// remaining capacity with the newest best-effort events, retaining original send order.
|
|
544
|
+
for (let index = restored.length - 1; index >= 0 && selected.size < maxQueue; index -= 1) {
|
|
545
|
+
if (restored[index]?.durable !== true) selected.add(index);
|
|
546
|
+
}
|
|
547
|
+
return restored.filter((_event, index) => selected.has(index));
|
|
344
548
|
} catch {
|
|
345
549
|
return [];
|
|
346
550
|
}
|
|
347
551
|
}
|
|
348
552
|
|
|
349
|
-
function persistQueue():
|
|
350
|
-
if (!options?.storage) return;
|
|
553
|
+
function persistQueue(): boolean {
|
|
554
|
+
if (!options?.storage) return false;
|
|
555
|
+
// Best-effort track() still mirrors through AsyncStorage, but the return value stays false so
|
|
556
|
+
// trackIdempotent() cannot claim synchronous durability that the adapter has not provided.
|
|
557
|
+
return writeStorageValue(
|
|
558
|
+
options.storage,
|
|
559
|
+
storageKey(options),
|
|
560
|
+
JSON.stringify(queue),
|
|
561
|
+
options.onError,
|
|
562
|
+
true,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function supportsDurableSyncWrites(storage: ConfigStorage): boolean {
|
|
567
|
+
return storage.supportsDurableSyncWrites !== false
|
|
568
|
+
&& !asynchronousStorageAdapters.has(storage as object);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function writeStorageValue(
|
|
572
|
+
storage: ConfigStorage,
|
|
573
|
+
key: string,
|
|
574
|
+
value: string,
|
|
575
|
+
onError: ((error: unknown) => void) | undefined,
|
|
576
|
+
allowKnownAsyncBestEffort: boolean,
|
|
577
|
+
): boolean {
|
|
578
|
+
const declaredOrObservedAsync = !supportsDurableSyncWrites(storage);
|
|
579
|
+
if (declaredOrObservedAsync && !allowKnownAsyncBestEffort) return false;
|
|
351
580
|
try {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
581
|
+
const result = (storage.set as unknown as (storageKey: string, stored: string) => unknown)(key, value);
|
|
582
|
+
if (isThenable(result)) {
|
|
583
|
+
asynchronousStorageAdapters.add(storage as object);
|
|
584
|
+
void Promise.resolve(result).catch((error) => reportStorageError(onError, error));
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
return !declaredOrObservedAsync;
|
|
588
|
+
} catch (error) {
|
|
589
|
+
reportStorageError(onError, error);
|
|
590
|
+
return false;
|
|
355
591
|
}
|
|
356
592
|
}
|
|
357
593
|
|
|
594
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
595
|
+
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
|
596
|
+
? typeof (value as { then?: unknown }).then === 'function'
|
|
597
|
+
: false;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function reportStorageError(
|
|
601
|
+
onError: ((error: unknown) => void) | undefined,
|
|
602
|
+
error: unknown,
|
|
603
|
+
): void {
|
|
604
|
+
try { onError?.(error); } catch { /* telemetry diagnostics never become an app failure */ }
|
|
605
|
+
}
|
|
606
|
+
|
|
358
607
|
function randomEventId(): string {
|
|
359
608
|
const cryptoLike = globalThis.crypto as { randomUUID?: () => string } | undefined;
|
|
360
609
|
if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
|
|
361
610
|
return `evt-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
|
|
362
611
|
}
|
|
612
|
+
|
|
613
|
+
function randomAnalyticsInstallationId(): string {
|
|
614
|
+
const cryptoLike = globalThis.crypto as {
|
|
615
|
+
randomUUID?: () => string;
|
|
616
|
+
getRandomValues?: (values: Uint8Array) => Uint8Array;
|
|
617
|
+
} | undefined;
|
|
618
|
+
if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID().toLowerCase();
|
|
619
|
+
|
|
620
|
+
const bytes = new Uint8Array(16);
|
|
621
|
+
if (typeof cryptoLike?.getRandomValues === 'function') cryptoLike.getRandomValues(bytes);
|
|
622
|
+
else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
623
|
+
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
|
|
624
|
+
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
|
|
625
|
+
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
|
626
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
627
|
+
}
|