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 ## 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. - [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. - [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 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. - [ ] 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. - [ ] 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. - [ ] 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. - [ ] 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 platform, unavailable procfs, or export trouble. No new health endpoint or host
inventory is provided here; do not sum host uptime from multiple daemons. 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 ## Operations
Completed attempts per second, including errors and timeouts: 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.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.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.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.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.active` | Agent PTY workers currently alive |
| `wakey.terminal.input.bytes` | Input bytes received by the terminal relay | | `wakey.terminal.input.bytes` | Input bytes received by the terminal relay |
| `wakey.terminal.output.bytes` | Output bytes read from the PTY, including while detached | | `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 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 `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. dashboards are not part of this initial slice.
Process and host gauges are registered automatically for Linux agent/CC daemons 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. No hostname, PID, command line or environment values are exported by this slice.
Service/instance resource attributes identify each daemon; two daemons on the 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. 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, RSS includes anonymous, file-backed and shared resident pages, not just heap,
and is an approximate kernel measurement. Process age uses procfs start ticks 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 fleet;
mod process; mod process;
pub use fleet::{AgentDeviceCounts, FleetCounts, FleetMetrics}; pub use fleet::{AgentDeviceCounts, FleetCounts, FleetMetrics};
pub use process::ProcessMetricsSource; pub use process::{HostLoad, HostMemory, ProcessMetricsSource};
struct Instruments { struct Instruments {
operations: opentelemetry::metrics::Counter<u64>, operations: opentelemetry::metrics::Counter<u64>,
duration: opentelemetry::metrics::Histogram<f64>, duration: opentelemetry::metrics::Histogram<f64>,
+104 -3
View File
@@ -1,22 +1,55 @@
//! OS-independent process/host instruments. Callers supply a platform reader. //! 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. //! 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; 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 /// on the metric collection thread: keep work small and local, never perform
/// network requests or emit exporter-directed logs from these callbacks. /// network requests or emit exporter-directed logs from these callbacks.
pub trait ProcessMetricsSource: Send + Sync + 'static { pub trait ProcessMetricsSource: Send + Sync + 'static {
fn process_uptime_seconds(&self) -> Option<f64>; fn process_uptime_seconds(&self) -> Option<f64>;
fn resident_memory_bytes(&self) -> Option<u64>; fn resident_memory_bytes(&self) -> Option<u64>;
fn host_uptime_seconds(&self) -> Option<f64>; 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 { pub(super) struct ProcessMetrics {
_uptime: ObservableGauge<f64>, _uptime: ObservableGauge<f64>,
_resident: ObservableGauge<u64>, _resident: ObservableGauge<u64>,
_host_uptime: ObservableGauge<f64>, _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 { impl ProcessMetrics {
@@ -24,6 +57,11 @@ impl ProcessMetrics {
let source = Arc::new(source); let source = Arc::new(source);
let uptime = source.clone(); let uptime = source.clone();
let resident = 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 { Self {
_uptime: meter _uptime: meter
.f64_observable_gauge("wakey.process.uptime") .f64_observable_gauge("wakey.process.uptime")
@@ -53,7 +91,7 @@ impl ProcessMetrics {
.with_unit("s") .with_unit("s")
.with_description("Host uptime reported by the platform reader") .with_description("Host uptime reported by the platform reader")
.with_callback(move |observer| { .with_callback(move |observer| {
if let Some(seconds) = source.host_uptime_seconds() if let Some(seconds) = host.host_uptime_seconds()
&& seconds.is_finite() && seconds.is_finite()
&& seconds >= 0.0 && seconds >= 0.0
{ {
@@ -61,6 +99,69 @@ impl ProcessMetrics {
} }
}) })
.build(), .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> { fn host_uptime_seconds(&self) -> Option<f64> {
Some(10_000.0) 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( let guard = telemetry::init(
&TelemetryConfig { &TelemetryConfig {
@@ -376,6 +395,21 @@ async fn exports_all_signals_and_preserves_remote_command_parent() {
"s", "s",
number_data_point::Value::AsDouble(10_000.0), 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 let metric = latest
.resource_metrics .resource_metrics
@@ -396,6 +430,46 @@ async fn exports_all_signals_and_preserves_remote_command_parent() {
); );
assert_eq!(point.value, Some(expected)); 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 [ for (name, label, expected) in [
( (
+48 -1
View File
@@ -1,6 +1,6 @@
//! Linux procfs reader for core's OS-independent process/host instruments. //! Linux procfs reader for core's OS-independent process/host instruments.
use procfs::Current; 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. /// 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. /// 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> { fn host_uptime_seconds(&self) -> Option<f64> {
host_uptime() 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> { fn host_uptime() -> Option<f64> {
@@ -56,5 +89,19 @@ mod tests {
assert!(age.is_finite() && age >= 0.0); assert!(age.is_finite() && age >= 0.0);
assert!(host.is_finite() && host >= age); assert!(host.is_finite() && host >= age);
assert!(source.resident_memory_bytes().expect("VmRSS available") > 0); 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);
}
} }
} }