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
139 lines
4.8 KiB
JavaScript
139 lines
4.8 KiB
JavaScript
const http = require("node:http");
|
|
const { setTimeout: delay } = require("node:timers/promises");
|
|
|
|
const { getCpuPercent, getDisk, collectMetrics } = require("../metrics.js");
|
|
|
|
const TOKEN = "test-token";
|
|
|
|
let logtoServer;
|
|
let logtoUrl;
|
|
let logtoDelayMs;
|
|
let server;
|
|
let baseUrl;
|
|
let handler;
|
|
|
|
// Stand-in for the Logto .well-known endpoint, with a settable delay so we can
|
|
// simulate a slow IdP without touching the network.
|
|
function startLogtoStub() {
|
|
logtoDelayMs = 0;
|
|
logtoServer = http.createServer(async (req, res) => {
|
|
if (logtoDelayMs > 0) await delay(logtoDelayMs);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ issuer: "http://127.0.0.1/oidc" }));
|
|
});
|
|
return new Promise((resolve) => {
|
|
logtoServer.listen(0, "127.0.0.1", () => {
|
|
const { port } = logtoServer.address();
|
|
logtoUrl = `http://127.0.0.1:${port}/oidc/.well-known/openid-configuration`;
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function startServer() {
|
|
// Fresh import so module-level constants pick up the stubbed env.
|
|
delete require.cache[require.resolve("../index.js")];
|
|
({ handler } = require("../index.js"));
|
|
server = http.createServer(handler);
|
|
return new Promise((resolve) => {
|
|
server.listen(0, () => {
|
|
const { port } = server.address();
|
|
baseUrl = `http://127.0.0.1:${port}`;
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function close(target) {
|
|
return new Promise((resolve) => {
|
|
if (!target) return resolve();
|
|
target.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
async function get(path, { auth = `Bearer ${TOKEN}` } = {}) {
|
|
const headers = {};
|
|
if (auth) headers.Authorization = auth;
|
|
const res = await fetch(`${baseUrl}${path}`, { headers });
|
|
const body = await res.json().catch(() => ({}));
|
|
return { status: res.status, body };
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await startLogtoStub();
|
|
process.env.HEALTH_TOKEN = TOKEN;
|
|
process.env.LOGTO_HEALTH_URL = logtoUrl;
|
|
await startServer();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await close(server);
|
|
await close(logtoServer);
|
|
});
|
|
|
|
describe("metrics module", () => {
|
|
test("exposes getCpuPercent, getDisk and collectMetrics", () => {
|
|
expect(typeof getCpuPercent).toBe("function");
|
|
expect(typeof getDisk).toBe("function");
|
|
expect(typeof collectMetrics).toBe("function");
|
|
});
|
|
|
|
test("getDisk returns the four numeric disk fields", () => {
|
|
const disk = getDisk();
|
|
expect(Object.keys(disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
|
|
for (const value of Object.values(disk)) expect(typeof value).toBe("number");
|
|
});
|
|
|
|
test("collectMetrics returns the cpu/memory/disk slice of /health", async () => {
|
|
const metrics = await collectMetrics();
|
|
expect(Object.keys(metrics)).toEqual(["cpu", "memory", "disk"]);
|
|
expect(Object.keys(metrics.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
|
|
expect(Object.keys(metrics.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
|
|
expect(metrics.cpu.cores).toBeGreaterThan(0);
|
|
expect(Array.isArray(metrics.cpu.loadAvg)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("GET /health", () => {
|
|
test("401 on invalid token", async () => {
|
|
const { status } = await get("/health", { auth: "Bearer wrong" });
|
|
expect(status).toBe(401);
|
|
});
|
|
|
|
test("200 returns the documented payload, field for field", async () => {
|
|
const { status, body } = await get("/health");
|
|
expect(status).toBe(200);
|
|
// Key order is part of the contract consumed by the admin dashboard.
|
|
expect(Object.keys(body)).toEqual([
|
|
"timestamp",
|
|
"hostname",
|
|
"uptime",
|
|
"cpu",
|
|
"memory",
|
|
"disk",
|
|
"logto",
|
|
]);
|
|
expect(Object.keys(body.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
|
|
expect(Object.keys(body.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
|
|
expect(Object.keys(body.disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
|
|
expect(body.logto.status).toBe("up");
|
|
expect(typeof body.logto.responseTimeMs).toBe("number");
|
|
expect(typeof body.uptime).toBe("number");
|
|
expect(typeof body.hostname).toBe("string");
|
|
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
|
|
});
|
|
|
|
// Latency regression guard. Metrics collection (500ms CPU sample) and the
|
|
// Logto check must run concurrently: serializing them would make /health take
|
|
// 500ms + the Logto response time. With a 1200ms Logto, concurrent lands at
|
|
// ~1200ms while serialized lands at ~1700ms — only the former clears 1.5s.
|
|
test("stays under 1.5s with a slow Logto (metrics stay concurrent)", async () => {
|
|
logtoDelayMs = 1200;
|
|
const start = performance.now();
|
|
const { status, body } = await get("/health");
|
|
const elapsed = performance.now() - start;
|
|
expect(status).toBe(200);
|
|
expect(body.logto.status).toBe("up");
|
|
expect(elapsed).toBeLessThan(1500);
|
|
}, 15000);
|
|
});
|