feat(voyage): add hooks/scripts/otel-export.mjs — Stop-hook orchestration SC #14, opt-in via VOYAGE_EXPORT_MODE
Step 12 av v4.1-execute (Wave 3, Session 5).
Stop-event hook (CC v2.1.105+) som leser ${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl,
applies field-allowlist (Step 11), og eksporterer enten Prometheus textfile eller
OTLP/HTTP. Strict opt-in via VOYAGE_EXPORT_MODE env-var (default off).
Modes:
- off (default): silent exit, ingen arbeid
- textfile: skriv voyage.prom til VOYAGE_TEXTFILE_DIR eller CLAUDE_PLUGIN_DATA
- otlp: POST OTLP/JSON til VOYAGE_OTEL_ENDPOINT (https kreves for non-private)
Hard invariants:
- Outer try/catch + process.exit(0) — stats failures MÅ IKKE blokkere Stop
- Tail-latency NFR: textfile <5ms p99, otlp <1500ms (AbortController)
- Allowlist redaction FØR eksport (CWE-212)
- Path/endpoint validation FØR I/O (CWE-22, CWE-918)
- Stderr prefix [voyage]
- EXDEV mitigation: tmp i samme dir som target (IKKE atomicWriteJson)
Heterogen trekexecute-stats disambiguering by record-shape:
- 'event'-felt → 'event-emit'-allowlist
- 'command_excerpt'/'session_id'-felt → 'post-bash-stats'-allowlist
- ellers → 'trekexecute' Phase 9-allowlist
Tester (7 nye, baseline 457 → 464):
- SC #14 off-mode silent exit
- SC #14 unset == off
- SC #14 textfile happy path (voyage.prom skrives med # HELP + # TYPE)
- SC #14 invalid mode → stderr warn + exit 0 (fail-soft)
- SC #14 otlp + invalid endpoint → stderr warn + exit 0
- SC #14 tail-latency < 800ms (cold-spawn allowed; in-process < 200ms NFR)
- SC #14 missing CLAUDE_PLUGIN_DATA → silent exit 0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ef379bedf7
commit
c5fb7456d5
2 changed files with 287 additions and 0 deletions
159
plugins/voyage/hooks/scripts/otel-export.mjs
Normal file
159
plugins/voyage/hooks/scripts/otel-export.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
#!/usr/bin/env node
|
||||
// otel-export.mjs — Stop-event hook (CC v2.1.105+)
|
||||
//
|
||||
// Reads ${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl, applies field-allowlist,
|
||||
// and exports either Prometheus textfile (default off) or OTLP/HTTP.
|
||||
//
|
||||
// Strict opt-in via VOYAGE_EXPORT_MODE env-var:
|
||||
// off (default) — silent exit, no work done
|
||||
// textfile — write voyage.prom to VOYAGE_TEXTFILE_DIR or CLAUDE_PLUGIN_DATA
|
||||
// otlp — POST OTLP/JSON to VOYAGE_OTEL_ENDPOINT (https required for non-private)
|
||||
//
|
||||
// Hard invariants:
|
||||
// - Outer try/catch with process.exit(0). Stats failures MUST NOT block Stop.
|
||||
// - Tail-latency NFR: textfile <5ms p99, otlp <1500ms (AbortController).
|
||||
// - Allowlist redaction MUST happen before any export (CWE-212).
|
||||
// - Path / endpoint validation MUST happen before any I/O (CWE-22, CWE-918).
|
||||
// - All stderr prefixed with [voyage].
|
||||
// - EXDEV mitigation: tmp file in same dir as target (do NOT use atomicWriteJson).
|
||||
|
||||
import { readFileSync, existsSync, writeFileSync, renameSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { transformToPrometheus } from '../../lib/exporters/textfile-format.mjs';
|
||||
import { transformToOtlpJson } from '../../lib/exporters/otlp-format.mjs';
|
||||
import { validateTextfilePath } from '../../lib/exporters/path-validator.mjs';
|
||||
import { validateOtlpEndpoint } from '../../lib/exporters/endpoint-validator.mjs';
|
||||
import { applyFieldAllowlist } from '../../lib/exporters/field-allowlist.mjs';
|
||||
|
||||
const VALID_MODES = new Set(['off', 'textfile', 'otlp']);
|
||||
const TEXTFILE_NAME = 'voyage.prom';
|
||||
const TEXTFILE_TMP_NAME = '.voyage-prom.tmp';
|
||||
const OTLP_TIMEOUT_MS = 1500;
|
||||
|
||||
// Map JSONL filename → schema_id for allowlist + exporter grouping
|
||||
const STATS_FILES = [
|
||||
{ file: 'trekbrief-stats.jsonl', schema: 'trekbrief' },
|
||||
{ file: 'trekresearch-stats.jsonl', schema: 'trekresearch' },
|
||||
{ file: 'trekplan-stats.jsonl', schema: 'trekplan' },
|
||||
{ file: 'trekexecute-stats.jsonl', schema: 'trekexecute' },
|
||||
{ file: 'trekreview-stats.jsonl', schema: 'trekreview' },
|
||||
{ file: 'trekcontinue-stats.jsonl', schema: 'trekcontinue' },
|
||||
];
|
||||
|
||||
function loadAndAllowlist(dataDir) {
|
||||
const out = [];
|
||||
for (const { file, schema } of STATS_FILES) {
|
||||
const path = join(dataDir, file);
|
||||
if (!existsSync(path)) continue;
|
||||
let text;
|
||||
try { text = readFileSync(path, 'utf-8'); }
|
||||
catch { continue; }
|
||||
const lines = text.split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
let record;
|
||||
try { record = JSON.parse(line); }
|
||||
catch { continue; }
|
||||
|
||||
let actualSchema = schema;
|
||||
if (schema === 'trekexecute') {
|
||||
if ('event' in record) actualSchema = 'event-emit';
|
||||
else if ('command_excerpt' in record || 'session_id' in record) actualSchema = 'post-bash-stats';
|
||||
}
|
||||
|
||||
out.push(applyFieldAllowlist(record, actualSchema));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function exportTextfile(records, env) {
|
||||
const targetDir = env.VOYAGE_TEXTFILE_DIR || env.CLAUDE_PLUGIN_DATA;
|
||||
if (!targetDir) {
|
||||
process.stderr.write('[voyage] otel-export: textfile mode requires VOYAGE_TEXTFILE_DIR or CLAUDE_PLUGIN_DATA\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = join(targetDir, TEXTFILE_NAME);
|
||||
const allowedRoots = [];
|
||||
if (env.VOYAGE_TEXTFILE_DIR) allowedRoots.push(env.VOYAGE_TEXTFILE_DIR);
|
||||
if (env.CLAUDE_PLUGIN_DATA) allowedRoots.push(env.CLAUDE_PLUGIN_DATA);
|
||||
const pathCheck = validateTextfilePath(targetPath, { allowedRoots });
|
||||
if (!pathCheck.valid) {
|
||||
process.stderr.write(`[voyage] otel-export: invalid textfile path: ${pathCheck.errors[0].message}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = transformToPrometheus(records);
|
||||
const tmpPath = join(dirname(targetPath), TEXTFILE_TMP_NAME);
|
||||
try {
|
||||
writeFileSync(tmpPath, text);
|
||||
renameSync(tmpPath, targetPath);
|
||||
} catch (e) {
|
||||
process.stderr.write(`[voyage] otel-export: textfile write failed: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportOtlp(records, env) {
|
||||
const url = env.VOYAGE_OTEL_ENDPOINT;
|
||||
if (!url) {
|
||||
process.stderr.write('[voyage] otel-export: otlp mode requires VOYAGE_OTEL_ENDPOINT\n');
|
||||
return;
|
||||
}
|
||||
const epCheck = validateOtlpEndpoint(url, { env });
|
||||
if (!epCheck.valid) {
|
||||
process.stderr.write(`[voyage] otel-export: invalid OTLP endpoint: ${epCheck.errors[0].message}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = transformToOtlpJson(records);
|
||||
const body = JSON.stringify(payload);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), OTLP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(epCheck.parsed.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'voyage/4.1.0' },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`[voyage] otel-export: OTLP endpoint returned ${res.status}\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
process.stderr.write(`[voyage] otel-export: OTLP request timed out after ${OTLP_TIMEOUT_MS}ms\n`);
|
||||
} else {
|
||||
process.stderr.write(`[voyage] otel-export: OTLP send failed: ${e.message}\n`);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const env = process.env;
|
||||
const mode = (env.VOYAGE_EXPORT_MODE || 'off').toLowerCase();
|
||||
|
||||
if (mode === 'off') return;
|
||||
if (!VALID_MODES.has(mode)) {
|
||||
process.stderr.write(`[voyage] otel-export: unknown VOYAGE_EXPORT_MODE="${mode}", expected one of [${[...VALID_MODES].join(', ')}]\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = env.CLAUDE_PLUGIN_DATA;
|
||||
if (!dataDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = loadAndAllowlist(dataDir);
|
||||
if (records.length === 0) return;
|
||||
|
||||
if (mode === 'textfile') await exportTextfile(records, env);
|
||||
else if (mode === 'otlp') await exportOtlp(records, env);
|
||||
} catch (e) {
|
||||
try { process.stderr.write(`[voyage] otel-export: unexpected error: ${e.message}\n`); } catch {}
|
||||
}
|
||||
process.exit(0);
|
||||
})();
|
||||
128
plugins/voyage/tests/hooks/otel-export.test.mjs
Normal file
128
plugins/voyage/tests/hooks/otel-export.test.mjs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// tests/hooks/otel-export.test.mjs
|
||||
// SC #14: Stop-hook orchestration — opt-in via VOYAGE_EXPORT_MODE.
|
||||
// Fail-soft contract: any error → exit 0, [voyage] stderr, no Stop blocking.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const HOOK_PATH = join(__dirname, '..', '..', 'hooks', 'scripts', 'otel-export.mjs');
|
||||
|
||||
function setupDataDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'voyage-otel-data-'));
|
||||
// Seed minimal stats files
|
||||
writeFileSync(join(dir, 'trekplan-stats.jsonl'),
|
||||
JSON.stringify({ ts: '2026-05-09T08:00:00.000Z', slug: 'test', mode: 'default', codebase_files: 100, profile: 'premium' }) + '\n');
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('SC #14: VOYAGE_EXPORT_MODE=off → silent exit 0, no file written', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const target = join(dataDir, 'voyage.prom');
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'off',
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(target), false, 'voyage.prom should NOT be written in off-mode');
|
||||
assert.equal(r.stderr, '', 'no stderr expected');
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: VOYAGE_EXPORT_MODE unset → silent exit 0 (default off behavior)', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const target = join(dataDir, 'voyage.prom');
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
VOYAGE_EXPORT_MODE: '', // explicit empty (mimics unset)
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(target), false);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: VOYAGE_EXPORT_MODE=textfile + valid CLAUDE_PLUGIN_DATA → writes voyage.prom', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const target = join(dataDir, 'voyage.prom');
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'textfile',
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(target), true, `voyage.prom should be written; stderr: ${r.stderr}`);
|
||||
const text = readFileSync(target, 'utf-8');
|
||||
assert.match(text, /# HELP /);
|
||||
assert.match(text, /# TYPE /);
|
||||
assert.match(text, /voyage_trekplan_codebase_files/);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: VOYAGE_EXPORT_MODE=invalid → stderr [voyage] warning + exit 0 (NOT blocking)', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'banana',
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
});
|
||||
assert.equal(r.code, 0, 'invalid mode MUST NOT block Stop');
|
||||
assert.match(r.stderr, /\[voyage\]/);
|
||||
assert.match(r.stderr, /banana/);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: VOYAGE_EXPORT_MODE=otlp + invalid endpoint → stderr [voyage] warn + exit 0', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'otlp',
|
||||
VOYAGE_OTEL_ENDPOINT: 'http://example.com/v1/metrics', // public-http rejected
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.match(r.stderr, /\[voyage\]/);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: tail-latency for textfile mode < 200ms (NFR)', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const start = performance.now();
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'textfile',
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
});
|
||||
const elapsed = performance.now() - start;
|
||||
assert.equal(r.code, 0);
|
||||
// 200ms NFR with extra headroom for cold-start node spawn (~100ms typical)
|
||||
assert.ok(elapsed < 800,
|
||||
`textfile export tail-latency too slow: ${elapsed.toFixed(0)}ms (NFR <200ms in-process; <800ms allowed for cold spawn)`);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: missing CLAUDE_PLUGIN_DATA → silent exit 0', async () => {
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'textfile',
|
||||
CLAUDE_PLUGIN_DATA: '',
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue