feat(voyage): add lib/exporters/textfile-format.mjs — Prometheus text-format pure transform SC #12
Step 9 av v4.1-execute (Wave 2, Session 3). Pure function transformToPrometheus(records) → Prometheus text-format 0.0.4. Hard rules: - NO client-side timestamps (research/01 node_exporter#1284 mitigation) - Allowlist-redacted records ONLY (caller responsibility — Step 11 enforces) - UTF-8 metric names normalized: lowercase, [.\\-\\s] → _, voyage_ prefix - Empty input → empty string output - Sorted output for determinism (snapshot-test-friendly) Heuristic metric typing: - counter: *_total, *_count, *_passed, *_failed, *_skipped - histogram: *_ms, *_duration, *_p\\d+, *_seconds - gauge: everything else (Prometheus convention) Snapshot: tests/fixtures/expected.prom byte-for-byte match. Regenerate: node scripts/gen-expected-prom.mjs > tests/fixtures/expected.prom Tester (6 nye, baseline 400 → 406): - Snapshot byte-for-byte match (SC #12) - Empty input handling (null, undefined, []) - Allowlist-redaction sanity (post-bash-stats uten command_excerpt) - NO client-side timestamps (token-count-assertion per linje) - normalizeMetricName edge-cases - Determinism (identisk input → identisk output) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f419121682
commit
2349d1d431
5 changed files with 297 additions and 0 deletions
135
plugins/voyage/lib/exporters/textfile-format.mjs
Normal file
135
plugins/voyage/lib/exporters/textfile-format.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// lib/exporters/textfile-format.mjs
|
||||
// Pure transform: voyage JSONL stats records → Prometheus text-format 0.0.4.
|
||||
//
|
||||
// Output contract (Prometheus exposition format 0.0.4):
|
||||
// # HELP voyage_<metric_name> <description>
|
||||
// # TYPE voyage_<metric_name> {gauge|counter|histogram}
|
||||
// voyage_<metric_name>{label="value",...} <numeric_value>
|
||||
//
|
||||
// Hard rules:
|
||||
// - NO client-side timestamps (per research/01 — node_exporter#1284 known issue
|
||||
// where stale textfile samples re-emit with old timestamps).
|
||||
// - Allowlist-redacted records ONLY (caller must apply field-allowlist first).
|
||||
// - UTF-8 metric names normalized: dots/dashes → underscore, lowercase, prefixed `voyage_`.
|
||||
// - Empty input → empty string output (no headers, no errors).
|
||||
|
||||
const METRIC_PREFIX = 'voyage_';
|
||||
|
||||
/**
|
||||
* Normalize a JSONL field name to a Prometheus-safe metric name.
|
||||
* Per Prometheus 3.0 rules: [a-zA-Z_:][a-zA-Z0-9_:]*. Replace dot/dash/space → '_'.
|
||||
*/
|
||||
function normalizeMetricName(name) {
|
||||
const safe = String(name).toLowerCase().replace(/[.\-\s]+/g, '_').replace(/[^a-zA-Z0-9_:]/g, '_');
|
||||
return METRIC_PREFIX + safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a Prometheus label value per spec § Format: backslash, double-quote, newline.
|
||||
*/
|
||||
function escapeLabel(v) {
|
||||
return String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which fields in a record are numeric metrics vs string labels.
|
||||
* Numeric → emitted as samples. String/bool → labels on the sample.
|
||||
*/
|
||||
function partitionRecord(record) {
|
||||
const labels = {};
|
||||
const metrics = {};
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
if (k === 'ts') continue; // never emit ts as label nor metric (Prom server timestamps)
|
||||
if (typeof v === 'number') metrics[k] = v;
|
||||
else if (typeof v === 'boolean') metrics[k] = v ? 1 : 0;
|
||||
else if (typeof v === 'string') labels[k] = v;
|
||||
// Skip arrays/objects — caller's allowlist should have flattened these.
|
||||
}
|
||||
return { labels, metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Group records by their schema_id (an extracted convention: schema_id is the
|
||||
* caller-provided identifier; if absent, fall back to 'unknown').
|
||||
*/
|
||||
function groupBySchema(records) {
|
||||
const groups = new Map();
|
||||
for (const r of records) {
|
||||
const id = (r && typeof r._schema_id === 'string') ? r._schema_id : 'unknown';
|
||||
if (!groups.has(id)) groups.set(id, []);
|
||||
groups.get(id).push(r);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform JSONL records into Prometheus text-format. Pure function.
|
||||
*
|
||||
* @param {Array<object>} records Allowlist-redacted records (caller responsibility).
|
||||
* @param {{help?: object}} [opts] Optional: help-text overrides per metric (object).
|
||||
* @returns {string} Prometheus text-format. Empty input → empty string.
|
||||
*/
|
||||
export function transformToPrometheus(records, opts = {}) {
|
||||
if (!Array.isArray(records) || records.length === 0) return '';
|
||||
|
||||
const lines = [];
|
||||
const helpMap = opts.help || {};
|
||||
const groups = groupBySchema(records);
|
||||
|
||||
// Track which metric names we've emitted HELP/TYPE for (per Prometheus spec:
|
||||
// emit HELP/TYPE once per metric, then all samples for that metric).
|
||||
const emittedMeta = new Set();
|
||||
|
||||
// First pass: collect all unique (schema, metric_field) → sample lines
|
||||
// grouped by metric so HELP/TYPE come before all samples for that metric.
|
||||
const samplesByMetric = new Map();
|
||||
|
||||
for (const [schemaId, group] of groups.entries()) {
|
||||
for (const record of group) {
|
||||
const { labels, metrics } = partitionRecord(record);
|
||||
const labelStr = Object.entries(labels)
|
||||
.map(([k, v]) => `${k}="${escapeLabel(v)}"`)
|
||||
.join(',');
|
||||
const labelBlock = labelStr ? `{${labelStr}}` : '';
|
||||
|
||||
for (const [metricField, value] of Object.entries(metrics)) {
|
||||
const metricName = normalizeMetricName(`${schemaId}_${metricField}`);
|
||||
if (!samplesByMetric.has(metricName)) {
|
||||
samplesByMetric.set(metricName, []);
|
||||
}
|
||||
samplesByMetric.get(metricName).push(`${metricName}${labelBlock} ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort metric names for deterministic output (snapshot-test-friendly)
|
||||
const sortedMetrics = [...samplesByMetric.keys()].sort();
|
||||
|
||||
for (const metricName of sortedMetrics) {
|
||||
const help = helpMap[metricName] || `voyage stats — ${metricName.slice(METRIC_PREFIX.length)}`;
|
||||
const type = inferMetricType(metricName);
|
||||
if (!emittedMeta.has(metricName)) {
|
||||
lines.push(`# HELP ${metricName} ${help}`);
|
||||
lines.push(`# TYPE ${metricName} ${type}`);
|
||||
emittedMeta.add(metricName);
|
||||
}
|
||||
// Sort samples for determinism
|
||||
const samples = samplesByMetric.get(metricName).sort();
|
||||
for (const s of samples) lines.push(s);
|
||||
}
|
||||
|
||||
return lines.join('\n') + (lines.length > 0 ? '\n' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: counter for *_total / *_count / *_passed / *_failed; histogram for
|
||||
* *_ms / *_duration / *_p50 / *_p99; gauge for everything else (per Prometheus
|
||||
* conventions). Pure & deterministic.
|
||||
*/
|
||||
function inferMetricType(metricName) {
|
||||
if (/_total$|_count$|_passed$|_failed$|_skipped$/.test(metricName)) return 'counter';
|
||||
if (/_ms$|_duration|_p\d+$|_seconds$/.test(metricName)) return 'histogram';
|
||||
return 'gauge';
|
||||
}
|
||||
|
||||
export { normalizeMetricName, partitionRecord, inferMetricType };
|
||||
21
plugins/voyage/scripts/gen-expected-prom.mjs
Normal file
21
plugins/voyage/scripts/gen-expected-prom.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/gen-expected-prom.mjs
|
||||
// Regenerate tests/fixtures/expected.prom snapshot from tests/fixtures/stats-sample.jsonl.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/gen-expected-prom.mjs > tests/fixtures/expected.prom
|
||||
//
|
||||
// When the snapshot is stale (e.g. after intentional format change or new
|
||||
// stats-sample row), regenerate via the command above and inspect the diff.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { transformToPrometheus } from '../lib/exporters/textfile-format.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SAMPLE_PATH = join(__dirname, '..', 'tests', 'fixtures', 'stats-sample.jsonl');
|
||||
|
||||
const text = readFileSync(SAMPLE_PATH, 'utf-8');
|
||||
const records = text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||||
process.stdout.write(transformToPrometheus(records));
|
||||
54
plugins/voyage/tests/fixtures/expected.prom
vendored
Normal file
54
plugins/voyage/tests/fixtures/expected.prom
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# HELP voyage_trekbrief_interview_turns voyage stats — trekbrief_interview_turns
|
||||
# TYPE voyage_trekbrief_interview_turns gauge
|
||||
voyage_trekbrief_interview_turns{_schema_id="trekbrief",slug="add-auth",mode="default",profile="economy",profile_source="env"} 7
|
||||
# HELP voyage_trekbrief_research_topics voyage stats — trekbrief_research_topics
|
||||
# TYPE voyage_trekbrief_research_topics gauge
|
||||
voyage_trekbrief_research_topics{_schema_id="trekbrief",slug="add-auth",mode="default",profile="economy",profile_source="env"} 3
|
||||
# HELP voyage_trekbrief_review_iterations voyage stats — trekbrief_review_iterations
|
||||
# TYPE voyage_trekbrief_review_iterations gauge
|
||||
voyage_trekbrief_review_iterations{_schema_id="trekbrief",slug="add-auth",mode="default",profile="economy",profile_source="env"} 2
|
||||
# HELP voyage_trekexecute_steps_failed voyage stats — trekexecute_steps_failed
|
||||
# TYPE voyage_trekexecute_steps_failed counter
|
||||
voyage_trekexecute_steps_failed{_schema_id="trekexecute",plan="trekplan-add-auth.md",plan_type="plan",mode="execute",result="completed",profile="premium",profile_source="inheritance"} 0
|
||||
# HELP voyage_trekexecute_steps_passed voyage stats — trekexecute_steps_passed
|
||||
# TYPE voyage_trekexecute_steps_passed counter
|
||||
voyage_trekexecute_steps_passed{_schema_id="trekexecute",plan="trekplan-add-auth.md",plan_type="plan",mode="execute",result="completed",profile="premium",profile_source="inheritance"} 12
|
||||
# HELP voyage_trekexecute_steps_skipped voyage stats — trekexecute_steps_skipped
|
||||
# TYPE voyage_trekexecute_steps_skipped counter
|
||||
voyage_trekexecute_steps_skipped{_schema_id="trekexecute",plan="trekplan-add-auth.md",plan_type="plan",mode="execute",result="completed",profile="premium",profile_source="inheritance"} 0
|
||||
# HELP voyage_trekexecute_steps_total voyage stats — trekexecute_steps_total
|
||||
# TYPE voyage_trekexecute_steps_total counter
|
||||
voyage_trekexecute_steps_total{_schema_id="trekexecute",plan="trekplan-add-auth.md",plan_type="plan",mode="execute",result="completed",profile="premium",profile_source="inheritance"} 12
|
||||
# HELP voyage_trekplan_agents_deployed voyage stats — trekplan_agents_deployed
|
||||
# TYPE voyage_trekplan_agents_deployed gauge
|
||||
voyage_trekplan_agents_deployed{_schema_id="trekplan",slug="add-auth",mode="default",profile="premium",profile_source="flag"} 7
|
||||
# HELP voyage_trekplan_codebase_files voyage stats — trekplan_codebase_files
|
||||
# TYPE voyage_trekplan_codebase_files gauge
|
||||
voyage_trekplan_codebase_files{_schema_id="trekplan",slug="add-auth",mode="default",profile="premium",profile_source="flag"} 156
|
||||
# HELP voyage_trekplan_deep_dives voyage stats — trekplan_deep_dives
|
||||
# TYPE voyage_trekplan_deep_dives gauge
|
||||
voyage_trekplan_deep_dives{_schema_id="trekplan",slug="add-auth",mode="default",profile="premium",profile_source="flag"} 2
|
||||
# HELP voyage_trekplan_research_briefs_used voyage stats — trekplan_research_briefs_used
|
||||
# TYPE voyage_trekplan_research_briefs_used gauge
|
||||
voyage_trekplan_research_briefs_used{_schema_id="trekplan",slug="add-auth",mode="default",profile="premium",profile_source="flag"} 3
|
||||
# HELP voyage_trekresearch_agents_external voyage stats — trekresearch_agents_external
|
||||
# TYPE voyage_trekresearch_agents_external gauge
|
||||
voyage_trekresearch_agents_external{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 3
|
||||
# HELP voyage_trekresearch_agents_local voyage stats — trekresearch_agents_local
|
||||
# TYPE voyage_trekresearch_agents_local gauge
|
||||
voyage_trekresearch_agents_local{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 5
|
||||
# HELP voyage_trekresearch_contradictions voyage stats — trekresearch_contradictions
|
||||
# TYPE voyage_trekresearch_contradictions gauge
|
||||
voyage_trekresearch_contradictions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 1
|
||||
# HELP voyage_trekresearch_dimensions voyage stats — trekresearch_dimensions
|
||||
# TYPE voyage_trekresearch_dimensions gauge
|
||||
voyage_trekresearch_dimensions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 4
|
||||
# HELP voyage_trekresearch_open_questions voyage stats — trekresearch_open_questions
|
||||
# TYPE voyage_trekresearch_open_questions gauge
|
||||
voyage_trekresearch_open_questions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 2
|
||||
# HELP voyage_trekreview_duration_ms voyage stats — trekreview_duration_ms
|
||||
# TYPE voyage_trekreview_duration_ms histogram
|
||||
voyage_trekreview_duration_ms{_schema_id="trekreview",slug="add-auth",verdict="ALLOW",mode="default",profile="balanced",profile_source="flag"} 4521
|
||||
# HELP voyage_trekreview_reviewed_files_count voyage stats — trekreview_reviewed_files_count
|
||||
# TYPE voyage_trekreview_reviewed_files_count counter
|
||||
voyage_trekreview_reviewed_files_count{_schema_id="trekreview",slug="add-auth",verdict="ALLOW",mode="default",profile="balanced",profile_source="flag"} 18
|
||||
5
plugins/voyage/tests/fixtures/stats-sample.jsonl
vendored
Normal file
5
plugins/voyage/tests/fixtures/stats-sample.jsonl
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{"_schema_id":"trekplan","ts":"2026-05-09T08:00:00.000Z","slug":"add-auth","mode":"default","codebase_files":156,"agents_deployed":7,"deep_dives":2,"research_briefs_used":3,"profile":"premium","profile_source":"flag"}
|
||||
{"_schema_id":"trekexecute","ts":"2026-05-09T08:30:00.000Z","plan":"trekplan-add-auth.md","plan_type":"plan","mode":"execute","result":"completed","steps_total":12,"steps_passed":12,"steps_failed":0,"steps_skipped":0,"profile":"premium","profile_source":"inheritance"}
|
||||
{"_schema_id":"trekreview","ts":"2026-05-09T09:00:00.000Z","slug":"add-auth","verdict":"ALLOW","reviewed_files_count":18,"mode":"default","duration_ms":4521,"profile":"balanced","profile_source":"flag"}
|
||||
{"_schema_id":"trekbrief","ts":"2026-05-09T07:00:00.000Z","slug":"add-auth","mode":"default","interview_turns":7,"review_iterations":2,"research_topics":3,"profile":"economy","profile_source":"env"}
|
||||
{"_schema_id":"trekresearch","ts":"2026-05-09T07:30:00.000Z","slug":"add-auth","mode":"default","scope":"both","dimensions":4,"agents_local":5,"agents_external":3,"contradictions":1,"open_questions":2,"profile":"premium","profile_source":"default"}
|
||||
82
plugins/voyage/tests/hooks/otel-export-textfile.test.mjs
Normal file
82
plugins/voyage/tests/hooks/otel-export-textfile.test.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// tests/hooks/otel-export-textfile.test.mjs
|
||||
// SC #12: lib/exporters/textfile-format.mjs produces deterministic Prometheus
|
||||
// text-format output that matches expected.prom byte-for-byte.
|
||||
//
|
||||
// To regenerate snapshot:
|
||||
// node scripts/gen-expected-prom.mjs > tests/fixtures/expected.prom
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { transformToPrometheus, normalizeMetricName } from '../../lib/exporters/textfile-format.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = join(__dirname, '..', 'fixtures');
|
||||
|
||||
function loadJsonl(name) {
|
||||
const text = readFileSync(join(FIXTURES, name), 'utf-8');
|
||||
return text.trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
}
|
||||
|
||||
test('SC #12: stats-sample.jsonl → expected.prom snapshot byte-for-byte match', () => {
|
||||
const records = loadJsonl('stats-sample.jsonl');
|
||||
const actual = transformToPrometheus(records);
|
||||
const expected = readFileSync(join(FIXTURES, 'expected.prom'), 'utf-8');
|
||||
assert.equal(actual, expected,
|
||||
`Snapshot drift detected. Regenerate via:\n` +
|
||||
` node scripts/gen-expected-prom.mjs > tests/fixtures/expected.prom`);
|
||||
});
|
||||
|
||||
test('empty-input handling: [] returns empty string (no headers)', () => {
|
||||
assert.equal(transformToPrometheus([]), '');
|
||||
assert.equal(transformToPrometheus(null), '');
|
||||
assert.equal(transformToPrometheus(undefined), '');
|
||||
});
|
||||
|
||||
test('allowlist-redaction: caller-redacted records (without command_excerpt/session_id) emit cleanly', () => {
|
||||
// Simulate an allowlist-applied post-bash-stats record (command_excerpt + session_id removed)
|
||||
const record = {
|
||||
_schema_id: 'post_bash_stats',
|
||||
ts: '2026-05-09T08:00:00.000Z',
|
||||
duration_ms: 152,
|
||||
success: true,
|
||||
};
|
||||
const out = transformToPrometheus([record]);
|
||||
// Must NOT contain command_excerpt nor session_id (caller's responsibility, but verify)
|
||||
assert.ok(!out.includes('command_excerpt'), 'command_excerpt leaked into output');
|
||||
assert.ok(!out.includes('session_id'), 'session_id leaked into output');
|
||||
// Must contain duration_ms metric
|
||||
assert.match(out, /voyage_post_bash_stats_duration_ms/);
|
||||
assert.match(out, / 152$/m);
|
||||
// Boolean coerced to 1
|
||||
assert.match(out, /voyage_post_bash_stats_success.* 1$/m);
|
||||
});
|
||||
|
||||
test('NO client-side timestamps in output (per research/01 node_exporter#1284 mitigation)', () => {
|
||||
const records = loadJsonl('stats-sample.jsonl');
|
||||
const out = transformToPrometheus(records);
|
||||
const lines = out.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#') || line === '') continue;
|
||||
// Sample line format: metric{labels} value [timestamp]
|
||||
// We must NOT have a trailing numeric timestamp after the value.
|
||||
const parts = line.trim().split(' ');
|
||||
assert.ok(parts.length === 2,
|
||||
`Line has unexpected token count (timestamp leaked?): ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeMetricName: dots/dashes/spaces → underscore, lowercase, voyage_ prefix', () => {
|
||||
assert.equal(normalizeMetricName('voyage.hook.duration_ms'), 'voyage_voyage_hook_duration_ms');
|
||||
assert.equal(normalizeMetricName('Plan-Critic Verdict'), 'voyage_plan_critic_verdict');
|
||||
assert.equal(normalizeMetricName('METRIC NAME'), 'voyage_metric_name');
|
||||
});
|
||||
|
||||
test('determinism: identical input produces identical output (sorted keys)', () => {
|
||||
const records = loadJsonl('stats-sample.jsonl');
|
||||
const out1 = transformToPrometheus(records);
|
||||
const out2 = transformToPrometheus([...records]);
|
||||
assert.equal(out1, out2);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue