feat(telemetry): add canonical VPS host monitoring
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@nodedc/infrastructure-telemetry-contract",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./src/index.mjs",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
export const HOST_TELEMETRY_SCHEMA =
|
||||
"nodedc.infrastructure.host-telemetry.v1";
|
||||
|
||||
export const HOST_TELEMETRY_PROFILE = "linux-host-telegraf-v1";
|
||||
|
||||
const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
|
||||
|
||||
export function telegrafBatchToHostTelemetry(input, context = {}) {
|
||||
const metrics = telegrafMetrics(input);
|
||||
const observedAt = latestMetricTimestamp(metrics) ?? normalizeTimestamp(
|
||||
context.observedAt ?? new Date().toISOString(),
|
||||
"observed_at",
|
||||
);
|
||||
const byName = new Map();
|
||||
for (const metric of metrics) {
|
||||
const values = byName.get(metric.name) ?? [];
|
||||
values.push(metric);
|
||||
byName.set(metric.name, values);
|
||||
}
|
||||
|
||||
const cpu = metricWithTag(byName.get("cpu"), "cpu", "cpu-total")
|
||||
?? firstMetric(byName.get("cpu"));
|
||||
const memory = firstMetric(byName.get("mem"));
|
||||
const swap = firstMetric(byName.get("swap"));
|
||||
const system = firstMetric(byName.get("system"));
|
||||
const processes = firstMetric(byName.get("processes"));
|
||||
const systemCpu = firstMetric(byName.get("system_cpu"));
|
||||
|
||||
return normalizeHostTelemetrySnapshot({
|
||||
schemaVersion: HOST_TELEMETRY_SCHEMA,
|
||||
profile: HOST_TELEMETRY_PROFILE,
|
||||
hostKey: context.hostKey,
|
||||
observedAt,
|
||||
source: {
|
||||
agent: "telegraf",
|
||||
agentVersion: context.agentVersion ?? "1.38.4",
|
||||
collectorRef: context.collectorRef ?? "service:nodedc-host-telemetry-agent",
|
||||
},
|
||||
hardware: {
|
||||
hostname: context.hostname ?? metricHost(metrics),
|
||||
architecture: context.architecture ?? null,
|
||||
platform: context.platform ?? "linux",
|
||||
kernelRelease: context.kernelRelease ?? null,
|
||||
cpuModel: context.cpuModel ?? null,
|
||||
logicalProcessors: finiteInteger(
|
||||
context.logicalProcessors ?? systemCpu?.fields.cpu_count,
|
||||
),
|
||||
},
|
||||
cpu: {
|
||||
usagePercent: finiteNumber(cpu?.fields.usage_active)
|
||||
?? percentFromIdle(cpu?.fields.usage_idle),
|
||||
load1: finiteNumber(system?.fields.load1),
|
||||
load5: finiteNumber(system?.fields.load5),
|
||||
load15: finiteNumber(system?.fields.load15),
|
||||
},
|
||||
memory: {
|
||||
totalBytes: finiteInteger(memory?.fields.total),
|
||||
availableBytes: finiteInteger(memory?.fields.available),
|
||||
usedBytes: finiteInteger(memory?.fields.used),
|
||||
usedPercent: finiteNumber(memory?.fields.used_percent),
|
||||
},
|
||||
swap: {
|
||||
totalBytes: finiteInteger(swap?.fields.total),
|
||||
freeBytes: finiteInteger(swap?.fields.free),
|
||||
usedBytes: finiteInteger(swap?.fields.used),
|
||||
usedPercent: finiteNumber(swap?.fields.used_percent),
|
||||
},
|
||||
system: {
|
||||
uptimeSeconds: finiteInteger(system?.fields.uptime),
|
||||
users: finiteInteger(system?.fields.n_users),
|
||||
processes: {
|
||||
total: finiteInteger(processes?.fields.total),
|
||||
running: finiteInteger(processes?.fields.running),
|
||||
sleeping: finiteInteger(processes?.fields.sleeping),
|
||||
blocked: finiteInteger(processes?.fields.blocked),
|
||||
zombies: finiteInteger(processes?.fields.zombies),
|
||||
},
|
||||
},
|
||||
disks: (byName.get("disk") ?? []).map((metric) => ({
|
||||
device: textOrNull(metric.tags.device),
|
||||
mount: textOrNull(metric.tags.path),
|
||||
filesystem: textOrNull(metric.tags.fstype),
|
||||
totalBytes: finiteInteger(metric.fields.total),
|
||||
freeBytes: finiteInteger(metric.fields.free),
|
||||
usedBytes: finiteInteger(metric.fields.used),
|
||||
usedPercent: finiteNumber(metric.fields.used_percent),
|
||||
})),
|
||||
network: (byName.get("net") ?? []).map((metric) => ({
|
||||
interface: textOrNull(metric.tags.interface),
|
||||
bytesReceived: finiteInteger(metric.fields.bytes_recv),
|
||||
bytesSent: finiteInteger(metric.fields.bytes_sent),
|
||||
packetsReceived: finiteInteger(metric.fields.packets_recv),
|
||||
packetsSent: finiteInteger(metric.fields.packets_sent),
|
||||
errorsReceived: finiteInteger(metric.fields.err_in),
|
||||
errorsSent: finiteInteger(metric.fields.err_out),
|
||||
droppedReceived: finiteInteger(metric.fields.drop_in),
|
||||
droppedSent: finiteInteger(metric.fields.drop_out),
|
||||
})),
|
||||
services: (byName.get("systemd_units") ?? []).map((metric) => ({
|
||||
name: textOrNull(metric.tags.name),
|
||||
loadState: textOrNull(metric.tags.load),
|
||||
activeState: textOrNull(metric.tags.active),
|
||||
subState: textOrNull(metric.tags.sub),
|
||||
memoryBytes: finiteInteger(metric.fields.mem_current),
|
||||
restarts: finiteInteger(metric.fields.restarts),
|
||||
pid: finiteInteger(metric.fields.pid),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeHostTelemetrySnapshot(input) {
|
||||
assertPlainObject(input, "host_telemetry");
|
||||
if (input.schemaVersion !== HOST_TELEMETRY_SCHEMA) {
|
||||
throw new TypeError("host_telemetry_schema_invalid");
|
||||
}
|
||||
if (input.profile !== HOST_TELEMETRY_PROFILE) {
|
||||
throw new TypeError("host_telemetry_profile_invalid");
|
||||
}
|
||||
const snapshot = {
|
||||
schemaVersion: HOST_TELEMETRY_SCHEMA,
|
||||
profile: HOST_TELEMETRY_PROFILE,
|
||||
hostKey: normalizeRef(input.hostKey, "host_key"),
|
||||
observedAt: normalizeTimestamp(input.observedAt, "observed_at"),
|
||||
source: normalizeSource(input.source),
|
||||
hardware: normalizeHardware(input.hardware),
|
||||
cpu: normalizeCpu(input.cpu),
|
||||
memory: normalizeMemory(input.memory, "memory"),
|
||||
swap: normalizeMemory(input.swap, "swap"),
|
||||
system: normalizeSystem(input.system),
|
||||
disks: normalizeArray(input.disks, normalizeDisk, 32),
|
||||
network: normalizeArray(input.network, normalizeNetwork, 64),
|
||||
services: normalizeArray(input.services, normalizeService, 64),
|
||||
};
|
||||
return deepFreeze(snapshot);
|
||||
}
|
||||
|
||||
function telegrafMetrics(input) {
|
||||
const candidate = Array.isArray(input)
|
||||
? input
|
||||
: input && typeof input === "object" && Array.isArray(input.metrics)
|
||||
? input.metrics
|
||||
: input && typeof input === "object"
|
||||
? [input]
|
||||
: null;
|
||||
if (!candidate || candidate.length < 1 || candidate.length > 512) {
|
||||
throw new TypeError("host_telemetry_telegraf_batch_invalid");
|
||||
}
|
||||
return candidate.map((metric) => {
|
||||
assertPlainObject(metric, "host_telemetry_telegraf_metric");
|
||||
assertPlainObject(metric.fields, "host_telemetry_telegraf_fields");
|
||||
const name = text(metric.name, 1, 80, "host_telemetry_telegraf_name_invalid");
|
||||
const tags = metric.tags == null ? {} : metric.tags;
|
||||
assertPlainObject(tags, "host_telemetry_telegraf_tags");
|
||||
return { name, fields: { ...metric.fields }, tags: { ...tags }, timestamp: metric.timestamp };
|
||||
});
|
||||
}
|
||||
|
||||
function latestMetricTimestamp(metrics) {
|
||||
let latest = null;
|
||||
for (const metric of metrics) {
|
||||
const raw = Number(metric.timestamp);
|
||||
if (!Number.isFinite(raw) || raw <= 0) continue;
|
||||
const milliseconds = raw > 10_000_000_000 ? raw / 1_000_000 : raw * 1000;
|
||||
if (!Number.isFinite(milliseconds)) continue;
|
||||
const value = new Date(milliseconds).toISOString();
|
||||
if (!latest || value > latest) latest = value;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function metricWithTag(metrics = [], key, value) {
|
||||
return metrics.find((metric) => metric.tags[key] === value) ?? null;
|
||||
}
|
||||
|
||||
function firstMetric(metrics = []) {
|
||||
return metrics[0] ?? null;
|
||||
}
|
||||
|
||||
function metricHost(metrics) {
|
||||
for (const metric of metrics) {
|
||||
if (typeof metric.tags.host === "string" && metric.tags.host.trim()) {
|
||||
return metric.tags.host.trim().slice(0, 160);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSource(input) {
|
||||
assertPlainObject(input, "host_telemetry_source");
|
||||
return {
|
||||
agent: text(input.agent, 1, 64, "host_telemetry_agent_invalid"),
|
||||
agentVersion: text(input.agentVersion, 1, 64, "host_telemetry_agent_version_invalid"),
|
||||
collectorRef: normalizeRef(input.collectorRef, "collector_ref"),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHardware(input) {
|
||||
assertPlainObject(input, "host_telemetry_hardware");
|
||||
return {
|
||||
hostname: optionalText(input.hostname, 160),
|
||||
architecture: optionalText(input.architecture, 64),
|
||||
platform: optionalText(input.platform, 64),
|
||||
kernelRelease: optionalText(input.kernelRelease, 160),
|
||||
cpuModel: optionalText(input.cpuModel, 256),
|
||||
logicalProcessors: optionalInteger(input.logicalProcessors, 1_024),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCpu(input) {
|
||||
assertPlainObject(input, "host_telemetry_cpu");
|
||||
return {
|
||||
usagePercent: optionalPercent(input.usagePercent),
|
||||
load1: optionalNumber(input.load1, 0, 100_000),
|
||||
load5: optionalNumber(input.load5, 0, 100_000),
|
||||
load15: optionalNumber(input.load15, 0, 100_000),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMemory(input, field) {
|
||||
assertPlainObject(input, `host_telemetry_${field}`);
|
||||
return {
|
||||
totalBytes: optionalInteger(input.totalBytes, Number.MAX_SAFE_INTEGER),
|
||||
availableBytes: optionalInteger(input.availableBytes, Number.MAX_SAFE_INTEGER),
|
||||
freeBytes: optionalInteger(input.freeBytes, Number.MAX_SAFE_INTEGER),
|
||||
usedBytes: optionalInteger(input.usedBytes, Number.MAX_SAFE_INTEGER),
|
||||
usedPercent: optionalPercent(input.usedPercent),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSystem(input) {
|
||||
assertPlainObject(input, "host_telemetry_system");
|
||||
assertPlainObject(input.processes, "host_telemetry_processes");
|
||||
return {
|
||||
uptimeSeconds: optionalInteger(input.uptimeSeconds, Number.MAX_SAFE_INTEGER),
|
||||
users: optionalInteger(input.users, 1_000_000),
|
||||
processes: {
|
||||
total: optionalInteger(input.processes.total, 1_000_000),
|
||||
running: optionalInteger(input.processes.running, 1_000_000),
|
||||
sleeping: optionalInteger(input.processes.sleeping, 1_000_000),
|
||||
blocked: optionalInteger(input.processes.blocked, 1_000_000),
|
||||
zombies: optionalInteger(input.processes.zombies, 1_000_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDisk(input) {
|
||||
assertPlainObject(input, "host_telemetry_disk");
|
||||
return {
|
||||
device: optionalText(input.device, 256),
|
||||
mount: optionalText(input.mount, 512),
|
||||
filesystem: optionalText(input.filesystem, 64),
|
||||
totalBytes: optionalInteger(input.totalBytes, Number.MAX_SAFE_INTEGER),
|
||||
freeBytes: optionalInteger(input.freeBytes, Number.MAX_SAFE_INTEGER),
|
||||
usedBytes: optionalInteger(input.usedBytes, Number.MAX_SAFE_INTEGER),
|
||||
usedPercent: optionalPercent(input.usedPercent),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNetwork(input) {
|
||||
assertPlainObject(input, "host_telemetry_network");
|
||||
return {
|
||||
interface: optionalText(input.interface, 64),
|
||||
bytesReceived: optionalInteger(input.bytesReceived, Number.MAX_SAFE_INTEGER),
|
||||
bytesSent: optionalInteger(input.bytesSent, Number.MAX_SAFE_INTEGER),
|
||||
packetsReceived: optionalInteger(input.packetsReceived, Number.MAX_SAFE_INTEGER),
|
||||
packetsSent: optionalInteger(input.packetsSent, Number.MAX_SAFE_INTEGER),
|
||||
errorsReceived: optionalInteger(input.errorsReceived, Number.MAX_SAFE_INTEGER),
|
||||
errorsSent: optionalInteger(input.errorsSent, Number.MAX_SAFE_INTEGER),
|
||||
droppedReceived: optionalInteger(input.droppedReceived, Number.MAX_SAFE_INTEGER),
|
||||
droppedSent: optionalInteger(input.droppedSent, Number.MAX_SAFE_INTEGER),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeService(input) {
|
||||
assertPlainObject(input, "host_telemetry_service");
|
||||
return {
|
||||
name: optionalText(input.name, 160),
|
||||
loadState: optionalText(input.loadState, 64),
|
||||
activeState: optionalText(input.activeState, 64),
|
||||
subState: optionalText(input.subState, 64),
|
||||
memoryBytes: optionalInteger(input.memoryBytes, Number.MAX_SAFE_INTEGER),
|
||||
restarts: optionalInteger(input.restarts, Number.MAX_SAFE_INTEGER),
|
||||
pid: optionalInteger(input.pid, Number.MAX_SAFE_INTEGER),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeArray(value, mapper, maximum) {
|
||||
if (!Array.isArray(value) || value.length > maximum) {
|
||||
throw new TypeError("host_telemetry_collection_invalid");
|
||||
}
|
||||
return value.map(mapper);
|
||||
}
|
||||
|
||||
function normalizeRef(value, field) {
|
||||
if (typeof value !== "string" || !REF_RE.test(value)) {
|
||||
throw new TypeError(`host_telemetry_${field}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value, field) {
|
||||
if (typeof value !== "string" || !ISO_TIMESTAMP_RE.test(value)) {
|
||||
throw new TypeError(`host_telemetry_${field}_invalid`);
|
||||
}
|
||||
const timestamp = new Date(value);
|
||||
if (!Number.isFinite(timestamp.valueOf())) {
|
||||
throw new TypeError(`host_telemetry_${field}_invalid`);
|
||||
}
|
||||
return timestamp.toISOString();
|
||||
}
|
||||
|
||||
function text(value, minimum, maximum, errorCode) {
|
||||
if (typeof value !== "string") throw new TypeError(errorCode);
|
||||
const normalized = value.trim();
|
||||
if (normalized.length < minimum || normalized.length > maximum) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function optionalText(value, maximum) {
|
||||
if (value == null || value === "") return null;
|
||||
return text(value, 1, maximum, "host_telemetry_text_invalid");
|
||||
}
|
||||
|
||||
function textOrNull(value) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function optionalInteger(value, maximum) {
|
||||
if (value == null) return null;
|
||||
const normalized = Number(value);
|
||||
if (!Number.isSafeInteger(normalized) || normalized < 0 || normalized > maximum) {
|
||||
throw new TypeError("host_telemetry_integer_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function optionalNumber(value, minimum, maximum) {
|
||||
if (value == null) return null;
|
||||
const normalized = Number(value);
|
||||
if (!Number.isFinite(normalized) || normalized < minimum || normalized > maximum) {
|
||||
throw new TypeError("host_telemetry_number_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function optionalPercent(value) {
|
||||
return optionalNumber(value, 0, 100);
|
||||
}
|
||||
|
||||
function finiteInteger(value) {
|
||||
const normalized = Number(value);
|
||||
return Number.isSafeInteger(normalized) && normalized >= 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function finiteNumber(value) {
|
||||
const normalized = Number(value);
|
||||
return Number.isFinite(normalized) && normalized >= 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function percentFromIdle(value) {
|
||||
const idle = finiteNumber(value);
|
||||
return idle == null ? null : Math.max(0, Math.min(100, 100 - idle));
|
||||
}
|
||||
|
||||
function assertPlainObject(value, field) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${field}_invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
for (const nested of Object.values(value)) deepFreeze(nested);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
HOST_TELEMETRY_SCHEMA,
|
||||
telegrafBatchToHostTelemetry,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
test("normalizes a bounded Telegraf Linux batch", () => {
|
||||
const value = telegrafBatchToHostTelemetry({ metrics: [
|
||||
{ name: "cpu", tags: { cpu: "cpu-total", host: "edge-01" }, fields: { usage_active: 21.5 }, timestamp: 1_777_000_000 },
|
||||
{ name: "mem", tags: { host: "edge-01" }, fields: { total: 1024, available: 700, used: 324, used_percent: 31.64 }, timestamp: 1_777_000_000 },
|
||||
{ name: "system", tags: { host: "edge-01" }, fields: { load1: 0.2, load5: 0.1, load15: 0.05, uptime: 120, n_users: 1 }, timestamp: 1_777_000_000 },
|
||||
{ name: "net", tags: { interface: "eth0", host: "edge-01" }, fields: { bytes_recv: 100, bytes_sent: 200 }, timestamp: 1_777_000_000 },
|
||||
{ name: "systemd_units", tags: { name: "nodedc-device-edge-channel.service", load: "loaded", active: "active", sub: "running" }, fields: { mem_current: 2048, restarts: 0, pid: 42 }, timestamp: 1_777_000_000 },
|
||||
] }, {
|
||||
hostKey: "robot2b-b2-edge-vps",
|
||||
architecture: "x64",
|
||||
kernelRelease: "6.8.0",
|
||||
cpuModel: "KVM CPU",
|
||||
logicalProcessors: 1,
|
||||
});
|
||||
|
||||
assert.equal(value.schemaVersion, HOST_TELEMETRY_SCHEMA);
|
||||
assert.equal(value.cpu.usagePercent, 21.5);
|
||||
assert.equal(value.memory.totalBytes, 1024);
|
||||
assert.equal(value.network[0].interface, "eth0");
|
||||
assert.equal(value.services[0].activeState, "active");
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
});
|
||||
|
||||
test("rejects an oversized Telegraf batch", () => {
|
||||
assert.throws(
|
||||
() => telegrafBatchToHostTelemetry({ metrics: Array.from({ length: 513 }, () => ({})) }, { hostKey: "host-01" }),
|
||||
/host_telemetry_telegraf_batch_invalid/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user