381 lines
14 KiB
JavaScript
381 lines
14 KiB
JavaScript
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;
|
|
}
|