Skip to main content

embeddenator_obs/obs/
prometheus.rs

1//! Prometheus Metrics Export
2//!
3//! Exports observability metrics in Prometheus text format for scraping
4//! by Prometheus servers or compatible monitoring systems.
5//!
6//! # Features
7//!
8//! - Counter metrics export
9//! - Gauge metrics export
10//! - Histogram buckets for operation timings
11//! - Label support for metric dimensions
12//! - Text format output (Prometheus standard)
13//!
14//! # Usage
15//!
16//! ```rust,ignore
17//! use embeddenator_obs::prometheus::PrometheusExporter;
18//!
19//! let exporter = PrometheusExporter::new("embeddenator");
20//! let snapshot = telemetry.snapshot();
21//! let prometheus_text = exporter.export(&snapshot);
22//!
23//! // Serve via HTTP endpoint
24//! // GET /metrics -> prometheus_text
25//! ```
26
27use crate::obs::telemetry::TelemetrySnapshot;
28use std::fmt::Write;
29
30/// Prometheus metrics exporter.
31pub struct PrometheusExporter {
32    /// Metric prefix (e.g., "embeddenator")
33    prefix: String,
34    /// Include help text
35    include_help: bool,
36    /// Include type annotations
37    include_type: bool,
38}
39
40impl PrometheusExporter {
41    /// Create new Prometheus exporter with prefix.
42    pub fn new(prefix: impl Into<String>) -> Self {
43        Self {
44            prefix: prefix.into(),
45            include_help: true,
46            include_type: true,
47        }
48    }
49
50    /// Disable help text (reduces output size).
51    pub fn without_help(mut self) -> Self {
52        self.include_help = false;
53        self
54    }
55
56    /// Disable type annotations.
57    pub fn without_type(mut self) -> Self {
58        self.include_type = false;
59        self
60    }
61
62    /// Export snapshot to Prometheus text format.
63    pub fn export(&self, snapshot: &TelemetrySnapshot) -> String {
64        let mut output = String::with_capacity(4096);
65
66        // Export counters
67        for (name, value) in &snapshot.counters {
68            self.write_counter(&mut output, name, *value);
69        }
70
71        // Export gauges
72        for (name, value) in &snapshot.gauges {
73            self.write_gauge(&mut output, name, *value);
74        }
75
76        // Export operation timings as histograms
77        for (name, stats) in &snapshot.operation_stats {
78            self.write_histogram(&mut output, name, stats);
79        }
80
81        // Export built-in metrics
82        self.write_counter(
83            &mut output,
84            "sub_cache_hits",
85            snapshot.metrics.sub_cache_hits,
86        );
87        self.write_counter(
88            &mut output,
89            "sub_cache_misses",
90            snapshot.metrics.sub_cache_misses,
91        );
92        self.write_counter(
93            &mut output,
94            "index_cache_evictions",
95            snapshot.metrics.index_cache_evictions,
96        );
97        self.write_counter(
98            &mut output,
99            "poison_recoveries_total",
100            snapshot.metrics.poison_recoveries_total,
101        );
102
103        // Export uptime as gauge
104        self.write_gauge(&mut output, "uptime_seconds", snapshot.uptime_secs as f64);
105
106        output
107    }
108
109    fn write_counter(&self, output: &mut String, name: &str, value: u64) {
110        let metric_name = format!("{}_{}", self.prefix, sanitize_name(name));
111
112        if self.include_help {
113            writeln!(output, "# HELP {} Counter metric", metric_name).ok();
114        }
115        if self.include_type {
116            writeln!(output, "# TYPE {} counter", metric_name).ok();
117        }
118        writeln!(output, "{} {}", metric_name, value).ok();
119    }
120
121    fn write_gauge(&self, output: &mut String, name: &str, value: f64) {
122        let metric_name = format!("{}_{}", self.prefix, sanitize_name(name));
123
124        if self.include_help {
125            writeln!(output, "# HELP {} Gauge metric", metric_name).ok();
126        }
127        if self.include_type {
128            writeln!(output, "# TYPE {} gauge", metric_name).ok();
129        }
130        writeln!(output, "{} {}", metric_name, value).ok();
131    }
132
133    fn write_histogram(
134        &self,
135        output: &mut String,
136        name: &str,
137        stats: &crate::obs::telemetry::OperationStats,
138    ) {
139        let metric_name = format!("{}_{}_duration_us", self.prefix, sanitize_name(name));
140
141        if self.include_help {
142            writeln!(
143                output,
144                "# HELP {} Operation duration histogram",
145                metric_name
146            )
147            .ok();
148        }
149        if self.include_type {
150            writeln!(output, "# TYPE {} histogram", metric_name).ok();
151        }
152
153        // Histogram buckets (microseconds): 100us, 500us, 1ms, 5ms, 10ms, 50ms, 100ms, +Inf
154        let buckets = [100, 500, 1000, 5000, 10000, 50000, 100000];
155        let mut cumulative = 0u64;
156
157        for bucket in &buckets {
158            cumulative += stats.count_below(*bucket);
159            writeln!(
160                output,
161                "{}_bucket{{le=\"{}\"}} {}",
162                metric_name, bucket, cumulative
163            )
164            .ok();
165        }
166
167        writeln!(
168            output,
169            "{}_bucket{{le=\"+Inf\"}} {}",
170            metric_name, stats.count
171        )
172        .ok();
173        writeln!(output, "{}_sum {}", metric_name, stats.total_us).ok();
174        writeln!(output, "{}_count {}", metric_name, stats.count).ok();
175    }
176}
177
178impl Default for PrometheusExporter {
179    fn default() -> Self {
180        Self::new("embeddenator")
181    }
182}
183
184/// Sanitize metric name for Prometheus (replace invalid chars with underscore).
185fn sanitize_name(name: &str) -> String {
186    name.chars()
187        .map(|c| {
188            if c.is_alphanumeric() || c == '_' {
189                c
190            } else {
191                '_'
192            }
193        })
194        .collect()
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::obs::telemetry::Telemetry;
201
202    #[test]
203    fn test_prometheus_export() {
204        let mut telemetry = Telemetry::default_config();
205        telemetry.increment_counter("requests");
206        telemetry.set_gauge("queue_size", 42.5);
207        telemetry.record_operation("query", 1250);
208
209        let snapshot = telemetry.snapshot();
210        let exporter = PrometheusExporter::new("test");
211        let output = exporter.export(&snapshot);
212
213        assert!(output.contains("test_requests"));
214        assert!(output.contains("test_queue_size"));
215        assert!(output.contains("test_query_duration_us"));
216    }
217
218    #[test]
219    fn test_sanitize_name() {
220        assert_eq!(sanitize_name("valid_name"), "valid_name");
221        assert_eq!(sanitize_name("invalid-name"), "invalid_name");
222        assert_eq!(sanitize_name("name.with.dots"), "name_with_dots");
223        assert_eq!(sanitize_name("name:with:colons"), "name_with_colons");
224    }
225
226    #[test]
227    fn test_histogram_buckets() {
228        let mut telemetry = Telemetry::default_config();
229        telemetry.record_operation("query", 50); // < 100us
230        telemetry.record_operation("query", 750); // < 1000us
231        telemetry.record_operation("query", 2500); // < 5000us
232
233        let snapshot = telemetry.snapshot();
234        let exporter = PrometheusExporter::new("test");
235        let output = exporter.export(&snapshot);
236
237        assert!(output.contains("test_query_duration_us_bucket"));
238        assert!(output.contains("test_query_duration_us_sum"));
239        assert!(output.contains("test_query_duration_us_count 3"));
240    }
241
242    #[test]
243    fn test_without_help_and_type() {
244        let mut telemetry = Telemetry::default_config();
245        telemetry.increment_counter("test");
246
247        let snapshot = telemetry.snapshot();
248        let exporter = PrometheusExporter::new("app").without_help().without_type();
249        let output = exporter.export(&snapshot);
250
251        assert!(!output.contains("# HELP"));
252        assert!(!output.contains("# TYPE"));
253        assert!(output.contains("app_test"));
254    }
255}