ktg-plugin-marketplace/plugins/voyage/tests/hooks/otel-export.test.mjs
Kjell Tore Guttormsen c5fb7456d5 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>
2026-05-09 09:44:13 +02:00

128 lines
4.6 KiB
JavaScript

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