feat(telemetry): report process CPU and FDs plus host memory and load
This commit is contained in:
7 files changed
+301
-8
No files matched your search
@@ -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>,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 [
|
||||
(
|
||||
|
||||
Reference in new issue
Block a user