From 2349d1d431611ae36b6d9dc8340dc0cee1149760 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 9 May 2026 09:30:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(voyage):=20add=20lib/exporters/textfile-fo?= =?UTF-8?q?rmat.mjs=20=E2=80=94=20Prometheus=20text-format=20pure=20transf?= =?UTF-8?q?orm=20SC=20#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../voyage/lib/exporters/textfile-format.mjs | 135 ++++++++++++++++++ plugins/voyage/scripts/gen-expected-prom.mjs | 21 +++ plugins/voyage/tests/fixtures/expected.prom | 54 +++++++ .../voyage/tests/fixtures/stats-sample.jsonl | 5 + .../tests/hooks/otel-export-textfile.test.mjs | 82 +++++++++++ 5 files changed, 297 insertions(+) create mode 100644 plugins/voyage/lib/exporters/textfile-format.mjs create mode 100644 plugins/voyage/scripts/gen-expected-prom.mjs create mode 100644 plugins/voyage/tests/fixtures/expected.prom create mode 100644 plugins/voyage/tests/fixtures/stats-sample.jsonl create mode 100644 plugins/voyage/tests/hooks/otel-export-textfile.test.mjs diff --git a/plugins/voyage/lib/exporters/textfile-format.mjs b/plugins/voyage/lib/exporters/textfile-format.mjs new file mode 100644 index 0000000..32f72a2 --- /dev/null +++ b/plugins/voyage/lib/exporters/textfile-format.mjs @@ -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_ +// # TYPE voyage_ {gauge|counter|histogram} +// voyage_{label="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} 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 }; diff --git a/plugins/voyage/scripts/gen-expected-prom.mjs b/plugins/voyage/scripts/gen-expected-prom.mjs new file mode 100644 index 0000000..4b3a509 --- /dev/null +++ b/plugins/voyage/scripts/gen-expected-prom.mjs @@ -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)); diff --git a/plugins/voyage/tests/fixtures/expected.prom b/plugins/voyage/tests/fixtures/expected.prom new file mode 100644 index 0000000..0b3637b --- /dev/null +++ b/plugins/voyage/tests/fixtures/expected.prom @@ -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 diff --git a/plugins/voyage/tests/fixtures/stats-sample.jsonl b/plugins/voyage/tests/fixtures/stats-sample.jsonl new file mode 100644 index 0000000..43aff39 --- /dev/null +++ b/plugins/voyage/tests/fixtures/stats-sample.jsonl @@ -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"} diff --git a/plugins/voyage/tests/hooks/otel-export-textfile.test.mjs b/plugins/voyage/tests/hooks/otel-export-textfile.test.mjs new file mode 100644 index 0000000..3025d94 --- /dev/null +++ b/plugins/voyage/tests/hooks/otel-export-textfile.test.mjs @@ -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); +});