@lorekit/cli 1.29.3 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lorekit.mjs +8 -2
- package/package.json +1 -1
- package/src/doctor.mjs +127 -2
- package/src/telemetry.mjs +126 -7
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/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
|
/**
|