feat(telemetry): report process CPU and FDs plus host memory and load

This commit is contained in:
lda committed 2026-10-12 16:57:06 +07:00 Verified
1 parent 4a4acedbc3
commit 4eef8c758f
7 files changed
+301 -8

No files matched your search

+4 -2
View File
@@ -56,8 +56,10 @@ or current fleet size. Metric labels should remain bounded; no per-device labels
## Process And Host Visibility
- [x] Add Linux process age/RSS and host uptime gauges for agent and CC through opt-in OTLP, using the existing collection interval. Core owns OS-independent instruments; wakey-linux supplies the optional procfs reader. Missing data omits observations; no added labels, config fields, health routes or polling tasks. Collector coverage verifies units and values; router/live dashboard validation pending.
- [ ] Extend process measurements with CPU time and open file descriptors where supported, plus start-time/version metadata if needed. Keep version as resource metadata rather than repeating it on every sample.
- [ ] Add a small host summary: hostname, OS/kernel, CPU count, host uptime, load, and available/total memory. Distinguish host uptime from process uptime so router reboots and agent restarts are visible separately. Sources must tolerate missing Linux files; not every agent host is a router.
- [x] Add cumulative process CPU time (counter) and current open-FD count (gauge) through the same Linux reader and OS-independent instruments. Exclude child processes, omit failed samples, and document one-core CPU-rate interpretation and sampling FD overhead. Extend collector/reader coverage rather than adding timing-based tests; live validation pending.
- [ ] Add start-time/version metadata if needed. Keep version as resource metadata rather than repeating it on every sample.
- [x] Add total/available host memory and 1/5/15-minute load gauges through the optional Linux reader, with byte conversion owned by procfs and fixed load-period labels. Omit unavailable MemAvailable instead of substituting free memory; document kernel views versus container limits. Collector/reader coverage included; live validation pending.
- [ ] Complete a small host summary with hostname, OS/kernel and CPU count, plus protected UI presentation of collected measurements. Sources must tolerate missing Linux files; not every agent host is a router.
- [ ] Expose changing measurements through OTLP on a modest sampling interval; show selected process/host details on Fleet Health and agent details. Keep host information behind the protected operator interface, not public health endpoints. Avoid expensive reads on every request and unbounded labels.
- [ ] Keep `/healthz` as cheap liveness; consider a separate bounded `/readyz` database-access check for CC. Collector outages and disconnected fleet agents must not make a functioning CC unready.
- [ ] Add application resource gauges where useful: DB pool usage, connected agents, pending commands, and active terminal sessions. Check existing instruments first to avoid duplicates; distinguish pool pressure from host/process pressure.
+53
View File
@@ -61,6 +61,59 @@ allocation alone. A missing series can mean disabled telemetry, an unsupported
platform, unavailable procfs, or export trouble. No new health endpoint or host
inventory is provided here; do not sum host uptime from multiple daemons.
CPU consumption as a percentage of **one CPU core**:
```promql
100 * rate(wakey_process_cpu_time_seconds_total[5m])
```
Set the unit to **percent (0-100)**. A multithreaded process can exceed 100%;
this is not normalized by the host's CPU count. The counter includes daemon
user/system CPU, not its child shells or commands. Allow at least two exports;
`rate` handles counter resets on process restart.
Current open descriptors:
```promql
wakey_process_open_fds
```
Read directly as a count, not a rate. The sampling read can contribute temporary
descriptors. Persistent growth can warrant investigation, but sockets, terminal
sessions and pool connections legitimately change the count too.
Host usable/available RAM, in bytes:
```promql
wakey_host_memory_total_bytes
```
```promql
wakey_host_memory_available_bytes
```
Set units to **bytes (IEC)**. Estimated percentage not available for new work:
```promql
100 * (1 - wakey_host_memory_available_bytes / wakey_host_memory_total_bytes)
```
Available includes reclaimable memory and is not merely free RAM. Missing
`MemAvailable` leaves a gap rather than substituting `MemFree`. Do not sum host
RAM from multiple daemon instances on the same host, or interpret these kernel
values as a container's cgroup limit.
Load averages by period:
```promql
wakey_host_load
```
Use `{{job}} / {{instance}} / {{period}}` as the legend. Fixed periods are `1m`,
`5m`, and `15m`. Read directly as a load count, not a percentage or a rate:
Linux includes runnable and uninterruptible tasks; a high load can include I/O
waiting rather than CPU work. These values are not normalized by CPU count.
## Operations
Completed attempts per second, including errors and timeouts:
+17 -1
View File
@@ -159,7 +159,12 @@ queries, rates, averages, percentiles, and Grafana units.
| `wakey.fleet.devices` | CC gauge of unfiltered grouped identities, labeled by `identity` (`saved` or `unsaved`) |
| `wakey.process.uptime` | Linux gauge: seconds since this process's kernel-reported start, including suspend |
| `wakey.process.memory.resident` | Linux gauge: approximate resident memory in bytes, from VmRSS |
| `wakey.process.cpu.time` | Linux counter: cumulative user + system CPU seconds for all process threads, excluding children |
| `wakey.process.open_fds` | Linux gauge: current count of open process file descriptors |
| `wakey.host.uptime` | Linux gauge: uptime in seconds reported by the process's procfs view, including suspend |
| `wakey.host.memory.total` | Linux gauge: total usable RAM in bytes from the visible meminfo |
| `wakey.host.memory.available` | Linux gauge: estimated available RAM in bytes; omitted if MemAvailable is unavailable |
| `wakey.host.load` | Linux gauge: load average, with fixed `period` labels `1m`, `5m`, `15m` |
| `wakey.terminal.active` | Agent PTY workers currently alive |
| `wakey.terminal.input.bytes` | Input bytes received by the terminal relay |
| `wakey.terminal.output.bytes` | Output bytes read from the PTY, including while detached |
@@ -167,7 +172,7 @@ queries, rates, averages, percentiles, and Grafana units.
Metric labels do not include request/session IDs, MACs, or IPs. New spans use
`skip_all` and explicitly select safe fields; terminal bytes and credentials are
not attached. Profiles, host CPU/total-memory metrics, connected-agent gauges, and export-loss
not attached. Profiles, host CPU-utilization metrics, connected-agent gauges, and export-loss
dashboards are not part of this initial slice.
Process and host gauges are registered automatically for Linux agent/CC daemons
@@ -182,6 +187,17 @@ omits the affected observation instead of reporting zero or failing the daemon.
No hostname, PID, command line or environment values are exported by this slice.
Service/instance resource attributes identify each daemon; two daemons on the
same host can report the same host uptime, so do not sum it across processes.
CPU time is not elapsed time or CPU percentage: use its rate for consumption.
Open-FD sampling can temporarily open descriptors counted by the measurement.
Process metrics describe the daemon itself, not its child shells/PTY programs
or the entire host. CPU/FD collection is also supplied by `wakey-linux`;
unsupported platform readers may omit these optional measurements.
Host memory/load come from the same optional Linux reader. `MemAvailable`
estimates memory reusable without swapping and is not `MemFree`; no fallback
is invented on older kernels. Load averages include runnable and uninterruptible
tasks, so they are not CPU percentages. These are the visible kernel/procfs
figures, not cgroup memory limits or container-specific load measurements;
multiple daemons may export duplicate views of the same host.
RSS includes anonymous, file-backed and shared resident pages, not just heap,
and is an approximate kernel measurement. Process age uses procfs start ticks
+1 -1
View File
@@ -309,7 +309,7 @@ static METRICS: OnceLock<Instruments> = OnceLock::new();
mod fleet;
mod process;
pub use fleet::{AgentDeviceCounts, FleetCounts, FleetMetrics};
pub use process::ProcessMetricsSource;
pub use process::{HostLoad, HostMemory, ProcessMetricsSource};
struct Instruments {
operations: opentelemetry::metrics::Counter<u64>,
duration: opentelemetry::metrics::Histogram<f64>,
+104 -3
View File
@@ -1,22 +1,55 @@
//! OS-independent process/host instruments. Callers supply a platform reader.
//! Failed reads omit a sample: zero must not masquerade as a restart or freed RAM.
use opentelemetry::metrics::{Meter, ObservableGauge};
use opentelemetry::KeyValue;
use opentelemetry::metrics::{Meter, ObservableCounter, ObservableGauge};
use std::sync::Arc;
/// Values are seconds/bytes, or None when unsupported/unavailable. Readers run
/// Host-visible memory in bytes; available is an estimate, not merely free RAM.
pub struct HostMemory {
pub total_bytes: u64,
pub available_bytes: Option<u64>,
}
/// Host load averages, not CPU utilization percentages.
pub struct HostLoad {
pub one_minute: f64,
pub five_minutes: f64,
pub fifteen_minutes: f64,
}
/// Values use the documented seconds/bytes/count units, or None when unavailable. Readers run
/// on the metric collection thread: keep work small and local, never perform
/// network requests or emit exporter-directed logs from these callbacks.
pub trait ProcessMetricsSource: Send + Sync + 'static {
fn process_uptime_seconds(&self) -> Option<f64>;
fn resident_memory_bytes(&self) -> Option<u64>;
fn host_uptime_seconds(&self) -> Option<f64>;
/// Cumulative user + system CPU seconds for the process, excluding children.
/// Optional additions default to unavailable for existing platform readers.
fn cpu_time_seconds(&self) -> Option<f64> {
None
}
fn open_file_descriptors(&self) -> Option<u64> {
None
}
fn host_memory(&self) -> Option<HostMemory> {
None
}
fn host_load(&self) -> Option<HostLoad> {
None
}
}
pub(super) struct ProcessMetrics {
_uptime: ObservableGauge<f64>,
_resident: ObservableGauge<u64>,
_host_uptime: ObservableGauge<f64>,
_cpu_time: ObservableCounter<f64>,
_open_fds: ObservableGauge<u64>,
_host_total_memory: ObservableGauge<u64>,
_host_available_memory: ObservableGauge<u64>,
_host_load: ObservableGauge<f64>,
}
impl ProcessMetrics {
@@ -24,6 +57,11 @@ impl ProcessMetrics {
let source = Arc::new(source);
let uptime = source.clone();
let resident = source.clone();
let host = source.clone();
let cpu = source.clone();
let fds = source.clone();
let total_memory = source.clone();
let available_memory = source.clone();
Self {
_uptime: meter
.f64_observable_gauge("wakey.process.uptime")
@@ -53,7 +91,7 @@ impl ProcessMetrics {
.with_unit("s")
.with_description("Host uptime reported by the platform reader")
.with_callback(move |observer| {
if let Some(seconds) = source.host_uptime_seconds()
if let Some(seconds) = host.host_uptime_seconds()
&& seconds.is_finite()
&& seconds >= 0.0
{
@@ -61,6 +99,69 @@ impl ProcessMetrics {
}
})
.build(),
_cpu_time: meter
.f64_observable_counter("wakey.process.cpu.time")
.with_unit("s")
.with_description("Cumulative user and system process CPU time, excluding children")
.with_callback(move |observer| {
if let Some(seconds) = cpu.cpu_time_seconds()
&& seconds.is_finite()
&& seconds >= 0.0
{
observer.observe(seconds, &[]);
}
})
.build(),
_open_fds: meter
.u64_observable_gauge("wakey.process.open_fds")
.with_description(
"Current open file-descriptor count reported by the platform reader",
)
.with_callback(move |observer| {
if let Some(count) = fds.open_file_descriptors() {
observer.observe(count, &[]);
}
})
.build(),
_host_total_memory: meter
.u64_observable_gauge("wakey.host.memory.total")
.with_unit("By")
.with_description("Total usable RAM reported by the platform reader")
.with_callback(move |observer| {
if let Some(memory) = total_memory.host_memory() {
observer.observe(memory.total_bytes, &[]);
}
})
.build(),
_host_available_memory: meter
.u64_observable_gauge("wakey.host.memory.available")
.with_unit("By")
.with_description("Estimated RAM available for applications without swapping")
.with_callback(move |observer| {
if let Some(memory) = available_memory.host_memory()
&& let Some(bytes) = memory.available_bytes
{
observer.observe(bytes, &[]);
}
})
.build(),
_host_load: meter
.f64_observable_gauge("wakey.host.load")
.with_description("Host load average over the labeled period, not CPU percent")
.with_callback(move |observer| {
if let Some(load) = source.host_load() {
for (period, value) in [
("1m", load.one_minute),
("5m", load.five_minutes),
("15m", load.fifteen_minutes),
] {
if value.is_finite() && value >= 0.0 {
observer.observe(value, &[KeyValue::new("period", period)]);
}
}
}
})
.build(),
}
}
}
+74
View File
@@ -210,6 +210,25 @@ async fn exports_all_signals_and_preserves_remote_command_parent() {
fn host_uptime_seconds(&self) -> Option<f64> {
Some(10_000.0)
}
fn cpu_time_seconds(&self) -> Option<f64> {
Some(1.25)
}
fn open_file_descriptors(&self) -> Option<u64> {
Some(12)
}
fn host_memory(&self) -> Option<telemetry::HostMemory> {
Some(telemetry::HostMemory {
total_bytes: 2 * 1024 * 1024 * 1024,
available_bytes: Some(512 * 1024 * 1024),
})
}
fn host_load(&self) -> Option<telemetry::HostLoad> {
Some(telemetry::HostLoad {
one_minute: 0.5,
five_minutes: 1.0,
fifteen_minutes: 1.5,
})
}
}
let guard = telemetry::init(
&TelemetryConfig {
@@ -376,6 +395,21 @@ async fn exports_all_signals_and_preserves_remote_command_parent() {
"s",
number_data_point::Value::AsDouble(10_000.0),
),
(
"wakey.process.open_fds",
"",
number_data_point::Value::AsInt(12),
),
(
"wakey.host.memory.total",
"By",
number_data_point::Value::AsInt(2 * 1024 * 1024 * 1024),
),
(
"wakey.host.memory.available",
"By",
number_data_point::Value::AsInt(512 * 1024 * 1024),
),
] {
let metric = latest
.resource_metrics
@@ -396,6 +430,46 @@ async fn exports_all_signals_and_preserves_remote_command_parent() {
);
assert_eq!(point.value, Some(expected));
}
let cpu = latest
.resource_metrics
.iter()
.flat_map(|resource| &resource.scope_metrics)
.flat_map(|scope| &scope.metrics)
.find(|metric| metric.name == "wakey.process.cpu.time")
.expect("CPU time exported");
assert_eq!(cpu.unit, "s");
let Some(metric::Data::Sum(sum)) = &cpu.data else {
panic!("CPU time must be a counter, not a gauge");
};
assert!(sum.is_monotonic);
assert_eq!(sum.data_points.len(), 1);
assert_eq!(
sum.data_points[0].value,
Some(number_data_point::Value::AsDouble(1.25))
);
assert!(sum.data_points[0].attributes.is_empty());
let load = latest
.resource_metrics
.iter()
.flat_map(|resource| &resource.scope_metrics)
.flat_map(|scope| &scope.metrics)
.find(|metric| metric.name == "wakey.host.load")
.expect("host load exported");
let Some(metric::Data::Gauge(gauge)) = &load.data else {
panic!("host load must be a gauge");
};
assert_eq!(gauge.data_points.len(), 3);
for (period, expected) in [("1m", 0.5), ("5m", 1.0), ("15m", 1.5)] {
let point = gauge.data_points.iter().find(|point| point.attributes.iter().any(|attribute| {
attribute.key == "period" && attribute.value.as_ref().and_then(|value| value.value.as_ref())
== Some(&opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(period.into()))
})).expect("load period exported");
assert_eq!(point.attributes.len(), 1, "only fixed period labels");
assert_eq!(
point.value,
Some(number_data_point::Value::AsDouble(expected))
);
}
}
for (name, label, expected) in [
(
+48 -1
View File
@@ -1,6 +1,6 @@
//! Linux procfs reader for core's OS-independent process/host instruments.
use procfs::Current;
use wakey_core::telemetry::ProcessMetricsSource;
use wakey_core::telemetry::{HostLoad, HostMemory, ProcessMetricsSource};
/// Reads only this process and the visible host clock; never scans other PIDs.
/// Constructing the reader does no I/O, so disabled OTLP remains local-only.
@@ -16,6 +16,39 @@ impl ProcessMetricsSource for ProcfsProcessMetrics {
fn host_uptime_seconds(&self) -> Option<f64> {
host_uptime()
}
fn cpu_time_seconds(&self) -> Option<f64> {
let stat = procfs::process::Process::myself().ok()?.stat().ok()?;
let ticks = procfs::ticks_per_second();
if ticks == 0 {
return None;
}
// utime/stime account for all process threads, not child processes.
Some(stat.utime.checked_add(stat.stime)? as f64 / ticks as f64)
}
fn open_file_descriptors(&self) -> Option<u64> {
let process = procfs::process::Process::myself().ok()?;
// procfs uses the kernel's count when available, otherwise enumerates
// directory entries without reading every FD's target. Sampling itself
// temporarily opens procfs descriptors, which can contribute to this count.
u64::try_from(process.fd_count().ok()?).ok()
}
fn host_memory(&self) -> Option<HostMemory> {
let memory = procfs::Meminfo::current().ok()?;
// Unlike Status::vmrss, Meminfo has already converted its fields to bytes.
// Do not substitute MemFree when an older kernel omits MemAvailable.
Some(HostMemory {
total_bytes: memory.mem_total,
available_bytes: memory.mem_available,
})
}
fn host_load(&self) -> Option<HostLoad> {
let load = procfs::LoadAverage::current().ok()?;
Some(HostLoad {
one_minute: f64::from(load.one),
five_minutes: f64::from(load.five),
fifteen_minutes: f64::from(load.fifteen),
})
}
}
fn host_uptime() -> Option<f64> {
@@ -56,5 +89,19 @@ mod tests {
assert!(age.is_finite() && age >= 0.0);
assert!(host.is_finite() && host >= age);
assert!(source.resident_memory_bytes().expect("VmRSS available") > 0);
let cpu = source
.cpu_time_seconds()
.expect("process CPU ticks available");
assert!(cpu.is_finite() && cpu >= 0.0);
assert!(source.open_file_descriptors().expect("FD count available") > 0);
let memory = source.host_memory().expect("host memory available");
assert!(memory.total_bytes > 0);
if let Some(available) = memory.available_bytes {
assert!(available <= memory.total_bytes);
}
let load = source.host_load().expect("host load available");
for average in [load.one_minute, load.five_minutes, load.fifteen_minutes] {
assert!(average.is_finite() && average >= 0.0);
}
}
}