@lorekit/cli 1.53.0 → 1.55.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.
@@ -0,0 +1,276 @@
1
+ // LoreKit CLI — durable telemetry identity.
2
+ //
3
+ // The CLI's own OTLP export (src/telemetry.mjs) was, by construction,
4
+ // unattributable: one span per command carrying the command name, a bounded
5
+ // flag allow-list and the runtime/OS tuple, and nothing that linked two runs.
6
+ // Server-side that gap does not exist — every authenticated REST/MCP call lands
7
+ // on an edge root span carrying `auth.user_id`, and `usage_events` records the
8
+ // same id — but a run that never leaves the machine (`--offline`, the two-tier
9
+ // local store, `lorekit hook`) makes no server call at all, so 1000 local spans
10
+ // could equally be one user or a thousand.
11
+ //
12
+ // This module closes that gap with TWO ids, because they answer different
13
+ // questions and neither substitutes for the other:
14
+ //
15
+ // • installId — a random, opaque, locally-minted id, persisted so it survives
16
+ // across runs. Always available once minted, including fully offline. It
17
+ // differentiates INSTALLS: the same person on a laptop and in CI is two
18
+ // ids, and two people sharing a container are one.
19
+ //
20
+ // • accountId — the LoreKit account (Supabase auth UUID) the CLI last
21
+ // authenticated as, learned from the `X-LoreKit-User-Id` response header
22
+ // that `restFetch` reads off any authenticated call and cached here. Once
23
+ // learned it is stamped on EVERY later run, offline ones included, which is
24
+ // what makes local CLI usage joinable to server-side `auth.user_id` and
25
+ // `usage_events.user_id`.
26
+ //
27
+ // PRIVACY — this runs on end-users' machines, so the invariants matter more
28
+ // than the feature:
29
+ //
30
+ // 1. Nothing is minted, and NO FILE IS EVER CREATED, while telemetry is
31
+ // disabled. `ensureInstallId` takes the resolved config and returns null
32
+ // when export is off, so an opt-out (LOREKIT_TELEMETRY=0, DO_NOT_TRACK=1,
33
+ // `telemetry.disabled`) never gets a tracking id written to their disk.
34
+ // 2. `rememberAccountId` only ever UPDATES an existing file — it never
35
+ // creates one. The file exists only where (1) already minted an install
36
+ // id, so an opted-out user's account id is not recorded either, even
37
+ // though the header is on every response they receive.
38
+ // 3. The install id is opaque and carries nothing derived from the machine —
39
+ // no hostname, no MAC, no username, no path. It is random, so it says
40
+ // "these runs are the same install" and nothing else about who that is.
41
+ // 4. It is a plain file the user owns and can inspect or delete; `doctor`
42
+ // prints its location for exactly that reason. Deleting it resets the
43
+ // install identity, which is the whole opt-out-after-the-fact story.
44
+ //
45
+ // Everything here is TOTAL: an unreadable file, an unwritable home, a corrupt
46
+ // JSON body and a full disk all degrade to "no identity" rather than throwing.
47
+ // Telemetry must never be the thing that breaks a command — the same contract
48
+ // `exportInvocation` holds.
49
+
50
+ import process from 'node:process';
51
+ import fs from 'node:fs';
52
+ import path from 'node:path';
53
+ import { homeRoot } from './control.mjs';
54
+ import { writeFileAtomic } from './config.mjs';
55
+
56
+ /** Filename under the home tier. Sits beside `config.json` and the local store. */
57
+ const IDENTITY_FILE = 'telemetry-id.json';
58
+
59
+ /**
60
+ * Absolute path to the identity file: `$LOREKIT_HOME/telemetry-id.json`,
61
+ * defaulting to `~/.lorekit/telemetry-id.json`.
62
+ *
63
+ * Resolved through `homeRoot` — the SAME function that resolves the home-tier
64
+ * store and `config.json` — rather than re-deriving `LOREKIT_HOME || ~/.lorekit`
65
+ * here. A duplicated path resolution is not like the duplicated regexes
66
+ * elsewhere in the zero-dep CLI: if the two drifted, the id would silently land
67
+ * in a different directory than the store it belongs to, minting a "new install"
68
+ * for a user who had one all along.
69
+ *
70
+ * @param {object} [env] defaults to process.env
71
+ */
72
+ export function identityPath(env = process.env) {
73
+ return path.join(homeRoot(env), IDENTITY_FILE);
74
+ }
75
+
76
+ /**
77
+ * Read the stored identity, or an empty object. TOTAL — a missing file, an
78
+ * unreadable one, a corrupt body, or a body that is valid JSON but not an
79
+ * object all yield `{}`.
80
+ *
81
+ * The `typeof` guards are not defensive noise: `JSON.parse('null')`,
82
+ * `JSON.parse('42')` and `JSON.parse('"x"')` all succeed, and a non-string
83
+ * `installId` reaching an attribute bag would be emitted as `String(value)` —
84
+ * so `{"installId": {}}` would ship the literal `[object Object]` as an
85
+ * identity and quietly fold every such install into one bucket.
86
+ *
87
+ * @param {string} [file] defaults to {@link identityPath}
88
+ * @returns {{ installId?: string, accountId?: string }}
89
+ */
90
+ export function readIdentity(file = identityPath()) {
91
+ try {
92
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
93
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
94
+ const out = {};
95
+ if (typeof parsed.installId === 'string' && parsed.installId) out.installId = parsed.installId;
96
+ if (typeof parsed.accountId === 'string' && parsed.accountId) out.accountId = parsed.accountId;
97
+ return out;
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Persist an identity object, creating the home directory if needed.
105
+ * Returns true on success, false on any failure (unwritable home, full disk).
106
+ *
107
+ * @param {{ installId?: string, accountId?: string }} identity
108
+ * @param {string} [file] defaults to {@link identityPath}
109
+ */
110
+ function writeIdentity(identity, file = identityPath()) {
111
+ try {
112
+ fs.mkdirSync(path.dirname(file), { recursive: true });
113
+ writeFileAtomic(file, `${JSON.stringify(identity, null, 2)}\n`);
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Mint a fresh install id: 32 random hex characters.
122
+ *
123
+ * Deliberately opaque and machine-independent. A hostname- or MAC-derived id
124
+ * would be stable without a file, but it would also be a fingerprint the user
125
+ * cannot reset and one that leaks their machine into a span attribute.
126
+ */
127
+ export function mintInstallId() {
128
+ const b = new Uint8Array(16);
129
+ // The WebCrypto global, as in telemetry.mjs — see the note there on why this
130
+ // is used unqualified rather than imported from `node:crypto`.
131
+ crypto.getRandomValues(b);
132
+ return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
133
+ }
134
+
135
+ /**
136
+ * Resolve the install id, minting and persisting one on first use.
137
+ *
138
+ * Returns null — no identity, same as today's behaviour — when:
139
+ * • telemetry export is disabled (`config.enabled !== true`). This is the
140
+ * opt-out invariant: no id is minted and no file is created.
141
+ * • the id could not be persisted. An id that cannot be stored would be a
142
+ * DIFFERENT value on every run, which is strictly worse than none: it
143
+ * inflates the distinct-install count to equal the invocation count and is
144
+ * indistinguishable, in the data, from that many real installs. A read-only
145
+ * home therefore reports no identity rather than a misleading one.
146
+ *
147
+ * @param {{ enabled?: boolean }} config the resolved telemetry config
148
+ * @param {object} [opts]
149
+ * @param {string} [opts.file] defaults to {@link identityPath}
150
+ * @returns {string | null}
151
+ */
152
+ export function ensureInstallId(config, { file = identityPath() } = {}) {
153
+ // Delegates rather than repeating the mint-and-persist logic: two copies of
154
+ // "read, mint if absent, preserve accountId, bail if unwritable" is two places
155
+ // for the opt-out invariant to be broken independently.
156
+ return ensureIdentity(config, { file }).installId;
157
+ }
158
+
159
+ /**
160
+ * Resolve the full identity for a run — install id (minted on first use) plus
161
+ * any cached account id — in ONE file read.
162
+ *
163
+ * The single entry point callers should use. `ensureInstallId` reads the file
164
+ * and so did a follow-up `readIdentity()` for the account id, which put two
165
+ * reads of the same small file on `lorekit hook`'s per-turn path for no reason.
166
+ *
167
+ * Same contract as {@link ensureInstallId}: nothing is minted and no file is
168
+ * created while export is disabled, and a non-persistable id reports as no
169
+ * identity rather than as a fresh one per run.
170
+ *
171
+ * @param {{ enabled?: boolean }} config the resolved telemetry config
172
+ * @param {object} [opts]
173
+ * @param {string} [opts.file] defaults to {@link identityPath}
174
+ * @returns {{ installId: string | null, accountId: string | null }}
175
+ */
176
+ export function ensureIdentity(config, { file = identityPath() } = {}) {
177
+ if (!config || config.enabled !== true) return { installId: null, accountId: null };
178
+ const stored = readIdentity(file);
179
+ const accountId = stored.accountId ?? null;
180
+ if (stored.installId) return { installId: stored.installId, accountId };
181
+ const installId = mintInstallId();
182
+ // Preserve any accountId already cached — a corrupt-but-partial file should
183
+ // not lose the account linkage on the run that repairs the install id.
184
+ if (!writeIdentity({ ...stored, installId }, file)) return { installId: null, accountId: null };
185
+ return { installId, accountId };
186
+ }
187
+
188
+ /**
189
+ * Cache the LoreKit account id the CLI just authenticated as.
190
+ *
191
+ * Called from `restFetch` for every authenticated response that carries an
192
+ * `X-LoreKit-User-Id` header — so the id is learned on any remote call and then
193
+ * available to every later run, including offline ones.
194
+ *
195
+ * NEVER CREATES THE FILE. It writes only when a file with an install id already
196
+ * exists, which is exactly the set of machines where telemetry was enabled and
197
+ * minted one. That is what keeps an opted-out user's account id off their disk
198
+ * without threading the telemetry config all the way into the HTTP layer.
199
+ *
200
+ * A no-op when the value is unchanged, so the common case is one read and no
201
+ * write. Returns true only when something was actually persisted.
202
+ *
203
+ * @param {string | null | undefined} accountId
204
+ * @param {object} [opts]
205
+ * @param {string} [opts.file] defaults to {@link identityPath}
206
+ */
207
+ export function rememberAccountId(accountId, { file = identityPath() } = {}) {
208
+ if (typeof accountId !== 'string' || !accountId) return false;
209
+ const stored = readIdentity(file);
210
+ if (!stored.installId) return false; // no file / telemetry never enabled
211
+ if (stored.accountId === accountId) return false;
212
+ return writeIdentity({ ...stored, accountId }, file);
213
+ }
214
+
215
+ /**
216
+ * The identity RESOURCE attribute, or `{}` when there is no identity.
217
+ *
218
+ * `service.instance.id` is the OTel semconv key for "which instance of this
219
+ * service produced the telemetry", which is exactly what an install is — so the
220
+ * install id belongs on the resource under the standard key rather than under a
221
+ * `lorekit.*` one a backend has no built-in understanding of. Distinct installs
222
+ * are then countable with no LoreKit-specific knowledge.
223
+ *
224
+ * @param {{ installId?: string | null }} identity
225
+ */
226
+ export function identityResourceAttributes({ installId } = {}) {
227
+ return installId ? { 'service.instance.id': installId } : {};
228
+ }
229
+
230
+ /**
231
+ * The identity attribute for a CLI span / metric data point, or `{}` when there
232
+ * is no identity to report.
233
+ *
234
+ * `user.id` is the ACCOUNT when one is known, else `install:<installId>`. This
235
+ * mirrors the browser RUM decision (`web/src/lib/dash0-rum.ts`: an `anon:<uuid>`
236
+ * upgraded in place to the real id), so a backend that folds `user.id` into
237
+ * unique-user analytics answers "how many PEOPLE use the CLI" — collapsing one
238
+ * person's laptop and CI runs into one user — while `service.instance.id` on the
239
+ * resource still tells those installs apart underneath. The `install:` prefix
240
+ * keeps a pre-auth run visibly distinct from a real account id instead of
241
+ * silently occupying the same value space.
242
+ *
243
+ * CARDINALITY: this rides on the `lorekit.cli.invocations` counter as well as
244
+ * the span, so the counter's series count is multiplied by the number of
245
+ * distinct users. That is inherent to being able to differentiate them at all,
246
+ * and it is bounded by real adoption rather than by anything a caller controls
247
+ * — but it is the reason no FURTHER identity dimension belongs on the metric.
248
+ *
249
+ * @param {{ installId?: string | null, accountId?: string | null }} identity
250
+ */
251
+ export function identityAttributes({ installId, accountId } = {}) {
252
+ if (!installId) {
253
+ // No install id means nothing was persisted (opted out, or unwritable
254
+ // home). An accountId alone is not emitted: it can only have come from a
255
+ // file that also holds an installId, so this branch means "no identity".
256
+ return {};
257
+ }
258
+ return { 'user.id': accountId || `install:${installId}` };
259
+ }
260
+
261
+ /**
262
+ * Read-only view of the identity for `doctor` to report: what is stored, where,
263
+ * and whether an account has been linked yet. Never mints anything, so running
264
+ * `doctor` cannot itself create the file.
265
+ *
266
+ * @param {object} [opts]
267
+ * @param {string} [opts.file] defaults to {@link identityPath}
268
+ */
269
+ export function describeIdentity({ file = identityPath() } = {}) {
270
+ const stored = readIdentity(file);
271
+ return {
272
+ file,
273
+ installId: stored.installId ?? null,
274
+ accountId: stored.accountId ?? null,
275
+ };
276
+ }
package/src/telemetry.mjs CHANGED
@@ -12,10 +12,19 @@
12
12
  // • Opt-out honored: LOREKIT_TELEMETRY=0|off|false|no|disable, or the
13
13
  // cross-vendor DO_NOT_TRACK=1, disables all export.
14
14
  // • No PII is ever attached: only the command name, a bounded allow-list of
15
- // boolean flags, the CLI/runtime/OS identity, and the outcome. Never a
16
- // path, cwd, token, endpoint, repo, or scope string.
15
+ // boolean flags, the CLI/runtime/OS identity, the outcome, and the durable
16
+ // telemetry identity described below. Never a path, cwd, token, endpoint,
17
+ // repo, or scope string.
17
18
  // • Disabled outright when no OTLP endpoint resolves.
18
19
  //
20
+ // IDENTITY — runs are attributable, which they deliberately were not before.
21
+ // `telemetry-identity.mjs` supplies an opaque, locally-minted install id
22
+ // (`service.instance.id` on the resource) and, once any authenticated call has
23
+ // taught the CLI which account it is, that account on `user.id`. Read that
24
+ // module's header for the invariants; the one that constrains THIS file is that
25
+ // nothing is minted while export is disabled, so `ensureInstallId` is only ever
26
+ // called with an already-enabled config.
27
+ //
19
28
  // The default endpoint + token below are baked into the published package and
20
29
  // are therefore public by design. The token MUST be Dash0 ingestion-only
21
30
  // (write/POST spans, no read/query/manage) — anyone can unpack the npm tarball
@@ -26,6 +35,11 @@
26
35
  import process from 'node:process';
27
36
  import { TELEMETRY_TOKEN } from './telemetry-token.mjs';
28
37
  import { readLorekitJson } from './config.mjs';
38
+ import {
39
+ ensureIdentity,
40
+ identityAttributes,
41
+ identityResourceAttributes,
42
+ } from './telemetry-identity.mjs';
29
43
 
30
44
  // ── Baked-in defaults (public by design) ──────────────────────────────────────
31
45
  // The endpoint is a committed default; the token is injected at publish time
@@ -231,7 +245,7 @@ export function resolveDeploymentEnvironment(env = process.env) {
231
245
  return value || undefined;
232
246
  }
233
247
 
234
- function resourceAttributes(version, env = process.env) {
248
+ function resourceAttributes(version, env = process.env, identity = {}) {
235
249
  const attrs = [
236
250
  { key: 'service.name', value: { stringValue: 'cli' } },
237
251
  { key: 'service.namespace', value: { stringValue: 'lorekit' } },
@@ -243,6 +257,12 @@ function resourceAttributes(version, env = process.env) {
243
257
  ];
244
258
  const deploymentEnv = resolveDeploymentEnvironment(env);
245
259
  if (deploymentEnv) attrs.push({ key: 'deployment.environment.name', value: { stringValue: deploymentEnv } });
260
+ // `service.instance.id` — the install. Omitted entirely when there is no
261
+ // identity (opted out, or an unwritable home), never placeholdered: a
262
+ // constant stand-in would fold every such machine into one instance.
263
+ for (const [key, value] of Object.entries(identityResourceAttributes(identity))) {
264
+ attrs.push({ key, value: { stringValue: String(value) } });
265
+ }
246
266
  return attrs;
247
267
  }
248
268
 
@@ -332,11 +352,11 @@ export function commandAttributes({ command, args = {}, outcome, exitCode, extra
332
352
  return attrs;
333
353
  }
334
354
 
335
- export function buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId }) {
355
+ export function buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId, identity }) {
336
356
  return {
337
357
  resourceSpans: [
338
358
  {
339
- resource: { attributes: resourceAttributes(version) },
359
+ resource: { attributes: resourceAttributes(version, process.env, identity) },
340
360
  scopeSpans: [
341
361
  {
342
362
  scope: { name: 'cli', version: String(version) },
@@ -362,11 +382,11 @@ export function buildTracePayload({ version, name, attributes, startMs, endMs, s
362
382
  };
363
383
  }
364
384
 
365
- export function buildMetricsPayload({ version, attributes, startMs, endMs }) {
385
+ export function buildMetricsPayload({ version, attributes, startMs, endMs, identity }) {
366
386
  return {
367
387
  resourceMetrics: [
368
388
  {
369
- resource: { attributes: resourceAttributes(version) },
389
+ resource: { attributes: resourceAttributes(version, process.env, identity) },
370
390
  scopeMetrics: [
371
391
  {
372
392
  scope: { name: 'cli', version: String(version) },
@@ -396,6 +416,31 @@ export function buildMetricsPayload({ version, attributes, startMs, endMs }) {
396
416
  };
397
417
  }
398
418
 
419
+ // ── Identity ──────────────────────────────────────────────────────────────────
420
+
421
+ /**
422
+ * Resolve the durable telemetry identity for this run: the install id (minting
423
+ * and persisting one on first use) plus any account id a previous authenticated
424
+ * call cached.
425
+ *
426
+ * TOTAL and side-effect-free when export is off — `ensureInstallId` returns
427
+ * null for a disabled config without touching the disk, so the opt-out
428
+ * invariant holds here by delegation rather than by a second check that could
429
+ * drift from it.
430
+ *
431
+ * @param {{ enabled?: boolean }} config
432
+ * @returns {{ installId: string | null, accountId: string | null }}
433
+ */
434
+ function resolveIdentity(config) {
435
+ try {
436
+ return ensureIdentity(config);
437
+ } catch {
438
+ // Identity is an enrichment, never a precondition. A failure here degrades
439
+ // to the pre-identity behaviour: telemetry still exports, unattributed.
440
+ return { installId: null, accountId: null };
441
+ }
442
+ }
443
+
399
444
  // ── Export ────────────────────────────────────────────────────────────────────
400
445
 
401
446
  async function post(url, headers, payload, timeoutMs) {
@@ -420,16 +465,71 @@ async function post(url, headers, payload, timeoutMs) {
420
465
  * can never delay or fail the CLI. Awaited before process exit (Node would
421
466
  * otherwise drop the in-flight request), but capped at timeoutMs.
422
467
  */
423
- export async function exportInvocation(config, { version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId }, { timeoutMs = 1500 } = {}) {
468
+ export async function exportInvocation(config, { version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId, identity }, { timeoutMs = 1500 } = {}) {
424
469
  if (!config || !config.enabled) return;
425
- const trace = buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId });
426
- const metric = buildMetricsPayload({ version, attributes, startMs, endMs });
470
+ // `user.id` rides on BOTH payloads: on the span so a trace is attributable,
471
+ // and on the counter data point so "distinct users who ran `search`" is
472
+ // answerable without touching traces at all.
473
+ const identified = { ...attributes, ...identityAttributes(identity) };
474
+ const trace = buildTracePayload({ version, name, attributes: identified, startMs, endMs, status, statusMessage, traceId, spanId, identity });
475
+ const metric = buildMetricsPayload({ version, attributes: identified, startMs, endMs, identity });
427
476
  await Promise.all([
428
477
  post(`${config.endpoint}/v1/traces`, config.headers, trace, timeoutMs),
429
478
  post(`${config.endpoint}/v1/metrics`, config.headers, metric, timeoutMs),
430
479
  ]);
431
480
  }
432
481
 
482
+ /**
483
+ * Export ONLY the invocation counter — no span — for the machine-facing
484
+ * commands (`hook`, `mcp`).
485
+ *
486
+ * These are the highest-volume entry points by a wide margin: `hook` fires on
487
+ * every agent turn, several times per turn. They were `traced: false` and so
488
+ * emitted nothing at all, which meant the identity work above would have
489
+ * differentiated users across the human-facing commands while the traffic that
490
+ * actually dominates stayed invisible.
491
+ *
492
+ * Metric-only, and on a much tighter budget than {@link exportInvocation}, for
493
+ * three reasons specific to this path:
494
+ * • A span per hook invocation would be a firehose of near-identical traces
495
+ * for no analytical gain over a counter — the question here is "how much,
496
+ * by whom", not "what happened inside this one".
497
+ * • `hook` and `mcp` own stdout as a machine contract (the host parses it),
498
+ * and `traceCommand`'s 1500 ms export budget sits between the command
499
+ * finishing and the process exiting. On a per-turn hook that is a latency
500
+ * the user feels; {@link METERED_TIMEOUT_MS} is the cap that keeps it
501
+ * imperceptible, accepting a dropped data point over a delayed agent.
502
+ * • Nothing here is allowed to change the exit code or stdout, so every
503
+ * failure path returns quietly.
504
+ *
505
+ * @param {object} config resolved telemetry config
506
+ * @param {string} command `hook` | `mcp`
507
+ * @param {string} version CLI version
508
+ * @param {object} [extraAttrs] bounded extra dimensions (e.g. the hook event)
509
+ */
510
+ export async function countInvocation(config, command, version, extraAttrs = {}) {
511
+ if (!config || !config.enabled) return;
512
+ const identity = resolveIdentity(config);
513
+ const now = Date.now();
514
+ const attributes = {
515
+ ...commandAttributes({ command, outcome: CLI_OUTCOMES.OK, extraAttrs }),
516
+ ...identityAttributes(identity),
517
+ };
518
+ await post(
519
+ `${config.endpoint}/v1/metrics`,
520
+ config.headers,
521
+ buildMetricsPayload({ version, attributes, startMs: now, endMs: now, identity }),
522
+ METERED_TIMEOUT_MS,
523
+ );
524
+ }
525
+
526
+ /**
527
+ * The export budget for the metric-only machine-facing path. Deliberately much
528
+ * shorter than the 1500 ms default: this sits on the agent's per-turn critical
529
+ * path, so a slow collector must cost a data point, not a visible stall.
530
+ */
531
+ export const METERED_TIMEOUT_MS = 400;
532
+
433
533
  /**
434
534
  * Send ONE synthetic span to the configured OTLP endpoint and report what the
435
535
  * collector said about it.
@@ -551,6 +651,54 @@ function normalizeExitCode(result) {
551
651
  return result ?? 0;
552
652
  }
553
653
 
654
+ /**
655
+ * Run a MACHINE-facing command (`hook`, `mcp`) and count the invocation —
656
+ * counter only, no span. Returns the command's exit code unchanged.
657
+ *
658
+ * The export is STARTED BEFORE the command runs and awaited after, so it
659
+ * overlaps the command's own work instead of being serialized behind it. That
660
+ * ordering is what makes this affordable on `hook`, which fires several times
661
+ * per agent turn: by the time there is anything to await, the POST has usually
662
+ * already completed, and {@link METERED_TIMEOUT_MS} caps the worst case.
663
+ *
664
+ * It also makes the count robust for `mcp`, which is a LONG-LIVED stdio server —
665
+ * `run()` does not return until the server exits, and a killed server would
666
+ * never have reported at all if the export waited for it. Since the counter
667
+ * reports the invocation rather than its outcome (a machine-facing command's
668
+ * verdict belongs to its stdout contract, which the host reads), there is
669
+ * nothing to learn by waiting.
670
+ *
671
+ * Nothing here can affect the command: the exit code is passed through
672
+ * untouched, and every telemetry failure is swallowed.
673
+ *
674
+ * @param {string} command `hook` | `mcp`
675
+ * @param {string} version CLI version
676
+ * @param {() => Promise<number>} run the command handler
677
+ */
678
+ export async function meterCommand(command, version, run) {
679
+ let config;
680
+ try {
681
+ config = resolveTelemetryConfig();
682
+ } catch {
683
+ config = { enabled: false };
684
+ }
685
+
686
+ // Fast path — no export configured. The overwhelmingly common case for these
687
+ // two commands (the baked-in token is absent in the source tree), so it must
688
+ // cost nothing at all: no identity read, no timer, no promise.
689
+ if (!config.enabled) return normalizeExitCode(await run());
690
+
691
+ // `.catch` attached IMMEDIATELY, before any await: an unawaited rejecting
692
+ // promise is an unhandled rejection, which on a machine-facing command would
693
+ // print to stderr and pollute a host's log.
694
+ const pending = countInvocation(config, command, version).catch(() => {});
695
+ try {
696
+ return normalizeExitCode(await run());
697
+ } finally {
698
+ await pending;
699
+ }
700
+ }
701
+
554
702
  /**
555
703
  * Time a human-facing command, record its outcome, and export one span + one
556
704
  * counter point. Returns the command's exit code unchanged. Telemetry failures
@@ -601,6 +749,14 @@ export async function traceCommand(command, args, version, run) {
601
749
  }
602
750
  }
603
751
 
752
+ // Resolved once, before the command runs, so the export in `finally` cannot
753
+ // pay for a first-run mint on the crash path — and so a command that itself
754
+ // learns the account id (any remote call, via `restFetch`) does not race the
755
+ // read. A run that learns it for the first time reports `install:<id>` and the
756
+ // NEXT run reports the account; one deliberately-late run beats a mid-command
757
+ // re-read whose result depends on which subcommand happened to phone home.
758
+ const identity = resolveIdentity(config);
759
+
604
760
  const startMs = Date.now();
605
761
  let exitCode = 0;
606
762
  // `outcome` is the command's VERDICT (ok | failure | error); `status` is the
@@ -673,6 +829,7 @@ export async function traceCommand(command, args, version, run) {
673
829
  statusMessage,
674
830
  traceId: _activeTraceId,
675
831
  spanId: _activeSpanId,
832
+ identity,
676
833
  });
677
834
  } catch {
678
835
  // never let telemetry break the CLI