@lorekit/cli 1.29.3 → 1.30.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 +8 -2
- package/package.json +1 -1
- package/src/deeplink-pure.mjs +12 -4
- package/src/doctor.mjs +127 -2
- package/src/telemetry.mjs +149 -10
package/bin/lorekit.mjs
CHANGED
|
@@ -125,6 +125,7 @@ ${c.bold('Options')}
|
|
|
125
125
|
--no-hooks Skip wiring the lifecycle hooks (install)
|
|
126
126
|
--force Overwrite existing skill files (install)
|
|
127
127
|
--deep Do a write→read→delete round-trip (doctor)
|
|
128
|
+
--telemetry Verify the OTLP export credential works (doctor)
|
|
128
129
|
--adapter <name> Host framework for hook: claude | cursor | codex
|
|
129
130
|
--event <name> Host hook event (else read from stdin payload)
|
|
130
131
|
-h, --help Show this help
|
|
@@ -230,10 +231,15 @@ ${c.bold('Options')}
|
|
|
230
231
|
-t, --token <token> Token override (else .mcp.json / LOREKIT_TOKEN)
|
|
231
232
|
--store <path> Local project-tier store directory (default: .lorekit)
|
|
232
233
|
--deep Do a write→read→delete round-trip
|
|
234
|
+
--telemetry Focused run: skip the other checks, POST a probe
|
|
235
|
+
span to the OTLP endpoint, and FAIL if the Dash0
|
|
236
|
+
ingesting token is missing or rejected. Without it,
|
|
237
|
+
telemetry is reported as info only.
|
|
233
238
|
|
|
234
239
|
${c.bold('Examples')}
|
|
235
240
|
npx @lorekit/cli doctor
|
|
236
241
|
npx @lorekit/cli doctor --deep
|
|
242
|
+
npx @lorekit/cli doctor --telemetry
|
|
237
243
|
npx @lorekit/cli doctor --mode local
|
|
238
244
|
`,
|
|
239
245
|
list: `${c.bold('lorekit list')} — list the memories that apply to the current directory ${c.dim('(alias: ls)')}
|
|
@@ -593,7 +599,7 @@ ${c.bold('Options')}
|
|
|
593
599
|
const KNOWN_FLAGS = [
|
|
594
600
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
595
601
|
'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'force', 'deep', 'adapter',
|
|
596
|
-
'event', 'json', 'scope', 'threshold', 'help', 'version',
|
|
602
|
+
'event', 'json', 'scope', 'threshold', 'help', 'version', 'telemetry',
|
|
597
603
|
'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
|
|
598
604
|
'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
|
|
599
605
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
@@ -621,7 +627,7 @@ async function main() {
|
|
|
621
627
|
const argv = process.argv.slice(2);
|
|
622
628
|
const args = parseArgs(argv, {
|
|
623
629
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
624
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl'],
|
|
630
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry'],
|
|
625
631
|
known: KNOWN_FLAGS,
|
|
626
632
|
});
|
|
627
633
|
|
package/package.json
CHANGED
package/src/deeplink-pure.mjs
CHANGED
|
@@ -29,7 +29,15 @@ export const LORE_PARAM_DEFAULTS = {
|
|
|
29
29
|
q: '', // string search query
|
|
30
30
|
range: null, // { from, to } | null (DateRange, "YYYY-MM-DD")
|
|
31
31
|
owner: 'all', // 'all' | 'personal' | { orgId }
|
|
32
|
-
|
|
32
|
+
// Filter[] | null — the Explorer's multi-dimension filter bar (label / agent /
|
|
33
|
+
// trigger / repo / branch / pr). `null`, NOT `[]`, is the default on purpose:
|
|
34
|
+
// the app has to tell "the param is absent" from "the bar is explicitly
|
|
35
|
+
// empty", because an absent `filters` falls back to the legacy `tags`
|
|
36
|
+
// shorthand while an empty one deliberately does not. Encoding `[]` here
|
|
37
|
+
// would emit the param (it is not the default) and mean the opposite of
|
|
38
|
+
// "unfiltered".
|
|
39
|
+
filters: null,
|
|
40
|
+
tags: [], // string[] — legacy label filter (AND across labels); [] means "no filter". Still READ by the app, superseded by `filters`
|
|
33
41
|
view: 'scope', // 'scope' | 'time'
|
|
34
42
|
archived: false, // boolean
|
|
35
43
|
lesson: null, // { scope, key } | null — opens the detail sheet
|
|
@@ -37,9 +45,9 @@ export const LORE_PARAM_DEFAULTS = {
|
|
|
37
45
|
|
|
38
46
|
// A stable, readable param order (also makes URLs deterministic for tests).
|
|
39
47
|
// Mirrors the `useUrlState` call order in `LoreExplorer.tsx` (+ the `lesson`
|
|
40
|
-
// param last), so `tags`
|
|
41
|
-
// `lesson` so a lesson link reads `?scope=…&lesson=…`.
|
|
42
|
-
const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'tags', 'view', 'archived', 'lesson'];
|
|
48
|
+
// param last), so `filters` and `tags` sit between `owner` and `view`. `scope`
|
|
49
|
+
// precedes `lesson` so a lesson link reads `?scope=…&lesson=…`.
|
|
50
|
+
const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', 'view', 'archived', 'lesson'];
|
|
43
51
|
|
|
44
52
|
// Strip trailing slashes from a base URL, falling back to the default when the
|
|
45
53
|
// input is empty/absent. Pure.
|
package/src/doctor.mjs
CHANGED
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
readLorekitJson,
|
|
18
18
|
} from './config.mjs';
|
|
19
19
|
import { splitEndpoint } from './mcp.mjs';
|
|
20
|
+
import {
|
|
21
|
+
resolveTelemetryConfig,
|
|
22
|
+
resolveTelemetryTokenSource,
|
|
23
|
+
probeTelemetryExport,
|
|
24
|
+
} from './telemetry.mjs';
|
|
20
25
|
import { deriveScope } from './scope.mjs';
|
|
21
26
|
import { loadControl } from './control.mjs';
|
|
22
27
|
import { createStore } from './store/index.mjs';
|
|
@@ -38,6 +43,19 @@ export async function doctor(args) {
|
|
|
38
43
|
heading('LoreKit doctor');
|
|
39
44
|
log(` project: ${c.dim(root)}\n`);
|
|
40
45
|
|
|
46
|
+
// 0. `--telemetry` is a FOCUSED run, not a modifier on the full sweep.
|
|
47
|
+
//
|
|
48
|
+
// It exists to be a CI gate on one specific credential — the Dash0
|
|
49
|
+
// ingesting-only OTLP token — and a bare runner has no skills installed, no
|
|
50
|
+
// .mcp.json and no LoreKit API token, so a full sweep there fails for four
|
|
51
|
+
// unrelated reasons and tells you nothing about the one thing being gated.
|
|
52
|
+
// Returning early keeps the exit code a clean answer to a single question:
|
|
53
|
+
// "can this build still emit telemetry?"
|
|
54
|
+
if (args.telemetry) {
|
|
55
|
+
await checkTelemetryExport(args, root, record);
|
|
56
|
+
return summarize(failures, warnings, failedChecks, 'Telemetry export is working.');
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
// 1. Runtime.
|
|
42
60
|
const major = Number(process.versions.node.split('.')[0]);
|
|
43
61
|
record(
|
|
@@ -123,6 +141,9 @@ export async function doctor(args) {
|
|
|
123
141
|
// 4b. BYOD storage connectivity check.
|
|
124
142
|
await checkBYODStorage(record);
|
|
125
143
|
|
|
144
|
+
// 4c. Dash0 telemetry export — is the CLI's own phone-home still working?
|
|
145
|
+
await checkTelemetryExport(args, root, record);
|
|
146
|
+
|
|
126
147
|
// 5. Scope.
|
|
127
148
|
const scope = deriveScope(root);
|
|
128
149
|
if (scope.hasRemote) {
|
|
@@ -165,10 +186,18 @@ export async function doctor(args) {
|
|
|
165
186
|
}
|
|
166
187
|
}
|
|
167
188
|
|
|
168
|
-
|
|
189
|
+
return summarize(failures, warnings, failedChecks);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Print the Summary block and build doctor's `{ exitCode, ...extra }` return.
|
|
194
|
+
* Extracted so the focused `--telemetry` run and the full sweep end the same
|
|
195
|
+
* way — same summary line, same exit-code rule, same bounded telemetry extra.
|
|
196
|
+
*/
|
|
197
|
+
function summarize(failures, warnings, failedChecks, ready = 'LoreKit memory is ready.') {
|
|
169
198
|
heading('Summary');
|
|
170
199
|
if (failures === 0 && warnings === 0) {
|
|
171
|
-
log(` ${c.green('All checks passed.')}
|
|
200
|
+
log(` ${c.green('All checks passed.')} ${ready}`);
|
|
172
201
|
} else {
|
|
173
202
|
log(
|
|
174
203
|
` ${failures ? c.red(failures + ' failed') : c.green('0 failed')}, ${
|
|
@@ -438,6 +467,102 @@ function detectDuplicateHooks(root) {
|
|
|
438
467
|
return CLAUDE_HOOK_EVENTS.filter((event) => inProject.has(event) && inGlobal.has(event));
|
|
439
468
|
}
|
|
440
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Is the CLI's own OpenTelemetry export still working?
|
|
472
|
+
*
|
|
473
|
+
* This is a DIFFERENT credential from the `token` / `authentication` checks
|
|
474
|
+
* above: those verify the LoreKit API token (`lk_rw_*`) that reads and writes
|
|
475
|
+
* memories. This one verifies the Dash0 ingesting-only OTLP token, which is
|
|
476
|
+
* baked into the published tarball at release time and is otherwise invisible —
|
|
477
|
+
* `exportInvocation` swallows every transport error by design, so a revoked
|
|
478
|
+
* token, a moved endpoint, or a release that published with the
|
|
479
|
+
* `LOREKIT_TELEMETRY_TOKEN` secret unset all look identical from the outside:
|
|
480
|
+
* silence. Nothing in CI noticed, because nothing was looking.
|
|
481
|
+
*
|
|
482
|
+
* Two tiers, so the default `doctor` run stays offline and side-effect-free:
|
|
483
|
+
* • always — report whether export is ON and where the credential came from,
|
|
484
|
+
* as `info`. Never a failure: end users legitimately opt out, and a user's
|
|
485
|
+
* machine having no phone-home is not a broken install.
|
|
486
|
+
* • `--telemetry` — actually POST a probe span and assert the endpoint
|
|
487
|
+
* accepted it. Here a dead export IS a failure; that flag is what CI passes.
|
|
488
|
+
*/
|
|
489
|
+
async function checkTelemetryExport(args, root, record) {
|
|
490
|
+
const required = Boolean(args.telemetry);
|
|
491
|
+
|
|
492
|
+
let config;
|
|
493
|
+
try {
|
|
494
|
+
// Read `.lorekit.json` from the SAME root every other check uses. Left to
|
|
495
|
+
// its default, `resolveTelemetryConfig` reads it from `process.cwd()`, so a
|
|
496
|
+
// `telemetry.disabled` in the shell's directory would decide the verdict for
|
|
497
|
+
// an unrelated `--dir`.
|
|
498
|
+
config = resolveTelemetryConfig(process.env, readLorekitJson(root));
|
|
499
|
+
} catch {
|
|
500
|
+
config = { enabled: false, reason: 'error' };
|
|
501
|
+
}
|
|
502
|
+
const source = resolveTelemetryTokenSource();
|
|
503
|
+
|
|
504
|
+
if (!config.enabled) {
|
|
505
|
+
// Report the reason `resolveTelemetryConfig` actually returned. Deriving it
|
|
506
|
+
// from the token source instead misreports every case where a credential
|
|
507
|
+
// variable is set but unusable — e.g. an OTEL_EXPORTER_OTLP_HEADERS that
|
|
508
|
+
// parses to zero headers, which is a broken credential, not an opt-out.
|
|
509
|
+
const detail =
|
|
510
|
+
config.reason === 'opted-out'
|
|
511
|
+
? 'export off — opted out via LOREKIT_TELEMETRY / DO_NOT_TRACK / `telemetry.disabled` in .lorekit.json'
|
|
512
|
+
: config.reason === 'no-endpoint'
|
|
513
|
+
? 'export off — no OTLP endpoint resolved. Published tarballs get one baked in at release; ' +
|
|
514
|
+
'set OTEL_EXPORTER_OTLP_ENDPOINT to override.'
|
|
515
|
+
: config.reason === 'error'
|
|
516
|
+
? 'export off — the telemetry config could not be resolved'
|
|
517
|
+
: 'export off — no OTLP credential resolved. Published tarballs get one injected at release; ' +
|
|
518
|
+
'set LOREKIT_TELEMETRY_TOKEN (or OTEL_EXPORTER_OTLP_HEADERS) to override.';
|
|
519
|
+
record(required ? 'fail' : 'info', 'telemetry', detail);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
record('info', 'telemetry', `export on → ${config.endpoint} ${c.dim(`(credential from ${source})`)}`);
|
|
524
|
+
|
|
525
|
+
// The probe writes a real span to a real backend — only on explicit request.
|
|
526
|
+
if (!required) return;
|
|
527
|
+
|
|
528
|
+
const probe = await probeTelemetryExport(config, { version: cliVersion(), timeoutMs: 5000 });
|
|
529
|
+
|
|
530
|
+
if (probe.networkError) {
|
|
531
|
+
record('fail', 'telemetry export', `could not reach ${config.endpoint} — ${probe.networkError}`);
|
|
532
|
+
} else if (probe.unauthorized) {
|
|
533
|
+
record(
|
|
534
|
+
'fail',
|
|
535
|
+
'telemetry export',
|
|
536
|
+
`probe span REJECTED (HTTP ${probe.httpStatus}) — the OTLP token is revoked, expired, or was never valid. ` +
|
|
537
|
+
'Mint a new Dash0 ingesting-only token and update the LOREKIT_TELEMETRY_TOKEN secret.',
|
|
538
|
+
);
|
|
539
|
+
} else if (probe.rejectedSpans) {
|
|
540
|
+
// A 2xx that dropped the span. Reporting the bare status here would read as
|
|
541
|
+
// a contradiction ("rejected (HTTP 200)"), so name the partial-success
|
|
542
|
+
// envelope the collector actually answered with.
|
|
543
|
+
record(
|
|
544
|
+
'fail',
|
|
545
|
+
'telemetry export',
|
|
546
|
+
`probe span REJECTED by the collector (HTTP ${probe.httpStatus}, partialSuccess.rejectedSpans=${probe.rejectedSpans}) — ` +
|
|
547
|
+
'the endpoint answered success but did not ingest the span' +
|
|
548
|
+
(probe.rejectionMessage ? `: ${probe.rejectionMessage}` : '.'),
|
|
549
|
+
);
|
|
550
|
+
} else if (probe.ok) {
|
|
551
|
+
record('pass', 'telemetry export', `probe span accepted (HTTP ${probe.httpStatus}) — the token can ingest`);
|
|
552
|
+
} else {
|
|
553
|
+
record('fail', 'telemetry export', `probe span rejected (HTTP ${probe.httpStatus})`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The running CLI version, for stamping the telemetry probe span. */
|
|
558
|
+
function cliVersion() {
|
|
559
|
+
try {
|
|
560
|
+
return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
561
|
+
} catch {
|
|
562
|
+
return '0.0.0';
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
441
566
|
async function checkBYODStorage(record) {
|
|
442
567
|
const storageUrl = process.env['LOREKIT_STORAGE_URL'];
|
|
443
568
|
const storageAnonKey = process.env['LOREKIT_STORAGE_ANON_KEY'];
|
package/src/telemetry.mjs
CHANGED
|
@@ -73,33 +73,42 @@ export function getActiveTraceparent() {
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* Resolve telemetry config from env + baked-in defaults + .lorekit.json.
|
|
76
|
-
* Returns { enabled: false } when disabled or unconfigured, else the
|
|
77
|
-
* and headers to export with.
|
|
76
|
+
* Returns { enabled: false, reason } when disabled or unconfigured, else the
|
|
77
|
+
* endpoint and headers to export with.
|
|
78
|
+
*
|
|
79
|
+
* `reason` names WHY export is off, because the three causes are not
|
|
80
|
+
* interchangeable to a user and cannot be re-derived from the outside:
|
|
81
|
+
* • `opted-out` — LOREKIT_TELEMETRY / DO_NOT_TRACK / `telemetry.disabled`.
|
|
82
|
+
* • `no-endpoint` — nothing to export to.
|
|
83
|
+
* • `no-credential` — an endpoint, but no usable auth header. Note that an
|
|
84
|
+
* OTEL_EXPORTER_OTLP_HEADERS that parses to zero headers lands here, which
|
|
85
|
+
* is exactly why a caller must not infer the cause from the token source:
|
|
86
|
+
* the variable is set, yet no credential resolved.
|
|
78
87
|
* @param {object} [env] defaults to process.env
|
|
79
88
|
* @param {object} [repoConfig] pre-loaded .lorekit.json (optional; read from cwd if absent)
|
|
80
89
|
*/
|
|
81
90
|
export function resolveTelemetryConfig(env = process.env, repoConfig) {
|
|
82
91
|
const optOut = env.LOREKIT_TELEMETRY;
|
|
83
92
|
if (optOut !== undefined && OFF_VALUES.has(String(optOut).trim().toLowerCase())) {
|
|
84
|
-
return { enabled: false };
|
|
93
|
+
return { enabled: false, reason: 'opted-out' };
|
|
85
94
|
}
|
|
86
95
|
// DNT spec designates exactly `1` as the opt-out signal (consoledonottrack.com).
|
|
87
96
|
// Match it precisely — a stray `DO_NOT_TRACK=false` should NOT disable export
|
|
88
97
|
// (use LOREKIT_TELEMETRY for the loose app-specific opt-out values).
|
|
89
98
|
if (env.DO_NOT_TRACK && String(env.DO_NOT_TRACK).trim() === '1') {
|
|
90
|
-
return { enabled: false };
|
|
99
|
+
return { enabled: false, reason: 'opted-out' };
|
|
91
100
|
}
|
|
92
101
|
// `telemetry.disabled: true` in .lorekit.json — team-level opt-out committed
|
|
93
102
|
// to the repo. Checked after env overrides (env always wins).
|
|
94
103
|
const cfg = repoConfig !== undefined ? repoConfig : readLorekitJson(process.cwd());
|
|
95
104
|
if (cfg['telemetry.disabled'] === true) {
|
|
96
|
-
return { enabled: false };
|
|
105
|
+
return { enabled: false, reason: 'opted-out' };
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
const endpoint = (env.OTEL_EXPORTER_OTLP_ENDPOINT || DEFAULT_ENDPOINT || '')
|
|
100
109
|
.trim()
|
|
101
110
|
.replace(/\/+$/, '');
|
|
102
|
-
if (!endpoint) return { enabled: false };
|
|
111
|
+
if (!endpoint) return { enabled: false, reason: 'no-endpoint' };
|
|
103
112
|
|
|
104
113
|
const headers = {};
|
|
105
114
|
// Auth header priority (highest first):
|
|
@@ -124,7 +133,7 @@ export function resolveTelemetryConfig(env = process.env, repoConfig) {
|
|
|
124
133
|
// headers) to authenticate — no point exporting an unauthenticated request.
|
|
125
134
|
const usingDefaultEndpoint = !env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
126
135
|
if (usingDefaultEndpoint && Object.keys(headers).length === 0) {
|
|
127
|
-
return { enabled: false };
|
|
136
|
+
return { enabled: false, reason: 'no-credential' };
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
// Dataset routing, highest precedence first: an explicit `Dash0-Dataset`
|
|
@@ -142,6 +151,21 @@ export function resolveTelemetryConfig(env = process.env, repoConfig) {
|
|
|
142
151
|
return { enabled: true, endpoint, headers };
|
|
143
152
|
}
|
|
144
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Which source supplied the OTLP credential, for `doctor` to report. Mirrors
|
|
156
|
+
* the priority order in `resolveTelemetryConfig` above — keep the two in step.
|
|
157
|
+
* Returns a human-readable label, or `'none'` when nothing resolved.
|
|
158
|
+
* @param {object} [env] defaults to process.env
|
|
159
|
+
*/
|
|
160
|
+
export function resolveTelemetryTokenSource(env = process.env) {
|
|
161
|
+
if (env.OTEL_EXPORTER_OTLP_HEADERS) return 'OTEL_EXPORTER_OTLP_HEADERS';
|
|
162
|
+
if (env.LOREKIT_TELEMETRY_TOKEN && String(env.LOREKIT_TELEMETRY_TOKEN).trim()) {
|
|
163
|
+
return 'LOREKIT_TELEMETRY_TOKEN';
|
|
164
|
+
}
|
|
165
|
+
if (DEFAULT_TOKEN) return 'the token baked in at publish time';
|
|
166
|
+
return 'none';
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
// ── ID + value helpers (mirror _shared/otel.ts) ───────────────────────────────
|
|
146
170
|
|
|
147
171
|
export function randHex(bytes) {
|
|
@@ -333,6 +357,101 @@ export async function exportInvocation(config, { version, name, attributes, star
|
|
|
333
357
|
]);
|
|
334
358
|
}
|
|
335
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Send ONE synthetic span to the configured OTLP endpoint and report what the
|
|
362
|
+
* collector said about it.
|
|
363
|
+
*
|
|
364
|
+
* Deliberately not routed through `post()`: command telemetry swallows every
|
|
365
|
+
* error on purpose (it must never disturb the CLI), which is exactly the
|
|
366
|
+
* behaviour that let export die silently. `doctor --telemetry` needs the
|
|
367
|
+
* opposite — the HTTP status, so it can tell "accepted" from "token rejected"
|
|
368
|
+
* from "endpoint unreachable".
|
|
369
|
+
*
|
|
370
|
+
* The probe span is tagged `lorekit.telemetry.probe=true` so it is trivially
|
|
371
|
+
* filtered out of usage dashboards, and carries the same bounded, non-PII
|
|
372
|
+
* attribute set as a real command span.
|
|
373
|
+
*
|
|
374
|
+
* `ok` means the span was INGESTED, not merely that the POST returned 2xx —
|
|
375
|
+
* OTLP/HTTP reports a dropped span as a 2xx carrying
|
|
376
|
+
* `partialSuccess.rejectedSpans`, so a status-only verdict would report a
|
|
377
|
+
* rejected probe as accepted. That false green is the exact failure this probe
|
|
378
|
+
* exists to catch, and the probe sends exactly one span, so any non-zero
|
|
379
|
+
* `rejectedSpans` means the thing we sent was dropped.
|
|
380
|
+
*
|
|
381
|
+
* @returns {Promise<{skipped?: boolean, ok?: boolean, unauthorized?: boolean, httpStatus?: number, rejectedSpans?: number, rejectionMessage?: string, networkError?: string}>}
|
|
382
|
+
*/
|
|
383
|
+
export async function probeTelemetryExport(config, { version = '0.0.0', timeoutMs = 5000 } = {}) {
|
|
384
|
+
if (!config || !config.enabled) return { skipped: true };
|
|
385
|
+
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
const payload = buildTracePayload({
|
|
388
|
+
version,
|
|
389
|
+
name: 'lorekit.cli.doctor.telemetry_probe',
|
|
390
|
+
attributes: {
|
|
391
|
+
'lorekit.cli.command': 'doctor',
|
|
392
|
+
'lorekit.cli.outcome': 'ok',
|
|
393
|
+
'lorekit.telemetry.probe': true,
|
|
394
|
+
},
|
|
395
|
+
startMs: now,
|
|
396
|
+
endMs: now,
|
|
397
|
+
status: 'ok',
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const controller = new AbortController();
|
|
401
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
402
|
+
try {
|
|
403
|
+
const res = await fetch(`${config.endpoint}/v1/traces`, {
|
|
404
|
+
method: 'POST',
|
|
405
|
+
headers: { 'content-type': 'application/json', ...config.headers },
|
|
406
|
+
body: JSON.stringify(payload),
|
|
407
|
+
signal: controller.signal,
|
|
408
|
+
});
|
|
409
|
+
const httpStatus = res.status;
|
|
410
|
+
const accepted = httpStatus >= 200 && httpStatus < 300;
|
|
411
|
+
|
|
412
|
+
// A 2xx only means the collector took the REQUEST. Read the OTLP
|
|
413
|
+
// partial-success envelope to find out whether it took the SPAN.
|
|
414
|
+
// Deliberately one-directional: only a positively parsed, non-zero
|
|
415
|
+
// `rejectedSpans` downgrades the verdict. An empty, non-JSON, or
|
|
416
|
+
// unreadable body leaves the status-based answer alone, so a terse but
|
|
417
|
+
// healthy collector can never be turned into a false red — and the read is
|
|
418
|
+
// wrapped in its own try so a failure here cannot erase an HTTP status we
|
|
419
|
+
// already have by falling into the transport `catch` below.
|
|
420
|
+
let rejectedSpans = 0;
|
|
421
|
+
let rejectionMessage;
|
|
422
|
+
if (accepted) {
|
|
423
|
+
try {
|
|
424
|
+
const partial = JSON.parse(await res.text())?.['partialSuccess'];
|
|
425
|
+
const count = Number(partial?.['rejectedSpans'] ?? 0);
|
|
426
|
+
if (Number.isFinite(count) && count > 0) {
|
|
427
|
+
rejectedSpans = count;
|
|
428
|
+
const message = partial?.['errorMessage'];
|
|
429
|
+
// Collector-controlled free text — bounded before it reaches output.
|
|
430
|
+
if (typeof message === 'string' && message) rejectionMessage = message.slice(0, 200);
|
|
431
|
+
}
|
|
432
|
+
} catch {
|
|
433
|
+
// Unreadable or non-OTLP body — fall back to the status alone.
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
httpStatus,
|
|
439
|
+
ok: accepted && rejectedSpans === 0,
|
|
440
|
+
unauthorized: httpStatus === 401 || httpStatus === 403,
|
|
441
|
+
...(rejectedSpans > 0 ? { rejectedSpans } : {}),
|
|
442
|
+
...(rejectionMessage ? { rejectionMessage } : {}),
|
|
443
|
+
};
|
|
444
|
+
} catch (e) {
|
|
445
|
+
const networkError =
|
|
446
|
+
e && e.name === 'AbortError'
|
|
447
|
+
? `no response within ${timeoutMs}ms`
|
|
448
|
+
: (e && e.message) || String(e);
|
|
449
|
+
return { networkError };
|
|
450
|
+
} finally {
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
336
455
|
// ── Command wrapper ───────────────────────────────────────────────────────────
|
|
337
456
|
|
|
338
457
|
/**
|
|
@@ -364,6 +483,11 @@ function normalizeExitCode(result) {
|
|
|
364
483
|
* counter point. Returns the command's exit code unchanged. Telemetry failures
|
|
365
484
|
* are swallowed — the command result is never affected.
|
|
366
485
|
*
|
|
486
|
+
* The span carries an ERROR status only when the command CRASHED. A command
|
|
487
|
+
* that ran to completion and exited non-zero (a failing `doctor` check, a
|
|
488
|
+
* `lint` finding) reports `lorekit.cli.outcome=failure` on a span the exporter
|
|
489
|
+
* emits as STATUS_CODE_OK — never ERROR.
|
|
490
|
+
*
|
|
367
491
|
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | link | migrate
|
|
368
492
|
* @param {object} args parsed CLI args (read for allow-listed flags only)
|
|
369
493
|
* @param {string} version CLI version (from package.json)
|
|
@@ -406,6 +530,10 @@ export async function traceCommand(command, args, version, run) {
|
|
|
406
530
|
|
|
407
531
|
const startMs = Date.now();
|
|
408
532
|
let exitCode = 0;
|
|
533
|
+
// `outcome` is the command's VERDICT (ok | failure | error); `status` is the
|
|
534
|
+
// SPAN status, and only a crash sets it to error. See the note above the
|
|
535
|
+
// non-zero-exit branch below.
|
|
536
|
+
let outcome = 'ok';
|
|
409
537
|
let status = 'ok';
|
|
410
538
|
let statusMessage;
|
|
411
539
|
let extraAttrs = {};
|
|
@@ -424,11 +552,22 @@ export async function traceCommand(command, args, version, run) {
|
|
|
424
552
|
}
|
|
425
553
|
}
|
|
426
554
|
if (typeof exitCode === 'number' && exitCode !== 0) {
|
|
427
|
-
|
|
428
|
-
|
|
555
|
+
// A non-zero exit is a REPORTED VERDICT, not a fault: `doctor` exits 1
|
|
556
|
+
// because a check it ran came back failing, and `lint` exits 1 because it
|
|
557
|
+
// found what it was asked to look for. Both commands did their job. Only
|
|
558
|
+
// a crash (the catch below) is an error, so `status` stays `'ok'` here —
|
|
559
|
+
// which `buildTracePayload` emits as STATUS_CODE_OK (1), the same status
|
|
560
|
+
// a zero-exit run gets — and the verdict is carried by
|
|
561
|
+
// `lorekit.cli.outcome=failure` +
|
|
562
|
+
// `lorekit.cli.exit_code` — which keeps the CLI's error rate a measure of
|
|
563
|
+
// the CLI being broken rather than of the user's environment being
|
|
564
|
+
// unhealthy. Query the failure verdicts on those attributes, never on the
|
|
565
|
+
// span status.
|
|
566
|
+
outcome = 'failure';
|
|
429
567
|
}
|
|
430
568
|
return exitCode;
|
|
431
569
|
} catch (e) {
|
|
570
|
+
outcome = 'error';
|
|
432
571
|
status = 'error';
|
|
433
572
|
// Record only a bounded, non-PII identifier — NEVER e.message. Node fs /
|
|
434
573
|
// network error messages embed absolute paths (e.g. "ENOENT: ... open
|
|
@@ -447,7 +586,7 @@ export async function traceCommand(command, args, version, run) {
|
|
|
447
586
|
const attributes = commandAttributes({
|
|
448
587
|
command,
|
|
449
588
|
args,
|
|
450
|
-
outcome
|
|
589
|
+
outcome,
|
|
451
590
|
exitCode: typeof exitCode === 'number' ? exitCode : undefined,
|
|
452
591
|
extraAttrs,
|
|
453
592
|
});
|