Files
NODEDC_DEVICE_CORE/packages/infrastructure-telemetry-contract/src/index.mjs
T

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;
}