From c5fb7456d57efe7419f55ad19f5cb887e5a9a241 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 9 May 2026 09:44:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(voyage):=20add=20hooks/scripts/otel-export?= =?UTF-8?q?.mjs=20=E2=80=94=20Stop-hook=20orchestration=20SC=20#14,=20opt-?= =?UTF-8?q?in=20via=20VOYAGE=5FEXPORT=5FMODE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- plugins/voyage/hooks/scripts/otel-export.mjs | 159 ++++++++++++++++++ .../voyage/tests/hooks/otel-export.test.mjs | 128 ++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 plugins/voyage/hooks/scripts/otel-export.mjs create mode 100644 plugins/voyage/tests/hooks/otel-export.test.mjs diff --git a/plugins/voyage/hooks/scripts/otel-export.mjs b/plugins/voyage/hooks/scripts/otel-export.mjs new file mode 100644 index 0000000..d36f46a --- /dev/null +++ b/plugins/voyage/hooks/scripts/otel-export.mjs @@ -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); +})(); diff --git a/plugins/voyage/tests/hooks/otel-export.test.mjs b/plugins/voyage/tests/hooks/otel-export.test.mjs new file mode 100644 index 0000000..7010a7c --- /dev/null +++ b/plugins/voyage/tests/hooks/otel-export.test.mjs @@ -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); +});