ktg-plugin-marketplace/plugins/voyage/tests/hooks/otel-export-otlp.test.mjs
Kjell Tore Guttormsen 08ecdc918d feat(voyage): add lib/exporters/otlp-format.mjs — OTLP/JSON enum-integer SC #13
Step 10 av v4.1-execute (Wave 2, Session 3).

Pure function transformToOtlpJson(records) → OTLP/JSON v1.0 metrics payload
matching OTLP metrics.proto wire format.

CRITICAL (per research/01 dim 4 + risk-assessor CRITICAL 2):
- AggregationTemporality enum values er INTEGERS i JSON, IKKE strings
  ("CUMULATIVE" → 2, ikke "CUMULATIVE")
- timeUnixNano er uint64 over wire — emit som decimal STRING i JSON for å
  unngå JS Number precision loss på nanosekund-skala

Inline integer enum constants ved module-scope:
- AGG_TEMPORALITY_UNSPECIFIED = 0
- AGG_TEMPORALITY_DELTA = 1
- AGG_TEMPORALITY_CUMULATIVE = 2
- DATA_POINT_FLAGS_NONE = 0
- DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK = 1

Output struktur: resourceMetrics → scopeMetrics → metrics array. Sum-metrics
(counters: *_total, *_count, *_passed, *_failed, *_skipped) får sum +
isMonotonic + aggregationTemporality. Andre får gauge.

Tester (7 nye, baseline 406 → 413):
- SC #13: typeof aggregationTemporality === 'number' (HEART of SC #13)
- SC #13: enum-konstant drift-pin (typeof + verdi-assert)
- SC #13: typeof timeUnixNano === 'string' (precision-loss mitigation)
- SC #13: strukturell shape-assertion
- Empty input → valid envelope, tomt metrics-array
- isSum heuristic counter vs gauge
- Allowlist-redaksjon sanity (command_excerpt + session_id leaker ikke)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 09:32:29 +02:00

110 lines
5.1 KiB
JavaScript

// tests/hooks/otel-export-otlp.test.mjs
// SC #13: lib/exporters/otlp-format.mjs returns OTLP/JSON v1.0 metrics payload
// with INTEGER (not string) enum constants and timeUnixNano as decimal STRING
// (JS precision-loss mitigation per research/01 + risk-assessor CRITICAL 2).
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 {
transformToOtlpJson,
AGG_TEMPORALITY_CUMULATIVE,
AGG_TEMPORALITY_DELTA,
AGG_TEMPORALITY_UNSPECIFIED,
} from '../../lib/exporters/otlp-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 #13: aggregationTemporality is INTEGER (typeof === "number"), not string', () => {
const records = loadJsonl('stats-sample.jsonl');
const payload = transformToOtlpJson(records);
// Find a sum metric (steps_passed is a counter)
const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics;
const sumMetric = metrics.find(m => 'sum' in m);
assert.ok(sumMetric, `expected at least one sum-metric in payload, got ${metrics.length} metrics`);
// CRITICAL: this assertion is the heart of SC #13 — typeof MUST be 'number'
assert.equal(typeof sumMetric.sum.aggregationTemporality, 'number',
`aggregationTemporality must be INTEGER (typeof number), got ${typeof sumMetric.sum.aggregationTemporality}`);
assert.equal(sumMetric.sum.aggregationTemporality, AGG_TEMPORALITY_CUMULATIVE);
assert.equal(sumMetric.sum.aggregationTemporality, 2);
});
test('SC #13: enum constants exported as integer literals (drift-pin)', () => {
assert.equal(typeof AGG_TEMPORALITY_UNSPECIFIED, 'number');
assert.equal(AGG_TEMPORALITY_UNSPECIFIED, 0);
assert.equal(typeof AGG_TEMPORALITY_DELTA, 'number');
assert.equal(AGG_TEMPORALITY_DELTA, 1);
assert.equal(typeof AGG_TEMPORALITY_CUMULATIVE, 'number');
assert.equal(AGG_TEMPORALITY_CUMULATIVE, 2);
});
test('SC #13: timeUnixNano is decimal STRING (typeof === "string"), JS precision-loss mitigation', () => {
const records = loadJsonl('stats-sample.jsonl');
const payload = transformToOtlpJson(records);
const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics;
// Pick first metric with a data point
const m = metrics.find(x => (x.sum?.dataPoints?.length || x.gauge?.dataPoints?.length) > 0);
const dp = (m.sum || m.gauge).dataPoints[0];
assert.equal(typeof dp.timeUnixNano, 'string',
`timeUnixNano must be decimal STRING, got ${typeof dp.timeUnixNano}: ${dp.timeUnixNano}`);
assert.equal(typeof dp.startTimeUnixNano, 'string');
// Should be a valid decimal-digit string
assert.match(dp.timeUnixNano, /^\d+$/);
});
test('SC #13: structural shape — resourceMetrics[].scopeMetrics[].metrics[] hierarchy', () => {
const records = loadJsonl('stats-sample.jsonl');
const payload = transformToOtlpJson(records);
assert.ok(Array.isArray(payload.resourceMetrics));
assert.ok(payload.resourceMetrics.length >= 1);
assert.ok(payload.resourceMetrics[0].resource);
assert.ok(Array.isArray(payload.resourceMetrics[0].scopeMetrics));
assert.ok(payload.resourceMetrics[0].scopeMetrics[0].scope);
assert.equal(payload.resourceMetrics[0].scopeMetrics[0].scope.name, 'voyage');
assert.ok(Array.isArray(payload.resourceMetrics[0].scopeMetrics[0].metrics));
});
test('Empty input: returns valid OTLP envelope with empty metrics array', () => {
const payload = transformToOtlpJson([]);
assert.ok(Array.isArray(payload.resourceMetrics));
assert.equal(payload.resourceMetrics[0].scopeMetrics[0].metrics.length, 0);
});
test('isSum heuristic: counter-named metrics get sum + isMonotonic; others get gauge', () => {
const records = [
{ _schema_id: 'test', ts: '2026-05-09T08:00:00.000Z', steps_total: 10 }, // counter
{ _schema_id: 'test', ts: '2026-05-09T08:00:00.000Z', cpu_pct: 42.5 }, // gauge
];
const payload = transformToOtlpJson(records);
const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics;
const totalMetric = metrics.find(m => m.name.endsWith('steps_total'));
const cpuMetric = metrics.find(m => m.name.endsWith('cpu_pct'));
assert.ok(totalMetric.sum, 'counter should have sum');
assert.equal(totalMetric.sum.isMonotonic, true);
assert.equal(typeof totalMetric.sum.aggregationTemporality, 'number');
assert.ok(cpuMetric.gauge, 'non-counter should have gauge');
assert.ok(!cpuMetric.sum, 'gauge should not have sum');
});
test('Allowlist redacted: callers strip command_excerpt before passing — verify nothing leaks', () => {
const record = {
_schema_id: 'post_bash_stats',
ts: '2026-05-09T08:00:00.000Z',
duration_ms: 152,
success: true,
};
const payload = transformToOtlpJson([record]);
const json = JSON.stringify(payload);
assert.ok(!json.includes('command_excerpt'));
assert.ok(!json.includes('session_id'));
// Should contain duration_ms metric
assert.match(json, /post_bash_stats\.duration_ms/);
});