use std::time::Duration;
use phoxal_api::v2 as api;
use phoxal_bus::{Bus, LogicalTime, OwnerCap, Publisher};
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
use tokio::sync::watch;
use tokio::task::JoinHandle;
pub(crate) const PROCESS_METRICS_INTERVAL: Duration = Duration::from_secs(3);
pub(crate) struct ProcessMetricsPublisher {
publisher: Option<Publisher<api::telemetry::Process>>,
}
impl ProcessMetricsPublisher {
pub(crate) fn attach(bus: Bus) -> Self {
let topic = api::topic::internal::new(OwnerCap::__mint())
.telemetry()
.process();
let publisher = Publisher::new(bus, &topic)
.map_err(|error| {
tracing::warn!(
target: "phoxal.runtime",
error = %error,
"process telemetry publisher could not be created"
);
error
})
.ok();
Self { publisher }
}
#[cfg(test)]
fn disabled() -> Self {
Self { publisher: None }
}
pub(crate) fn publish(&self, at: LogicalTime, body: api::telemetry::Process) {
let Some(publisher) = &self.publisher else {
return;
};
if let Err(error) = publisher.try_publish(at, body) {
tracing::warn!(
target: "phoxal.runtime",
error = %error,
"process telemetry publish failed"
);
}
}
}
pub(crate) fn spawn_sampler() -> (
watch::Receiver<Option<api::telemetry::Process>>,
JoinHandle<()>,
) {
let (tx, rx) = watch::channel(None);
let handle = tokio::spawn(run_sampler(tx));
(rx, handle)
}
async fn run_sampler(tx: watch::Sender<Option<api::telemetry::Process>>) {
let pid = Pid::from_u32(std::process::id());
let mut system = match tokio::task::spawn_blocking(|| {
System::new_with_specifics(
RefreshKind::nothing()
.with_processes(ProcessRefreshKind::nothing().with_cpu().with_memory()),
)
})
.await
{
Ok(system) => system,
Err(error) => {
tracing::warn!(
target: "phoxal.runtime",
error = %error,
"process telemetry sampler initialization failed; stopping self-sampling"
);
return;
}
};
let mut interval = tokio::time::interval(PROCESS_METRICS_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
let (returned, sample) = match tokio::task::spawn_blocking(move || {
let sample = sample_process(&mut system, pid);
(system, sample)
})
.await
{
Ok(pair) => pair,
Err(error) => {
tracing::warn!(
target: "phoxal.runtime",
error = %error,
"process telemetry sampler task failed; stopping self-sampling"
);
return;
}
};
system = returned;
if let Some(body) = sample {
if tx.send(Some(body)).is_err() {
return;
}
}
}
}
fn sample_process(system: &mut System, pid: Pid) -> Option<api::telemetry::Process> {
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
false,
ProcessRefreshKind::nothing().with_cpu().with_memory(),
);
let process = system.process(pid)?;
Some(api::telemetry::Process {
cpu_pct: process.cpu_usage(),
rss_bytes: process.memory(),
window_ns: u64::try_from(PROCESS_METRICS_INTERVAL.as_nanos()).unwrap_or(u64::MAX),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_process_metrics_publisher_is_a_noop() {
let metrics = ProcessMetricsPublisher::disabled();
metrics.publish(
LogicalTime::new(0, 1),
api::telemetry::Process {
cpu_pct: 0.0,
rss_bytes: 0,
window_ns: 0,
},
);
}
#[test]
fn process_metrics_interval_is_slower_than_the_heartbeat() {
assert!(PROCESS_METRICS_INTERVAL > crate::participant::heartbeat::HEARTBEAT_INTERVAL);
}
#[test]
fn sample_process_reports_this_process_over_the_interval_window() {
let mut system = System::new_with_specifics(
RefreshKind::nothing()
.with_processes(ProcessRefreshKind::nothing().with_cpu().with_memory()),
);
let pid = Pid::from_u32(std::process::id());
let sample = sample_process(&mut system, pid)
.expect("the current process is always present in its own sysinfo refresh");
assert_eq!(sample.window_ns, 3_000_000_000);
assert!(sample.rss_bytes > 0);
}
}