textfile-metrics 0.1.0

Non-blocking Prometheus textfile metrics writer with Counter and Gauge helpers
Documentation
// Copyright (c) Ted Kaplan. All Rights Reserved.
// SPDX-License-Identifier: MIT

//! Non-blocking Prometheus textfile metrics writer.
//!
//! This crate provides a production-ready metrics system that writes to
//! Prometheus textfile format with a non-blocking, thread-safe design. Metrics
//! are snapshot under lock, then written to disk after releasing the lock.
//!
//! # Features
//!
//! - **Non-blocking I/O pattern**: Takes snapshot under lock, drops lock, then
//!   writes
//! - **Thread-safe**: Uses `DashMap` for lock-free metric updates
//! - **Lazy configuration**: Reads `METRICS_TEXTFILE_PATH` env var (default:
//!   `/var/lib/node_exporter/textfile_collector/`)
//! - **Prometheus format**: Writes valid `.prom` files
//! - **Type-safe helpers**: `Counter` and `Gauge` abstractions with label
//!   support
//!
//! # Quick Start
//!
//! ```no_run
//! use textfile_metrics::MetricsWriter;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//!     let metrics = MetricsWriter::new()?;
//!
//!     // Increment a counter with labels
//!     metrics.counter("requests_total", vec![
//!         ("method".to_string(), "GET".to_string()),
//!         ("status".to_string(), "200".to_string()),
//!     ], 1.0)?;
//!
//!     // Set a gauge value
//!     metrics.gauge("temperature_celsius", vec![
//!         ("location".to_string(), "warehouse_a".to_string()),
//!     ], 23.5)?;
//!
//!     // Flush metrics to disk
//!     metrics.flush().await?;
//!
//!     Ok(())
//! }
//! ```
//!
//! # Environment Variables
//!
//! - `METRICS_TEXTFILE_PATH`: Directory to write metrics files (default:
//!   `/var/lib/node_exporter/textfile_collector/`)
//! - `METRICS_DEBUG`: Enable debug logging (optional)

#![forbid(unsafe_code)]
#![warn(missing_docs, unused_imports, dead_code)]

mod errors;
mod labels;
mod metric;
mod writer;

pub use errors::{MetricsError, Result};
pub use labels::Labels;
pub use metric::{Counter, Gauge, MetricType};
pub use writer::MetricsWriter;

/// Pre-initialized global metrics writer.
///
/// # Example
///
/// ```ignore
/// use textfile_metrics::GLOBAL_METRICS;
///
/// GLOBAL_METRICS.counter("requests_total", vec![], 1).ok();
/// ```
pub fn get_global_metrics() -> &'static tokio::sync::OnceCell<MetricsWriter> {
    static GLOBAL: tokio::sync::OnceCell<MetricsWriter> = tokio::sync::OnceCell::const_new();
    &GLOBAL
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_labels_formatting() {
        let labels = Labels::from(vec![
            ("method".to_string(), "GET".to_string()),
            ("status".to_string(), "200".to_string()),
        ]);

        let formatted = labels.to_string();
        assert!(formatted.contains("method=\"GET\""));
        assert!(formatted.contains("status=\"200\""));
    }

    #[tokio::test]
    async fn test_metrics_writer_creation() {
        let writer = MetricsWriter::new();
        assert!(writer.is_ok());
    }

    #[tokio::test]
    async fn test_counter_increment() -> anyhow::Result<()> {
        let metrics = MetricsWriter::new()?;
        metrics.counter("test_counter", Vec::<(String, String)>::new(), 5.0)?;
        metrics.counter("test_counter", Vec::<(String, String)>::new(), 3.0)?;
        Ok(())
    }

    #[tokio::test]
    async fn test_gauge_set() -> anyhow::Result<()> {
        let metrics = MetricsWriter::new()?;
        metrics.gauge("test_gauge", Vec::<(String, String)>::new(), 42.5)?;
        Ok(())
    }

    #[test]
    fn test_labels_sorting() {
        let labels = Labels::from(vec![
            ("z_label".to_string(), "last".to_string()),
            ("a_label".to_string(), "first".to_string()),
        ]);

        let formatted = labels.to_string();
        let a_pos = formatted.find("a_label").unwrap();
        let z_pos = formatted.find("z_label").unwrap();
        assert!(a_pos < z_pos, "Labels should be sorted");
    }
}