captrack 0.1.0

Capacity telemetry for Rust collections — call-site macros that record peak capacity, with zero overhead when disabled.
// dump_capacity_stats — always available, no-op in off-feature mode.
//
// In on-feature mode: serialises the global registry to a pretty-printed JSON
// file, sorted by max(samples) descending so the biggest allocations surface
// first.
//
// Intended use: call once at the end of a benchmark main function.
//   captrack::dump_capacity_stats("target/capacity-stats/my_bench.json")?;

#[cfg(feature = "telemetry")]
mod inner {
    use std::cmp::Reverse;
    use std::path::Path;
    use std::sync::atomic::Ordering;

    use serde::Serialize;

    use crate::registry::CapStats;

    #[derive(Serialize)]
    struct Entry {
        name: &'static str,
        file: &'static str,
        line: u32,
        column: u32,
        creation_count: u64,
        /// Reservoir snapshot — at most CAPTRACK_SAMPLE_CAP elements.
        /// Statistically representative subset of all observed capacity values.
        samples: Vec<usize>,
        /// Total number of capacity observations (including evicted reservoir
        /// entries).  Always >= samples.len().  Consumers use this for correct
        /// percentile scaling when samples.len() < total_observed.
        #[serde(skip_serializing_if = "Option::is_none")]
        total_observed: Option<u64>,
    }

    #[derive(Serialize)]
    struct Dump {
        version: u32,
        stats: Vec<Entry>,
    }

    fn entry_from((file, line, column): (&'static str, u32, u32), stats: &CapStats) -> Entry {
        // Reservoir snapshot — non-destructive read, no push-back needed.
        let samples = stats.samples.snapshot();
        let total_observed = stats.samples.total_observed();
        Entry {
            name: stats.name,
            file,
            line,
            column,
            creation_count: stats.creation_count.load(Ordering::Relaxed),
            samples,
            total_observed: Some(total_observed),
        }
    }

    /// Serializes concurrent `dump_capacity_stats` calls *within one process*
    /// (the periodic autodump thread and the atexit destructor both call it,
    /// unsynchronized otherwise). Cross-process safety against two OS
    /// processes of the same binary racing on the same destination path is
    /// handled separately by the PID-qualified `tmp_path` below — this lock
    /// only prevents this process's own writer threads from interleaving.
    static DUMP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    pub fn dump_capacity_stats(path: impl AsRef<Path>) -> std::io::Result<()> {
        let _guard = DUMP_LOCK
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        let mut entries: Vec<Entry> = Vec::new();
        crate::registry::registry().scan(|loc, stats| {
            entries.push(entry_from(*loc, stats));
        });
        // Sort by max sample descending — entries with no samples sort last (0).
        entries.sort_by_key(|e| Reverse(e.samples.iter().copied().max().unwrap_or(0)));

        let dump = Dump {
            version: 1,
            stats: entries,
        };
        let path = path.as_ref();
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        // Atomic write: serialise to `<path>.tmp`, then `rename`.  Rationale:
        // the periodic autodump tick (captrack/src/autodump.rs) writes a fresh
        // snapshot every few hundred ms; a `TerminateProcess` / `SIGKILL`
        // landing between `File::create` (which truncates) and the end of
        // `to_writer_pretty` would otherwise leave a zero- or partial-byte
        // file at the destination — destroying the previous successful
        // snapshot.  `rename` is atomic on POSIX *and* on NTFS for
        // same-volume moves, so the destination is either the prior tick or
        // the new one, never a half-written torso.
        //
        // `tmp_path` includes the PID: `default_dump_path()` (autodump.rs)
        // derives the destination filename from `current_exe()`'s stem, which
        // is IDENTICAL across every OS process that runs the same compiled
        // test/bench binary — e.g. `cargo nextest` launches one fresh process
        // per test by default, so N concurrent processes of the same binary
        // all target the same destination `path`. Without a PID-qualified
        // `tmp_path`, two such processes racing `File::create(tmp_path)` can
        // interleave writes into the *same* temp file (one process's
        // `File::create` truncates mid-write, or both write through separate
        // handles to the same NTFS file record) and produce a temp file with
        // two concatenated JSON documents, which then gets renamed over the
        // destination — corrupting it. The final `rename` onto the *shared*
        // `path` is still a last-writer-wins race across processes, which is
        // fine (atomic replace, no corruption, by design); only the tmp file
        // needs to be exclusive per-writer.
        let tmp_path = match path.file_name() {
            Some(name) => {
                let mut tmp_name = name.to_os_string();
                tmp_name.push(format!(".{}.tmp", std::process::id()));
                path.with_file_name(tmp_name)
            }
            None => {
                return Err(std::io::Error::new(
                    std::io::ErrorKind::InvalidInput,
                    "dump_capacity_stats: path has no file name",
                ))
            }
        };
        {
            let f = std::fs::File::create(&tmp_path)?;
            serde_json::to_writer_pretty(f, &dump).map_err(std::io::Error::other)?;
            // File handle dropped here flushes the userspace buffer.
        }
        // `rename` over an existing destination is allowed on both POSIX and
        // modern Windows (Rust's std uses MoveFileExW with REPLACE_EXISTING).
        std::fs::rename(&tmp_path, path)?;
        Ok(())
    }
}

/// Write accumulated capacity statistics to a JSON file, sorted by
/// `max(samples)` descending.
///
/// In off-feature mode this is a no-op that returns `Ok(())` immediately so
/// benchmark code can call it unconditionally without `#[cfg]` guards.
///
/// # Examples
///
/// ```ignore
/// // At the end of a benchmark:
/// captrack::dump_capacity_stats("target/capacity-stats/my_bench.json")?;
/// ```
#[cfg(feature = "telemetry")]
pub use inner::dump_capacity_stats;

/// No-op stub — compiled when the `telemetry` feature is not enabled.
#[cfg(not(feature = "telemetry"))]
pub fn dump_capacity_stats<P: AsRef<std::path::Path>>(_path: P) -> std::io::Result<()> {
    Ok(())
}