@lorekit/cli 1.54.0 → 1.55.1
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 +16 -5
- package/package.json +1 -1
- package/src/commands.mjs +7 -0
- package/src/control.mjs +15 -5
- package/src/deeplink-pure.mjs +7 -0
- package/src/doctor.mjs +18 -0
- package/src/mcp.mjs +25 -1
- package/src/store/remote.mjs +21 -1
- package/src/telemetry-identity.mjs +276 -0
- package/src/telemetry.mjs +167 -10
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.
|
|
867
|
-
//
|
|
868
|
-
//
|
|
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
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 =
|
|
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 =
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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/deeplink-pure.mjs
CHANGED
|
@@ -131,6 +131,13 @@ export function loreScopeUrl(scope, opts = {}) {
|
|
|
131
131
|
// own scope, coherent with the detail view. A lesson older than that window or
|
|
132
132
|
// archived can still open blank — a dashboard-side limitation the link can't fix.
|
|
133
133
|
// `scope` is the lesson's own scope. Pure.
|
|
134
|
+
//
|
|
135
|
+
// NOTE FOR ANYONE HAND-BUILDING A URL INSTEAD OF CALLING THIS: the `lesson`
|
|
136
|
+
// param takes a JSON-encoded `{scope, key}`, NOT a memory id. If you hold the
|
|
137
|
+
// `id` a write returned, the param is `?memoryId=<uuid>` (raw, not JSON-encoded).
|
|
138
|
+
// `/lore` now also accepts a UUID in `?lesson=` so the wrong combination
|
|
139
|
+
// resolves rather than silently doing nothing — but that is a safety net, and
|
|
140
|
+
// this function is the thing to call when you have a scope and key.
|
|
134
141
|
export function buildLessonUrl(scope, key, opts = {}) {
|
|
135
142
|
const params = { lesson: { scope, key } };
|
|
136
143
|
if (scope) params.scope = scope;
|
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 {
|
package/src/store/remote.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|