@ngockhoale/ukit 3.0.5 → 3.0.7
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 -0
- package/package.json +1 -1
- package/scripts/bench/data-foundation.mjs +562 -0
- package/src/core/observability/adapters/common.js +75 -0
- package/src/core/observability/adapters/contextAdapter.js +55 -0
- package/src/core/observability/adapters/decisionAdapter.js +61 -0
- package/src/core/observability/adapters/routeAdapter.js +135 -0
- package/src/core/observability/analytics/digest.js +186 -0
- package/src/core/observability/analytics/fingerprints.js +126 -0
- package/src/core/observability/analytics/opportunities.js +329 -0
- package/src/core/observability/analytics/rebuild.js +56 -0
- package/src/core/observability/analytics/summary.js +298 -0
- package/src/core/observability/emit/config.js +29 -0
- package/src/core/observability/emit/recorder.js +297 -0
- package/src/core/observability/evaluation/aiPacket.js +230 -0
- package/src/core/observability/evaluation/optimizationKnowledge.js +172 -0
- package/src/core/observability/evaluation/replay.js +143 -0
- package/src/core/observability/evaluation/scorecard.js +445 -0
- package/src/core/observability/privacy/allowlist.js +185 -0
- package/src/core/observability/privacy/redaction.js +113 -0
- package/src/core/observability/privacy/sanitizeForSupport.js +133 -0
- package/src/core/observability/privacy/sanitizeObserved.js +134 -0
- package/src/core/observability/rollout.js +155 -0
- package/src/core/observability/schema/constants.js +66 -0
- package/src/core/observability/schema/registry.js +223 -0
- package/src/core/observability/schema/validate.js +227 -0
- package/src/core/observability/segments/internal.js +241 -0
- package/src/core/observability/segments/readSegments.js +215 -0
- package/src/core/observability/segments/recovery.js +123 -0
- package/src/core/observability/segments/retention.js +381 -0
- package/src/core/observability/support/import.js +402 -0
- package/src/core/observability/support/manifest.js +135 -0
- package/src/core/observability/support/paths.js +94 -0
- package/src/core/observability/support/projector.js +483 -0
- package/src/core/observability/support/renderer.js +130 -0
- package/src/core/observability/support/retention.js +155 -0
- package/src/core/runtimeConfig.js +6 -3
- package/src/decision/client.js +11 -3
- package/template_project/.claude/ukit/index/unic-decision.mjs +6 -2
- package/template_project/.omp/RULES.md +6 -6
- package/template_project/.omp/config.yml +6 -0
- package/template_project/docs/UKIT_INTERNALS.md +9 -0
- package/template_project/instructions/overlays/omp-rules.md +6 -6
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js (TASK-007, SPEC §8 / §13) — observability stage gate.
|
|
3
|
+
*
|
|
4
|
+
* resolveStage(config) → 'off' | 'shadow' | 'canary' | 'default'
|
|
5
|
+
*
|
|
6
|
+
* Reads `observability.stage` from the project runtime config object.
|
|
7
|
+
* Stages promote off → shadow → canary → default; the kill switch is
|
|
8
|
+
* setting the value back to 'off'. Absent config, absent key, non-object
|
|
9
|
+
* intermediate, or malformed stage value all resolve to 'off' — a bad
|
|
10
|
+
* config can never promote the recorder.
|
|
11
|
+
*
|
|
12
|
+
* Same semantics as resolveConfigStage(config, 'observability.stage') in
|
|
13
|
+
* src/core/runtimeConfig.js; kept dependency-free here so the emit hot
|
|
14
|
+
* path never pulls the runtime-config module graph (and its package.json
|
|
15
|
+
* read) into a hook deadline. VALID_ROUTE_STAGES is the frozen vocabulary
|
|
16
|
+
* this mirrors — a stage rename is a breaking config change either way.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const VALID_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
|
|
20
|
+
|
|
21
|
+
function isPlainObject(value) {
|
|
22
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveStage(config = null) {
|
|
26
|
+
const node = isPlainObject(config) ? config.observability : undefined;
|
|
27
|
+
const stage = isPlainObject(node) ? node.stage : undefined;
|
|
28
|
+
return VALID_STAGES.has(stage) ? stage : 'off';
|
|
29
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recorder.js (TASK-007, SPEC §5 DF-FR04/DF-FR08, §8) — the single semantic
|
|
3
|
+
* emission seam.
|
|
4
|
+
*
|
|
5
|
+
* createRecorder({ root, config, queueSize?, writer?, clock? })
|
|
6
|
+
* → { emit, startSpan, endSpan, flush, health }
|
|
7
|
+
*
|
|
8
|
+
* Pipeline per emit(): stage gate → fill missing envelope fields →
|
|
9
|
+
* validateSemanticRecord → sanitizeObserved → bounded in-memory queue.
|
|
10
|
+
* Nothing reaches disk from emit(); flush() drains the queue through the
|
|
11
|
+
* segment store's appendRecord (TASK-006) — the recorder composes the
|
|
12
|
+
* writer, it never reimplements writes.
|
|
13
|
+
*
|
|
14
|
+
* Guarantees (DF-FR04):
|
|
15
|
+
* - emit() is synchronous, non-blocking, and never throws — every failure
|
|
16
|
+
* is a typed { status: 'dropped', reason } plus a health() counter.
|
|
17
|
+
* - The queue is bounded; at the bound the OLDEST queued record is
|
|
18
|
+
* evicted (dropped_oldest++) and emit reports { status: 'dropped',
|
|
19
|
+
* reason: 'queue-full' } so the caller sees the loss — the incoming
|
|
20
|
+
* record is still queued (flight-recorder policy: newest data wins).
|
|
21
|
+
* - flush({ deadlineMs }) races every write against the remaining
|
|
22
|
+
* deadline and returns 'partial' rather than overrunning; a writer
|
|
23
|
+
* failure returns a typed 'error' FlushResult and never fails the
|
|
24
|
+
* user task.
|
|
25
|
+
* - No LLM, no sync fs, no daemon. Stage is read live per emit, so
|
|
26
|
+
* config.observability.stage = 'off' is an immediate kill switch.
|
|
27
|
+
*
|
|
28
|
+
* DF-FR08: model.completed/model.failed records emitted without
|
|
29
|
+
* payload.resource are stamped { value: null, source: 'UNKNOWN' } —
|
|
30
|
+
* host-blind stays explicitly unknown, never 0, never invented.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import crypto from 'node:crypto';
|
|
34
|
+
|
|
35
|
+
import { validateSemanticRecord } from '../schema/validate.js';
|
|
36
|
+
import { sanitizeObserved } from '../privacy/sanitizeObserved.js';
|
|
37
|
+
import { SEMANTIC_REGISTRY } from '../schema/registry.js';
|
|
38
|
+
import { SCHEMA_VERSION } from '../schema/constants.js';
|
|
39
|
+
import { appendRecord } from '../segments/retention.js';
|
|
40
|
+
import { resolveStage } from './config.js';
|
|
41
|
+
|
|
42
|
+
const DEFAULT_QUEUE_SIZE = 1024;
|
|
43
|
+
const SPAN_STATUSES = new Set(['completed', 'failed', 'blocked']);
|
|
44
|
+
// Semantic names whose payload carries typed resource usage (DF-FR08).
|
|
45
|
+
const RESOURCE_SEMANTICS = new Set(['model.completed', 'model.failed']);
|
|
46
|
+
|
|
47
|
+
function isPlainObject(value) {
|
|
48
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultClock() {
|
|
52
|
+
return {
|
|
53
|
+
nowMs: () => Date.now(),
|
|
54
|
+
nowNs: () => process.hrtime.bigint(),
|
|
55
|
+
iso: () => new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createRecorder({ root, config, queueSize, writer, clock } = {}) {
|
|
60
|
+
const bound = Number.isInteger(queueSize) && queueSize > 0 ? queueSize : DEFAULT_QUEUE_SIZE;
|
|
61
|
+
const write = typeof writer === 'function' ? writer : (record) => appendRecord(root, record);
|
|
62
|
+
const time = clock && typeof clock.nowNs === 'function' ? clock : defaultClock();
|
|
63
|
+
|
|
64
|
+
const state = {
|
|
65
|
+
queue: [],
|
|
66
|
+
pumping: null,
|
|
67
|
+
sequence: 0,
|
|
68
|
+
boot_id: `boot-${crypto.randomUUID()}`,
|
|
69
|
+
writer_id: `recorder-${crypto.randomUUID()}`,
|
|
70
|
+
accepted: 0,
|
|
71
|
+
written: 0,
|
|
72
|
+
dropped_stage_off: 0,
|
|
73
|
+
dropped_invalid: 0,
|
|
74
|
+
dropped_sanitized: 0,
|
|
75
|
+
dropped_oldest: 0,
|
|
76
|
+
dropped_write: 0,
|
|
77
|
+
write_failures: 0,
|
|
78
|
+
invalid_spans: 0,
|
|
79
|
+
last_error: null,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function fillEnvelope(record) {
|
|
83
|
+
const out = { ...record };
|
|
84
|
+
if (out.record_type === undefined || out.record_type === null) out.record_type = 'fact';
|
|
85
|
+
if (out.schema_version === undefined || out.schema_version === null) {
|
|
86
|
+
out.schema_version = SCHEMA_VERSION;
|
|
87
|
+
}
|
|
88
|
+
if (typeof out.record_id !== 'string' || out.record_id.length === 0) {
|
|
89
|
+
out.record_id = `rec-${crypto.randomUUID()}`;
|
|
90
|
+
}
|
|
91
|
+
if (typeof out.boot_id !== 'string' || out.boot_id.length === 0) out.boot_id = state.boot_id;
|
|
92
|
+
if (typeof out.writer_id !== 'string' || out.writer_id.length === 0) {
|
|
93
|
+
out.writer_id = state.writer_id;
|
|
94
|
+
}
|
|
95
|
+
// The recorder owns sequence only when the caller did not supply one —
|
|
96
|
+
// caller-provided (writer_id, sequence) pairs pass through untouched so
|
|
97
|
+
// corpus/adapter records keep their identity.
|
|
98
|
+
if (!Number.isInteger(out.sequence)) out.sequence = ++state.sequence;
|
|
99
|
+
if (typeof out.wall_time_utc !== 'string') out.wall_time_utc = time.iso();
|
|
100
|
+
if (out.monotonic_ns === undefined || out.monotonic_ns === null) {
|
|
101
|
+
out.monotonic_ns = Number(time.nowNs());
|
|
102
|
+
}
|
|
103
|
+
if (typeof out.importance !== 'string') out.importance = 'normal';
|
|
104
|
+
if (typeof out.privacy_class !== 'string') {
|
|
105
|
+
const entry = SEMANTIC_REGISTRY[out.semantic_name];
|
|
106
|
+
out.privacy_class = entry ? entry.privacy_class : 'internal';
|
|
107
|
+
}
|
|
108
|
+
if (isPlainObject(out.payload)) {
|
|
109
|
+
out.payload = { ...out.payload };
|
|
110
|
+
if (RESOURCE_SEMANTICS.has(out.semantic_name) && out.payload.resource === undefined) {
|
|
111
|
+
out.payload.resource = { value: null, source: 'UNKNOWN' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function emit(record) {
|
|
118
|
+
try {
|
|
119
|
+
if (resolveStage(config) === 'off') {
|
|
120
|
+
state.dropped_stage_off += 1;
|
|
121
|
+
return { status: 'dropped', reason: 'stage-off' };
|
|
122
|
+
}
|
|
123
|
+
const candidate = isPlainObject(record) ? fillEnvelope(record) : record;
|
|
124
|
+
const validation = validateSemanticRecord(candidate);
|
|
125
|
+
if (!validation.ok) {
|
|
126
|
+
state.dropped_invalid += 1;
|
|
127
|
+
return { status: 'dropped', reason: 'invalid' };
|
|
128
|
+
}
|
|
129
|
+
const clean = sanitizeObserved(candidate);
|
|
130
|
+
if (!clean.ok) {
|
|
131
|
+
state.dropped_sanitized += 1;
|
|
132
|
+
return { status: 'dropped', reason: 'sanitize-rejected' };
|
|
133
|
+
}
|
|
134
|
+
let evicted = false;
|
|
135
|
+
if (state.queue.length >= bound) {
|
|
136
|
+
state.queue.shift();
|
|
137
|
+
state.dropped_oldest += 1;
|
|
138
|
+
evicted = true;
|
|
139
|
+
}
|
|
140
|
+
state.queue.push(clean.record);
|
|
141
|
+
state.accepted += 1;
|
|
142
|
+
return evicted
|
|
143
|
+
? { status: 'dropped', reason: 'queue-full' }
|
|
144
|
+
: { status: 'accepted' };
|
|
145
|
+
} catch {
|
|
146
|
+
state.dropped_invalid += 1;
|
|
147
|
+
return { status: 'dropped', reason: 'recorder-error' };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function startSpan(traceId, operation) {
|
|
152
|
+
const trace_id = typeof traceId === 'string' && traceId.length > 0
|
|
153
|
+
? traceId
|
|
154
|
+
: `trace-${crypto.randomUUID()}`;
|
|
155
|
+
const span = {
|
|
156
|
+
trace_id,
|
|
157
|
+
span_id: `span-${crypto.randomUUID()}`,
|
|
158
|
+
execution_id: `exec-${trace_id}`,
|
|
159
|
+
operation: typeof operation === 'string' ? operation : 'task',
|
|
160
|
+
start_ns: time.nowNs(),
|
|
161
|
+
ended: false,
|
|
162
|
+
};
|
|
163
|
+
emit({
|
|
164
|
+
semantic_name: 'execution.started',
|
|
165
|
+
trace_id: span.trace_id,
|
|
166
|
+
span_id: span.span_id,
|
|
167
|
+
parent_span_id: null,
|
|
168
|
+
execution_id: span.execution_id,
|
|
169
|
+
payload: { operation: span.operation, duration_ms: null },
|
|
170
|
+
});
|
|
171
|
+
return span;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function endSpan(span, status) {
|
|
175
|
+
if (!isPlainObject(span) || span.ended || !SPAN_STATUSES.has(status)) {
|
|
176
|
+
state.invalid_spans += 1;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
span.ended = true;
|
|
180
|
+
const duration_ms = Math.max(0, Number(time.nowNs() - span.start_ns) / 1e6);
|
|
181
|
+
emit({
|
|
182
|
+
semantic_name: `execution.${status}`,
|
|
183
|
+
trace_id: span.trace_id,
|
|
184
|
+
span_id: span.span_id,
|
|
185
|
+
parent_span_id: null,
|
|
186
|
+
execution_id: span.execution_id,
|
|
187
|
+
payload: { operation: span.operation, duration_ms },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Race one write against the remaining deadline. The record stays at the
|
|
192
|
+
// head of the queue until a write is known to have completed: on timeout
|
|
193
|
+
// the late write may still land, so a later retry can duplicate the line —
|
|
194
|
+
// bounded telemetry accepts a rare duplicate over blocking the caller.
|
|
195
|
+
|
|
196
|
+
async function writeBounded(record, budgetMs) {
|
|
197
|
+
const writePromise = Promise.resolve()
|
|
198
|
+
.then(() => write(record))
|
|
199
|
+
.then((r) => (r && r.ok ? 'ok' : (r && r.reason) || 'write-failed'))
|
|
200
|
+
.catch(() => 'write-failed');
|
|
201
|
+
if (!Number.isFinite(budgetMs) || budgetMs <= 0) return 'deadline';
|
|
202
|
+
let timer;
|
|
203
|
+
try {
|
|
204
|
+
return await Promise.race([
|
|
205
|
+
writePromise,
|
|
206
|
+
new Promise((resolve) => {
|
|
207
|
+
timer = setTimeout(() => resolve('deadline'), budgetMs);
|
|
208
|
+
if (timer.unref) timer.unref();
|
|
209
|
+
}),
|
|
210
|
+
]);
|
|
211
|
+
} finally {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function pump(deadlineMs) {
|
|
217
|
+
const started = time.nowMs();
|
|
218
|
+
const deadline = Number.isFinite(deadlineMs) ? started + deadlineMs : null;
|
|
219
|
+
const result = { status: 'ok', written: 0, dropped: 0, remaining: 0, elapsed_ms: 0 };
|
|
220
|
+
|
|
221
|
+
while (state.queue.length > 0) {
|
|
222
|
+
const remaining = deadline === null ? Infinity : deadline - time.nowMs();
|
|
223
|
+
if (remaining <= 0) {
|
|
224
|
+
result.status = 'partial';
|
|
225
|
+
result.reason = 'deadline';
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
const record = state.queue[0];
|
|
229
|
+
const outcome = await writeBounded(record, remaining);
|
|
230
|
+
if (outcome === 'ok') {
|
|
231
|
+
state.queue.shift();
|
|
232
|
+
state.written += 1;
|
|
233
|
+
result.written += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (outcome === 'deadline') {
|
|
237
|
+
result.status = 'partial';
|
|
238
|
+
result.reason = 'deadline';
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
// Writer failure: drop the failed record (never silently requeue a
|
|
242
|
+
// poison record into an infinite retry), abort the batch, report a
|
|
243
|
+
// typed error — the caller's task is unaffected.
|
|
244
|
+
state.queue.shift();
|
|
245
|
+
state.dropped_write += 1;
|
|
246
|
+
state.write_failures += 1;
|
|
247
|
+
state.last_error = outcome;
|
|
248
|
+
result.status = 'error';
|
|
249
|
+
result.reason = outcome;
|
|
250
|
+
result.dropped += 1;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
result.remaining = state.queue.length;
|
|
255
|
+
result.elapsed_ms = Math.max(0, time.nowMs() - started);
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function flush({ deadlineMs } = {}) {
|
|
260
|
+
// Serialize concurrent flushes: a second caller waits for the in-flight
|
|
261
|
+
// pump (bounded by that pump's own deadline), then drains what remains.
|
|
262
|
+
if (state.pumping) {
|
|
263
|
+
try {
|
|
264
|
+
await state.pumping;
|
|
265
|
+
} catch {
|
|
266
|
+
/* pump() never rejects; belt-and-suspenders */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const run = pump(deadlineMs);
|
|
270
|
+
state.pumping = run;
|
|
271
|
+
try {
|
|
272
|
+
return await run;
|
|
273
|
+
} finally {
|
|
274
|
+
if (state.pumping === run) state.pumping = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function health() {
|
|
279
|
+
return {
|
|
280
|
+
stage: resolveStage(config),
|
|
281
|
+
queue_depth: state.queue.length,
|
|
282
|
+
queue_bound: bound,
|
|
283
|
+
accepted: state.accepted,
|
|
284
|
+
written: state.written,
|
|
285
|
+
dropped_stage_off: state.dropped_stage_off,
|
|
286
|
+
dropped_invalid: state.dropped_invalid,
|
|
287
|
+
dropped_sanitized: state.dropped_sanitized,
|
|
288
|
+
dropped_oldest: state.dropped_oldest,
|
|
289
|
+
dropped_write: state.dropped_write,
|
|
290
|
+
write_failures: state.write_failures,
|
|
291
|
+
invalid_spans: state.invalid_spans,
|
|
292
|
+
last_error: state.last_error,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { emit, startSpan, endSpan, flush, health };
|
|
297
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiPacket.js (TASK-013, SPEC §5 DF-FR12, §8) — compact offline evaluator
|
|
3
|
+
* packet.
|
|
4
|
+
*
|
|
5
|
+
* buildEvaluatorPacket(summaries): SafePacket
|
|
6
|
+
*
|
|
7
|
+
* Builds the bounded, sanitized packet an offline AI evaluator consumes.
|
|
8
|
+
* The packet carries ONLY the evaluator's reading sequence — summary →
|
|
9
|
+
* anomalies → representative evidence refs — derived from TraceSummaries.
|
|
10
|
+
* It never contains raw records, prompts, tee output, or payload content.
|
|
11
|
+
*
|
|
12
|
+
* Contract:
|
|
13
|
+
* - Hard cap: serialized packet never exceeds PACKET_MAX_BYTES (16KB).
|
|
14
|
+
* Overflow drops whole traces deterministically with explicit
|
|
15
|
+
* `truncated`/`omitted_traces` markers — never silent.
|
|
16
|
+
* - Sanitized: every string passes redactString (redaction rules +
|
|
17
|
+
* secret scanner + length cap); unknown fields are dropped, not
|
|
18
|
+
* carried through.
|
|
19
|
+
* - Deterministic: no clock, no randomness; traces are sorted by
|
|
20
|
+
* trace_id so input order cannot change the output.
|
|
21
|
+
* - Evaluator opinion is a separate provenance layer (DF-FR02): this
|
|
22
|
+
* packet is INPUT to an evaluator, never a decision itself.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { scanText } from '../../sensitiveValueScanner.js';
|
|
26
|
+
import { redactString } from '../privacy/redaction.js';
|
|
27
|
+
|
|
28
|
+
export const PACKET_MAX_BYTES = 16 * 1024;
|
|
29
|
+
export const PACKET_VERSION = 'df-eval-packet-1';
|
|
30
|
+
|
|
31
|
+
const MAX_STRING_CHARS = 1024;
|
|
32
|
+
const MAX_SPANS_PER_TRACE = 16;
|
|
33
|
+
const MAX_EVIDENCE_REFS_PER_TRACE = 24;
|
|
34
|
+
const MAX_DEPTH = 8;
|
|
35
|
+
const MAX_OBJECT_KEYS = 64;
|
|
36
|
+
const MAX_ARRAY_ITEMS = 128;
|
|
37
|
+
|
|
38
|
+
function isPlainObject(value) {
|
|
39
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function num(value) {
|
|
43
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Recursive sanitizer: strings go through the shared redaction pipeline,
|
|
48
|
+
* containers are bounded, unknown types collapse to null.
|
|
49
|
+
*/
|
|
50
|
+
function sanitizeValue(value, depth) {
|
|
51
|
+
if (value === null) return null;
|
|
52
|
+
const t = typeof value;
|
|
53
|
+
if (t === 'boolean') return value;
|
|
54
|
+
if (t === 'number') return Number.isFinite(value) ? value : null;
|
|
55
|
+
if (t === 'string') {
|
|
56
|
+
return redactString(value, { scanner: scanText, maxChars: MAX_STRING_CHARS });
|
|
57
|
+
}
|
|
58
|
+
if (depth >= MAX_DEPTH) return null;
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
return value.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitizeValue(item, depth + 1));
|
|
61
|
+
}
|
|
62
|
+
if (isPlainObject(value)) {
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const key of Object.keys(value).slice(0, MAX_OBJECT_KEYS)) {
|
|
65
|
+
out[key] = sanitizeValue(value[key], depth + 1);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Typed anomaly list — codes + counts only, mirroring digest.js rules. */
|
|
73
|
+
function anomaliesOf(summary) {
|
|
74
|
+
const out = [];
|
|
75
|
+
const drops = isPlainObject(summary.drops) ? summary.drops : {};
|
|
76
|
+
const retries = isPlainObject(summary.retries) ? summary.retries : {};
|
|
77
|
+
const resource = isPlainObject(summary.resource) ? summary.resource : {};
|
|
78
|
+
const coverage = isPlainObject(summary.coverage) ? summary.coverage : {};
|
|
79
|
+
|
|
80
|
+
if (summary.telemetry_complete === false) out.push({ code: 'telemetry_incomplete' });
|
|
81
|
+
if (num(drops.dropped_count) > 0) {
|
|
82
|
+
out.push({
|
|
83
|
+
code: 'dropped_records',
|
|
84
|
+
count: drops.dropped_count,
|
|
85
|
+
reasons: Object.keys(isPlainObject(drops.by_reason) ? drops.by_reason : {}).sort(),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (num(drops.redacted) > 0) out.push({ code: 'redacted_records', count: drops.redacted });
|
|
89
|
+
if (num(retries.retries) > 0) {
|
|
90
|
+
out.push({ code: 'retries', count: retries.retries, model_attempts: num(retries.model_attempts) });
|
|
91
|
+
}
|
|
92
|
+
if (num(retries.failed_spans) > 0) out.push({ code: 'failed_spans', count: retries.failed_spans });
|
|
93
|
+
if (num(resource.unknown_count) > 0) {
|
|
94
|
+
out.push({ code: 'host_blind_resource', count: resource.unknown_count });
|
|
95
|
+
}
|
|
96
|
+
if (num(coverage.open_spans) > 0) out.push({ code: 'open_spans', count: coverage.open_spans });
|
|
97
|
+
if (num(coverage.orphan_spans) > 0) {
|
|
98
|
+
out.push({ code: 'orphan_spans', count: coverage.orphan_spans });
|
|
99
|
+
}
|
|
100
|
+
if (num(coverage.duplicate_ends) > 0) {
|
|
101
|
+
out.push({ code: 'duplicate_ends', count: coverage.duplicate_ends });
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Representative evidence: record IDs of anomalous spans (failed/blocked/
|
|
108
|
+
* retried) first, then the root span's end record — refs only, never
|
|
109
|
+
* record content.
|
|
110
|
+
*/
|
|
111
|
+
function evidenceRefsOf(summary) {
|
|
112
|
+
const spans = Array.isArray(summary.spans) ? summary.spans : [];
|
|
113
|
+
const refs = [];
|
|
114
|
+
for (const span of spans) {
|
|
115
|
+
if (!isPlainObject(span)) continue;
|
|
116
|
+
const anomalous =
|
|
117
|
+
span.status === 'failed' ||
|
|
118
|
+
span.status === 'blocked' ||
|
|
119
|
+
(typeof span.attempt_index === 'number' && span.attempt_index > 1);
|
|
120
|
+
if (anomalous && typeof span.end_record_id === 'string') refs.push(span.end_record_id);
|
|
121
|
+
}
|
|
122
|
+
const root = spans.find((s) => isPlainObject(s) && s.parent_span_id === null);
|
|
123
|
+
if (root && typeof root.end_record_id === 'string') refs.push(root.end_record_id);
|
|
124
|
+
return [...new Set(refs)].slice(0, MAX_EVIDENCE_REFS_PER_TRACE);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function spanDigestOf(span) {
|
|
128
|
+
if (!isPlainObject(span)) return null;
|
|
129
|
+
return {
|
|
130
|
+
span_id: typeof span.span_id === 'string' ? span.span_id : null,
|
|
131
|
+
family: typeof span.family === 'string' ? span.family : 'unknown',
|
|
132
|
+
operation: typeof span.operation === 'string' ? span.operation : null,
|
|
133
|
+
status: typeof span.status === 'string' ? span.status : 'open',
|
|
134
|
+
duration_ms: num(span.duration_ms),
|
|
135
|
+
attempt_index: Number.isInteger(span.attempt_index) ? span.attempt_index : null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function traceEntryOf(summary) {
|
|
140
|
+
const s = isPlainObject(summary) ? summary : {};
|
|
141
|
+
const spans = Array.isArray(s.spans) ? s.spans : [];
|
|
142
|
+
const durations = isPlainObject(s.durations) ? s.durations : {};
|
|
143
|
+
const retries = isPlainObject(s.retries) ? s.retries : {};
|
|
144
|
+
const drops = isPlainObject(s.drops) ? s.drops : {};
|
|
145
|
+
const cache = isPlainObject(s.cache) ? s.cache : {};
|
|
146
|
+
const resource = isPlainObject(s.resource) ? s.resource : {};
|
|
147
|
+
const coverage = isPlainObject(s.coverage) ? s.coverage : {};
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
trace_id: typeof s.trace_id === 'string' ? s.trace_id : null,
|
|
151
|
+
metric_version: typeof s.metric_version === 'string' ? s.metric_version : null,
|
|
152
|
+
telemetry_complete: s.telemetry_complete === true,
|
|
153
|
+
critical_path_ms: num(s.critical_path_ms),
|
|
154
|
+
durations: {
|
|
155
|
+
overall: sanitizeValue(durations.overall, 1),
|
|
156
|
+
model: sanitizeValue(durations.model, 1),
|
|
157
|
+
tool: sanitizeValue(durations.tool, 1),
|
|
158
|
+
},
|
|
159
|
+
retries: {
|
|
160
|
+
retries: num(retries.retries),
|
|
161
|
+
model_attempts: num(retries.model_attempts),
|
|
162
|
+
failed_spans: num(retries.failed_spans),
|
|
163
|
+
},
|
|
164
|
+
drops: { events: num(drops.events), dropped_count: num(drops.dropped_count) },
|
|
165
|
+
cache: { hit_rate: num(cache.hit_rate), hits: num(cache.hits), misses: num(cache.misses) },
|
|
166
|
+
resource: {
|
|
167
|
+
total_value: num(resource.total_value),
|
|
168
|
+
numeric_count: num(resource.numeric_count),
|
|
169
|
+
unknown_count: num(resource.unknown_count),
|
|
170
|
+
},
|
|
171
|
+
coverage: { records: num(coverage.records), spans: num(coverage.spans) },
|
|
172
|
+
anomalies: anomaliesOf(s),
|
|
173
|
+
spans: spans.slice(0, MAX_SPANS_PER_TRACE).map(spanDigestOf).filter(Boolean),
|
|
174
|
+
evidence_refs: evidenceRefsOf(s),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildPacket(traces, totalCount, truncated, omitted) {
|
|
179
|
+
return {
|
|
180
|
+
packet_version: PACKET_VERSION,
|
|
181
|
+
generated_by: 'buildEvaluatorPacket',
|
|
182
|
+
metric_versions: [...new Set(traces.map((t) => t.metric_version).filter(Boolean))].sort(),
|
|
183
|
+
trace_count: totalCount,
|
|
184
|
+
truncated,
|
|
185
|
+
omitted_traces: omitted,
|
|
186
|
+
limits: {
|
|
187
|
+
max_bytes: PACKET_MAX_BYTES,
|
|
188
|
+
max_spans_per_trace: MAX_SPANS_PER_TRACE,
|
|
189
|
+
max_evidence_refs_per_trace: MAX_EVIDENCE_REFS_PER_TRACE,
|
|
190
|
+
},
|
|
191
|
+
traces,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function packetBytes(packet) {
|
|
196
|
+
return Buffer.byteLength(JSON.stringify(packet), 'utf8');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function buildEvaluatorPacket(summaries) {
|
|
200
|
+
if (!Array.isArray(summaries)) {
|
|
201
|
+
throw new TypeError('buildEvaluatorPacket: summaries must be an array of TraceSummary');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const traces = summaries
|
|
205
|
+
.map(traceEntryOf)
|
|
206
|
+
.map((entry) => sanitizeValue(entry, 0))
|
|
207
|
+
.sort((a, b) => {
|
|
208
|
+
const at = a.trace_id === null ? '' : a.trace_id;
|
|
209
|
+
const bt = b.trace_id === null ? '' : b.trace_id;
|
|
210
|
+
return at < bt ? -1 : at > bt ? 1 : 0;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const total = traces.length;
|
|
214
|
+
let packet = buildPacket(traces, total, false, 0);
|
|
215
|
+
if (packetBytes(packet) <= PACKET_MAX_BYTES) return packet;
|
|
216
|
+
|
|
217
|
+
// Deterministic bound: keep the largest trace prefix that fits the cap.
|
|
218
|
+
let lo = 0;
|
|
219
|
+
let hi = traces.length;
|
|
220
|
+
while (lo < hi) {
|
|
221
|
+
const mid = (lo + hi + 1) >> 1;
|
|
222
|
+
if (packetBytes(buildPacket(traces.slice(0, mid), total, true, total - mid)) <= PACKET_MAX_BYTES) {
|
|
223
|
+
lo = mid;
|
|
224
|
+
} else {
|
|
225
|
+
hi = mid - 1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
packet = buildPacket(traces.slice(0, lo), total, true, total - lo);
|
|
229
|
+
return packet;
|
|
230
|
+
}
|