@ngockhoale/ukit 3.0.11 → 3.1.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/CHANGELOG.md +17 -1
- package/README.md +1 -0
- package/manifests/documentation.yaml +12 -0
- package/package.json +1 -1
- package/scripts/bench/data-foundation.mjs +368 -50
- package/src/cli/commands/doctor.js +232 -3
- package/src/cli/commands/feedback.js +64 -1
- package/src/cli/commands/install.js +18 -0
- package/src/cli/commands/memory.js +42 -37
- package/src/cli/commands/telemetry.js +460 -0
- package/src/cli/index.js +7 -0
- package/src/core/agentRuntime/adapters.js +83 -2
- package/src/core/agentRuntime/diagnostics.js +104 -0
- package/src/core/agentRuntime/supervisor.js +137 -0
- package/src/core/agentRuntime/telemetry.js +204 -0
- package/src/core/memory/memoryEmit.js +131 -0
- package/src/core/memory/memoryHit.js +1 -1
- package/src/core/memory/migrate.js +18 -11
- package/src/core/memory/migrateMapping.js +15 -7
- package/src/core/memory/mutateMemory.js +22 -4
- package/src/core/memory/recordIndex.js +10 -3
- package/src/core/memory/recordStore.js +28 -3
- package/src/core/memory/retrieval.js +79 -38
- package/src/core/memory/store.js +37 -37
- package/src/core/memory/storeV2.js +28 -25
- package/src/core/memory/storeV2Loader.js +2 -2
- package/src/core/observability/adapters/ingest.js +576 -0
- package/src/core/observability/analytics/anomalies.js +415 -0
- package/src/core/observability/analytics/summary.js +16 -1
- package/src/core/observability/emit/config.js +69 -1
- package/src/core/observability/emit/crash.js +434 -0
- package/src/core/observability/emit/lifecycle.js +349 -0
- package/src/core/observability/emit/recorder.js +135 -9
- package/src/core/observability/evaluation/aiPacket.js +52 -10
- package/src/core/observability/evaluation/outcomes.js +95 -0
- package/src/core/observability/evaluation/runner.js +225 -0
- package/src/core/observability/privacy/allowlist.js +23 -3
- package/src/core/observability/schema/compatibility.js +48 -3
- package/src/core/observability/schema/constants.js +5 -0
- package/src/core/observability/schema/registry.js +57 -0
- package/src/core/observability/schema/validate.js +68 -6
- package/src/core/observability/segments/internal.js +42 -8
- package/src/core/observability/segments/readSegments.js +35 -1
- package/src/core/observability/segments/recovery.js +3 -2
- package/src/core/observability/segments/retention.js +137 -33
- package/src/core/observability/support/projector.js +88 -18
- package/src/core/observability/support/provision.js +160 -0
- package/src/core/observability/support/renderer.js +2 -2
- package/src/core/observability/support/schedule.js +174 -0
- package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
- package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
- package/template_project/.claude/hooks/verification-guard.sh +13 -4
- package/template_project/.claude/ukit/runtime/async-lock.mjs +26 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// `ukit telemetry` (TASK-009, SPEC §5 DF2-FR09, §8, §9) — the maintainer
|
|
2
|
+
// surface over the local flight recorder.
|
|
3
|
+
//
|
|
4
|
+
// collect ingestStoredTelemetry → flush → maybeRefreshSupport (canary+)
|
|
5
|
+
// status [--json] stage, segments, counters, support lag, crashes
|
|
6
|
+
// digest [--json] rebuildIndex → anomalies + digest markdown
|
|
7
|
+
// export-support projectSupport now (explicit user act — works below canary)
|
|
8
|
+
// import <path> validateSupportBundle on a received bundle
|
|
9
|
+
// evaluate [--json] run the AI evaluator lane (provider-configured only)
|
|
10
|
+
//
|
|
11
|
+
// Exit codes: 0 success, 2 typed failure, 1 usage error. Read-only surfaces
|
|
12
|
+
// degrade to `unknown`, never to a fabricated zero; written output carries
|
|
13
|
+
// pseudonymous refs only (project dir is proj-<sha256>), no raw paths/secrets.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { inspectRuntimeConfig } from '../../core/runtimeConfig.js';
|
|
20
|
+
import { resolveStage } from '../../core/observability/emit/config.js';
|
|
21
|
+
import {
|
|
22
|
+
getRecorder,
|
|
23
|
+
recorderHealth,
|
|
24
|
+
observabilityRoot,
|
|
25
|
+
segmentsRoot,
|
|
26
|
+
} from '../../core/observability/emit/lifecycle.js';
|
|
27
|
+
import { readCrashReports } from '../../core/observability/emit/crash.js';
|
|
28
|
+
import { ingestStoredTelemetry } from '../../core/observability/adapters/ingest.js';
|
|
29
|
+
import { listSegments, totalSegmentBytes } from '../../core/observability/segments/internal.js';
|
|
30
|
+
import { readSegments } from '../../core/observability/segments/readSegments.js';
|
|
31
|
+
import { rebuildIndex } from '../../core/observability/analytics/rebuild.js';
|
|
32
|
+
import { renderStoreDigest } from '../../core/observability/analytics/storeDigest.js';
|
|
33
|
+
import { renderTraceDigest } from '../../core/observability/analytics/digest.js';
|
|
34
|
+
import {
|
|
35
|
+
projectSupport,
|
|
36
|
+
projectDirName,
|
|
37
|
+
SUPPORT_FORMAT,
|
|
38
|
+
} from '../../core/observability/support/projector.js';
|
|
39
|
+
import { maybeRefreshSupport } from '../../core/observability/support/schedule.js';
|
|
40
|
+
import { resolveSupportDir, SUPPORT_DIR_NAME } from '../../core/observability/support/paths.js';
|
|
41
|
+
import { validateSupportBundle } from '../../core/observability/support/import.js';
|
|
42
|
+
import { runEvaluation } from '../../core/observability/evaluation/runner.js';
|
|
43
|
+
|
|
44
|
+
const HELP_FLAGS = new Set(['--help', '-h', 'help']);
|
|
45
|
+
const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'import', 'evaluate']);
|
|
46
|
+
const JSON_SUBCOMMANDS = new Set(['status', 'digest', 'evaluate']);
|
|
47
|
+
|
|
48
|
+
// The automatic support refresh's canary gate lives in schedule.js via
|
|
49
|
+
// resolveSeamStages (projector seam minimum) — the CLI does not pre-gate.
|
|
50
|
+
|
|
51
|
+
const COLLECT_DEADLINE_MS = 60_000;
|
|
52
|
+
const FLUSH_DEADLINE_MS = 15_000;
|
|
53
|
+
const MAX_TRACE_DIGESTS = 8;
|
|
54
|
+
const MAX_ANOMALY_LINES = 20;
|
|
55
|
+
|
|
56
|
+
function printUsage() {
|
|
57
|
+
console.log('Usage: ukit telemetry <command> [options]');
|
|
58
|
+
console.log('');
|
|
59
|
+
console.log('Commands:');
|
|
60
|
+
console.log(' collect Ingest stored telemetry into the flight recorder,');
|
|
61
|
+
console.log(' flush it, and refresh the support view (stage canary+)');
|
|
62
|
+
console.log(' status Show stage, segments, counters, support lag, crashes');
|
|
63
|
+
console.log(' digest Rebuild the trace index; print anomalies + digest markdown');
|
|
64
|
+
console.log(' export-support Write the sanitized UKit Support bundle now');
|
|
65
|
+
console.log(' import <path> Validate a received support bundle (zip or directory)');
|
|
66
|
+
console.log(' evaluate Run the AI evaluator lane (needs observability.evaluator config)');
|
|
67
|
+
console.log('');
|
|
68
|
+
console.log('Options:');
|
|
69
|
+
console.log(' --json JSON output (status, digest, evaluate)');
|
|
70
|
+
console.log(' -h, --help Show this help');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Invalid/missing config must not break the read path — stage resolves 'off'
|
|
74
|
+
// on an empty object, which is the fail-safe posture everywhere else.
|
|
75
|
+
async function loadConfig(projectRoot) {
|
|
76
|
+
try {
|
|
77
|
+
const inspection = await inspectRuntimeConfig(projectRoot, { homeDir: os.homedir() });
|
|
78
|
+
return inspection && inspection.config && typeof inspection.config === 'object'
|
|
79
|
+
? inspection.config
|
|
80
|
+
: {};
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Bundle-local pseudonym convention shared with the projector: the support
|
|
87
|
+
// view is addressed by hashed key, never by the project path — the
|
|
88
|
+
// projectDirName helper lives in support/projector.js.
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
function fmt(value) {
|
|
92
|
+
return value === null || value === undefined ? 'unknown' : String(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- status ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
async function supportLagMs(projectRoot) {
|
|
98
|
+
const resolved = await resolveSupportDir({});
|
|
99
|
+
if (!resolved.ok) return null;
|
|
100
|
+
const manifestPath = path.join(resolved.dir, projectDirName(projectRoot), 'manifest.json');
|
|
101
|
+
try {
|
|
102
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
103
|
+
if (!manifest || manifest.format !== SUPPORT_FORMAT) return null;
|
|
104
|
+
const generated = Date.parse(manifest.generated_at);
|
|
105
|
+
return Number.isFinite(generated) ? Math.max(0, Date.now() - generated) : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function statusCommand({ projectRoot, config, json }) {
|
|
112
|
+
const stage = resolveStage(config);
|
|
113
|
+
// getRecorder memoizes per root; in a one-shot CLI its counters read zero —
|
|
114
|
+
// that is honest per-process state, not a store claim.
|
|
115
|
+
getRecorder({ projectRoot, config });
|
|
116
|
+
const health = recorderHealth({ projectRoot });
|
|
117
|
+
|
|
118
|
+
const root = segmentsRoot(projectRoot);
|
|
119
|
+
const listing = await listSegments(root);
|
|
120
|
+
let active = null;
|
|
121
|
+
let sealed = null;
|
|
122
|
+
let bytes = null;
|
|
123
|
+
let records = null;
|
|
124
|
+
let readGaps = null;
|
|
125
|
+
if (listing.ok) {
|
|
126
|
+
active = listing.active ? 1 : 0;
|
|
127
|
+
sealed = listing.sealed.length;
|
|
128
|
+
bytes = totalSegmentBytes(listing);
|
|
129
|
+
const stream = readSegments(root);
|
|
130
|
+
let n = 0;
|
|
131
|
+
for await (const _record of stream) n += 1;
|
|
132
|
+
records = n;
|
|
133
|
+
const cov = stream.coverage;
|
|
134
|
+
readGaps =
|
|
135
|
+
(cov.corrupt_lines || 0) +
|
|
136
|
+
(cov.quarantined_segments || 0) +
|
|
137
|
+
(cov.expired_segments || 0) +
|
|
138
|
+
(Array.isArray(cov.degraded) ? cov.degraded.length : 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const lag = await supportLagMs(projectRoot);
|
|
142
|
+
const crashes = readCrashReports(observabilityRoot(projectRoot));
|
|
143
|
+
const crashCount = crashes.ok ? crashes.crashes.length : null;
|
|
144
|
+
const crashDetail = crashes.ok
|
|
145
|
+
? { parsed: crashes.coverage.parsed, corrupt: crashes.coverage.corrupt, expired: crashes.coverage.expired }
|
|
146
|
+
: { error: crashes.reason };
|
|
147
|
+
|
|
148
|
+
if (json) {
|
|
149
|
+
console.log(
|
|
150
|
+
JSON.stringify(
|
|
151
|
+
{
|
|
152
|
+
stage,
|
|
153
|
+
segments: { active, sealed, bytes, records, read_gaps: readGaps },
|
|
154
|
+
counters: {
|
|
155
|
+
emitted: health.emitted ?? 0,
|
|
156
|
+
dropped: health.dropped ?? 0,
|
|
157
|
+
flushed: health.flushed ?? 0,
|
|
158
|
+
queue_depth: health.queue_depth ?? 0,
|
|
159
|
+
queue_bound: health.queue_bound ?? 0,
|
|
160
|
+
flushes: health.flushes ?? 0,
|
|
161
|
+
write_failures: health.write_failures ?? 0,
|
|
162
|
+
},
|
|
163
|
+
last_flush: health.last_flush ?? null,
|
|
164
|
+
support_lag_ms: lag,
|
|
165
|
+
crashes: { count: crashCount, ...crashDetail },
|
|
166
|
+
},
|
|
167
|
+
null,
|
|
168
|
+
2,
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log('telemetry status');
|
|
175
|
+
console.log(` stage: ${stage}`);
|
|
176
|
+
console.log(` segments: active=${fmt(active)} sealed=${fmt(sealed)} bytes=${fmt(bytes)}`);
|
|
177
|
+
console.log(
|
|
178
|
+
records === 0 && active === 0 && sealed === 0
|
|
179
|
+
? ' records: 0 (no records retained)'
|
|
180
|
+
: ` records: ${fmt(records)}`,
|
|
181
|
+
);
|
|
182
|
+
console.log(
|
|
183
|
+
` counters: emitted=${health.emitted ?? 0} dropped=${health.dropped ?? 0} ` +
|
|
184
|
+
`flushed=${health.flushed ?? 0} queue=${health.queue_depth ?? 0}/${health.queue_bound ?? 0}`,
|
|
185
|
+
);
|
|
186
|
+
const lastFlush = health.last_flush;
|
|
187
|
+
console.log(
|
|
188
|
+
lastFlush
|
|
189
|
+
? ` last_flush: ${lastFlush.status} written=${lastFlush.written} dropped=${lastFlush.dropped}`
|
|
190
|
+
: ' last_flush: none',
|
|
191
|
+
);
|
|
192
|
+
console.log(` support_lag: ${fmt(lag === null ? null : `${lag}ms`)}`);
|
|
193
|
+
console.log(
|
|
194
|
+
crashCount === null
|
|
195
|
+
? ` crashes: unknown (${crashDetail.error})`
|
|
196
|
+
: ` crashes: ${crashCount}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- collect -----------------------------------------------------------------
|
|
201
|
+
async function collectCommand({ projectRoot, config }) {
|
|
202
|
+
const stage = resolveStage(config);
|
|
203
|
+
const recorder = getRecorder({ projectRoot, config });
|
|
204
|
+
console.log(`telemetry collect: stage=${stage}`);
|
|
205
|
+
|
|
206
|
+
// Provision the recorder root: appendRecord's resolveRoot requires the
|
|
207
|
+
// parent to already exist, so a project whose .ukit was created by an
|
|
208
|
+
// older install has no observability dir yet. UKit-owned path, mkdir -p.
|
|
209
|
+
try {
|
|
210
|
+
await fs.mkdir(segmentsRoot(projectRoot), { recursive: true });
|
|
211
|
+
} catch {
|
|
212
|
+
// Unwritable roots surface as typed degrade on the flush/ingest lanes.
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ingest = await ingestStoredTelemetry({
|
|
216
|
+
projectRoot,
|
|
217
|
+
recorder,
|
|
218
|
+
deadlineMs: COLLECT_DEADLINE_MS,
|
|
219
|
+
});
|
|
220
|
+
if (!ingest || ingest.ok !== true) {
|
|
221
|
+
console.log(`collect: failed reason=${ingest && ingest.reason ? ingest.reason : 'unknown'}`);
|
|
222
|
+
process.exitCode = 2;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
for (const [name, cov] of Object.entries(ingest.coverage ?? {})) {
|
|
226
|
+
const reason = cov.reason ? ` reason=${cov.reason}` : '';
|
|
227
|
+
console.log(
|
|
228
|
+
` ${name}: read=${cov.read ?? 0} emitted=${cov.emitted ?? 0} ` +
|
|
229
|
+
`rejected=${cov.rejected ?? 0} skipped=${cov.skipped ?? 0}${reason}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const flushRes = await recorder.flush({ deadlineMs: FLUSH_DEADLINE_MS });
|
|
234
|
+
const flushReason = flushRes.reason ? ` reason=${flushRes.reason}` : '';
|
|
235
|
+
console.log(
|
|
236
|
+
` flush: ${flushRes.status} written=${flushRes.written ?? 0} ` +
|
|
237
|
+
`dropped=${flushRes.dropped ?? 0} remaining=${flushRes.remaining ?? 0}${flushReason}`,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// DF2-FR12: automatic support refresh goes through the schedule seam —
|
|
241
|
+
// projector stage gate + ≥15min interval gate + stamp. A manual
|
|
242
|
+
// `export-support` is an explicit user act and bypasses both gates.
|
|
243
|
+
const support = await maybeRefreshSupport({ projectRoot, config, recorder });
|
|
244
|
+
console.log(
|
|
245
|
+
` support: ${support.status}` +
|
|
246
|
+
(support.status === 'refreshed' ? ` bytes=${support.bytes ?? 0}` : '') +
|
|
247
|
+
(support.reason ? ` reason=${support.reason}` : ''),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Typed failure is about the collect lane itself: a degraded ingest or a
|
|
251
|
+
// failed flush lost/queued records. Support refresh is the optional
|
|
252
|
+
// add-on (DF2-FR12) — its degrade is reported, not promoted to exit 2;
|
|
253
|
+
// `export-support` is the command whose own mission is the projection.
|
|
254
|
+
const failed =
|
|
255
|
+
ingest.status === 'degraded' ||
|
|
256
|
+
ingest.status === 'partial' ||
|
|
257
|
+
flushRes.status === 'error' ||
|
|
258
|
+
flushRes.status === 'partial';
|
|
259
|
+
const cursorNote = ingest.cursor_error ? ` cursor_error=${ingest.cursor_error}` : '';
|
|
260
|
+
console.log(`status: ${failed ? 'degraded' : ingest.status}${cursorNote}`);
|
|
261
|
+
if (failed) process.exitCode = 2;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- digest ------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
// Coarse per-trace anomaly roll-up mirroring digest.js anomalyLines: the
|
|
267
|
+
// detector set is the summary fields, not re-scanned records.
|
|
268
|
+
function traceAnomalies(summary) {
|
|
269
|
+
const s = summary && typeof summary === 'object' ? summary : {};
|
|
270
|
+
const kinds = [];
|
|
271
|
+
if (s.telemetry_complete === false) kinds.push('telemetry_incomplete');
|
|
272
|
+
const drops = s.drops && typeof s.drops === 'object' ? s.drops : {};
|
|
273
|
+
if ((drops.dropped_count ?? 0) > 0) kinds.push(`drops=${drops.dropped_count}`);
|
|
274
|
+
const retries = s.retries && typeof s.retries === 'object' ? s.retries : {};
|
|
275
|
+
if ((retries.failed_spans ?? 0) > 0) kinds.push(`failed_spans=${retries.failed_spans}`);
|
|
276
|
+
if ((retries.retries ?? 0) > 0) kinds.push(`retries=${retries.retries}`);
|
|
277
|
+
const cov = s.coverage && typeof s.coverage === 'object' ? s.coverage : {};
|
|
278
|
+
if ((cov.open_spans ?? 0) > 0) kinds.push(`open_spans=${cov.open_spans}`);
|
|
279
|
+
if ((cov.orphan_spans ?? 0) > 0) kinds.push(`orphan_spans=${cov.orphan_spans}`);
|
|
280
|
+
if ((cov.duplicate_ends ?? 0) > 0) kinds.push(`duplicate_ends=${cov.duplicate_ends}`);
|
|
281
|
+
const spans = Array.isArray(s.spans) ? s.spans : [];
|
|
282
|
+
const failed = spans.filter(
|
|
283
|
+
(sp) => sp && (sp.status === 'failed' || sp.status === 'blocked'),
|
|
284
|
+
).length;
|
|
285
|
+
if (failed > 0) kinds.push(`span_status_failed=${failed}`);
|
|
286
|
+
return kinds;
|
|
287
|
+
}
|
|
288
|
+
async function digestCommand({ projectRoot, json }) {
|
|
289
|
+
const result = await rebuildIndex(segmentsRoot(projectRoot));
|
|
290
|
+
const summaries = Array.isArray(result.summaries) ? result.summaries : [];
|
|
291
|
+
const anomalies = summaries
|
|
292
|
+
.map((s) => ({ trace_id: s && s.trace_id ? s.trace_id : 'unknown', kinds: traceAnomalies(s) }))
|
|
293
|
+
.filter((a) => a.kinds.length > 0);
|
|
294
|
+
|
|
295
|
+
if (json) {
|
|
296
|
+
console.log(
|
|
297
|
+
JSON.stringify(
|
|
298
|
+
{
|
|
299
|
+
ok: result.ok === true,
|
|
300
|
+
coverage: result.coverage ?? {},
|
|
301
|
+
anomalies,
|
|
302
|
+
traces_total: summaries.length,
|
|
303
|
+
},
|
|
304
|
+
null,
|
|
305
|
+
2,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const out = ['# telemetry digest', '', '## anomalies'];
|
|
312
|
+
const shown = anomalies.slice(0, MAX_ANOMALY_LINES);
|
|
313
|
+
if (shown.length === 0) {
|
|
314
|
+
out.push('- none');
|
|
315
|
+
} else {
|
|
316
|
+
for (const a of shown) out.push(`- ${a.trace_id} · ${a.kinds.join(', ')}`);
|
|
317
|
+
if (anomalies.length > shown.length) {
|
|
318
|
+
out.push(`- … truncated: ${anomalies.length - shown.length} anomalous trace(s) omitted`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
out.push('', renderStoreDigest(result));
|
|
322
|
+
|
|
323
|
+
// Per-trace digests for anomalous traces only — the store digest already
|
|
324
|
+
// carries every summary line; full per-trace dumps belong in the bundle.
|
|
325
|
+
const anomalousSummaries = anomalies.slice(0, MAX_TRACE_DIGESTS).map(
|
|
326
|
+
(a) => summaries.find((s) => (s && s.trace_id ? s.trace_id : 'unknown') === a.trace_id),
|
|
327
|
+
);
|
|
328
|
+
for (const s of anomalousSummaries) {
|
|
329
|
+
if (!s) continue;
|
|
330
|
+
out.push('', renderTraceDigest(s, { coverage: result.coverage }));
|
|
331
|
+
}
|
|
332
|
+
if (anomalies.length > MAX_TRACE_DIGESTS) {
|
|
333
|
+
out.push('', `_${anomalies.length - MAX_TRACE_DIGESTS} more anomalous trace digest(s) omitted_`);
|
|
334
|
+
}
|
|
335
|
+
console.log(out.join('\n'));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// --- export-support / import --------------------------------------------------
|
|
339
|
+
|
|
340
|
+
async function exportSupportCommand({ projectRoot, config }) {
|
|
341
|
+
// Manual export is an explicit user act: the stage gate governs automatic
|
|
342
|
+
// projection, so a stage-off config is lifted to 'default' for this call.
|
|
343
|
+
// Everything else (sanitizer, pseudonyms, caps) runs unchanged.
|
|
344
|
+
const effective =
|
|
345
|
+
resolveStage(config) === 'off'
|
|
346
|
+
? { ...config, observability: { ...(config?.observability ?? {}), stage: 'default' } }
|
|
347
|
+
: config;
|
|
348
|
+
let res;
|
|
349
|
+
try {
|
|
350
|
+
res = await projectSupport(
|
|
351
|
+
{ root: segmentsRoot(projectRoot) },
|
|
352
|
+
{ config: effective, projectRoot },
|
|
353
|
+
);
|
|
354
|
+
} catch {
|
|
355
|
+
res = { status: 'degraded', reason: 'projector_error', bytes: 0 };
|
|
356
|
+
}
|
|
357
|
+
if (res.status === 'written') {
|
|
358
|
+
// Pseudonymous location only — the absolute path is user-private.
|
|
359
|
+
console.log(
|
|
360
|
+
`export-support: written bytes=${res.bytes ?? 0} ` +
|
|
361
|
+
`dir=${SUPPORT_DIR_NAME}/${projectDirName(projectRoot)}`,
|
|
362
|
+
);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
console.log(
|
|
366
|
+
`export-support: ${res.status}` +
|
|
367
|
+
(res.reason ? ` reason=${res.reason}` : '') +
|
|
368
|
+
` bytes=${res.bytes ?? 0}`,
|
|
369
|
+
);
|
|
370
|
+
process.exitCode = 2;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function importCommand({ bundlePath }) {
|
|
374
|
+
const res = await validateSupportBundle({ path: bundlePath });
|
|
375
|
+
if (!res.ok) {
|
|
376
|
+
// Typed reason verbatim — the maintainer keys remediation off it.
|
|
377
|
+
console.log(`import: ${res.reason}`);
|
|
378
|
+
process.exitCode = 2;
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const report = res.bundle.report;
|
|
382
|
+
console.log(
|
|
383
|
+
`import: ok files=${report.files} records=${report.records} ` +
|
|
384
|
+
`untrusted_prose=${report.untrusted_prose_present} ` +
|
|
385
|
+
`injection_signals=${report.injection_signals.length}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// --- evaluate -----------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
// DF2-FR14: the evaluator lane runs only when the operator configured
|
|
392
|
+
// observability.evaluator — skipped/no-provider is the honest default,
|
|
393
|
+
// never a failure. Degraded is a typed failure (exit 2), matching collect.
|
|
394
|
+
async function evaluateCommand({ projectRoot, config, json }) {
|
|
395
|
+
const result = await runEvaluation({ root: observabilityRoot(projectRoot), config });
|
|
396
|
+
if (json) {
|
|
397
|
+
console.log(JSON.stringify(result, null, 2));
|
|
398
|
+
} else {
|
|
399
|
+
const reason = result.reason ? ` reason=${result.reason}` : '';
|
|
400
|
+
console.log(
|
|
401
|
+
`telemetry evaluate: ${result.status}${reason} ` +
|
|
402
|
+
`packet_bytes=${result.packet_bytes ?? 0} ` +
|
|
403
|
+
`findings_accepted=${result.findings_accepted ?? 0} ` +
|
|
404
|
+
`findings_rejected=${result.findings_rejected ?? 0} ` +
|
|
405
|
+
`kb_records=${(result.kb_ids ?? []).length}`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (result.status === 'degraded') process.exitCode = 2;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// --- dispatch ----------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
|
|
414
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
415
|
+
const sub = (args[0] ?? '').toLowerCase();
|
|
416
|
+
|
|
417
|
+
if (args.length === 0 || HELP_FLAGS.has(sub)) {
|
|
418
|
+
printUsage();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (!SUBCOMMANDS.has(sub)) {
|
|
422
|
+
console.error(`[UKit] Unknown telemetry subcommand: ${sub}`);
|
|
423
|
+
printUsage();
|
|
424
|
+
process.exitCode = 1;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const rest = args.slice(1);
|
|
429
|
+
if (rest.some((a) => HELP_FLAGS.has(a))) {
|
|
430
|
+
printUsage();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
434
|
+
const flags = rest.filter((a) => a.startsWith('-'));
|
|
435
|
+
const allowed = JSON_SUBCOMMANDS.has(sub) ? new Set(['--json']) : new Set();
|
|
436
|
+
const unknownFlags = flags.filter((f) => !allowed.has(f));
|
|
437
|
+
const maxPositional = sub === 'import' ? 1 : 0;
|
|
438
|
+
|
|
439
|
+
if (unknownFlags.length > 0) {
|
|
440
|
+
console.error(`[UKit] Unknown telemetry flag(s): ${unknownFlags.join(', ')}`);
|
|
441
|
+
printUsage();
|
|
442
|
+
process.exitCode = 1;
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (positional.length > maxPositional || (sub === 'import' && positional.length === 0)) {
|
|
446
|
+
printUsage();
|
|
447
|
+
process.exitCode = 1;
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const config = await loadConfig(projectRoot);
|
|
452
|
+
const json = flags.includes('--json');
|
|
453
|
+
|
|
454
|
+
if (sub === 'collect') return collectCommand({ projectRoot, config });
|
|
455
|
+
if (sub === 'status') return statusCommand({ projectRoot, config, json });
|
|
456
|
+
if (sub === 'digest') return digestCommand({ projectRoot, json });
|
|
457
|
+
if (sub === 'export-support') return exportSupportCommand({ projectRoot, config });
|
|
458
|
+
if (sub === 'evaluate') return evaluateCommand({ projectRoot, config, json });
|
|
459
|
+
return importCommand({ bundlePath: positional[0] });
|
|
460
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { runMemory } from './commands/memory.js';
|
|
|
8
8
|
import { runUpdate } from './commands/update.js';
|
|
9
9
|
import { runCode } from './commands/code.js';
|
|
10
10
|
import { runMetrics } from './commands/metrics.js';
|
|
11
|
+
import { runTelemetry } from './commands/telemetry.js';
|
|
11
12
|
import { runFeedback } from './commands/feedback.js';
|
|
12
13
|
import { runPlaybook } from './commands/playbook.js';
|
|
13
14
|
|
|
@@ -78,6 +79,11 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
|
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
if (command === 'telemetry') {
|
|
83
|
+
await runTelemetry({ projectRoot, packageRoot, argv: commandArgv });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
81
87
|
if (command === 'feedback') {
|
|
82
88
|
await runFeedback({ projectRoot, argv: commandArgv });
|
|
83
89
|
return;
|
|
@@ -118,6 +124,7 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
|
|
|
118
124
|
console.log(' memory Inspect shared UKit memory');
|
|
119
125
|
console.log(' playbook List/show playbooks (project > user > builtin)');
|
|
120
126
|
console.log(' metrics Telemetry roll-up (route outcomes, failure patterns, memory)');
|
|
127
|
+
console.log(' telemetry Flight recorder (collect/status/digest/export-support/import/evaluate)');
|
|
121
128
|
console.log(' feedback Record or list wrong-route feedback labels');
|
|
122
129
|
console.log(' update Upgrade the global UKit CLI to the latest version');
|
|
123
130
|
console.log(' version Show UKit version');
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
* `PASSWORD=...`, `AUTHORIZATION: ...`) are redacted before any payload
|
|
23
23
|
* or digest is built.
|
|
24
24
|
*
|
|
25
|
-
* Pure module
|
|
26
|
-
*
|
|
25
|
+
* Pure module for summarization; the DF2-FR07 tool-span emit helpers
|
|
26
|
+
* (beginToolSpan/endToolSpan) own `tool.*` records and emit through the
|
|
27
|
+
* recorder via telemetry.js — synchronous, never-throw, stage-gated
|
|
28
|
+
* inside emit(). `now` and `artifactStore` are injectable for tests.
|
|
27
29
|
*
|
|
28
30
|
* `artifactStore` protocol (injected): `put(buffer)` →
|
|
29
31
|
* `Promise<string | { path: string }>`; the returned ref is recorded on
|
|
@@ -37,6 +39,12 @@ import {
|
|
|
37
39
|
CONTRACT_VERSION,
|
|
38
40
|
MAX_SAFE_PAYLOAD_BYTES,
|
|
39
41
|
} from './contract.js';
|
|
42
|
+
import {
|
|
43
|
+
makeSpanContext,
|
|
44
|
+
emitSpanRecord,
|
|
45
|
+
durationMs,
|
|
46
|
+
registryCode,
|
|
47
|
+
} from './telemetry.js';
|
|
40
48
|
|
|
41
49
|
export const TOOL_PROFILES = Object.freeze(['generic', 'vitest']);
|
|
42
50
|
|
|
@@ -176,6 +184,59 @@ async function storeArtifact(artifactStore, buf) {
|
|
|
176
184
|
}
|
|
177
185
|
}
|
|
178
186
|
|
|
187
|
+
// --- DF2-FR07: tool.* span records (TASK-008) ------------------------------
|
|
188
|
+
// A tool span is a CHILD of the run's execution span — beginToolSpan is
|
|
189
|
+
// only ever called with a parent ctx emitted in the same trace, so
|
|
190
|
+
// parent_span_id always resolves (validator causality rule). Tool name is
|
|
191
|
+
// an opaque code string (`spec.toolName`, profile, or operationId) — raw
|
|
192
|
+
// command/argv/output are DENIED_PAYLOAD_FIELDS and never enter payloads.
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Open a `tool.started` span under a run ctx. Returns the child span ctx
|
|
196
|
+
* (with start_ns) or null when telemetry is off — callers branch once on
|
|
197
|
+
* null, the emit path itself is never reached.
|
|
198
|
+
* @param {object|null} telemetry resolved lane (telemetry.js)
|
|
199
|
+
* @param {object} parent run span ctx {trace_id, span_id, execution_id, agent_id?}
|
|
200
|
+
* @param {object} [opts] {toolName?, operationId?, attempt?}
|
|
201
|
+
*/
|
|
202
|
+
export function beginToolSpan(telemetry, parent, { toolName, operationId, attempt } = {}) {
|
|
203
|
+
if (telemetry == null || parent == null) return null;
|
|
204
|
+
const ctx = makeSpanContext(parent);
|
|
205
|
+
const name = typeof toolName === 'string' && toolName.length > 0
|
|
206
|
+
? toolName
|
|
207
|
+
: String(operationId ?? 'tool');
|
|
208
|
+
const payload = { operation: name, tool_name: name };
|
|
209
|
+
if (Number.isInteger(attempt)) payload.attempt = attempt;
|
|
210
|
+
emitSpanRecord(telemetry, ctx, 'tool.started', payload);
|
|
211
|
+
return ctx;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Close a tool span. `status` is 'completed' | 'failed' | 'blocked'; a
|
|
216
|
+
* failure carries a registry error_code (UPPER_SNAKE only). Duration is
|
|
217
|
+
* measured from the begin() ctx's start_ns on the same injected clock.
|
|
218
|
+
* @param {object|null} telemetry
|
|
219
|
+
* @param {object} ctx span ctx returned by beginToolSpan
|
|
220
|
+
* @param {object} [opts] {status?, errorCode?, toolName?, operationId?, exitCode?}
|
|
221
|
+
*/
|
|
222
|
+
export function endToolSpan(telemetry, ctx, { status = 'completed', errorCode, toolName, operationId, exitCode } = {}) {
|
|
223
|
+
if (telemetry == null || ctx == null) return;
|
|
224
|
+
const semantic = `tool.${status === 'completed' ? 'completed' : status === 'blocked' ? 'blocked' : 'failed'}`;
|
|
225
|
+
const name = typeof toolName === 'string' && toolName.length > 0
|
|
226
|
+
? toolName
|
|
227
|
+
: String(operationId ?? 'tool');
|
|
228
|
+
const payload = {
|
|
229
|
+
operation: name,
|
|
230
|
+
tool_name: name,
|
|
231
|
+
duration_ms: durationMs(ctx.start_ns, ctx.nowNs ? ctx.nowNs() : null),
|
|
232
|
+
};
|
|
233
|
+
if (semantic === 'tool.failed') {
|
|
234
|
+
payload.error_code = registryCode(errorCode);
|
|
235
|
+
}
|
|
236
|
+
if (Number.isInteger(exitCode)) payload.exit_code = exitCode;
|
|
237
|
+
emitSpanRecord(telemetry, ctx, semantic, payload);
|
|
238
|
+
}
|
|
239
|
+
|
|
179
240
|
/**
|
|
180
241
|
* @param {object} args
|
|
181
242
|
* @param {AsyncIterable<Buffer|string>} [args.stream] async-iterable output.
|
|
@@ -185,6 +246,11 @@ async function storeArtifact(artifactStore, buf) {
|
|
|
185
246
|
* @param {number} [args.exitCode] child exit code when known.
|
|
186
247
|
* @param {() => string} [args.now] injectable clock (ISO string).
|
|
187
248
|
* @param {{put: (buf: Buffer) => Promise<string|{path:string}>}} [args.artifactStore]
|
|
249
|
+
* @param {object} [args.telemetry] resolved telemetry lane (DF2-FR07).
|
|
250
|
+
* @param {object} [args.toolSpan] open span ctx from beginToolSpan — when
|
|
251
|
+
* supplied, adaptOutput closes it as tool.completed/tool.failed using
|
|
252
|
+
* the summarized status (the adapter boundary IS the tool's outcome:
|
|
253
|
+
* 'failed' status or a throw inside adaptation → tool.failed).
|
|
188
254
|
* @returns {Promise<{events: object[], artifactRefs: string[],
|
|
189
255
|
* parseStatus: 'parsed'|'generic'|'parse_fallback'}>}
|
|
190
256
|
*/
|
|
@@ -196,6 +262,8 @@ export async function adaptOutput({
|
|
|
196
262
|
exitCode,
|
|
197
263
|
now = () => new Date().toISOString(),
|
|
198
264
|
artifactStore,
|
|
265
|
+
telemetry,
|
|
266
|
+
toolSpan,
|
|
199
267
|
} = {}) {
|
|
200
268
|
const raw = await collectInput({ stream, chunks });
|
|
201
269
|
const redacted = redactSecrets(raw.toString('utf8'));
|
|
@@ -250,5 +318,18 @@ export async function adaptOutput({
|
|
|
250
318
|
};
|
|
251
319
|
}
|
|
252
320
|
|
|
321
|
+
// DF2-FR07: the summarizer owns the tool invocation's terminal record.
|
|
322
|
+
// status 'failed' (or an unparseable/indeterminate verdict never fakes
|
|
323
|
+
// a completion — indeterminate still closes 'completed' because the
|
|
324
|
+
// invocation itself finished; the payload carries the real status).
|
|
325
|
+
if (toolSpan) {
|
|
326
|
+
endToolSpan(telemetry, toolSpan, {
|
|
327
|
+
status: safePayload.status === 'failed' ? 'failed' : 'completed',
|
|
328
|
+
errorCode: safePayload.status === 'failed' ? 'HOST_BLIND' : undefined,
|
|
329
|
+
toolName: safePayload.profile,
|
|
330
|
+
operationId,
|
|
331
|
+
exitCode,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
253
334
|
return { events: [event], artifactRefs, parseStatus };
|
|
254
335
|
}
|