Skip to main content

textfile_metrics/
lib.rs

1// Copyright (c) Ted Kaplan. All Rights Reserved.
2// SPDX-License-Identifier: MIT
3
4//! Non-blocking Prometheus textfile metrics writer.
5//!
6//! This crate provides a production-ready metrics system that writes to
7//! Prometheus textfile format with a non-blocking, thread-safe design. Metrics
8//! are snapshot under lock, then written to disk after releasing the lock.
9//!
10//! # Features
11//!
12//! - **Non-blocking I/O pattern**: Takes snapshot under lock, drops lock, then
13//!   writes
14//! - **Thread-safe**: Uses `DashMap` for lock-free metric updates
15//! - **Lazy configuration**: Reads `METRICS_TEXTFILE_PATH` env var (default:
16//!   `/var/lib/node_exporter/textfile_collector/`)
17//! - **Prometheus format**: Writes valid `.prom` files
18//! - **Type-safe helpers**: `Counter` and `Gauge` abstractions with label
19//!   support
20//!
21//! # Quick Start
22//!
23//! ```no_run
24//! use textfile_metrics::MetricsWriter;
25//!
26//! #[tokio::main]
27//! async fn main() -> anyhow::Result<()> {
28//!     let metrics = MetricsWriter::new()?;
29//!
30//!     // Increment a counter with labels
31//!     metrics.counter("requests_total", vec![
32//!         ("method".to_string(), "GET".to_string()),
33//!         ("status".to_string(), "200".to_string()),
34//!     ], 1.0)?;
35//!
36//!     // Set a gauge value
37//!     metrics.gauge("temperature_celsius", vec![
38//!         ("location".to_string(), "warehouse_a".to_string()),
39//!     ], 23.5)?;
40//!
41//!     // Flush metrics to disk
42//!     metrics.flush().await?;
43//!
44//!     Ok(())
45//! }
46//! ```
47//!
48//! # Environment Variables
49//!
50//! - `METRICS_TEXTFILE_PATH`: Directory to write metrics files (default:
51//!   `/var/lib/node_exporter/textfile_collector/`)
52//! - `METRICS_DEBUG`: Enable debug logging (optional)
53
54#![forbid(unsafe_code)]
55#![warn(missing_docs, unused_imports, dead_code)]
56
57mod errors;
58mod labels;
59mod metric;
60mod writer;
61
62pub use errors::{MetricsError, Result};
63pub use labels::Labels;
64pub use metric::{Counter, Gauge, MetricType};
65pub use writer::MetricsWriter;
66
67/// Pre-initialized global metrics writer.
68///
69/// # Example
70///
71/// ```ignore
72/// use textfile_metrics::GLOBAL_METRICS;
73///
74/// GLOBAL_METRICS.counter("requests_total", vec![], 1).ok();
75/// ```
76pub fn get_global_metrics() -> &'static tokio::sync::OnceCell<MetricsWriter> {
77    static GLOBAL: tokio::sync::OnceCell<MetricsWriter> = tokio::sync::OnceCell::const_new();
78    &GLOBAL
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_labels_formatting() {
87        let labels = Labels::from(vec![
88            ("method".to_string(), "GET".to_string()),
89            ("status".to_string(), "200".to_string()),
90        ]);
91
92        let formatted = labels.to_string();
93        assert!(formatted.contains("method=\"GET\""));
94        assert!(formatted.contains("status=\"200\""));
95    }
96
97    #[tokio::test]
98    async fn test_metrics_writer_creation() {
99        let writer = MetricsWriter::new();
100        assert!(writer.is_ok());
101    }
102
103    #[tokio::test]
104    async fn test_counter_increment() -> anyhow::Result<()> {
105        let metrics = MetricsWriter::new()?;
106        metrics.counter("test_counter", Vec::<(String, String)>::new(), 5.0)?;
107        metrics.counter("test_counter", Vec::<(String, String)>::new(), 3.0)?;
108        Ok(())
109    }
110
111    #[tokio::test]
112    async fn test_gauge_set() -> anyhow::Result<()> {
113        let metrics = MetricsWriter::new()?;
114        metrics.gauge("test_gauge", Vec::<(String, String)>::new(), 42.5)?;
115        Ok(())
116    }
117
118    #[test]
119    fn test_labels_sorting() {
120        let labels = Labels::from(vec![
121            ("z_label".to_string(), "last".to_string()),
122            ("a_label".to_string(), "first".to_string()),
123        ]);
124
125        let formatted = labels.to_string();
126        let a_pos = formatted.find("a_label").unwrap();
127        let z_pos = formatted.find("z_label").unwrap();
128        assert!(a_pos < z_pos, "Labels should be sorted");
129    }
130}