@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.
- package/README.md +49 -4
- package/bin/lorekit.mjs +200 -92
- package/package.json +1 -1
- package/src/commands.mjs +112 -0
- package/src/control.mjs +15 -5
- package/src/doctor.mjs +18 -0
- package/src/mcp-server.mjs +68 -181
- package/src/mcp.mjs +25 -1
- package/src/purge.mjs +191 -0
- package/src/store/remote.mjs +80 -1
- package/src/surfaces.generated.mjs +516 -0
- package/src/telemetry-identity.mjs +276 -0
- package/src/telemetry.mjs +167 -10
|
@@ -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,
|
|
16
|
-
// path, cwd, token, endpoint,
|
|
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
|
-
|
|
426
|
-
|
|
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
|