pulse-updates 1.0.21 → 1.1.1

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.
@@ -447,6 +447,10 @@ function parseArgs() {
447
447
  options: {},
448
448
  };
449
449
 
450
+ // Positionals collect into `_`, which is how `experiment <key>` and
451
+ // `promote <key>` name their subject. Anything consumed as a flag's value is not one.
452
+ result.options._ = [];
453
+
450
454
  for (let i = 1; i < args.length; i++) {
451
455
  const arg = args[i];
452
456
  if (arg.startsWith('--')) {
@@ -458,6 +462,8 @@ function parseArgs() {
458
462
  } else {
459
463
  result.options[key] = true;
460
464
  }
465
+ } else {
466
+ result.options._.push(arg);
461
467
  }
462
468
  }
463
469
 
@@ -1101,6 +1107,174 @@ async function registerCapabilities(options) {
1101
1107
  logSuccess(`Capabilities registered (${nativeModules.length} native modules)`);
1102
1108
  }
1103
1109
 
1110
+
1111
+ /**
1112
+ * The experiments an app is running, and how their split is holding.
1113
+ *
1114
+ * The CLI half of the dashboard's list. It exists because the decisions an experiment
1115
+ * produces — is it broken, has it won, ship the winner — happen in a terminal as often
1116
+ * as in a browser, and the alternative was curl against an undocumented route.
1117
+ */
1118
+ async function experiments(options) {
1119
+ const { apiUrl, apiKey } = requireApi(options);
1120
+
1121
+ const res = await fetch(`${apiUrl}/api/apps/experiments`, { headers: { 'X-API-Key': apiKey } });
1122
+ if (!res.ok) throw new Error(`Could not list experiments: ${res.status} ${await res.text()}`);
1123
+
1124
+ const { experiments: rows } = await res.json();
1125
+ if (!rows?.length) {
1126
+ log('No experiments yet.', colors.dim);
1127
+ return;
1128
+ }
1129
+
1130
+ for (const row of rows) {
1131
+ const arms = JSON.parse(row.variants || '[]').map((v) => v.key).join(', ');
1132
+ const seen = (row.exposure || []).reduce((sum, e) => sum + e.devices, 0);
1133
+ log(`${colors.bright}${row.key}${colors.reset} ${row.status} [${arms}] ${seen.toLocaleString()} installs`);
1134
+ if (row.description) log(` ${row.description}`, colors.dim);
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * What an experiment says, in the same words the dashboard uses.
1140
+ *
1141
+ * The verdict is the server's, not this script's: a CLI that judged the numbers itself
1142
+ * would be a second opinion nobody asked for, and the first thing it would disagree with
1143
+ * is the page a human just read.
1144
+ */
1145
+ async function experimentSummary(key, options) {
1146
+ if (!key) throw new Error('Name the experiment: pulse-updates experiment <key>');
1147
+ const { apiUrl, apiKey } = requireApi(options);
1148
+
1149
+ const query = new URLSearchParams(
1150
+ Object.entries({
1151
+ asOf: options['as-of'],
1152
+ platform: options.platform,
1153
+ }).filter(([, v]) => Boolean(v))
1154
+ ).toString();
1155
+
1156
+ const res = await fetch(
1157
+ `${apiUrl}/api/apps/experiments/${encodeURIComponent(key)}/summary${query ? `?${query}` : ''}`,
1158
+ { headers: { 'X-API-Key': apiKey } }
1159
+ );
1160
+ if (!res.ok) throw new Error(`Could not read ${key}: ${res.status} ${await res.text()}`);
1161
+
1162
+ const summary = await res.json();
1163
+
1164
+ log(`${colors.bright}${summary.key}${colors.reset} ${summary.status} (as of ${summary.asOf}${summary.slice ? `, ${summary.slice}` : ''})`);
1165
+
1166
+ if (summary.srm?.mismatch) {
1167
+ logError('The split is not the one that was asked for — every number below is measuring that.');
1168
+ for (const arm of summary.srm.arms) {
1169
+ log(` ${arm.variant}: ${arm.observed.toLocaleString()} vs ${Math.round(arm.expected).toLocaleString()} expected`, colors.dim);
1170
+ }
1171
+ }
1172
+
1173
+ log('');
1174
+ log(summary.headline.sentence);
1175
+ log('');
1176
+
1177
+ for (const effect of summary.effects) {
1178
+ const size = effect.relative != null
1179
+ ? `${(effect.relative * 100).toFixed(1)}%`
1180
+ : `${(effect.absolute * 100).toFixed(1)}pt`;
1181
+ const decisive = effect.isPrimary ? effect.significant : effect.significantAdjusted;
1182
+ const mark = decisive ? (effect.better ? '+' : '-') : ' ';
1183
+ log(` ${mark} ${effect.label} · ${effect.variant} ${size}${decisive ? '' : ' (noise)'}`);
1184
+ }
1185
+
1186
+ if (summary.headline.winner) {
1187
+ log('');
1188
+ log(` pulse-updates promote ${summary.key} --variant ${summary.headline.winner}`, colors.cyan);
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Ship the arm that won: its values become the config, and the experiment ends.
1194
+ *
1195
+ * Prints the plan and asks, unless --yes. This writes production config for every
1196
+ * install, and a confirmation that only names the arm confirms nothing — which keys move
1197
+ * and what they move from is the question.
1198
+ */
1199
+ async function promoteExperiment(key, options) {
1200
+ if (!key) throw new Error('Name the experiment: pulse-updates promote <key> --variant <arm>');
1201
+ const variant = options.variant;
1202
+ if (!variant) throw new Error('Name the arm to promote: --variant <arm>');
1203
+
1204
+ const { apiUrl, apiKey } = requireApi(options);
1205
+ const scope = options.scope || 'everyone';
1206
+ const complete = !options['keep-running'];
1207
+
1208
+ const planQuery = new URLSearchParams({ variant, scope, complete: String(complete) }).toString();
1209
+ const planRes = await fetch(
1210
+ `${apiUrl}/api/apps/experiments/${encodeURIComponent(key)}/promotion?${planQuery}`,
1211
+ { headers: { 'X-API-Key': apiKey } }
1212
+ );
1213
+
1214
+ const plan = await planRes.json();
1215
+ if (!planRes.ok) {
1216
+ throw new Error(`${plan.error}${plan.hint ? `\n ${plan.hint}` : ''}`);
1217
+ }
1218
+
1219
+ log(`${colors.bright}Promoting ${plan.variant} of ${plan.experimentKey}${colors.reset}${scope === 'audience' ? ' (to the experiment audience)' : ''}`);
1220
+ for (const row of plan.rows) {
1221
+ log(` ${row.key}: ${row.current ?? '—'} → ${row.next} [${row.action}]`);
1222
+ }
1223
+ for (const warning of plan.warnings) log(` ! ${warning}`, colors.yellow);
1224
+
1225
+ if (!options.yes) {
1226
+ // Refused rather than prompted: a pipe has no stdin to answer with, and a config
1227
+ // write that happens because nobody typed anything is the worst of both.
1228
+ log('');
1229
+ log('Re-run with --yes to write these values.', colors.cyan);
1230
+ return;
1231
+ }
1232
+
1233
+ const res = await fetch(`${apiUrl}/api/apps/experiments/${encodeURIComponent(key)}/promote`, {
1234
+ method: 'POST',
1235
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
1236
+ body: JSON.stringify({ variant, scope, complete }),
1237
+ });
1238
+
1239
+ const result = await res.json();
1240
+ if (!res.ok) throw new Error(`${result.error}${result.hint ? `\n ${result.hint}` : ''}`);
1241
+
1242
+ logSuccess(
1243
+ `${result.variant} promoted — ${result.updated} key(s) written` +
1244
+ (result.status === 'completed' ? ', experiment completed' : ', experiment still running')
1245
+ );
1246
+ }
1247
+
1248
+ /**
1249
+ * The two things every experiment command needs, and the error that says where to put them.
1250
+ *
1251
+ * Deliberately not loadConfig: that one auto-detects the runtime version, the API URL
1252
+ * and whether the app uses Hermes by reading native build files, which is right before a
1253
+ * publish and is three lines of irrelevant chatter before reading a result.
1254
+ */
1255
+ function requireApi(options) {
1256
+ const file = (() => {
1257
+ const configPath = path.join(process.cwd(), 'pulse.config.json');
1258
+ try {
1259
+ return fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf8')) : {};
1260
+ } catch {
1261
+ return {};
1262
+ }
1263
+ })();
1264
+
1265
+ const apiUrl = options['api-url'] || process.env.PULSE_API_URL || file.apiUrl;
1266
+ const apiKey = options['api-key'] || process.env.PULSE_API_KEY || file.apiKey;
1267
+
1268
+ if (!apiUrl) {
1269
+ throw new Error('API URL is required. Set --api-url, PULSE_API_URL env, or apiUrl in pulse.config.json');
1270
+ }
1271
+ if (!apiKey) {
1272
+ throw new Error('API key is required. Set --api-key, PULSE_API_KEY env, or apiKey in pulse.config.json');
1273
+ }
1274
+
1275
+ return { apiUrl: apiUrl.replace(/\/+$/, ''), apiKey };
1276
+ }
1277
+
1104
1278
  /**
1105
1279
  * Print a fresh signing keypair, with copy-paste config for the server and the app.
1106
1280
  */
@@ -1153,6 +1327,16 @@ ${colors.cyan}Options:${colors.reset}
1153
1327
  --message <msg> Release message/notes
1154
1328
  --key-id <id> Key id for keygen (default: auto-generated)
1155
1329
 
1330
+ ${colors.cyan}Experiments:${colors.reset}
1331
+ pulse-updates experiments List them, with the split each one got
1332
+ pulse-updates experiment <key> What it says: the verdict and what moved
1333
+ --platform <ios|android> Read one platform's slice of it
1334
+ --as-of <YYYY-MM-DD> Replay the result as it stood that day
1335
+ pulse-updates promote <key> --variant <arm> Write the arm's values into the config
1336
+ --scope audience As a rule for the audience it ran on
1337
+ --keep-running Do not finish the experiment
1338
+ --yes Actually write (without it, prints the plan)
1339
+
1156
1340
  ${colors.cyan}Configuration:${colors.reset}
1157
1341
  Options can be set via (in priority order):
1158
1342
  1. Command line options
@@ -1192,6 +1376,15 @@ async function main() {
1192
1376
  case 'register-capabilities':
1193
1377
  await registerCapabilities(options);
1194
1378
  break;
1379
+ case 'experiments':
1380
+ await experiments(options);
1381
+ break;
1382
+ case 'experiment':
1383
+ await experimentSummary(options._?.[0], options);
1384
+ break;
1385
+ case 'promote':
1386
+ await promoteExperiment(options._?.[0], options);
1387
+ break;
1195
1388
  case 'keygen':
1196
1389
  keygen(options);
1197
1390
  break;
package/src/config.ts CHANGED
@@ -288,6 +288,9 @@ export function startConfigAutoRefresh(appState?: {
288
288
  if (interval > 0) {
289
289
  stopPolling();
290
290
  pollTimer = setInterval(() => void fetchConfig(), interval);
291
+ // Node keeps a process alive for a pending interval; React Native does not care.
292
+ // Without this a CLI or a test that only imports the module never exits.
293
+ (pollTimer as unknown as { unref?: () => void }).unref?.();
291
294
  }
292
295
 
293
296
  return () => {
package/src/index.ts CHANGED
@@ -3,3 +3,5 @@ export * from './types';
3
3
  export { usePulseUpdates } from './usePulseUpdates';
4
4
  export { initializeAssetResolver, updateLocalAssets } from './assetResolver';
5
5
  export * from './config';
6
+ export * from './track';
7
+ export * from './init';
package/src/init.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * One call to wire an app to Pulse.
3
+ *
4
+ * The three halves — updates, config, events — each have their own setup, and each
5
+ * one is a place to make a mistake that shows up as an empty chart weeks later: a
6
+ * config url that points at one app and a track url at another, an install with no
7
+ * stable id (so every launch looks like a new device and every arm is re-dealt), a
8
+ * context that forgets the app version (so an experiment cannot be aimed at a build).
9
+ * They are the same three facts every time, so they are asked for once.
10
+ *
11
+ * Everything it derives, it derives from the platform and only when the caller has
12
+ * not said otherwise:
13
+ *
14
+ * - the urls, from the base url and the slug, which is what stops the two halves
15
+ * from ever addressing different apps;
16
+ * - platform and OS version, from React Native;
17
+ * - the device id, minted and persisted on first launch — an app that has a stable
18
+ * identifier of its own (the vendor id, an installation id) should pass it, and an
19
+ * app that has none should not have to invent one to be measurable.
20
+ *
21
+ * Deliberately not automatic: the app version. Nothing here can read the store build
22
+ * reliably on both platforms, and a version guessed wrong aims a rollout at the wrong
23
+ * installs — a targeting bug that looks like a rollout that "did not work".
24
+ */
25
+
26
+ import {
27
+ configureConfig,
28
+ fetchConfig,
29
+ startConfigAutoRefresh,
30
+ type ConfigContext,
31
+ type ConfigStorage,
32
+ type ConfigValue,
33
+ } from './config';
34
+ import { configureTracking } from './track';
35
+
36
+ export interface InitPulseOptions {
37
+ /** Where Pulse lives, e.g. https://pulse.example.com — no path. */
38
+ apiUrl: string;
39
+
40
+ /** The app's slug in Pulse. Both urls are built from it. */
41
+ appSlug: string;
42
+
43
+ /** Values in force before the server has ever been reached. */
44
+ defaults?: Record<string, ConfigValue>;
45
+
46
+ /**
47
+ * Somewhere to keep the last good config and the device id. Without it both are
48
+ * memory-only: the config falls back to defaults after a restart, and — worse —
49
+ * the install gets a new id every launch, which re-deals its arm every launch.
50
+ */
51
+ storage?: ConfigStorage;
52
+
53
+ /** The store build, e.g. "5.2.0". Needed to target a rollout at a version. */
54
+ appVersion?: string;
55
+
56
+ /**
57
+ * A stable id for this install. Left out, one is minted and persisted.
58
+ *
59
+ * Worth passing when the app already has one the rest of its analytics uses: the
60
+ * arm is a hash of this value, and a metric joined on a different id is not
61
+ * slightly wrong, it is randomised.
62
+ */
63
+ deviceId?: string;
64
+
65
+ /** The signed-in account, read fresh on every request. Only user-level experiments use it. */
66
+ getUserId?: () => string | undefined;
67
+
68
+ /** Attributes a rule can target — a plan, a storefront. Keep them few and stable. */
69
+ getUserAttributes?: () => Record<string, string> | undefined;
70
+
71
+ /** Foreground poll interval for the config. 0 disables it; launch and resume still fetch. */
72
+ pollIntervalMs?: number;
73
+
74
+ /** How often queued events are sent. Default 10s. */
75
+ flushIntervalMs?: number;
76
+
77
+ /** App-state hook, so the config refreshes on return from background. */
78
+ appState?: { addEventListener: (type: 'change', handler: (state: string) => void) => { remove: () => void } };
79
+
80
+ /** Event keys the server did not recognise — a typo, or an event nobody declared. */
81
+ onIgnoredEvents?: (events: string[]) => void;
82
+
83
+ onError?: (error: unknown) => void;
84
+ }
85
+
86
+ const DEVICE_ID_KEY = 'pulse.device-id';
87
+
88
+ /** Wires config and events, and returns the context the two share. */
89
+ export function initPulse(opts: InitPulseOptions): { deviceId: string; configUrl: string; trackUrl: string } {
90
+ const base = opts.apiUrl.replace(/\/+$/, '');
91
+ const slug = opts.appSlug.trim();
92
+ const configUrl = `${base}/pulse/config/${slug}`;
93
+ const trackUrl = `${base}/pulse/track/${slug}`;
94
+
95
+ const deviceId = opts.deviceId?.trim() || resolveDeviceId(opts.storage);
96
+ const platform = detectPlatform();
97
+ const osVersion = detectOsVersion();
98
+
99
+ const getContext = (): ConfigContext => ({
100
+ platform,
101
+ osVersion,
102
+ appVersion: opts.appVersion,
103
+ deviceId,
104
+ userId: opts.getUserId?.(),
105
+ userAttributes: opts.getUserAttributes?.(),
106
+ });
107
+
108
+ configureConfig({
109
+ url: configUrl,
110
+ defaults: opts.defaults,
111
+ storage: opts.storage,
112
+ getContext,
113
+ pollIntervalMs: opts.pollIntervalMs,
114
+ onError: opts.onError,
115
+ });
116
+
117
+ configureTracking({
118
+ url: trackUrl,
119
+ getContext,
120
+ flushIntervalMs: opts.flushIntervalMs,
121
+ onIgnored: opts.onIgnoredEvents,
122
+ onError: opts.onError,
123
+ });
124
+
125
+ // The two fields nothing can derive, said out loud when they are missing. Both
126
+ // fail silently and late: without a version, every rule and slice that names one
127
+ // simply does not match this install — which reads as "the rollout did nothing".
128
+ // Without storage the id is new on every launch, so the arm is re-dealt each time
129
+ // and no experiment can mean anything.
130
+ if (!opts.appVersion) {
131
+ opts.onError?.(new Error(
132
+ 'Pulse: no appVersion given — version targeting and version slices will not match this install'));
133
+ }
134
+ if (!opts.storage && !opts.deviceId) {
135
+ opts.onError?.(new Error(
136
+ 'Pulse: no storage and no deviceId — this install gets a new id every launch, which re-deals its arm'));
137
+ }
138
+
139
+ // Fetch now rather than on the first read: a launch that reads the config before
140
+ // the first response is the launch that runs on defaults, and defaults are the
141
+ // off position of every flag.
142
+ void fetchConfig();
143
+ startConfigAutoRefresh(opts.appState);
144
+
145
+ return { deviceId, configUrl, trackUrl };
146
+ }
147
+
148
+ /**
149
+ * The install's id: the one it already had, or a new one kept from now on.
150
+ *
151
+ * Without storage this is a new id every launch, which is worse than it looks — the
152
+ * arm is a hash of it, so every launch would be a fresh coin toss and no experiment
153
+ * could mean anything. Said out loud through onError rather than hidden.
154
+ */
155
+ function resolveDeviceId(storage?: ConfigStorage): string {
156
+ if (!storage) return randomId();
157
+
158
+ try {
159
+ const existing = storage.getString(DEVICE_ID_KEY);
160
+ if (existing) return existing;
161
+ const minted = randomId();
162
+ storage.set(DEVICE_ID_KEY, minted);
163
+ return minted;
164
+ } catch {
165
+ return randomId();
166
+ }
167
+ }
168
+
169
+ function randomId(): string {
170
+ const bytes = new Uint8Array(16);
171
+ const crypto = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => void } }).crypto;
172
+
173
+ if (crypto?.getRandomValues) {
174
+ crypto.getRandomValues(bytes);
175
+ } else {
176
+ // React Native without a crypto polyfill. Weaker, and only ever used to tell one
177
+ // install from another — never as a secret.
178
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
179
+ }
180
+
181
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
182
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
183
+
184
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
185
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
186
+ }
187
+
188
+ /** React Native when it is there, and nothing invented when it is not. */
189
+ function detectPlatform(): string | undefined {
190
+ try {
191
+ // Required lazily so the module stays importable from a plain Node script — the
192
+ // CLI and the tests both do that.
193
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
194
+ const rn = require('react-native') as { Platform?: { OS?: string } };
195
+ return rn?.Platform?.OS;
196
+ } catch {
197
+ return undefined;
198
+ }
199
+ }
200
+
201
+ function detectOsVersion(): string | undefined {
202
+ try {
203
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
204
+ const rn = require('react-native') as { Platform?: { Version?: string | number } };
205
+ const version = rn?.Platform?.Version;
206
+ return version === undefined || version === null ? undefined : String(version);
207
+ } catch {
208
+ return undefined;
209
+ }
210
+ }
package/src/track.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Pulse Track — the app's own events, sent to Pulse.
3
+ *
4
+ * The point of this half: an experiment is only worth running if something can be
5
+ * measured on it, and until now measuring meant owning a warehouse — a ClickHouse,
6
+ * a stream, and somebody to keep both alive. An app that has just adopted Pulse has
7
+ * none of that, so its experiments could report retention and nothing else.
8
+ *
9
+ * The event has to be declared in the registry first. That is deliberate and it is
10
+ * the same rule the server enforces: an event nobody declared could never be named by
11
+ * a metric, so accepting it would be a write into a place nothing reads. The response
12
+ * says which keys were ignored, and `onIgnored` surfaces them — a typo in an event
13
+ * name is otherwise invisible until someone wonders why a chart is empty.
14
+ *
15
+ * Batched on purpose. The endpoint takes up to a hundred events per request and is
16
+ * rate-limited per address, so one request per event would spend the whole allowance
17
+ * of a shared network on a single install. Events are queued, flushed on a timer and
18
+ * whenever the batch fills, and dropped oldest-first if the queue overflows: an event
19
+ * lost is a row missing from an average, and a request that blocks the app is a bug
20
+ * everybody sees.
21
+ */
22
+
23
+ import type { ConfigContext } from './config';
24
+
25
+ export interface TrackOptions {
26
+ /**
27
+ * The config url this app already uses (…/pulse/config/{slug}) or the track url
28
+ * itself. Accepting the first is not a convenience: it means the two halves cannot
29
+ * end up pointing at different apps, which would put an app's events in another
30
+ * app's registry and read as an empty metric on both sides.
31
+ */
32
+ url: string;
33
+
34
+ /** The same context the config uses — identity travels with the batch, once. */
35
+ getContext?: () => ConfigContext;
36
+
37
+ /** How often a non-empty queue is sent. Default 10s. */
38
+ flushIntervalMs?: number;
39
+
40
+ /** Events per request. The server refuses more than 100. */
41
+ maxBatch?: number;
42
+
43
+ /** Events held before the oldest are dropped. Default 500. */
44
+ maxQueue?: number;
45
+
46
+ /** Called with the event keys the server did not recognise. */
47
+ onIgnored?: (events: string[]) => void;
48
+
49
+ onError?: (error: unknown) => void;
50
+ }
51
+
52
+ export interface TrackedEventInput {
53
+ event: string;
54
+ props?: Record<string, string | number | boolean | null | undefined>;
55
+ time?: Date;
56
+ }
57
+
58
+ interface QueuedEvent {
59
+ event: string;
60
+ time: string;
61
+ props: Record<string, string>;
62
+ }
63
+
64
+ const DEFAULT_FLUSH_MS = 10_000;
65
+ const DEFAULT_MAX_BATCH = 100;
66
+ const DEFAULT_MAX_QUEUE = 500;
67
+
68
+ let options: TrackOptions | null = null;
69
+ let queue: QueuedEvent[] = [];
70
+ let timer: ReturnType<typeof setInterval> | null = null;
71
+ let sending = false;
72
+
73
+ /** Turns a config url into the track url; leaves an explicit track url alone. */
74
+ function trackUrl(url: string): string {
75
+ const trimmed = url.replace(/\/+$/, '');
76
+ return trimmed.includes('/pulse/config/')
77
+ ? trimmed.replace('/pulse/config/', '/pulse/track/')
78
+ : trimmed;
79
+ }
80
+
81
+ export function configureTracking(opts: TrackOptions): void {
82
+ options = opts;
83
+ startTimer();
84
+ }
85
+
86
+ /**
87
+ * Queue an event. Never throws, never awaits: a call site in a tap handler must not
88
+ * be able to make the tap slow, and an analytics call that can throw is one every
89
+ * caller has to wrap.
90
+ */
91
+ export function track(event: string, props?: TrackedEventInput['props'], time?: Date): void {
92
+ if (!options || !event) return;
93
+
94
+ const flat: Record<string, string> = {};
95
+ for (const [key, value] of Object.entries(props ?? {})) {
96
+ if (value === null || value === undefined) continue;
97
+ flat[key] = typeof value === 'string' ? value : String(value);
98
+ }
99
+
100
+ queue.push({ event, time: (time ?? new Date()).toISOString(), props: flat });
101
+
102
+ const maxQueue = options.maxQueue ?? DEFAULT_MAX_QUEUE;
103
+ if (queue.length > maxQueue) queue = queue.slice(queue.length - maxQueue);
104
+
105
+ if (queue.length >= (options.maxBatch ?? DEFAULT_MAX_BATCH)) void flushEvents();
106
+ }
107
+
108
+ /**
109
+ * Send what is queued. Returns how many events the server accepted.
110
+ *
111
+ * Failures put the batch back at the front of the queue rather than dropping it: the
112
+ * usual cause is a network that is about to come back, and the usual moment is right
113
+ * after launch, when the first events of a session are the ones a funnel needs most.
114
+ */
115
+ export async function flushEvents(): Promise<number> {
116
+ if (!options || sending || queue.length === 0) return 0;
117
+
118
+ const opts = options;
119
+ const ctx = opts.getContext?.() ?? {};
120
+
121
+ // Without a device id the server cannot put the event in an arm, and an empty id
122
+ // would pool every such install into one. Held rather than dropped: the id usually
123
+ // arrives a moment after boot.
124
+ if (!ctx.deviceId) return 0;
125
+
126
+ const batch = queue.slice(0, opts.maxBatch ?? DEFAULT_MAX_BATCH);
127
+ queue = queue.slice(batch.length);
128
+ sending = true;
129
+
130
+ try {
131
+ const response = await fetch(trackUrl(opts.url), {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({
135
+ deviceId: ctx.deviceId,
136
+ userId: ctx.userId,
137
+ platform: ctx.platform,
138
+ appVersion: ctx.appVersion,
139
+ events: batch.map((e) => ({ event: e.event, time: e.time, props: e.props })),
140
+ }),
141
+ });
142
+
143
+ if (!response.ok) {
144
+ // Rate limited or briefly down: keep the events, try on the next tick.
145
+ queue = [...batch, ...queue];
146
+ if (response.status !== 429) opts.onError?.(new Error(`Pulse track failed: ${response.status}`));
147
+ return 0;
148
+ }
149
+
150
+ const body = (await response.json()) as { accepted?: number; ignored?: string[] };
151
+ if (body.ignored && body.ignored.length > 0) opts.onIgnored?.(body.ignored);
152
+ return body.accepted ?? 0;
153
+ } catch (error) {
154
+ queue = [...batch, ...queue];
155
+ opts.onError?.(error);
156
+ return 0;
157
+ } finally {
158
+ sending = false;
159
+ }
160
+ }
161
+
162
+ /** How many events are waiting — for a caller that wants to flush before backgrounding. */
163
+ export function pendingEventCount(): number {
164
+ return queue.length;
165
+ }
166
+
167
+ export function stopTracking(): void {
168
+ if (timer) {
169
+ clearInterval(timer);
170
+ timer = null;
171
+ }
172
+ options = null;
173
+ queue = [];
174
+ sending = false;
175
+ }
176
+
177
+ function startTimer(): void {
178
+ if (timer) clearInterval(timer);
179
+ const every = options?.flushIntervalMs ?? DEFAULT_FLUSH_MS;
180
+ timer = setInterval(() => void flushEvents(), every);
181
+ // Node keeps the process alive for a pending interval; React Native does not care,
182
+ // and a CLI importing this should still be able to exit.
183
+ (timer as unknown as { unref?: () => void }).unref?.();
184
+ }