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>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-09 09:32:29 +02:00
commit 08ecdc918d
2 changed files with 307 additions and 0 deletions

View file

@ -0,0 +1,197 @@
// lib/exporters/otlp-format.mjs
// Pure transform: voyage JSONL stats records → OTLP/JSON v1.0 metrics payload.
//
// Per OpenTelemetry Protocol § metrics.proto + research/01 dim 4 (CRITICAL):
// AggregationTemporality enum values are INTEGERS in JSON, NOT strings.
// "CUMULATIVE" → 2 (not the string)
// "DELTA" → 1
// timeUnixNano is uint64 over the wire — emit as decimal STRING in JSON to
// avoid JS Number precision loss (per research/01 + risk-assessor CRITICAL 2).
//
// Output contract:
// {
// resourceMetrics: [{
// resource: { attributes: [...] },
// scopeMetrics: [{
// scope: { name: 'voyage', version: '...' },
// metrics: [{
// name: 'voyage.<metric>',
// description: '...',
// unit: '1' | 'ms' | ...,
// sum: { dataPoints: [{ ... aggregationTemporality: <int> ...}] }
// | gauge: { dataPoints: [...] }
// }]
// }]
// }]
// }
// ---- Inline integer enum constants (CRITICAL: integers, NOT strings) -------
const AGG_TEMPORALITY_UNSPECIFIED = 0;
const AGG_TEMPORALITY_DELTA = 1;
const AGG_TEMPORALITY_CUMULATIVE = 2;
const DATA_POINT_FLAGS_NONE = 0;
const DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK = 1;
const VOYAGE_SCOPE_NAME = 'voyage';
const VOYAGE_SCOPE_VERSION = '4.1.0';
// ---- Helpers ---------------------------------------------------------------
/**
* Convert ISO-8601 timestamp to OTLP timeUnixNano (uint64 as decimal STRING).
* Avoids Number precision loss for nanosecond-scale values.
*/
function toUnixNanoString(iso) {
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return '0';
// ms × 1e6 = nanoseconds; use BigInt for precision
return (BigInt(ms) * 1000000n).toString();
}
/**
* Build OTLP attribute object: {key, value: {stringValue: "..."}} or numeric variants.
*/
function attribute(key, value) {
if (typeof value === 'string') return { key, value: { stringValue: value } };
if (typeof value === 'boolean') return { key, value: { boolValue: value } };
if (typeof value === 'number' && Number.isInteger(value)) return { key, value: { intValue: String(value) } };
if (typeof value === 'number') return { key, value: { doubleValue: value } };
return { key, value: { stringValue: String(value) } };
}
/**
* Partition record into numeric metrics and string/bool labels.
* (Same convention as textfile-format.mjs.)
*/
function partitionRecord(record) {
const labels = {};
const metrics = {};
for (const [k, v] of Object.entries(record)) {
if (k === 'ts' || k === '_schema_id') continue;
if (typeof v === 'number') metrics[k] = v;
else if (typeof v === 'boolean') metrics[k] = v ? 1 : 0;
else if (typeof v === 'string') labels[k] = v;
}
return { labels, metrics };
}
/**
* Build OTLP DataPoint object for a numeric value.
*/
function dataPoint(value, ts, labels) {
const tsNano = toUnixNanoString(ts);
return {
attributes: Object.entries(labels).map(([k, v]) => attribute(k, v)),
startTimeUnixNano: tsNano,
timeUnixNano: tsNano,
asDouble: Number.isInteger(value) ? undefined : value,
asInt: Number.isInteger(value) ? String(value) : undefined,
flags: DATA_POINT_FLAGS_NONE,
};
}
/**
* Decide metric "kind": sum (for *_total/*_count/*_passed/*_failed) or gauge.
* Sum metrics get aggregationTemporality + isMonotonic; gauges get neither.
*/
function isSumMetric(name) {
return /_total$|_count$|_passed$|_failed$|_skipped$/.test(name);
}
/**
* Transform JSONL records OTLP/JSON metrics payload. Pure function.
*
* @param {Array<object>} records Allowlist-redacted records (caller responsibility).
* @param {{help?: object}} [opts]
* @returns {object} OTLP-shaped payload (POST body for /v1/metrics).
*/
export function transformToOtlpJson(records, opts = {}) {
const helpMap = opts.help || {};
if (!Array.isArray(records) || records.length === 0) {
return {
resourceMetrics: [{
resource: { attributes: [attribute('service.name', VOYAGE_SCOPE_NAME)] },
scopeMetrics: [{
scope: { name: VOYAGE_SCOPE_NAME, version: VOYAGE_SCOPE_VERSION },
metrics: [],
}],
}],
};
}
// Group all data points by metric name (schema_id_field).
const metricsMap = new Map();
for (const record of records) {
const schemaId = (record && typeof record._schema_id === 'string') ? record._schema_id : 'unknown';
const ts = record.ts || new Date().toISOString();
const { labels, metrics } = partitionRecord(record);
const allLabels = { ...labels, _schema_id: schemaId };
for (const [field, value] of Object.entries(metrics)) {
const name = `${VOYAGE_SCOPE_NAME}.${schemaId}.${field}`;
if (!metricsMap.has(name)) {
metricsMap.set(name, {
name,
description: helpMap[name] || `voyage stats — ${schemaId}.${field}`,
unit: /_ms$|_duration/.test(field) ? 'ms' : (/_seconds$/.test(field) ? 's' : '1'),
dataPoints: [],
isSum: isSumMetric(name),
});
}
metricsMap.get(name).dataPoints.push(dataPoint(value, ts, allLabels));
}
}
// Sort metrics for deterministic output
const sortedNames = [...metricsMap.keys()].sort();
const otlpMetrics = sortedNames.map(name => {
const m = metricsMap.get(name);
if (m.isSum) {
return {
name: m.name,
description: m.description,
unit: m.unit,
sum: {
dataPoints: m.dataPoints,
aggregationTemporality: AGG_TEMPORALITY_CUMULATIVE, // INTEGER 2
isMonotonic: true,
},
};
}
return {
name: m.name,
description: m.description,
unit: m.unit,
gauge: {
dataPoints: m.dataPoints,
},
};
});
return {
resourceMetrics: [{
resource: {
attributes: [
attribute('service.name', VOYAGE_SCOPE_NAME),
attribute('service.version', VOYAGE_SCOPE_VERSION),
],
},
scopeMetrics: [{
scope: { name: VOYAGE_SCOPE_NAME, version: VOYAGE_SCOPE_VERSION },
metrics: otlpMetrics,
}],
}],
};
}
export {
AGG_TEMPORALITY_UNSPECIFIED,
AGG_TEMPORALITY_DELTA,
AGG_TEMPORALITY_CUMULATIVE,
DATA_POINT_FLAGS_NONE,
DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK,
};