@lorekit/cli 1.54.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.
package/bin/lorekit.mjs CHANGED
@@ -10,7 +10,7 @@ import { COMMANDS_BY_NAME, STRICT_FLAG_COMMANDS, COMMAND_ALIASES } from '../src/
10
10
  // Catalog-derived, so the default this help PROMISES is the one the server
11
11
  // applies — see src/surfaces.generated.mjs.
12
12
  import { PURGE_RETENTION_DAYS_DEFAULT } from '../src/surfaces.generated.mjs';
13
- import { traceCommand } from '../src/telemetry.mjs';
13
+ import { traceCommand, meterCommand } from '../src/telemetry.mjs';
14
14
  import { loadDotEnv } from '../src/dotenv.mjs';
15
15
 
16
16
  // Read the version from package.json so it always matches the published
@@ -863,12 +863,23 @@ async function main() {
863
863
 
864
864
  // Machine-facing commands (`hook`, `mcp`) own their stdout — a host's JSON
865
865
  // contract and JSON-RPC frames respectively — so they must bypass the usage
866
- // and version branches, which print. They are also deliberately UNTRACED:
867
- // they fire on every agent event, and a span per event is a cost the caller
868
- // never asked for. `machine` in the registry is the single statement of that.
866
+ // and version branches, which print. `machine` in the registry is the single
867
+ // statement of that.
868
+ //
869
+ // They stay UNTRACED — a span per agent event is a firehose of near-identical
870
+ // traces, and these fire several times per turn — but they are no longer
871
+ // SILENT. `meterCommand` emits the invocation COUNTER only, carrying the same
872
+ // identity attributes the traced commands do, on a much tighter export budget
873
+ // (see `METERED_TIMEOUT_MS`). Without it the durable telemetry identity would
874
+ // differentiate users across `list`/`search`/`stats` while the traffic that
875
+ // actually dominates — the hooks on every turn — stayed invisible.
876
+ //
877
+ // The command runs FIRST and its exit code is returned unchanged: the host's
878
+ // stdout contract is written by `run` before any export is attempted, and a
879
+ // telemetry failure can neither alter the exit code nor corrupt the frame.
869
880
  const machineEntry = COMMANDS_BY_NAME.get(command);
870
881
  if (machineEntry?.machine) {
871
- return machineEntry.run(args);
882
+ return meterCommand(machineEntry.name, VERSION, () => machineEntry.run(args));
872
883
  }
873
884
 
874
885
  if (args.version) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.54.0",
3
+ "version": "1.55.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/commands.mjs CHANGED
@@ -25,6 +25,13 @@
25
25
  // event, and own their stdout (a host's JSON contract / JSON-RPC frames), so a
26
26
  // span's cost and an error message's bytes are both unacceptable there. They are
27
27
  // marked `machine` and dispatched before the usage branches.
28
+ //
29
+ // `traced: false` does NOT mean unmeasured. The dispatcher routes `machine`
30
+ // commands through `meterCommand`, which emits the invocation COUNTER (with the
31
+ // same identity attributes the traced commands carry) and no span, on a tighter
32
+ // export budget. These two are the highest-volume entry points in the CLI, so
33
+ // leaving them entirely silent meant the usage that dominates was the usage
34
+ // nobody could see — a span each is still the wrong trade, a counter is not.
28
35
 
29
36
  import { install } from './install.mjs';
30
37
  import { uninstall } from './uninstall.mjs';
package/src/control.mjs CHANGED
@@ -513,7 +513,7 @@ function projectDirFrom({ env, userConfig, repoConfig, root }) {
513
513
  // `store` override). Used by `migrate` so it works regardless of the active
514
514
  // mode. `home` is the per-user tier root; `project` is the opt-in repo tier.
515
515
  export function localStoreDirs(root = process.cwd(), env = process.env) {
516
- const home = userConfigDir(env);
516
+ const home = homeRoot(env);
517
517
  const userConfig = readJson(path.join(home, 'config.json'));
518
518
  const repoConfig = readJson(path.join(root, '.lorekit.json'));
519
519
  return { home, project: projectDirFrom({ env, userConfig, repoConfig, root }) };
@@ -534,7 +534,7 @@ export function resolveDenies(root, { env = process.env } = {}) {
534
534
 
535
535
  // IO wrapper — load env + config files, derive the connection, then resolve.
536
536
  export function loadControl(root, { env = process.env } = {}) {
537
- const home = userConfigDir(env);
537
+ const home = homeRoot(env);
538
538
  const userConfig = readJson(path.join(home, 'config.json'));
539
539
  const repoConfig = readJson(path.join(root, '.lorekit.json'));
540
540
  const conn = resolveProjectConnection(root, splitEndpoint);
@@ -545,9 +545,19 @@ export function loadControl(root, { env = process.env } = {}) {
545
545
  return resolveControl({ env, userConfig, repoConfig, connection, root, home });
546
546
  }
547
547
 
548
- // The per-user home tier root (also holds config.json): $LOREKIT_HOME, default
549
- // `~/.lorekit`. Moved from the old `~/.agent-memory` location.
550
- function userConfigDir(env) {
548
+ /**
549
+ * The per-user home tier root (also holds config.json and telemetry-id.json):
550
+ * $LOREKIT_HOME, default `~/.lorekit`. Moved from the old `~/.agent-memory`
551
+ * location.
552
+ *
553
+ * Exported because `telemetry-identity.mjs` stores the install id in the same
554
+ * directory and must resolve it the SAME way. Re-deriving
555
+ * `LOREKIT_HOME || ~/.lorekit` there would put a second copy of this rule in
556
+ * the tree, and a drift between them would not fail loudly — it would mint a
557
+ * fresh "install" for a user who already had one, in a directory unrelated to
558
+ * their store.
559
+ */
560
+ export function homeRoot(env = process.env) {
551
561
  return env.LOREKIT_HOME || path.join(os.homedir(), '.lorekit');
552
562
  }
553
563
 
package/src/doctor.mjs CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  resolveTelemetryTokenSource,
24
24
  probeTelemetryExport,
25
25
  } from './telemetry.mjs';
26
+ import { describeIdentity } from './telemetry-identity.mjs';
26
27
  import { deriveScope } from './scope.mjs';
27
28
  import { loadControl, HOOK_INSTRUCTION_EVENTS } from './control.mjs';
28
29
  import { createStore } from './store/index.mjs';
@@ -537,6 +538,23 @@ async function checkTelemetryExport(args, root, record) {
537
538
 
538
539
  record('info', 'telemetry', `export on → ${config.endpoint} ${c.dim(`(credential from ${source})`)}`);
539
540
 
541
+ // What identity the exported telemetry carries, and where it lives. Reported
542
+ // because the id is otherwise invisible: it is minted silently on first run,
543
+ // and a user who wants to see or reset it needs the path. `describeIdentity`
544
+ // never mints, so running `doctor` cannot itself create the file — the line
545
+ // below reads "not yet minted" on a machine that has only ever run `doctor`.
546
+ const identity = describeIdentity();
547
+ const linked = identity.accountId
548
+ ? `account ${identity.accountId}`
549
+ : 'no account linked yet — any authenticated command links it';
550
+ record(
551
+ 'info',
552
+ 'telemetry',
553
+ identity.installId
554
+ ? `identity: install ${identity.installId} · ${linked} ${c.dim(`(${identity.file} — delete to reset)`)}`
555
+ : `identity: not yet minted ${c.dim(`(will be written to ${identity.file})`)}`,
556
+ );
557
+
540
558
  // The probe writes a real span to a real backend — only on explicit request.
541
559
  if (!required) return;
542
560
 
package/src/mcp.mjs CHANGED
@@ -26,6 +26,20 @@ export function buildRemoteUrl(endpoint, token) {
26
26
 
27
27
  let idCounter = 0;
28
28
 
29
+ /**
30
+ * Response header naming the account the request authenticated as, set by the
31
+ * edge REST router for every non-service-role caller.
32
+ *
33
+ * The CLI's only way to know its own account id without a dedicated `/me`
34
+ * round-trip: it rides along on calls the CLI was making anyway. Cached by
35
+ * `RemoteStore._rest` so LOCAL, fully-offline runs can still report which
36
+ * account they belong to — see `telemetry-identity.mjs`.
37
+ *
38
+ * Kept in step with `CALLER_USER_ID_HEADER` in
39
+ * `supabase/functions/_shared/api/router.ts`.
40
+ */
41
+ export const USER_ID_HEADER = 'x-lorekit-user-id';
42
+
29
43
  // Returns { ok, httpStatus, result, error, networkError }.
30
44
  //
31
45
  // `opts.traceparent` is an optional W3C traceparent header value (see
@@ -223,10 +237,20 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
223
237
  const text = await res.text();
224
238
  let data = null;
225
239
  try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON body */ }
240
+ // The account this call authenticated as, as the server resolved it. Read on
241
+ // BOTH the success and failure paths: a 429 or a 404 is still an
242
+ // authenticated request, and rate-limited traffic is exactly when knowing
243
+ // whose it is matters most. Surfaced, not cached, here — `mcp.mjs` is in
244
+ // `control.mjs`'s import graph (`splitEndpoint`), and the identity module
245
+ // reads `homeRoot` from `control.mjs`, so importing it here would close an
246
+ // import cycle. `RemoteStore._rest` — the ONE caller of this function, so
247
+ // nothing is missed by doing it a layer out — does the caching.
248
+ const userId = res.headers?.get?.(USER_ID_HEADER) ?? null;
226
249
  if (!res.ok) {
227
250
  return {
228
251
  ok: false,
229
252
  httpStatus: res.status,
253
+ userId,
230
254
  // How long the server asked the caller to wait, in seconds, or null when
231
255
  // it did not say. Only a 429 carries one today (`tooManyRequests` sets
232
256
  // BOTH a `retryAfterSeconds` body field and the `Retry-After` header),
@@ -236,7 +260,7 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
236
260
  error: data?.error ? { message: data.error, code: data.code } : { code: res.status, message: text.slice(0, 200) || res.statusText },
237
261
  };
238
262
  }
239
- return { ok: true, httpStatus: res.status, data };
263
+ return { ok: true, httpStatus: res.status, data, userId };
240
264
  } catch (e) {
241
265
  return { ok: false, networkError: String(e?.message ?? e) };
242
266
  } finally {
@@ -19,6 +19,7 @@
19
19
  // no longer a transport for this store.
20
20
  // Zero-dependency.
21
21
  import { restFetch, mcpToRestBase } from '../mcp.mjs';
22
+ import { rememberAccountId } from '../telemetry-identity.mjs';
22
23
  import { getActiveTraceparent } from '../telemetry.mjs';
23
24
  import { withReadFields } from './entry-fields.mjs';
24
25
  import { normalizeCreatedAt } from './created-at.mjs';
@@ -139,7 +140,26 @@ class RemoteStore {
139
140
 
140
141
  async _rest(path, opts = {}) {
141
142
  if (!this.usable()) return { ok: false, unusable: true };
142
- return restFetch(this.restBase, this.token, path, { ...opts, traceparent: this._tp() });
143
+ const res = await restFetch(this.restBase, this.token, path, { ...opts, traceparent: this._tp() });
144
+ // Learn (and persist) which account this token belongs to, from the
145
+ // `X-LoreKit-User-Id` header the edge sets on every authenticated response.
146
+ // This is the ONE choke point for the CLI's remote traffic, so a single call
147
+ // site covers every command.
148
+ //
149
+ // Why cache it at all, when the server already knows: a run that never
150
+ // leaves the machine (`--offline`, the local two-tier store, `lorekit hook`)
151
+ // makes no request, so there is no server-side span to carry `auth.user_id`.
152
+ // Persisting it here is what lets THOSE runs report an account and join to
153
+ // server-side `usage_events` / `auth.user_id`.
154
+ //
155
+ // `rememberAccountId` is total, is a no-op when the value is unchanged, and
156
+ // never creates the identity file — so a user who opted out of telemetry
157
+ // gets nothing written even though they still receive the header. Guarded
158
+ // anyway so a store operation can never fail on a telemetry concern.
159
+ try {
160
+ rememberAccountId(res?.userId);
161
+ } catch { /* identity is an enrichment, never a precondition */ }
162
+ return res;
143
163
  }
144
164
 
145
165
  // ── Memory operations → REST ──────────────────────────────────────────────
@@ -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