From 52b56dcd5893e0176c905984d4f4001a9964d1ad Mon Sep 17 00:00:00 2001 From: Bryce Zuccaro Date: Fri, 14 Aug 2026 10:39:26 -0600 Subject: [PATCH] feat: add Prometheus metrics parser for llama-swap GPU series --- src/lib/metrics-parser.ts | 67 ++++++++++++++++++++++++++++++++++++ tests/fixtures/metrics.txt | 25 ++++++++++++++ tests/metrics-parser.test.ts | 35 +++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/lib/metrics-parser.ts create mode 100644 tests/fixtures/metrics.txt create mode 100644 tests/metrics-parser.test.ts diff --git a/src/lib/metrics-parser.ts b/src/lib/metrics-parser.ts new file mode 100644 index 0000000..361d8d0 --- /dev/null +++ b/src/lib/metrics-parser.ts @@ -0,0 +1,67 @@ +export interface PromSample { + metric: string; + labels: Record; + value: number; +} + +const LINE_RE = /^([A-Za-z_:][A-Za-z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)$/; + +export function parsePrometheus(text: string): PromSample[] { + const samples: PromSample[] = []; + for (const rawLine of text.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const m = LINE_RE.exec(line); + if (!m) continue; + const labels: Record = {}; + if (m[2]) { + for (const pair of m[2].split(",")) { + const eq = pair.indexOf("="); + if (eq === -1) continue; + const value = pair.slice(eq + 1).trim(); + labels[pair.slice(0, eq).trim()] = value.replace(/^"(.*)"$/, "$1"); + } + } + samples.push({ metric: m[1], labels, value: parseFloat(m[3]) }); + } + return samples; +} + +export type GpuMetricKind = "util_percent" | "memory_util_percent" | "temperature" | "power" | "fan"; + +export const GPU_SERIES: Record = { + llamaswap_gpu_util_percent: "util_percent", + llamaswap_gpu_memory_util_percent: "memory_util_percent", + llamaswap_gpu_temperature_celsius: "temperature", + llamaswap_gpu_power_draw_watts: "power", + llamaswap_gpu_fan_speed_percent: "fan", +}; + +export interface GpuSample { + id: string; + name: string; + kind: GpuMetricKind; + value: number; +} + +export function parseGpuMetrics(text: string): GpuSample[] { + const out: GpuSample[] = []; + for (const s of parsePrometheus(text)) { + const kind = GPU_SERIES[s.metric]; + if (!kind) continue; + const id = s.labels["id"]; + if (id === undefined) continue; + out.push({ id, name: s.labels["name"] ?? "", kind, value: s.value }); + } + return out; +} + +export function gpuInfos(samples: GpuSample[]): { id: string; name: string }[] { + const byId = new Map(); + for (const s of samples) { + if (!byId.has(s.id)) byId.set(s.id, s.name); + } + return [...byId.entries()] + .sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10)) + .map(([id, name]) => ({ id, name })); +} diff --git a/tests/fixtures/metrics.txt b/tests/fixtures/metrics.txt new file mode 100644 index 0000000..9a000f1 --- /dev/null +++ b/tests/fixtures/metrics.txt @@ -0,0 +1,25 @@ +# HELP llamaswap_gpu_util_percent GPU utilization percent (0-100) +# TYPE llamaswap_gpu_util_percent gauge +llamaswap_gpu_util_percent{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 100 +llamaswap_gpu_util_percent{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 100 +llamaswap_gpu_util_percent{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 100 +# HELP llamaswap_gpu_memory_util_percent GPU memory utilization percent (0-100) +# TYPE llamaswap_gpu_memory_util_percent gauge +llamaswap_gpu_memory_util_percent{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 98.41347676402383 +llamaswap_gpu_memory_util_percent{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 98.41245517790922 +llamaswap_gpu_memory_util_percent{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 97.02211181648113 +# HELP llamaswap_gpu_temperature_celsius GPU temperature in Celsius +# TYPE llamaswap_gpu_temperature_celsius gauge +llamaswap_gpu_temperature_celsius{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 51 +llamaswap_gpu_temperature_celsius{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 55 +llamaswap_gpu_temperature_celsius{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 64 +# HELP llamaswap_gpu_power_draw_watts GPU power draw in watts +# TYPE llamaswap_gpu_power_draw_watts gauge +llamaswap_gpu_power_draw_watts{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 316.56 +llamaswap_gpu_power_draw_watts{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 336.17 +llamaswap_gpu_power_draw_watts{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 377.28 +# HELP llamaswap_gpu_fan_speed_percent GPU fan speed percent (0-100) +# TYPE llamaswap_gpu_fan_speed_percent gauge +llamaswap_gpu_fan_speed_percent{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 51 +llamaswap_gpu_fan_speed_percent{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 62 +llamaswap_gpu_fan_speed_percent{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 89 diff --git a/tests/metrics-parser.test.ts b/tests/metrics-parser.test.ts new file mode 100644 index 0000000..a89ed1b --- /dev/null +++ b/tests/metrics-parser.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { gpuInfos, parseGpuMetrics, parsePrometheus } from "../src/lib/metrics-parser"; + +const FIXTURE = readFileSync(new URL("./fixtures/metrics.txt", import.meta.url), "utf8"); + +test("parsePrometheus parses a gauge with labels", () => { + const samples = parsePrometheus(`# TYPE llamaswap_gpu_util_percent gauge\nllamaswap_gpu_util_percent{id="0",name="RTX 5090"} 42.5\n`); + assert.equal(samples.length, 1); + assert.equal(samples[0].metric, "llamaswap_gpu_util_percent"); + assert.deepEqual(samples[0].labels, { id: "0", name: "RTX 5090" }); + assert.equal(samples[0].value, 42.5); +}); + +test("parsePrometheus ignores comments and blank lines", () => { + const samples = parsePrometheus("# HELP x y\n# TYPE x gauge\n\n"); + assert.equal(samples.length, 0); +}); + +test("parseGpuMetrics extracts all five kinds for three GPUs from the fixture", () => { + const samples = parseGpuMetrics(FIXTURE); + assert.equal(samples.length, 15); + const kinds = new Set(samples.map((s) => s.kind)); + assert.deepEqual([...kinds].sort(), ["fan", "memory_util_percent", "power", "temperature", "util_percent"]); + const power0 = samples.find((s) => s.kind === "power" && s.id === "0"); + assert.ok(power0); + assert.equal(power0.value, 316.56); +}); + +test("gpuInfos dedupes, preserves names, sorts numerically by id", () => { + const infos = gpuInfos(parseGpuMetrics(FIXTURE)); + assert.deepEqual(infos.map((i) => i.id), ["0", "1", "2"]); + assert.equal(infos[2].name, "NVIDIA GeForce RTX 5090"); +});