pulse-updates 1.0.21 → 1.1.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 +34 -0
- package/lib/commonjs/config.js +3 -0
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/index.js +24 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/init.js +138 -0
- package/lib/commonjs/init.js.map +1 -0
- package/lib/commonjs/track.js +149 -0
- package/lib/commonjs/track.js.map +1 -0
- package/lib/module/config.js +3 -0
- package/lib/module/config.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/init.js +132 -0
- package/lib/module/init.js.map +1 -0
- package/lib/module/track.js +139 -0
- package/lib/module/track.js.map +1 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +2 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +73 -0
- package/lib/typescript/init.d.ts.map +1 -0
- package/lib/typescript/track.d.ts +66 -0
- package/lib/typescript/track.d.ts.map +1 -0
- package/package.json +1 -1
- package/scripts/publish.mjs +193 -0
- package/src/config.ts +3 -0
- package/src/index.ts +2 -0
- package/src/init.ts +196 -0
- package/src/track.ts +184 -0
package/scripts/publish.mjs
CHANGED
|
@@ -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
package/src/init.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
// Fetch now rather than on the first read: a launch that reads the config before
|
|
126
|
+
// the first response is the launch that runs on defaults, and defaults are the
|
|
127
|
+
// off position of every flag.
|
|
128
|
+
void fetchConfig();
|
|
129
|
+
startConfigAutoRefresh(opts.appState);
|
|
130
|
+
|
|
131
|
+
return { deviceId, configUrl, trackUrl };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The install's id: the one it already had, or a new one kept from now on.
|
|
136
|
+
*
|
|
137
|
+
* Without storage this is a new id every launch, which is worse than it looks — the
|
|
138
|
+
* arm is a hash of it, so every launch would be a fresh coin toss and no experiment
|
|
139
|
+
* could mean anything. Said out loud through onError rather than hidden.
|
|
140
|
+
*/
|
|
141
|
+
function resolveDeviceId(storage?: ConfigStorage): string {
|
|
142
|
+
if (!storage) return randomId();
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const existing = storage.getString(DEVICE_ID_KEY);
|
|
146
|
+
if (existing) return existing;
|
|
147
|
+
const minted = randomId();
|
|
148
|
+
storage.set(DEVICE_ID_KEY, minted);
|
|
149
|
+
return minted;
|
|
150
|
+
} catch {
|
|
151
|
+
return randomId();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function randomId(): string {
|
|
156
|
+
const bytes = new Uint8Array(16);
|
|
157
|
+
const crypto = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => void } }).crypto;
|
|
158
|
+
|
|
159
|
+
if (crypto?.getRandomValues) {
|
|
160
|
+
crypto.getRandomValues(bytes);
|
|
161
|
+
} else {
|
|
162
|
+
// React Native without a crypto polyfill. Weaker, and only ever used to tell one
|
|
163
|
+
// install from another — never as a secret.
|
|
164
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
|
|
168
|
+
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
|
|
169
|
+
|
|
170
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
171
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** React Native when it is there, and nothing invented when it is not. */
|
|
175
|
+
function detectPlatform(): string | undefined {
|
|
176
|
+
try {
|
|
177
|
+
// Required lazily so the module stays importable from a plain Node script — the
|
|
178
|
+
// CLI and the tests both do that.
|
|
179
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
180
|
+
const rn = require('react-native') as { Platform?: { OS?: string } };
|
|
181
|
+
return rn?.Platform?.OS;
|
|
182
|
+
} catch {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function detectOsVersion(): string | undefined {
|
|
188
|
+
try {
|
|
189
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
190
|
+
const rn = require('react-native') as { Platform?: { Version?: string | number } };
|
|
191
|
+
const version = rn?.Platform?.Version;
|
|
192
|
+
return version === undefined || version === null ? undefined : String(version);
|
|
193
|
+
} catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
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
|
+
}
|