ipfrs_storage/
prometheus.rs

1//! Prometheus metrics exporter
2//!
3//! This module provides integration with Prometheus for production monitoring.
4//! It exports storage metrics in Prometheus text format for scraping.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use ipfrs_storage::{PrometheusExporter, StorageMetrics};
10//!
11//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
12//! let metrics = StorageMetrics::default();
13//! let exporter = PrometheusExporter::new("ipfrs_storage".to_string());
14//! let prometheus_text = exporter.export(&metrics);
15//! println!("{}", prometheus_text);
16//! # Ok(())
17//! # }
18//! ```
19
20use crate::StorageMetrics;
21use std::fmt::Write;
22
23/// Prometheus metrics exporter
24#[derive(Debug, Clone)]
25pub struct PrometheusExporter {
26    /// Namespace for metrics (e.g., "ipfrs_storage")
27    namespace: String,
28    /// Additional labels to add to all metrics
29    labels: Vec<(String, String)>,
30}
31
32impl PrometheusExporter {
33    /// Create a new Prometheus exporter
34    pub fn new(namespace: String) -> Self {
35        Self {
36            namespace,
37            labels: Vec::new(),
38        }
39    }
40
41    /// Add a label to all exported metrics
42    pub fn with_label(mut self, key: String, value: String) -> Self {
43        self.labels.push((key, value));
44        self
45    }
46
47    /// Export metrics in Prometheus text format
48    pub fn export(&self, metrics: &StorageMetrics) -> String {
49        let mut output = String::new();
50        let labels = self.format_labels();
51
52        // Helper macro to write metrics
53        macro_rules! write_metric {
54            ($name:expr, $type:expr, $help:expr, $value:expr) => {
55                writeln!(output, "# HELP {}_{} {}", self.namespace, $name, $help).unwrap();
56                writeln!(output, "# TYPE {}_{} {}", self.namespace, $name, $type).unwrap();
57                writeln!(output, "{}_{}{} {}", self.namespace, $name, labels, $value).unwrap();
58            };
59        }
60
61        // Operation counters
62        write_metric!(
63            "put_total",
64            "counter",
65            "Total number of put operations",
66            metrics.put_count
67        );
68        write_metric!(
69            "get_total",
70            "counter",
71            "Total number of get operations",
72            metrics.get_count
73        );
74        write_metric!(
75            "has_total",
76            "counter",
77            "Total number of has operations",
78            metrics.has_count
79        );
80        write_metric!(
81            "delete_total",
82            "counter",
83            "Total number of delete operations",
84            metrics.delete_count
85        );
86
87        // Cache metrics
88        write_metric!(
89            "get_hits_total",
90            "counter",
91            "Total number of successful gets",
92            metrics.get_hits
93        );
94        write_metric!(
95            "get_misses_total",
96            "counter",
97            "Total number of failed gets",
98            metrics.get_misses
99        );
100        write_metric!(
101            "cache_hit_rate",
102            "gauge",
103            "Cache hit rate (0.0 to 1.0)",
104            metrics.cache_hit_rate()
105        );
106
107        // Bytes transferred
108        write_metric!(
109            "bytes_written_total",
110            "counter",
111            "Total bytes written",
112            metrics.bytes_written
113        );
114        write_metric!(
115            "bytes_read_total",
116            "counter",
117            "Total bytes read",
118            metrics.bytes_read
119        );
120
121        // Latency metrics
122        write_metric!(
123            "put_latency_microseconds",
124            "gauge",
125            "Average put operation latency in microseconds",
126            metrics.avg_put_latency_us
127        );
128        write_metric!(
129            "get_latency_microseconds",
130            "gauge",
131            "Average get operation latency in microseconds",
132            metrics.avg_get_latency_us
133        );
134        write_metric!(
135            "has_latency_microseconds",
136            "gauge",
137            "Average has operation latency in microseconds",
138            metrics.avg_has_latency_us
139        );
140        write_metric!(
141            "peak_put_latency_microseconds",
142            "gauge",
143            "Peak put operation latency in microseconds",
144            metrics.peak_put_latency_us
145        );
146        write_metric!(
147            "peak_get_latency_microseconds",
148            "gauge",
149            "Peak get operation latency in microseconds",
150            metrics.peak_get_latency_us
151        );
152        write_metric!(
153            "operation_latency_microseconds",
154            "gauge",
155            "Average operation latency in microseconds",
156            metrics.avg_operation_latency_us()
157        );
158
159        // Error metrics
160        write_metric!(
161            "errors_total",
162            "counter",
163            "Total number of errors encountered",
164            metrics.error_count
165        );
166
167        output
168    }
169
170    /// Format labels for Prometheus
171    fn format_labels(&self) -> String {
172        if self.labels.is_empty() {
173            String::new()
174        } else {
175            let label_str = self
176                .labels
177                .iter()
178                .map(|(k, v)| format!("{k}=\"{v}\""))
179                .collect::<Vec<_>>()
180                .join(",");
181            format!("{{{label_str}}}")
182        }
183    }
184
185    /// Export metrics as HTTP response body (suitable for /metrics endpoint)
186    pub fn export_http(&self, metrics: &StorageMetrics) -> (String, String) {
187        let body = self.export(metrics);
188        let content_type = "text/plain; version=0.0.4; charset=utf-8".to_string();
189        (content_type, body)
190    }
191}
192
193/// Builder for creating a Prometheus exporter with multiple configurations
194#[derive(Debug, Default)]
195pub struct PrometheusExporterBuilder {
196    namespace: Option<String>,
197    labels: Vec<(String, String)>,
198}
199
200impl PrometheusExporterBuilder {
201    /// Create a new builder
202    pub fn new() -> Self {
203        Self::default()
204    }
205
206    /// Set the namespace for metrics
207    pub fn namespace(mut self, namespace: String) -> Self {
208        self.namespace = Some(namespace);
209        self
210    }
211
212    /// Add a label to all metrics
213    pub fn label(mut self, key: String, value: String) -> Self {
214        self.labels.push((key, value));
215        self
216    }
217
218    /// Add the instance label (common for Prometheus)
219    pub fn instance(self, instance: String) -> Self {
220        self.label("instance".to_string(), instance)
221    }
222
223    /// Add the job label (common for Prometheus)
224    pub fn job(self, job: String) -> Self {
225        self.label("job".to_string(), job)
226    }
227
228    /// Build the exporter
229    pub fn build(self) -> PrometheusExporter {
230        let namespace = self
231            .namespace
232            .unwrap_or_else(|| "ipfrs_storage".to_string());
233        PrometheusExporter {
234            namespace,
235            labels: self.labels,
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_prometheus_export_basic() {
246        let mut metrics = StorageMetrics::default();
247        metrics.put_count = 100;
248        metrics.get_count = 200;
249        metrics.get_hits = 180;
250        metrics.get_misses = 20;
251        metrics.bytes_written = 1024000;
252        metrics.bytes_read = 2048000;
253
254        let exporter = PrometheusExporter::new("test".to_string());
255        let output = exporter.export(&metrics);
256
257        // Check that output contains expected metrics
258        assert!(output.contains("# HELP test_put_total"));
259        assert!(output.contains("# TYPE test_put_total counter"));
260        assert!(output.contains("test_put_total 100"));
261        assert!(output.contains("test_get_total 200"));
262        assert!(output.contains("test_get_hits_total 180"));
263        assert!(output.contains("test_get_misses_total 20"));
264        assert!(output.contains("test_bytes_written_total 1024000"));
265        assert!(output.contains("test_bytes_read_total 2048000"));
266    }
267
268    #[test]
269    fn test_prometheus_export_with_labels() {
270        let metrics = StorageMetrics::default();
271        let exporter = PrometheusExporter::new("test".to_string())
272            .with_label("instance".to_string(), "node1".to_string())
273            .with_label("datacenter".to_string(), "us-west".to_string());
274
275        let output = exporter.export(&metrics);
276
277        // Check that labels are included
278        assert!(output.contains("{instance=\"node1\",datacenter=\"us-west\"}"));
279    }
280
281    #[test]
282    fn test_prometheus_export_cache_hit_rate() {
283        let mut metrics = StorageMetrics::default();
284        metrics.get_hits = 90;
285        metrics.get_misses = 10;
286
287        let exporter = PrometheusExporter::new("test".to_string());
288        let output = exporter.export(&metrics);
289
290        // Cache hit rate should be 0.9
291        assert!(output.contains("test_cache_hit_rate 0.9"));
292    }
293
294    #[test]
295    fn test_prometheus_export_builder() {
296        let exporter = PrometheusExporterBuilder::new()
297            .namespace("custom".to_string())
298            .instance("node1".to_string())
299            .job("storage".to_string())
300            .label("region".to_string(), "us-east".to_string())
301            .build();
302
303        let metrics = StorageMetrics::default();
304        let output = exporter.export(&metrics);
305
306        assert!(output.contains("custom_put_total"));
307        assert!(output.contains("instance=\"node1\""));
308        assert!(output.contains("job=\"storage\""));
309        assert!(output.contains("region=\"us-east\""));
310    }
311
312    #[test]
313    fn test_http_export() {
314        let metrics = StorageMetrics::default();
315        let exporter = PrometheusExporter::new("test".to_string());
316        let (content_type, body) = exporter.export_http(&metrics);
317
318        assert_eq!(content_type, "text/plain; version=0.0.4; charset=utf-8");
319        assert!(body.contains("# HELP"));
320        assert!(body.contains("# TYPE"));
321    }
322}