#!/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); })();