impel-cli 0.20.45 → 0.20.46-beta.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/src/posthog.js ADDED
@@ -0,0 +1,833 @@
1
+ // The CLI half of product telemetry: a disk outbox plus a detached sender.
2
+ //
3
+ // Two properties shape every decision here.
4
+ //
5
+ // It never blocks. `captureEvent` validates and appends one small file, then
6
+ // returns; nothing on a command's foreground path waits on a network call. The
7
+ // send happens in a separate detached process (`impel _telemetry flush`) that
8
+ // the exiting command spawns and never waits for.
9
+ //
10
+ // It never leaks. Events are validated against an allowlist embedded from the
11
+ // server's own projection — an event name, property key, or value the contract
12
+ // does not name is rejected locally, before it reaches disk. On top of that, a
13
+ // redaction tripwire re-reads the serialized event and refuses to spool
14
+ // anything that still looks like a credential after redaction.
15
+ //
16
+ // The tripwire firing is a bug, not a routine outcome, so it is *observable*:
17
+ // the event is dropped, a bounded local notice is written, and `impel status`
18
+ // prints it. `src/nativeAgentTelemetry.js` throws into a silent catch in the
19
+ // same situation; that is right for opt-in performance traces nobody is
20
+ // waiting on and wrong here, where a silent drop is indistinguishable from
21
+ // working telemetry.
22
+ //
23
+ // Everything this module writes lives under `CONFIG_DIR`, so `impel nuke`
24
+ // erases it along with the rest of the local state.
25
+
26
+ import crypto from "node:crypto";
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+
31
+ import {
32
+ CONFIG_DIR,
33
+ loadConfig,
34
+ normalizeGatewayUrl,
35
+ redactCredentialText,
36
+ redactSecretText,
37
+ resolveDefaultAppUrl,
38
+ } from "./config.js";
39
+ import {
40
+ FEATURE_NOT_ENABLED_EXIT_CODE,
41
+ FLAG_STATE_UNKNOWN_EXIT_CODE,
42
+ TELEMETRY_FLUSH_FAILED_EXIT_CODE,
43
+ } from "./exitCodes.js";
44
+ import { fetchHttp1 } from "./http1.js";
45
+ import { brandedEnvironmentName } from "./runtimeBrand.js";
46
+ import {
47
+ analyticsConsentGranted,
48
+ TELEMETRY_FLUSH_COMMAND,
49
+ telemetryCaptureAllowed,
50
+ telemetryFlushAllowed,
51
+ } from "./telemetryConsent.js";
52
+
53
+ export { TELEMETRY_FLUSH_COMMAND };
54
+ import { installedVersion, spawnDetachedTelemetryFlush, updateTagForVersion } from "./updates.js";
55
+ import { renameWithWindowsRetry } from "./windowsFs.js";
56
+
57
+ /**
58
+ * The closed allowlist, embedded from `next`'s generated projection.
59
+ *
60
+ * This is a deliberate second copy of a contract that also lives in the server
61
+ * repo: the CLI ships without runtime dependencies and cannot import it, and a
62
+ * client that validates against something *other* than what the server accepts
63
+ * would discover the difference as a 400 in production.
64
+ *
65
+ * Regenerate it from `next`'s projection at
66
+ * `src/features/cli-telemetry/contracts/cli-telemetry-contract.json` whenever
67
+ * that changes; the two are kept in step by review and by CI with both repos
68
+ * checked out, not by anything in this repo alone. Local tests can only check
69
+ * this copy's internal shape — see `test/cli-telemetry-contract.test.js`.
70
+ */
71
+ export const TELEMETRY_CONTRACT = Object.freeze({
72
+ schema: "impel.cli-telemetry-contract.v1",
73
+ protocolVersion: 1,
74
+ limits: {
75
+ maxEventsPerBatch: 100,
76
+ maxEventPropertyKeys: 24,
77
+ maxPropertyKeyLength: 64,
78
+ maxPropertyValueLength: 256,
79
+ maxSerializedBatchBytes: 262144,
80
+ maxSerializedBugReportBytes: 131072,
81
+ maxFlagsPerResponse: 64,
82
+ },
83
+ reservedIdentityPropertyKeys: [
84
+ "$device_id",
85
+ "$group_key",
86
+ "$group_set",
87
+ "$groups",
88
+ "$process_person_profile",
89
+ "$session_id",
90
+ "$set",
91
+ "$set_once",
92
+ "$user_id",
93
+ "api_key",
94
+ "distinct_id",
95
+ "uuid",
96
+ ],
97
+ featureFlagKeys: ["cli-cursor-experiment"],
98
+ bugReportReasons: ["scope_required", "task_creation_failed", "workspace_required"],
99
+ events: [
100
+ {
101
+ name: "cli_bug_report",
102
+ origin: "server",
103
+ properties: [
104
+ { key: "channel", type: "string", required: true, values: ["latest", "next"] },
105
+ { key: "cliVersion", type: "string", required: true, maxLength: 32 },
106
+ { key: "hasMessage", type: "boolean", required: true },
107
+ {
108
+ key: "platform",
109
+ type: "string",
110
+ required: true,
111
+ values: ["darwin", "linux", "unknown", "win32"],
112
+ },
113
+ {
114
+ key: "reason",
115
+ type: "string",
116
+ required: false,
117
+ values: ["scope_required", "task_creation_failed", "workspace_required"],
118
+ },
119
+ { key: "taskCreated", type: "boolean", required: true },
120
+ ],
121
+ },
122
+ {
123
+ name: "cli_command_run",
124
+ origin: "cli",
125
+ properties: [
126
+ { key: "channel", type: "string", required: true, values: ["latest", "next"] },
127
+ { key: "cliVersion", type: "string", required: true, maxLength: 32 },
128
+ { key: "command", type: "string", required: true, maxLength: 64 },
129
+ {
130
+ key: "durationBucket",
131
+ type: "string",
132
+ required: true,
133
+ values: ["lt_1s", "lt_5s", "lt_30s", "lt_2m", "gte_2m"],
134
+ },
135
+ {
136
+ key: "outcome",
137
+ type: "string",
138
+ required: true,
139
+ values: ["failure", "refused", "success"],
140
+ },
141
+ {
142
+ key: "platform",
143
+ type: "string",
144
+ required: true,
145
+ values: ["darwin", "linux", "unknown", "win32"],
146
+ },
147
+ ],
148
+ },
149
+ ],
150
+ });
151
+
152
+ /** The route this module posts to. */
153
+ export const TELEMETRY_ENDPOINT_PATH = "/api/cli/telemetry";
154
+
155
+ /** Set on the detached sender so it can adopt the lock its parent created. */
156
+ export const TELEMETRY_FLUSH_CHILD_ENV = brandedEnvironmentName("TELEMETRY_FLUSH_CHILD");
157
+
158
+ const REQUEST_TIMEOUT_MS = 10_000;
159
+
160
+ /**
161
+ * How long a transient failure suppresses further attempts. Long enough that a
162
+ * gateway outage costs one request per half hour rather than one per command,
163
+ * short enough that a recovered gateway is noticed the same session.
164
+ */
165
+ const TRANSIENT_BACKOFF_MS = 30 * 60_000;
166
+
167
+ /**
168
+ * How long a 404 disables capture. R16: a `next` that predates the route is a
169
+ * deployment-ordering state, not an error — it must cost nothing and heal
170
+ * without user action. A day is well past any rollout window.
171
+ */
172
+ const DISABLED_TTL_MS = 24 * 60 * 60_000;
173
+
174
+ /** Permanent-failure attempts before a batch is quarantined rather than retried. */
175
+ const MAX_BATCH_FAILURES = 3;
176
+
177
+ /** Spool ceiling. Beyond this the oldest events are dropped with a notice. */
178
+ const MAX_SPOOLED_EVENTS = 500;
179
+
180
+ /** Quarantine ceiling; the oldest quarantined events are discarded past it. */
181
+ const MAX_QUARANTINED_EVENTS = 100;
182
+
183
+ /** A local problem stops being news after a week. */
184
+ const NOTICE_TTL_MS = 7 * 24 * 60 * 60_000;
185
+
186
+ /** A flush lock older than this belonged to a process that died mid-send. */
187
+ const LOCK_STALE_MS = 60_000;
188
+
189
+ /**
190
+ * Property values are identifiers, versions, and enum members — never free
191
+ * text. Constraining the character set as well as the length means a value
192
+ * that somehow carried a credential fragment or an escape sequence is rejected
193
+ * by shape before the tripwire has to catch it by content.
194
+ */
195
+ const SAFE_VALUE_RE = /^[A-Za-z0-9 _.:@/+-]{1,256}$/u;
196
+
197
+ /** HTTP statuses where resending the identical batch cannot succeed. */
198
+ const PERMANENT_STATUSES = new Set([
199
+ 400, // Allowlist violation: the server rejected the batch's shape.
200
+ 413, // Oversize: the batch exceeds what the route will read.
201
+ 422, // Semantically rejected.
202
+ ]);
203
+
204
+ const EVENTS_BY_NAME = new Map(TELEMETRY_CONTRACT.events.map((event) => [event.name, event]));
205
+ const RESERVED_KEYS = new Set(TELEMETRY_CONTRACT.reservedIdentityPropertyKeys);
206
+
207
+ function telemetryRoot() {
208
+ return process.env[brandedEnvironmentName("TELEMETRY_STATE_DIR")]
209
+ || path.join(CONFIG_DIR, "telemetry");
210
+ }
211
+
212
+ function spoolDirectory() {
213
+ return path.join(telemetryRoot(), "spool");
214
+ }
215
+
216
+ function quarantineDirectory() {
217
+ return path.join(telemetryRoot(), "quarantine");
218
+ }
219
+
220
+ function statePath() {
221
+ return path.join(telemetryRoot(), "state.json");
222
+ }
223
+
224
+ function noticePath() {
225
+ return path.join(telemetryRoot(), "notice.json");
226
+ }
227
+
228
+ function lockPath() {
229
+ return path.join(telemetryRoot(), "flush.lock");
230
+ }
231
+
232
+ function privateDirectory(directory) {
233
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
234
+ try {
235
+ fs.chmodSync(directory, 0o700);
236
+ } catch {
237
+ // Best effort on platforms without POSIX modes.
238
+ }
239
+ }
240
+
241
+ function atomicWrite(filePath, contents) {
242
+ privateDirectory(path.dirname(filePath));
243
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
244
+ try {
245
+ fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
246
+ renameWithWindowsRetry(temporaryPath, filePath);
247
+ } finally {
248
+ try {
249
+ fs.rmSync(temporaryPath, { force: true });
250
+ } catch {
251
+ // A successful rename already removed it; cleanup must not mask the write.
252
+ }
253
+ }
254
+ }
255
+
256
+ function readJson(filePath, fallback = null) {
257
+ try {
258
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
259
+ } catch {
260
+ return fallback;
261
+ }
262
+ }
263
+
264
+ function removeQuietly(filePath) {
265
+ try {
266
+ fs.rmSync(filePath, { force: true });
267
+ } catch {
268
+ // A file we cannot remove is retried on the next flush.
269
+ }
270
+ }
271
+
272
+ /* -------------------------------------------------------------------------- */
273
+ /* Durable state */
274
+ /* -------------------------------------------------------------------------- */
275
+
276
+ function readState() {
277
+ const state = readJson(statePath());
278
+ return state && typeof state === "object" ? state : {};
279
+ }
280
+
281
+ function writeState(patch) {
282
+ const next = { ...readState(), ...patch };
283
+ atomicWrite(statePath(), `${JSON.stringify(next, null, 2)}\n`);
284
+ return next;
285
+ }
286
+
287
+ /**
288
+ * True while the route is known-absent. R16's quiet disable: an older `next`
289
+ * has no telemetry route, and the CLI must neither spool events it can never
290
+ * deliver nor tell the user about a deployment they do not control.
291
+ *
292
+ * The marker expires rather than being cleared by a probe, so the re-probe
293
+ * costs one request per day instead of one per command.
294
+ */
295
+ function telemetryDisabled(state, now) {
296
+ return Number.isFinite(state.disabledUntil) && state.disabledUntil > now;
297
+ }
298
+
299
+ function flushBackedOff(state, now) {
300
+ return Number.isFinite(state.retryAfter) && state.retryAfter > now;
301
+ }
302
+
303
+ /* -------------------------------------------------------------------------- */
304
+ /* Local notices */
305
+ /* -------------------------------------------------------------------------- */
306
+
307
+ const NOTICE_REASONS = Object.freeze({
308
+ redaction_tripwire: "an event looked like it still contained a credential after redaction",
309
+ quarantined: "the server rejected them and they were discarded",
310
+ overflow: "the local queue was full",
311
+ });
312
+
313
+ /**
314
+ * Record that events were dropped locally.
315
+ *
316
+ * `kind` selects a fixed sentence from `NOTICE_REASONS`; no caller-supplied
317
+ * text is stored. That is deliberate for the tripwire in particular: it fires
318
+ * *because* redaction was incomplete, so echoing the offending value into a
319
+ * file — and from there into `impel status` output — would persist and print
320
+ * exactly the credential the tripwire just caught.
321
+ */
322
+ function noteDroppedEvents(kind, count, now = Date.now()) {
323
+ if (!NOTICE_REASONS[kind] || count < 1) return;
324
+ const previous = readJson(noticePath());
325
+ const carried = previous?.kind === kind && Number.isSafeInteger(previous.count) ? previous.count : 0;
326
+ try {
327
+ atomicWrite(noticePath(), `${JSON.stringify({
328
+ at: new Date(now).toISOString(),
329
+ kind,
330
+ count: Math.min(carried + count, Number.MAX_SAFE_INTEGER),
331
+ }, null, 2)}\n`);
332
+ } catch {
333
+ // The notice is an observability aid; failing to write it must not turn a
334
+ // dropped event into a failed command.
335
+ }
336
+ }
337
+
338
+ function clearNotice() {
339
+ removeQuietly(noticePath());
340
+ }
341
+
342
+ /**
343
+ * The one-line drop notice for `impel status`, or null when there is nothing
344
+ * to say. Exported so the status command reads it through its injectable `io`
345
+ * bag rather than reaching into this module's files.
346
+ */
347
+ export function telemetryNoticeLine({ now = Date.now() } = {}) {
348
+ const notice = readJson(noticePath());
349
+ const reason = NOTICE_REASONS[notice?.kind];
350
+ if (!reason || !Number.isSafeInteger(notice.count) || notice.count < 1) return null;
351
+ const at = Date.parse(notice.at);
352
+ if (!Number.isFinite(at) || now - at > NOTICE_TTL_MS) return null;
353
+ const events = notice.count === 1 ? "1 event" : `${notice.count} events`;
354
+ return `Telemetry: ${events} dropped locally because ${reason}; nothing was sent.`;
355
+ }
356
+
357
+ /* -------------------------------------------------------------------------- */
358
+ /* Validation */
359
+ /* -------------------------------------------------------------------------- */
360
+
361
+ function validatedValue(spec, value) {
362
+ if (spec.type === "boolean") return typeof value === "boolean" ? value : undefined;
363
+ if (typeof value !== "string") return undefined;
364
+ if (!SAFE_VALUE_RE.test(value)) return undefined;
365
+ if (value.length > TELEMETRY_CONTRACT.limits.maxPropertyValueLength) return undefined;
366
+ if (spec.values && !spec.values.includes(value)) return undefined;
367
+ if (spec.maxLength && value.length > spec.maxLength) return undefined;
368
+ return value;
369
+ }
370
+
371
+ /**
372
+ * Build the wire event for `name`, or null when anything about it falls
373
+ * outside the contract. Rejecting here rather than at the server keeps a
374
+ * client bug from becoming a 400 loop that quarantines real data.
375
+ */
376
+ function contractEvent(name, properties) {
377
+ const spec = EVENTS_BY_NAME.get(name);
378
+ // `origin: "server"` events are minted by `next` from data it already holds.
379
+ // A CLI that could post one would be asserting a server-side fact.
380
+ if (!spec || spec.origin !== "cli") return null;
381
+
382
+ const supplied = properties && typeof properties === "object" ? properties : {};
383
+ const keys = Object.keys(supplied);
384
+ if (keys.length > TELEMETRY_CONTRACT.limits.maxEventPropertyKeys) return null;
385
+ for (const key of keys) {
386
+ if (key.length > TELEMETRY_CONTRACT.limits.maxPropertyKeyLength) return null;
387
+ // The server strips forged identity keys rather than rejecting them, so a
388
+ // client that sent one would be silently ignored. Refusing locally means
389
+ // the mistake is visible where it can be fixed.
390
+ if (RESERVED_KEYS.has(key)) return null;
391
+ if (!spec.properties.some((property) => property.key === key)) return null;
392
+ }
393
+
394
+ const result = {};
395
+ for (const property of spec.properties) {
396
+ const raw = supplied[property.key];
397
+ if (raw === undefined) {
398
+ if (property.required) return null;
399
+ continue;
400
+ }
401
+ const value = validatedValue(property, raw);
402
+ if (value === undefined) return null;
403
+ result[property.key] = value;
404
+ }
405
+ return { event: name, properties: result };
406
+ }
407
+
408
+ /**
409
+ * True when the serialized event survives redaction unchanged.
410
+ *
411
+ * Same construction as `nativeAgentTelemetry.js`: if redacting the text
412
+ * changes it, the text contained something redaction recognizes as a secret,
413
+ * and no schema check caught it.
414
+ */
415
+ function redactionClean(serialized) {
416
+ return redactCredentialText(serialized) === serialized
417
+ && redactSecretText(serialized) === serialized;
418
+ }
419
+
420
+ /* -------------------------------------------------------------------------- */
421
+ /* Capture */
422
+ /* -------------------------------------------------------------------------- */
423
+
424
+ function spoolFileName(now) {
425
+ return `${String(now).padStart(15, "0")}-${crypto.randomUUID()}.json`;
426
+ }
427
+
428
+ function spooledFiles(directory = spoolDirectory()) {
429
+ try {
430
+ // Names lead with a zero-padded timestamp, so lexicographic order is
431
+ // chronological and the oldest event is always the batch's first element.
432
+ return fs.readdirSync(directory).filter((name) => name.endsWith(".json")).sort();
433
+ } catch {
434
+ return [];
435
+ }
436
+ }
437
+
438
+ function enforceSpoolCeiling(now) {
439
+ const directory = spoolDirectory();
440
+ const names = spooledFiles(directory);
441
+ const excess = names.length - MAX_SPOOLED_EVENTS;
442
+ if (excess <= 0) return;
443
+ for (const name of names.slice(0, excess)) removeQuietly(path.join(directory, name));
444
+ noteDroppedEvents("overflow", excess, now);
445
+ }
446
+
447
+ /**
448
+ * Validate, redaction-check, and spool one event.
449
+ *
450
+ * Returns true when the event reached disk. Never throws: this runs on the way
451
+ * out of every command, and a telemetry problem must not become the user's
452
+ * problem.
453
+ */
454
+ export function captureEvent(name, properties, options = {}) {
455
+ const {
456
+ now = Date.now(),
457
+ environment = process.env,
458
+ homeDir = os.homedir(),
459
+ // The consent guard keys on the command being run, not on the event, so
460
+ // the caller passes the command it is reporting about. Defaulting to the
461
+ // real argv keeps a direct `captureEvent` call honest.
462
+ argv = process.argv.slice(2),
463
+ config = undefined,
464
+ } = options;
465
+ try {
466
+ if (!telemetryCaptureAllowed(environment, argv, homeDir)) return false;
467
+ if (!analyticsConsentGranted(config === undefined ? loadConfig() : config, environment)) return false;
468
+ if (telemetryDisabled(readState(), now)) return false;
469
+
470
+ const event = contractEvent(name, properties);
471
+ if (!event) return false;
472
+
473
+ const record = { ...event, timestamp: new Date(now).toISOString() };
474
+ const serialized = JSON.stringify(record);
475
+ if (!redactionClean(serialized)) {
476
+ noteDroppedEvents("redaction_tripwire", 1, now);
477
+ return false;
478
+ }
479
+
480
+ atomicWrite(path.join(spoolDirectory(), spoolFileName(now)), `${serialized}\n`);
481
+ enforceSpoolCeiling(now);
482
+ return true;
483
+ } catch {
484
+ // Disk full, a read-only config dir, a clock that produces a bad date —
485
+ // every one of these is a reason to lose a metric, never a reason to fail
486
+ // the command the user actually ran.
487
+ return false;
488
+ }
489
+ }
490
+
491
+ /* -------------------------------------------------------------------------- */
492
+ /* Command-run capture */
493
+ /* -------------------------------------------------------------------------- */
494
+
495
+ const SUPPORTED_PLATFORMS = new Set(["darwin", "linux", "win32"]);
496
+
497
+ export function platformLabel(platform = process.platform) {
498
+ return SUPPORTED_PLATFORMS.has(platform) ? platform : "unknown";
499
+ }
500
+
501
+ export function durationBucket(milliseconds) {
502
+ if (!Number.isFinite(milliseconds) || milliseconds < 0) return "lt_1s";
503
+ if (milliseconds < 1_000) return "lt_1s";
504
+ if (milliseconds < 5_000) return "lt_5s";
505
+ if (milliseconds < 30_000) return "lt_30s";
506
+ if (milliseconds < 120_000) return "lt_2m";
507
+ return "gte_2m";
508
+ }
509
+
510
+ /**
511
+ * Exit codes that mean the CLI declined on purpose. Both are set by the flag
512
+ * gate, which returns normally rather than throwing, so without this set they
513
+ * would be indistinguishable from a crash in the data — and the refusal rate is
514
+ * the signal the fail-closed gate exists to make queryable.
515
+ *
516
+ * `REPORT_SPOOLED_EXIT_CODE` is deliberately absent: the send genuinely did not
517
+ * happen, so it stays a failure.
518
+ */
519
+ const REFUSAL_EXIT_CODES = Object.freeze(new Set([
520
+ FLAG_STATE_UNKNOWN_EXIT_CODE,
521
+ FEATURE_NOT_ENABLED_EXIT_CODE,
522
+ ]));
523
+
524
+ function commandOutcome(threw, exitCode) {
525
+ if (threw) return "failure";
526
+ if (!Number.isInteger(exitCode) || exitCode === 0) return "success";
527
+ return REFUSAL_EXIT_CODES.has(exitCode) ? "refused" : "failure";
528
+ }
529
+
530
+ /**
531
+ * Capture one `cli_command_run`.
532
+ *
533
+ * `outcome` is derived from what the process is about to do, not from what the
534
+ * command believes: a throw is a failure, a deliberate-refusal exit code is a
535
+ * refusal, and any other non-zero code is a failure. A declined `impel report`
536
+ * confirmation exits 0 and so still reads as a success — reaching it would take
537
+ * an explicit outcome passed down from the command, not a code to read.
538
+ */
539
+ export function captureCommandRun({
540
+ command,
541
+ durationMs,
542
+ threw = false,
543
+ exitCode = process.exitCode,
544
+ platform = process.platform,
545
+ version = process.env.IMPEL_CLI_EXTENSION_VERSION || installedVersion(),
546
+ now = Date.now(),
547
+ ...options
548
+ } = {}) {
549
+ const resolvedVersion = typeof version === "string" && version ? version.slice(0, 32) : "unknown";
550
+ return captureEvent("cli_command_run", {
551
+ channel: updateTagForVersion(resolvedVersion),
552
+ cliVersion: resolvedVersion,
553
+ command: typeof command === "string" ? command.slice(0, 64) : "",
554
+ durationBucket: durationBucket(durationMs),
555
+ outcome: commandOutcome(threw, exitCode),
556
+ platform: platformLabel(platform),
557
+ }, { ...options, argv: [command], now });
558
+ }
559
+
560
+ /* -------------------------------------------------------------------------- */
561
+ /* Flush lock */
562
+ /* -------------------------------------------------------------------------- */
563
+
564
+ /**
565
+ * Take the flush lock, or return null when another sender holds it.
566
+ *
567
+ * The parent takes this lock *before* spawning and does not release it — the
568
+ * child inherits ownership and releases it when the send finishes. Locking at
569
+ * the spawn seam rather than inside the child is what makes two commands
570
+ * exiting at the same instant produce one sender instead of two racing to
571
+ * acquire it after both have already spawned.
572
+ */
573
+ export function acquireFlushLock({ now = Date.now(), adopt = false } = {}) {
574
+ const lock = lockPath();
575
+ privateDirectory(telemetryRoot());
576
+ const token = crypto.randomUUID();
577
+ const release = () => {
578
+ const current = readJson(lock);
579
+ // Only the owner clears it; a process whose lock was taken over as stale
580
+ // must not delete the successor's.
581
+ if (current?.token === token) removeQuietly(lock);
582
+ };
583
+ for (let attempt = 0; attempt < 2; attempt += 1) {
584
+ try {
585
+ const descriptor = fs.openSync(lock, "wx", 0o600);
586
+ fs.writeFileSync(descriptor, `${JSON.stringify({ token, pid: process.pid, at: now })}\n`);
587
+ fs.closeSync(descriptor);
588
+ return release;
589
+ } catch (error) {
590
+ if (error?.code !== "EEXIST") return null;
591
+ }
592
+ const owner = readJson(lock);
593
+ const age = Number.isFinite(owner?.at) ? now - owner.at : Number.POSITIVE_INFINITY;
594
+ // `adopt` is the spawned child claiming the lock its own parent took out.
595
+ // Without it the child would always see a fresh lock and exit having sent
596
+ // nothing, and the spool would never drain.
597
+ if (!adopt && age <= LOCK_STALE_MS) return null;
598
+ removeQuietly(lock);
599
+ }
600
+ return null;
601
+ }
602
+
603
+ /* -------------------------------------------------------------------------- */
604
+ /* Flush */
605
+ /* -------------------------------------------------------------------------- */
606
+
607
+ function batchWithinLimit(records, names) {
608
+ let events = records;
609
+ let files = names;
610
+ // The server rejects an oversize batch outright, so trim before sending
611
+ // rather than discovering the ceiling as a 413 that quarantines real events.
612
+ while (events.length > 1
613
+ && JSON.stringify({ events }).length > TELEMETRY_CONTRACT.limits.maxSerializedBatchBytes) {
614
+ events = events.slice(0, events.length - 1);
615
+ files = files.slice(0, events.length);
616
+ }
617
+ return { events, files };
618
+ }
619
+
620
+ function quarantineBatch(files, now) {
621
+ const source = spoolDirectory();
622
+ const target = quarantineDirectory();
623
+ privateDirectory(target);
624
+ for (const name of files) {
625
+ try {
626
+ renameWithWindowsRetry(path.join(source, name), path.join(target, name));
627
+ } catch {
628
+ // The fallback deletes evidence, so it must only run once the retry has
629
+ // ruled out a scanner holding the file open for a moment.
630
+ removeQuietly(path.join(source, name));
631
+ }
632
+ }
633
+ const retained = spooledFiles(target);
634
+ const excess = retained.length - MAX_QUARANTINED_EVENTS;
635
+ if (excess > 0) {
636
+ for (const name of retained.slice(0, excess)) removeQuietly(path.join(target, name));
637
+ }
638
+ noteDroppedEvents("quarantined", files.length, now);
639
+ }
640
+
641
+ /**
642
+ * Classify a response. Explicitly enumerated rather than derived from the
643
+ * status class: "4xx is permanent" would quarantine a batch on the 401 a
644
+ * re-authentication fixes, and "5xx is transient" would retry a 501 forever.
645
+ */
646
+ function failureKind(status) {
647
+ if (status === 404) return "absent";
648
+ if (PERMANENT_STATUSES.has(status)) return "permanent";
649
+ return "transient";
650
+ }
651
+
652
+ /**
653
+ * Send one batch of spooled events.
654
+ *
655
+ * Returns a status describing what happened to the spool, never throwing: the
656
+ * caller is a detached process whose only jobs are to drain the spool and set
657
+ * an exit code.
658
+ */
659
+ export async function flushOutbox(options = {}) {
660
+ const {
661
+ fetchImpl = fetchHttp1,
662
+ now = Date.now(),
663
+ config = loadConfig(),
664
+ timeoutMs = REQUEST_TIMEOUT_MS,
665
+ } = options;
666
+
667
+ const state = readState();
668
+ if (telemetryDisabled(state, now)) return { status: "disabled", sent: 0 };
669
+ if (flushBackedOff(state, now)) return { status: "backoff", sent: 0 };
670
+ if (!config?.pat) return { status: "unauthenticated", sent: 0 };
671
+
672
+ const directory = spoolDirectory();
673
+ const names = spooledFiles(directory).slice(0, TELEMETRY_CONTRACT.limits.maxEventsPerBatch);
674
+ if (names.length === 0) return { status: "empty", sent: 0 };
675
+
676
+ const loaded = [];
677
+ const readable = [];
678
+ for (const name of names) {
679
+ const record = readJson(path.join(directory, name));
680
+ // A truncated or hand-edited spool file is unsendable. Drop it silently:
681
+ // it carries no user-visible data and nothing downstream can act on it.
682
+ if (!record?.event) {
683
+ removeQuietly(path.join(directory, name));
684
+ continue;
685
+ }
686
+ loaded.push(record);
687
+ readable.push(name);
688
+ }
689
+ if (loaded.length === 0) return { status: "empty", sent: 0 };
690
+
691
+ const { events, files } = batchWithinLimit(loaded, readable);
692
+ const appUrl = normalizeGatewayUrl(config.appUrl || resolveDefaultAppUrl());
693
+ const controller = new AbortController();
694
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
695
+ let response;
696
+ try {
697
+ response = await fetchImpl(new URL(TELEMETRY_ENDPOINT_PATH, appUrl), {
698
+ method: "POST",
699
+ headers: {
700
+ accept: "application/json",
701
+ authorization: `Bearer ${config.pat}`,
702
+ "content-type": "application/json",
703
+ },
704
+ body: JSON.stringify({ events }),
705
+ signal: controller.signal,
706
+ });
707
+ } catch {
708
+ // A network error, a DNS failure, or our own timeout. The spool is left
709
+ // exactly as it was and the failure count is untouched — an unreachable
710
+ // gateway must never look like a poisoned batch.
711
+ writeState({ retryAfter: now + TRANSIENT_BACKOFF_MS });
712
+ return { status: "transient", sent: 0 };
713
+ } finally {
714
+ clearTimeout(timeout);
715
+ }
716
+
717
+ if (response.ok) {
718
+ for (const name of files) removeQuietly(path.join(directory, name));
719
+ // `{accepted: true, forwarded: 0}` is the success path for a deployment
720
+ // with no PostHog configured — accepted-and-dropped, not a failure.
721
+ writeState({ retryAfter: 0, failures: {}, disabledUntil: 0 });
722
+ clearNotice();
723
+ return { status: "sent", sent: files.length };
724
+ }
725
+
726
+ const kind = failureKind(response.status);
727
+ if (kind === "absent") {
728
+ // R16: this `next` predates the route. Discard the batch — it can never be
729
+ // delivered — and stop capturing until the marker expires.
730
+ for (const name of files) removeQuietly(path.join(directory, name));
731
+ writeState({ disabledUntil: now + DISABLED_TTL_MS, retryAfter: 0, failures: {} });
732
+ return { status: "disabled", sent: 0 };
733
+ }
734
+ if (kind === "transient") {
735
+ writeState({ retryAfter: now + TRANSIENT_BACKOFF_MS });
736
+ return { status: "transient", sent: 0, httpStatus: response.status };
737
+ }
738
+
739
+ // Permanent. Count against the batch's oldest event rather than the whole
740
+ // set, so a capture landing between two attempts does not reset the counter
741
+ // and let a rejected batch retry forever.
742
+ const key = files[0];
743
+ const failures = { ...(state.failures && typeof state.failures === "object" ? state.failures : {}) };
744
+ const count = (Number.isSafeInteger(failures[key]) ? failures[key] : 0) + 1;
745
+ if (count >= MAX_BATCH_FAILURES) {
746
+ quarantineBatch(files, now);
747
+ delete failures[key];
748
+ writeState({ failures });
749
+ return { status: "quarantined", sent: 0, httpStatus: response.status };
750
+ }
751
+ failures[key] = count;
752
+ writeState({ failures, retryAfter: now + TRANSIENT_BACKOFF_MS });
753
+ return { status: "permanent", sent: 0, httpStatus: response.status };
754
+ }
755
+
756
+ /* -------------------------------------------------------------------------- */
757
+ /* Detached sender */
758
+ /* -------------------------------------------------------------------------- */
759
+
760
+ /**
761
+ * Decide whether the exiting command should spawn a sender, and do it.
762
+ *
763
+ * Called from `main()`'s `finally`, which runs in the sender too — so the
764
+ * sender must be excluded by name. `telemetryFlushAllowed` deliberately admits
765
+ * `_telemetry`, and without this guard a transient failure (which leaves the
766
+ * spool intact on purpose) would have the child spawn another child on the way
767
+ * out, with nothing between that and unbounded fan-out but a lock the exiting
768
+ * parent is in the middle of releasing.
769
+ */
770
+ export function maybeSpawnTelemetryFlush(command, options = {}) {
771
+ const {
772
+ now = Date.now(),
773
+ environment = process.env,
774
+ homeDir = os.homedir(),
775
+ spawn = spawnDetachedTelemetryFlush,
776
+ } = options;
777
+ try {
778
+ if (command === TELEMETRY_FLUSH_COMMAND) return false;
779
+ if (!telemetryFlushAllowed(environment, [command], homeDir)) return false;
780
+
781
+ const state = readState();
782
+ if (telemetryDisabled(state, now) || flushBackedOff(state, now)) return false;
783
+ if (spooledFiles().length === 0) return false;
784
+
785
+ const release = acquireFlushLock({ now });
786
+ if (!release) return false;
787
+ try {
788
+ spawn();
789
+ } catch {
790
+ // The lock would otherwise pin the spool shut until it went stale.
791
+ release();
792
+ return false;
793
+ }
794
+ return true;
795
+ } catch {
796
+ return false;
797
+ }
798
+ }
799
+
800
+ /**
801
+ * `impel _telemetry flush` — the hidden, detached sender.
802
+ *
803
+ * Hidden means hidden: it is absent from `HELP` and from
804
+ * `RUNTIME_BRAND.capabilities.commands`, exactly like `_converge` and
805
+ * `_app-launch`, and `test/cli-surface.test.js` asserts it stays that way.
806
+ */
807
+ export async function cmdTelemetry(argv = [], options = {}) {
808
+ const {
809
+ environment = process.env,
810
+ homeDir = os.homedir(),
811
+ now = Date.now(),
812
+ ...rest
813
+ } = options;
814
+ if (argv[0] !== "flush") {
815
+ // Not user-facing surface area; a wrong invocation is a bug in our own
816
+ // spawn call, and exiting quietly keeps it out of a user's terminal.
817
+ return { status: "unknown-subcommand" };
818
+ }
819
+ if (!telemetryFlushAllowed(environment, [TELEMETRY_FLUSH_COMMAND], homeDir)) {
820
+ return { status: "not-allowed" };
821
+ }
822
+
823
+ const adopt = environment[TELEMETRY_FLUSH_CHILD_ENV] === "1";
824
+ const release = acquireFlushLock({ now, adopt });
825
+ if (!release) return { status: "locked" };
826
+ try {
827
+ const result = await flushOutbox({ now, ...rest });
828
+ if (result.status === "transient") process.exitCode = TELEMETRY_FLUSH_FAILED_EXIT_CODE;
829
+ return result;
830
+ } finally {
831
+ release();
832
+ }
833
+ }