halldyll_core/observe/
prometheus.rs

1//! Prometheus Metrics Exporter
2//!
3//! Exports metrics in Prometheus text format for monitoring.
4//!
5//! ## Usage
6//!
7//! ```rust,ignore
8//! let collector = MetricsCollector::new();
9//! let exporter = PrometheusExporter::new(&collector);
10//! 
11//! // Get metrics in Prometheus format
12//! let output = exporter.export();
13//! // Output:
14//! // # HELP halldyll_requests_total Total number of HTTP requests
15//! // # TYPE halldyll_requests_total counter
16//! // halldyll_requests_total 1234
17//! ```
18
19use crate::observe::metrics::{MetricsCollector, MetricsSnapshot};
20
21/// Prometheus metrics exporter
22pub struct PrometheusExporter<'a> {
23    collector: &'a MetricsCollector,
24    prefix: String,
25}
26
27impl<'a> PrometheusExporter<'a> {
28    /// Create a new exporter with default prefix "halldyll"
29    pub fn new(collector: &'a MetricsCollector) -> Self {
30        Self {
31            collector,
32            prefix: "halldyll".to_string(),
33        }
34    }
35
36    /// Create with custom prefix
37    pub fn with_prefix(collector: &'a MetricsCollector, prefix: impl Into<String>) -> Self {
38        Self {
39            collector,
40            prefix: prefix.into(),
41        }
42    }
43
44    /// Export metrics in Prometheus text format
45    pub fn export(&self) -> String {
46        let snapshot = self.collector.global().snapshot();
47        self.format_metrics(&snapshot)
48    }
49
50    /// Export with additional labels
51    pub fn export_with_labels(&self, labels: &[(&str, &str)]) -> String {
52        let snapshot = self.collector.global().snapshot();
53        self.format_metrics_with_labels(&snapshot, labels)
54    }
55
56    fn format_metrics(&self, snapshot: &MetricsSnapshot) -> String {
57        let mut output = String::with_capacity(2048);
58        let p = &self.prefix;
59
60        // Requests total
61        output.push_str(&format!(
62            "# HELP {p}_requests_total Total number of HTTP requests\n\
63             # TYPE {p}_requests_total counter\n\
64             {p}_requests_total {}\n\n",
65            snapshot.requests_total
66        ));
67
68        // Requests success
69        output.push_str(&format!(
70            "# HELP {p}_requests_success_total Total number of successful requests\n\
71             # TYPE {p}_requests_success_total counter\n\
72             {p}_requests_success_total {}\n\n",
73            snapshot.requests_success
74        ));
75
76        // Requests failed
77        output.push_str(&format!(
78            "# HELP {p}_requests_failed_total Total number of failed requests\n\
79             # TYPE {p}_requests_failed_total counter\n\
80             {p}_requests_failed_total {}\n\n",
81            snapshot.requests_failed
82        ));
83
84        // Requests rate limited
85        output.push_str(&format!(
86            "# HELP {p}_requests_rate_limited_total Total number of rate-limited requests\n\
87             # TYPE {p}_requests_rate_limited_total counter\n\
88             {p}_requests_rate_limited_total {}\n\n",
89            snapshot.requests_rate_limited
90        ));
91
92        // Bytes downloaded
93        output.push_str(&format!(
94            "# HELP {p}_bytes_downloaded_total Total bytes downloaded\n\
95             # TYPE {p}_bytes_downloaded_total counter\n\
96             {p}_bytes_downloaded_total {}\n\n",
97            snapshot.bytes_downloaded
98        ));
99
100        // Retries
101        output.push_str(&format!(
102            "# HELP {p}_retries_total Total number of retries performed\n\
103             # TYPE {p}_retries_total counter\n\
104             {p}_retries_total {}\n\n",
105            snapshot.retries_total
106        ));
107
108        // Documents extracted
109        output.push_str(&format!(
110            "# HELP {p}_documents_extracted_total Total number of documents extracted\n\
111             # TYPE {p}_documents_extracted_total counter\n\
112             {p}_documents_extracted_total {}\n\n",
113            snapshot.documents_extracted
114        ));
115
116        // Success rate (gauge)
117        output.push_str(&format!(
118            "# HELP {p}_success_rate Current success rate (0.0-1.0)\n\
119             # TYPE {p}_success_rate gauge\n\
120             {p}_success_rate {:.4}\n\n",
121            snapshot.success_rate
122        ));
123
124        // Average latency (gauge)
125        output.push_str(&format!(
126            "# HELP {p}_avg_latency_ms Average request latency in milliseconds\n\
127             # TYPE {p}_avg_latency_ms gauge\n\
128             {p}_avg_latency_ms {:.2}\n",
129            snapshot.avg_latency_ms
130        ));
131
132        output
133    }
134
135    fn format_metrics_with_labels(&self, snapshot: &MetricsSnapshot, labels: &[(&str, &str)]) -> String {
136        let label_str = if labels.is_empty() {
137            String::new()
138        } else {
139            let pairs: Vec<String> = labels
140                .iter()
141                .map(|(k, v)| format!("{}=\"{}\"", k, v))
142                .collect();
143            format!("{{{}}}", pairs.join(","))
144        };
145
146        let mut output = String::with_capacity(2048);
147        let p = &self.prefix;
148
149        output.push_str(&format!(
150            "# HELP {p}_requests_total Total number of HTTP requests\n\
151             # TYPE {p}_requests_total counter\n\
152             {p}_requests_total{label_str} {}\n\n",
153            snapshot.requests_total
154        ));
155
156        output.push_str(&format!(
157            "# HELP {p}_requests_success_total Total number of successful requests\n\
158             # TYPE {p}_requests_success_total counter\n\
159             {p}_requests_success_total{label_str} {}\n\n",
160            snapshot.requests_success
161        ));
162
163        output.push_str(&format!(
164            "# HELP {p}_requests_failed_total Total number of failed requests\n\
165             # TYPE {p}_requests_failed_total counter\n\
166             {p}_requests_failed_total{label_str} {}\n\n",
167            snapshot.requests_failed
168        ));
169
170        output.push_str(&format!(
171            "# HELP {p}_requests_rate_limited_total Total number of rate-limited requests\n\
172             # TYPE {p}_requests_rate_limited_total counter\n\
173             {p}_requests_rate_limited_total{label_str} {}\n\n",
174            snapshot.requests_rate_limited
175        ));
176
177        output.push_str(&format!(
178            "# HELP {p}_bytes_downloaded_total Total bytes downloaded\n\
179             # TYPE {p}_bytes_downloaded_total counter\n\
180             {p}_bytes_downloaded_total{label_str} {}\n\n",
181            snapshot.bytes_downloaded
182        ));
183
184        output.push_str(&format!(
185            "# HELP {p}_retries_total Total number of retries performed\n\
186             # TYPE {p}_retries_total counter\n\
187             {p}_retries_total{label_str} {}\n\n",
188            snapshot.retries_total
189        ));
190
191        output.push_str(&format!(
192            "# HELP {p}_documents_extracted_total Total number of documents extracted\n\
193             # TYPE {p}_documents_extracted_total counter\n\
194             {p}_documents_extracted_total{label_str} {}\n\n",
195            snapshot.documents_extracted
196        ));
197
198        output.push_str(&format!(
199            "# HELP {p}_success_rate Current success rate (0.0-1.0)\n\
200             # TYPE {p}_success_rate gauge\n\
201             {p}_success_rate{label_str} {:.4}\n\n",
202            snapshot.success_rate
203        ));
204
205        output.push_str(&format!(
206            "# HELP {p}_avg_latency_ms Average request latency in milliseconds\n\
207             # TYPE {p}_avg_latency_ms gauge\n\
208             {p}_avg_latency_ms{label_str} {:.2}\n",
209            snapshot.avg_latency_ms
210        ));
211
212        output
213    }
214}
215
216/// Standalone function to export metrics
217pub fn export_prometheus(collector: &MetricsCollector) -> String {
218    PrometheusExporter::new(collector).export()
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_prometheus_export() {
227        let collector = MetricsCollector::new();
228        collector.global().inc_requests();
229        collector.global().inc_success();
230        collector.global().add_bytes(1024);
231
232        let exporter = PrometheusExporter::new(&collector);
233        let output = exporter.export();
234
235        assert!(output.contains("halldyll_requests_total 1"));
236        assert!(output.contains("halldyll_requests_success_total 1"));
237        assert!(output.contains("halldyll_bytes_downloaded_total 1024"));
238        assert!(output.contains("# TYPE halldyll_requests_total counter"));
239    }
240
241    #[test]
242    fn test_prometheus_with_labels() {
243        let collector = MetricsCollector::new();
244        collector.global().inc_requests();
245
246        let exporter = PrometheusExporter::new(&collector);
247        let output = exporter.export_with_labels(&[("instance", "scraper-1"), ("env", "prod")]);
248
249        assert!(output.contains("halldyll_requests_total{instance=\"scraper-1\",env=\"prod\"} 1"));
250    }
251
252    #[test]
253    fn test_custom_prefix() {
254        let collector = MetricsCollector::new();
255        collector.global().inc_requests();
256
257        let exporter = PrometheusExporter::with_prefix(&collector, "myapp_scraper");
258        let output = exporter.export();
259
260        assert!(output.contains("myapp_scraper_requests_total 1"));
261    }
262}