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/README.md +134 -0
- package/docs/native-agent-host-capability-matrix.md +2 -2
- package/package.json +1 -1
- package/src/apps.js +8 -8
- package/src/autoReport.js +289 -0
- package/src/bugReport.js +499 -0
- package/src/cli.js +61 -5
- package/src/commands/auth.js +5 -1
- package/src/commands/converge.js +2 -1
- package/src/commands/cursorExperimental.js +74 -2
- package/src/commands/nuke.js +2 -2
- package/src/commands/report.js +309 -0
- package/src/commands/setup.js +134 -1
- package/src/commands/status.js +28 -3
- package/src/commands/update.js +82 -1
- package/src/cursorLocal.js +8 -0
- package/src/exitCodes.js +61 -0
- package/src/featureFlags.js +393 -0
- package/src/posthog.js +833 -0
- package/src/runtimeBrand.js +2 -2
- package/src/telemetryConsent.js +334 -0
- package/src/telemetryNotice.js +147 -0
- package/src/tenants.js +48 -0
- package/src/updates.js +26 -1
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
|
+
}
|