vps-health-api/metrics.js
le king fu 79cb813767 refactor: extract CPU/RAM/disk collection into metrics.js
Move readProcStat, getCpuPercent, getDisk and the cpu/memory/disk payload
assembly out of index.js into a standalone metrics.js, so the upcoming
local workstation agent can reuse the same collection code. No behaviour
change: /health returns the exact same fields, in the same order.

Notes:
- collectMetrics() returns the { cpu, memory, disk } slice; getHealth()
  spreads it, keeping the JSON key order the admin dashboard relies on.
- getHealth() keeps Promise.all([collectMetrics(), getLogtoHealth()]).
  The 500ms CPU sample and the 3s Logto check are deliberately concurrent;
  serializing them would push the p99 of /health to ~3.5s.
- collectMetrics() awaits the CPU sample as its only await, so callers
  running it inside a Promise.all keep their concurrency.
- Dockerfile COPY lists files one by one, so metrics.js had to be added
  there or the container would crash on MODULE_NOT_FOUND at startup.
- New __tests__/health.test.js: metrics.js exports, field-for-field
  payload shape, and a latency guard (<1.5s with a 1200ms stubbed Logto).
  Verified by mutation: serializing the two calls fails the latency test
  at ~1743ms while the field comparison still passes.
- Runtime stays 0-dependency; the 14 existing tests are untouched.

Resolves #11
2026-08-16 11:30:03 -04:00

84 lines
2.9 KiB
JavaScript

const os = require("node:os");
const { execSync } = require("node:child_process");
const { setTimeout: delay } = require("node:timers/promises");
// Host metrics collection (CPU / memory / disk), extracted from index.js so the
// same code can be reused by the local workstation agent. Pure host probing:
// no HTTP, no config, no side effects — the caller owns the response shape.
function readProcStat() {
try {
const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim();
const parts = line.split(/\s+/).slice(1).map(Number);
const idle = parts[3] + parts[4];
const total = parts.reduce((a, b) => a + b, 0);
return { idle, total };
} catch {
return null;
}
}
async function getCpuPercent() {
const t1 = readProcStat();
if (!t1) return 0;
const { idle: idle1, total: total1 } = t1;
// Sample over 500ms without blocking the event loop, so other async work
// (e.g. the Logto healthcheck) can run concurrently.
await delay(500);
const t2 = readProcStat();
if (!t2) return 0;
const dIdle = t2.idle - idle1;
const dTotal = t2.total - total1;
if (dTotal === 0) return 0;
return Math.round((1 - dIdle / dTotal) * 100);
}
function getDisk() {
try {
// Alpine df doesn't support --output, use standard POSIX format
const out = execSync("df -k /", { encoding: "utf-8" });
const parts = out.trim().split("\n")[1].trim().split(/\s+/);
// df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted
const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1);
const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1);
const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1);
const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0;
return { totalGB, usedGB, freeGB, usagePercent };
} catch {
return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 };
}
}
// Collect CPU, memory and disk into the `{ cpu, memory, disk }` slice of the
// /health payload. Awaiting getCpuPercent() first is deliberate: the 500ms
// sample is the only await here, so a caller running this inside a
// Promise.all() keeps its other work (the Logto check) fully concurrent.
// Everything else is cheap and synchronous.
async function collectMetrics() {
const cpus = os.cpus();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const cpuUsagePercent = await getCpuPercent();
return {
cpu: {
model: cpus[0]?.model?.trim() || "unknown",
cores: cpus.length,
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
usagePercent: cpuUsagePercent,
},
memory: {
totalGB: +(totalMem / 1e9).toFixed(1),
usedGB: +(usedMem / 1e9).toFixed(1),
freeGB: +(freeMem / 1e9).toFixed(1),
usagePercent: Math.round((usedMem / totalMem) * 100),
},
disk: getDisk(),
};
}
module.exports = { getCpuPercent, getDisk, collectMetrics };