/* Pure conversion from accumulated metric points to OTLP metric points (RFC-0337). Extracted from pusher.ts for testability without HTTP I/O. Do not import node: modules — must be Workers-compatible. Do not perform HTTP — that lives in pusher.ts. Do not validate metric names/labels — pusher.ts validates at accumulation time. @ai-invariant: Histogram bucket boundaries are inclusive — a value exactly on a bound goes into the lower bucket (value <= bounds[i]). RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. */ import { findMetricSpec } from "./metric-registry.ts"; import { nowUnixNano, type OtlpGaugePoint, type OtlpHistogramPoint, type OtlpMetricPoint, type OtlpSumPoint, type UnixNanoString, } from "./otlp-json.ts"; import type { OtlpKeyValue } from "./conventions.ts"; export interface AccumulatedPoint { name: string; labels: Record; value: number; kind: "counter" | "gauge" | "histogram"; } function labelsToAttributes(labels: Record): OtlpKeyValue[] { return Object.entries(labels).map(([key, value]) => ({ key, value: { stringValue: value }, })); } export function convertAccumulatedToOtlp( points: readonly AccumulatedPoint[], startTimeNano: UnixNanoString, ): OtlpMetricPoint[] { const byName = new Map(); for (const pt of points) { const existing = byName.get(pt.name); if (existing) { existing.push(pt); } else { byName.set(pt.name, [pt]); } } const otlpPoints: OtlpMetricPoint[] = []; for (const [name, group] of byName) { const spec = findMetricSpec(name); if (!spec) continue; if (spec.kind === "counter") { const sumPoints: OtlpSumPoint[] = group.map((pt) => ({ asDouble: pt.value, startTimeUnixNano: startTimeNano, timeUnixNano: nowUnixNano(), attributes: labelsToAttributes(pt.labels), })); otlpPoints.push({ name, kind: "sum", unit: spec.unit, isMonotonic: true, sumPoints, }); } else if (spec.kind === "gauge") { const gaugePoints: OtlpGaugePoint[] = group.map((pt) => ({ asDouble: pt.value, timeUnixNano: nowUnixNano(), attributes: labelsToAttributes(pt.labels), })); otlpPoints.push({ name, kind: "gauge", unit: spec.unit, gaugePoints, }); } else if (spec.kind === "histogram") { const bounds = spec.buckets ?? []; const bucketCounts = new Array(bounds.length + 1).fill(0) as number[]; let sum = 0; for (const pt of group) { sum += pt.value; let placed = false; for (let i = 0; i < bounds.length; i++) { if (pt.value <= bounds[i]!) { bucketCounts[i]!++; placed = true; break; } } if (!placed) { bucketCounts[bucketCounts.length - 1]!++; } } const histogramPoints: OtlpHistogramPoint[] = [ { startTimeUnixNano: startTimeNano, timeUnixNano: nowUnixNano() as UnixNanoString, count: String(group.length), sum, bucketCounts: bucketCounts.map(String), explicitBounds: [...bounds], }, ]; otlpPoints.push({ name, kind: "histogram", unit: spec.unit, histogramPoints, }); } } return otlpPoints; }