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.
- package/README.md +50 -10
- package/SECURITY.md +42 -0
- package/android/src/main/java/app/pulse/updates/PulseController.kt +6 -2
- package/android/src/main/java/app/pulse/updates/PulseUpdatesModule.kt +25 -0
- package/app.plugin.js +47 -0
- package/ios/PulseUpdates/PulseController.swift +7 -2
- package/ios/PulseUpdates/PulseUpdates.m +7 -0
- package/ios/PulseUpdates/PulseUpdates.swift +22 -0
- package/lib/commonjs/NativePulseUpdates.js.map +1 -1
- package/lib/commonjs/PulseUpdates.js +36 -0
- package/lib/commonjs/PulseUpdates.js.map +1 -1
- package/lib/commonjs/config.js +175 -21
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/decisions.js +128 -0
- package/lib/commonjs/decisions.js.map +1 -0
- package/lib/commonjs/index.js +12 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/init.js +164 -12
- package/lib/commonjs/init.js.map +1 -1
- package/lib/commonjs/track.js +154 -12
- package/lib/commonjs/track.js.map +1 -1
- package/lib/commonjs/usePulseUpdates.js +21 -16
- package/lib/commonjs/usePulseUpdates.js.map +1 -1
- package/lib/module/NativePulseUpdates.js.map +1 -1
- package/lib/module/PulseUpdates.js +33 -0
- package/lib/module/PulseUpdates.js.map +1 -1
- package/lib/module/config.js +174 -21
- package/lib/module/config.js.map +1 -1
- package/lib/module/decisions.js +122 -0
- package/lib/module/decisions.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/init.js +165 -14
- package/lib/module/init.js.map +1 -1
- package/lib/module/track.js +152 -12
- package/lib/module/track.js.map +1 -1
- package/lib/module/usePulseUpdates.js +21 -16
- package/lib/module/usePulseUpdates.js.map +1 -1
- package/lib/typescript/NativePulseUpdates.d.ts +1 -0
- package/lib/typescript/NativePulseUpdates.d.ts.map +1 -1
- package/lib/typescript/PulseUpdates.d.ts +20 -0
- package/lib/typescript/PulseUpdates.d.ts.map +1 -1
- package/lib/typescript/config.d.ts +22 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/decisions.d.ts +55 -0
- package/lib/typescript/decisions.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +1 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +51 -4
- package/lib/typescript/init.d.ts.map +1 -1
- package/lib/typescript/track.d.ts +29 -1
- package/lib/typescript/track.d.ts.map +1 -1
- package/lib/typescript/usePulseUpdates.d.ts.map +1 -1
- package/logo.png +0 -0
- package/package.json +14 -3
- package/scripts/publish.mjs +68 -3
- package/src/NativePulseUpdates.ts +1 -0
- package/src/PulseUpdates.ts +54 -0
- package/src/config.ts +234 -21
- package/src/decisions.ts +179 -0
- package/src/index.ts +1 -0
- package/src/init.ts +240 -11
- package/src/track.ts +191 -13
- 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 {
|
|
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):
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|