Skip to content
vicansoPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

zstats

A system performance metrics collection library for Rust. One unified, serializable data model; collection fully decoupled from output; collection cost tunable down to tray-app levels.

Install

Prebuilt binaries (macOS arm64/x86_64, static-musl Linux arm64/x86_64):

curl -fsSL https://raw.githubusercontent.com/vicanso/zstats/main/install.sh | sh

The script picks the asset for your platform from the latest release, verifies its published SHA-256, and installs to /usr/local/bin. Override with ZSTATS_INSTALL_DIR=~/.local/bin or pin a release with ZSTATS_VERSION=v0.1.0.

From source

Requires Rust 1.95+ (MSRV; driven by sysinfo 0.39).

# from crates.io (after the crate is published)
cargo install zstats

# from git
cargo install --git https://github.com/vicanso/zstats

# from a local checkout
cargo install --path .

This installs the zstats binary to ~/.cargo/bin (keep that directory on your PATH). Default features include cli; do not pass --no-default-features unless you only want the library built without the binary.

Upgrade later with cargo install zstats --force (or re-run the git/path form with --force).

Desktop app

For a GUI, zstats.app is a menu-bar monitor built on this crate: live CPU in the tray, a panel with processes, whole-app process trees, hardware and history, per-app alert thresholds delivered as native notifications, and disk-space analysis with recoverable cleanup.

  • macOS (Apple Silicon and Intel; universal, signed and notarized): download zstats.dmg from its Releases and drag it into Applications.
  • Linux (preview, Wayland only): zstats-linux-x86_64.tar.gz and zstats-linux-aarch64.tar.gz on the same page, each with an install script.

Releases are mirrored to Gitee for networks that cannot reach GitHub.

The app and the CLI share ~/.zstats, so config, alert overrides and the daily history carry over between them. The app collects in-process through Monitor, though, so run it instead of zstats serve, not alongside: two collectors would each raise every alert and each append the same history.

Design

Collector (trait, sync) ──SystemSnapshot──▶ Scheduler ──concurrent dispatch──▶ MetricSink × N
  • SystemSnapshot is the single data contract: host info (including uptime), CPU (overall, per-core, frequency on a slower cadence), memory/swap (on macOS also the compressor's swap-in/out activity, its thrash verdict and the kernel's own available-memory figure), load averages, hardware temperatures (slow cadence; platform sensors with garbage values filtered), and — each individually toggleable — disks (capacity + IO rates, kind, removable; optional per-device dedupe), drives (per physical disk: IOPS, service time, queue depth, errors; macOS), GPUs (utilisation and memory per accelerator; macOS), network interfaces (rx/tx byte/packet/error rates), and processes (top-N by CPU and by memory, with command lines, virtual memory, run time, and optional per-process disk IO). Serde-serializable; timestamps are RFC 3339 (jiff). Process lists are Arc-shared so cloning a snapshot is cheap.
  • Listening sockets are a one-shot call, not a snapshot field: zstats::listeners() returns every TCP/UDP socket waiting for connections — address, port, owning pid, process and uid — on macOS, Linux and Windows, and runs only when called. On macOS the kernel lists other processes' sockets only to a caller with an app-bundle identity (such as zstats.app); a bare executable gets CollectError::Restricted. See docs/listeners.md.
  • Per-process network bytes are the same kind of call (macOS and Linux): zstats::process_traffic() returns each process's cumulative received and transmitted bytes. On macOS that is netstat -anv's rxbytes / txbytes, summed across that process's TCP and UDP sockets, and the same app-bundle rule as listeners() applies. On Linux it is TCP only (tcp_info); a process whose /proc/<pid>/fd cannot be read is omitted and coverage says so. One call is not a rate; diff two results. Windows returns Unsupported.
  • Rate metrics are computed internally by diffing cumulative counters between samples, so snapshots only ever expose per-second values (counter wraparound handled; the first sample reports None/0).
  • Sinks are isolated: every sink's async write runs concurrently with its own timeout; one slow or failing sink never affects the others, and a failed collect just skips the round.

Quick start

use std::sync::Arc;
use std::time::Duration;
use zstats::{CollectorConfig, LocalChannelSink, LocalCollector, MetricSink, Scheduler};

let collector = LocalCollector::new(CollectorConfig::default());
let (sink, mut rx) = LocalChannelSink::channel();

let mut scheduler = Scheduler::new(
    Box::new(collector),
    vec![Arc::new(sink) as Arc<dyn MetricSink>],
    Duration::from_secs(2),
);
scheduler.start().await?;

// The subscriber side reads the latest snapshot at any time
rx.changed().await?;
let snapshot = rx.borrow().clone();

Scheduler::collect_once triggers an immediate collect-and-dispatch for app-initiated refresh. Without the runtime feature the library is fully synchronous — drive LocalCollector::collect() from your own loop.

Custom sinks

Implement MetricSink to add a backend (async_trait is re-exported):

use zstats::{async_trait, MetricSink, SinkError, SystemSnapshot};

struct MySink;

#[async_trait]
impl MetricSink for MySink {
    async fn write(&self, snapshot: &SystemSnapshot) -> Result<(), SinkError> {
        // push to your storage / channel / endpoint
        Ok(())
    }

    fn name(&self) -> &str {
        "my_sink"
    }
}

Built-in sinks: LocalChannelSink (tokio watch channel for low-latency in-process delivery) and StdoutSink (JSON lines, debugging).

Collection cost controls

CPU, memory, and load are always collected — they cost microseconds. The expensive subsystems are opt-out and throttled (CollectorConfig):

Knob Default Effect
collect_disks / collect_networks / collect_processes / collect_temperatures true Disable a subsystem entirely (its snapshot field becomes None). collect_temperatures defaults to false on Windows, where the reading goes through WMI and initialises COM process-wide
temperature_refresh_interval 15s Hardware temps change slowly; readings are reused between refreshes
disk_storage_refresh_interval 60s Disk capacity (statfs, the most expensive call) refreshes on its own slow cadence; IO counters still refresh every collect, and a newly mounted volume is read as soon as it appears
dedupe_disks true Keep one entry per device name (shortest mount); collapses APFS synthetic mounts
cpu_frequency_refresh_interval 30s CPU frequency refreshes on its own cadence; usage still every collect
process_refresh_interval 0 (every collect) Throttle the process list; the last list is reused between refreshes
collect_battery true Charge, health, cycles, temperature and power draw of the main battery (None on machines without one)
collect_gpu / gpu_refresh_interval true / 10s GPU utilisation and memory per accelerator, read from the IORegistry via the stock ioreg tool (the library's one child process, killed after 1s if the registry stalls). About 45 ms of CPU per read at a real cadence, more than the process-table walk; LocalCollector::set_collect_gpu switches it at runtime. macOS only; None elsewhere
collect_drives / drive_refresh_interval true / 10s Per physical disk: IOPS, average service time, mean queue depth, errors since boot — what the volume layer cannot report. Same ioreg path and cost; rates diff across the cadence; set_collect_drives switches it at runtime. macOS only
process_boost_cpu_cores auto: 30% of cores While overall load is ≥ this many logical cores of work, the process list refreshes every collect. Unset = 30% of the machine's logical cores; explicit value pins the bar in core units; 0 = off
collect_process_disk_io false Per-process read/write byte rates (extra refresh cost when on)
max_processes 50 Kept processes; the budget is split between top-by-CPU and top-by-memory so idle memory hogs stay visible
collect_timeout 2s Enforced by the Scheduler around each collect

As a reference (Apple Silicon, 2s interval, release build): full collection runs at about 1.3% of one core, ~0.45% with a 10s process cadence, and about 0% with only CPU/memory/load enabled.

Cargo features

Feature Contents
none Data contract + synchronous LocalCollector — no tokio
runtime Scheduler and the sink module (tokio, async-trait, tracing)
config CollectorConfig::load_from_dir — read <dir>/config.toml
client Typed client for the zstats serve daemon socket (unix only)
frontend Frontend building blocks: alert rule engine (alerts), rolling per-process averages (rolling), daily metrics history (records), full config-file model (settings) — sync only, no tokio
cli (default) The zstats command-line binary and its extra tokio features

Library consumers who drive their own scheduling can depend on default-features = false; add runtime for the async pipeline.

For a single-process frontend (e.g. a tray GUI) that embeds collection, Monitor wires collection, the alert rules, rolling averages and history persistence into one call — no tokio, no callbacks, the caller owns the loop and therefore decides which thread touches the UI:

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let mut monitor = zstats::Monitor::new(zstats::settings::default_dir())?;
    loop {
        let tick = monitor.tick()?;
        for alert in &tick.alerts {
            println!("{}", alert.message); // deliver however you like
        }
        // tick.snapshot / tick.process_stats drive the views;
        // tick.records are already persisted
        std::thread::sleep(std::time::Duration::from_secs(2));
    }
}

See examples/embedded.rs (cargo run --example embedded) for the full skeleton, including reading history back for charts.

With the client feature, any frontend can attach to a running zstats serve daemon and receive typed snapshots — buffered history first, then live data — without knowing the wire format:

async fn follow() -> Result<(), zstats::ClientError> {
    let mut stream = zstats::client::attach().await?;
    while let Some(snapshot) = stream.next().await? {
        let live = stream.history_remaining() == 0;
        println!("{} (live: {live})", snapshot.timestamp);
    }
    Ok(())
}

License

Apache-2.0

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages