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:
Kjell Tore Guttormsen 2026-05-09 09:30:58 +02:00
commit 2349d1d431
5 changed files with 297 additions and 0 deletions

View 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

View 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"}

View 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);
});