feat(telemetry): add canonical VPS host monitoring
This commit is contained in:
@@ -18,6 +18,9 @@ import {
|
||||
normalizeAdapterMessage,
|
||||
normalizeDiscoverySignal,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import {
|
||||
normalizeHostTelemetrySnapshot,
|
||||
} from "../../../packages/infrastructure-telemetry-contract/src/index.mjs";
|
||||
|
||||
// Runtime-owned transport implementation; kept inside the deployable Core context.
|
||||
const CHANNEL_TRACKER_SESSION_ID = "channel:control";
|
||||
@@ -277,7 +280,7 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
|
||||
return;
|
||||
}
|
||||
if (envelope.messageKind === "channel.heartbeat") return;
|
||||
if (["discovery.observed", "adapter.message", "command.status"].includes(envelope.messageKind)) {
|
||||
if (["discovery.observed", "adapter.message", "command.status", "host.telemetry.observed"].includes(envelope.messageKind)) {
|
||||
scheduleTrackerEvent(connection, envelope);
|
||||
return;
|
||||
}
|
||||
@@ -297,7 +300,9 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
|
||||
? acceptDiscovery(connection, envelope)
|
||||
: envelope.messageKind === "adapter.message"
|
||||
? acceptAdapterMessage(connection, envelope)
|
||||
: acceptCommandStatus(connection, envelope))
|
||||
: envelope.messageKind === "command.status"
|
||||
? acceptCommandStatus(connection, envelope)
|
||||
: acceptHostTelemetry(connection, envelope))
|
||||
.catch((error) => failConnection(connection, error))
|
||||
.finally(() => {
|
||||
if (connection.sessionChains.get(envelope.trackerSessionId) === work) {
|
||||
@@ -371,6 +376,23 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptHostTelemetry(connection, envelope) {
|
||||
try {
|
||||
const snapshot = normalizeHostTelemetrySnapshot(envelope.payload?.snapshot);
|
||||
const receipt = await config.recordHostTelemetry(snapshot, {
|
||||
authenticatedEdgeRef: connection.registration.edgeRegistrationId,
|
||||
});
|
||||
if (receipt?.status !== "recorded") {
|
||||
throw new Error("device_gateway_core_host_telemetry_receipt_invalid");
|
||||
}
|
||||
sendEventResult(connection, envelope, { status: "recorded" });
|
||||
totalEventsAccepted += 1;
|
||||
} catch (error) {
|
||||
sendEventRejection(connection, envelope, error);
|
||||
totalEventsRejected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptCommandStatus(connection, envelope) {
|
||||
try {
|
||||
await config.recordCommandStatus(envelope.payload?.status);
|
||||
@@ -580,7 +602,13 @@ function normalizeConfig(options) {
|
||||
}
|
||||
const offerCommand = options.offerCommand ?? (async () => null);
|
||||
const recordCommandStatus = options.recordCommandStatus ?? (async () => undefined);
|
||||
if (typeof offerCommand !== "function" || typeof recordCommandStatus !== "function") {
|
||||
const recordHostTelemetry = options.recordHostTelemetry
|
||||
?? (async () => { throw new Error("device_host_telemetry_repository_unavailable"); });
|
||||
if (
|
||||
typeof offerCommand !== "function"
|
||||
|| typeof recordCommandStatus !== "function"
|
||||
|| typeof recordHostTelemetry !== "function"
|
||||
) {
|
||||
throw new TypeError("device_gateway_core_command_runtime_invalid");
|
||||
}
|
||||
const registrationProvider = typeof options.registrationProvider === "function"
|
||||
@@ -627,6 +655,7 @@ function normalizeConfig(options) {
|
||||
commandTransport,
|
||||
offerCommand,
|
||||
recordCommandStatus,
|
||||
recordHostTelemetry,
|
||||
keepaliveMs,
|
||||
deadPeerMs,
|
||||
connectTimeoutMs: normalizeInteger(
|
||||
|
||||
@@ -116,6 +116,15 @@ export function createDeviceEdgeChannelSupervisor(options = {}) {
|
||||
message,
|
||||
{ authenticatedEdgeRef: registration.edgeRegistrationId },
|
||||
),
|
||||
recordHostTelemetry: (snapshot, context) =>
|
||||
typeof config.repository.recordInfrastructureHostTelemetry === "function"
|
||||
? config.repository.recordInfrastructureHostTelemetry({
|
||||
snapshot,
|
||||
authenticatedEdgeRef: context.authenticatedEdgeRef,
|
||||
})
|
||||
: Promise.reject(new Error(
|
||||
"device_host_telemetry_repository_unavailable",
|
||||
)),
|
||||
commandTransport: config.typedCommandRuntime
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
normalizeHostTelemetrySnapshot,
|
||||
} from "../../../packages/infrastructure-telemetry-contract/src/index.mjs";
|
||||
|
||||
const FRESHNESS_SECONDS = 15;
|
||||
const RETENTION_DAYS = 7;
|
||||
|
||||
export async function recordInfrastructureHostTelemetry(client, input) {
|
||||
const snapshot = normalizeHostTelemetrySnapshot(input?.snapshot);
|
||||
const edgeId = entityId(input?.authenticatedEdgeRef, "edge");
|
||||
const relation = await client.query(
|
||||
`select disi.id as service_instance_id, disi.host_id,
|
||||
disi.project_id, disi.owner_scope_id, dih.host_key
|
||||
from device_infrastructure_service_instances disi
|
||||
join device_infrastructure_hosts dih on dih.id = disi.host_id
|
||||
where disi.edge_id = $1
|
||||
and disi.lifecycle_state in ('active', 'degraded')
|
||||
and dih.lifecycle_state = 'active'
|
||||
order by disi.updated_at desc, disi.id
|
||||
limit 2`,
|
||||
[edgeId],
|
||||
);
|
||||
if (relation.rows.length !== 1) {
|
||||
throw domainError("device_host_telemetry_edge_host_binding_invalid", 409);
|
||||
}
|
||||
const target = relation.rows[0];
|
||||
if (target.host_key !== snapshot.hostKey) {
|
||||
throw domainError("device_host_telemetry_host_key_mismatch", 409);
|
||||
}
|
||||
const observedAt = new Date(snapshot.observedAt);
|
||||
const clockSkewMs = Math.abs(Date.now() - observedAt.valueOf());
|
||||
if (!Number.isFinite(observedAt.valueOf()) || clockSkewMs > 5 * 60 * 1000) {
|
||||
throw domainError("device_host_telemetry_clock_skew_invalid", 409);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const provenanceRef = `${input.authenticatedEdgeRef}:${snapshot.source.collectorRef}`;
|
||||
const inserted = await client.query(
|
||||
`with inserted as (
|
||||
insert into device_infrastructure_host_telemetry_samples (
|
||||
id, owner_scope_id, project_id, host_id, service_instance_id, edge_id,
|
||||
observed_at, expires_at, schema_version, profile_ref,
|
||||
agent_name, agent_version, collector_ref, provenance_ref, snapshot
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $7::timestamptz + ($8 * interval '1 second'), $9, $10,
|
||||
$11, $12, $13, $14, $15::jsonb
|
||||
)
|
||||
on conflict (edge_id, observed_at) do nothing
|
||||
returning id, received_at, expires_at, false as replayed
|
||||
)
|
||||
select id, received_at, expires_at, replayed from inserted
|
||||
union all
|
||||
select id, received_at, expires_at, true as replayed
|
||||
from device_infrastructure_host_telemetry_samples
|
||||
where edge_id = $6 and observed_at = $7::timestamptz
|
||||
and not exists (select 1 from inserted)
|
||||
limit 1`,
|
||||
[
|
||||
id,
|
||||
target.owner_scope_id,
|
||||
target.project_id,
|
||||
target.host_id,
|
||||
target.service_instance_id,
|
||||
edgeId,
|
||||
snapshot.observedAt,
|
||||
FRESHNESS_SECONDS,
|
||||
snapshot.schemaVersion,
|
||||
snapshot.profile,
|
||||
snapshot.source.agent,
|
||||
snapshot.source.agentVersion,
|
||||
snapshot.source.collectorRef,
|
||||
provenanceRef,
|
||||
JSON.stringify(snapshot),
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`delete from device_infrastructure_host_telemetry_samples
|
||||
where observed_at < now() - ($1 * interval '1 day')`,
|
||||
[RETENTION_DAYS],
|
||||
);
|
||||
const row = inserted.rows[0];
|
||||
return Object.freeze({
|
||||
status: "recorded",
|
||||
replayed: row.replayed,
|
||||
observationRef: `observation:${row.id}`,
|
||||
hostRef: `host:${target.host_id}`,
|
||||
observedAt: snapshot.observedAt,
|
||||
receivedAt: new Date(row.received_at).toISOString(),
|
||||
expiresAt: new Date(row.expires_at).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function entityId(value, kind) {
|
||||
const match = new RegExp(`^${kind}:([0-9a-f-]{36})$`, "i").exec(String(value || ""));
|
||||
if (!match) throw domainError(`device_${kind}_ref_invalid`, 400);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export async function getDeviceProjectOntologyProjection(client, actor, projectI
|
||||
{ lock: false },
|
||||
);
|
||||
|
||||
const [assets, assetBindings, hosts, endpoints, deployments, services] =
|
||||
const [assets, assetBindings, hosts, endpoints, deployments, services, telemetry] =
|
||||
await Promise.all([
|
||||
client.query(
|
||||
`select * from device_assets
|
||||
@@ -82,8 +82,33 @@ export async function getDeviceProjectOntologyProjection(client, actor, projectI
|
||||
order by disi.display_name, disi.id`,
|
||||
[projectId],
|
||||
),
|
||||
client.query(
|
||||
`select * from (
|
||||
select dihts.id, dihts.host_id, dihts.service_instance_id,
|
||||
dihts.edge_id, dihts.observed_at, dihts.received_at,
|
||||
dihts.expires_at, dihts.profile_ref, dihts.agent_name,
|
||||
dihts.agent_version, dihts.collector_ref,
|
||||
dihts.provenance_ref, dihts.snapshot,
|
||||
dihts.ontology_entity_id, dihts.ontology_catalog_hash,
|
||||
row_number() over (
|
||||
partition by dihts.host_id order by dihts.observed_at desc, dihts.id desc
|
||||
) as sample_rank
|
||||
from device_infrastructure_host_telemetry_samples dihts
|
||||
where dihts.project_id = $1
|
||||
) ranked
|
||||
where sample_rank <= 120
|
||||
order by host_id, observed_at desc, id desc`,
|
||||
[projectId],
|
||||
),
|
||||
]);
|
||||
|
||||
const telemetryByHost = telemetry.rows.reduce((result, row) => {
|
||||
const rows = result.get(row.host_id) ?? [];
|
||||
rows.push(row);
|
||||
result.set(row.host_id, rows);
|
||||
return result;
|
||||
}, new Map());
|
||||
|
||||
return {
|
||||
ontology: {
|
||||
catalogHash: "229c61c02a790906",
|
||||
@@ -91,7 +116,7 @@ export async function getDeviceProjectOntologyProjection(client, actor, projectI
|
||||
},
|
||||
assets: assets.rows.map(assetView),
|
||||
assetBindings: assetBindings.rows.map(assetBindingView),
|
||||
hosts: hosts.rows.map(hostView),
|
||||
hosts: hosts.rows.map((row) => hostView(row, telemetryByHost.get(row.id) ?? [])),
|
||||
endpoints: endpoints.rows.map(endpointView),
|
||||
deployments: deployments.rows.map(deploymentView),
|
||||
serviceInstances: services.rows.map(serviceInstanceView),
|
||||
@@ -131,7 +156,7 @@ function assetBindingView(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function hostView(row) {
|
||||
function hostView(row, telemetryRows) {
|
||||
return {
|
||||
hostRef: `host:${row.id}`,
|
||||
hostKey: row.host_key,
|
||||
@@ -141,10 +166,72 @@ function hostView(row) {
|
||||
managementCredentialConfigured: row.management_credential_configured === true,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
health: healthView(row),
|
||||
telemetry: hostTelemetryView(telemetryRows),
|
||||
ontology: ontologyView(row),
|
||||
};
|
||||
}
|
||||
|
||||
function hostTelemetryView(rows) {
|
||||
const latest = rows[0];
|
||||
if (!latest) {
|
||||
return {
|
||||
state: "unobserved",
|
||||
freshness: "missing",
|
||||
observedAt: null,
|
||||
receivedAt: null,
|
||||
expiresAt: null,
|
||||
current: null,
|
||||
history: [],
|
||||
observation: null,
|
||||
};
|
||||
}
|
||||
const fresh = new Date(latest.expires_at).valueOf() > Date.now();
|
||||
return {
|
||||
state: fresh ? "online" : "unobserved",
|
||||
freshness: fresh ? "fresh" : "stale",
|
||||
observedAt: toIso(latest.observed_at),
|
||||
receivedAt: toIso(latest.received_at),
|
||||
expiresAt: toIso(latest.expires_at),
|
||||
current: latest.snapshot,
|
||||
history: [...rows].reverse().map((row) => ({
|
||||
observedAt: toIso(row.observed_at),
|
||||
cpuUsagePercent: numberOrNull(row.snapshot?.cpu?.usagePercent),
|
||||
memoryUsedPercent: numberOrNull(row.snapshot?.memory?.usedPercent),
|
||||
network: Array.isArray(row.snapshot?.network)
|
||||
? row.snapshot.network.map((item) => ({
|
||||
interface: item.interface ?? null,
|
||||
bytesReceived: numberOrNull(item.bytesReceived),
|
||||
bytesSent: numberOrNull(item.bytesSent),
|
||||
}))
|
||||
: [],
|
||||
})),
|
||||
observation: {
|
||||
observationRef: `observation:${latest.id}`,
|
||||
entityId: latest.ontology_entity_id,
|
||||
catalogHash: latest.ontology_catalog_hash,
|
||||
targetRef: `host:${latest.host_id}`,
|
||||
serviceInstanceRef: `service-instance:${latest.service_instance_id}`,
|
||||
edgeRef: `edge:${latest.edge_id}`,
|
||||
profileRef: latest.profile_ref,
|
||||
source: {
|
||||
agent: latest.agent_name,
|
||||
agentVersion: latest.agent_version,
|
||||
collectorRef: latest.collector_ref,
|
||||
provenanceRef: latest.provenance_ref,
|
||||
},
|
||||
observedProperties: [
|
||||
"host.cpu.utilization",
|
||||
"host.memory.utilization",
|
||||
"host.swap.utilization",
|
||||
"host.disk.utilization",
|
||||
"host.network.counters",
|
||||
"host.process.counts",
|
||||
"host.systemd.unit-state",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function endpointView(row) {
|
||||
return {
|
||||
endpointRef: `endpoint:${row.id}`,
|
||||
@@ -211,3 +298,8 @@ function ontologyView(row) {
|
||||
function toIso(value) {
|
||||
return value == null ? null : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
const normalized = Number(value);
|
||||
return Number.isFinite(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ import {
|
||||
planTypedServicePing,
|
||||
recordTypedCommandStatus,
|
||||
} from "./typed-command-repository.mjs";
|
||||
import {
|
||||
recordInfrastructureHostTelemetry,
|
||||
} from "./host-telemetry-repository.mjs";
|
||||
|
||||
const { Pool } = pg;
|
||||
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -75,6 +78,7 @@ const migrationFiles = [
|
||||
"014_device_registry_profile_commands.sql",
|
||||
"015_device_integration_identity.sql",
|
||||
"016_device_asset_infrastructure_ontology.sql",
|
||||
"017_infrastructure_host_telemetry.sql",
|
||||
];
|
||||
|
||||
export class PostgresDeviceRepository {
|
||||
@@ -238,6 +242,12 @@ export class PostgresDeviceRepository {
|
||||
return this.#executeWrite((client) => recordTypedCommandStatus(client, input));
|
||||
}
|
||||
|
||||
async recordInfrastructureHostTelemetry(input) {
|
||||
return this.#executeWrite((client) =>
|
||||
recordInfrastructureHostTelemetry(client, input)
|
||||
);
|
||||
}
|
||||
|
||||
async #executeWrite(operation) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user