1use crate::obs::telemetry::TelemetrySnapshot;
28use std::fmt::Write;
29
30pub struct PrometheusExporter {
32 prefix: String,
34 include_help: bool,
36 include_type: bool,
38}
39
40impl PrometheusExporter {
41 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 pub fn without_help(mut self) -> Self {
52 self.include_help = false;
53 self
54 }
55
56 pub fn without_type(mut self) -> Self {
58 self.include_type = false;
59 self
60 }
61
62 pub fn export(&self, snapshot: &TelemetrySnapshot) -> String {
64 let mut output = String::with_capacity(4096);
65
66 for (name, value) in &snapshot.counters {
68 self.write_counter(&mut output, name, *value);
69 }
70
71 for (name, value) in &snapshot.gauges {
73 self.write_gauge(&mut output, name, *value);
74 }
75
76 for (name, stats) in &snapshot.operation_stats {
78 self.write_histogram(&mut output, name, stats);
79 }
80
81 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 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 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
184fn 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); telemetry.record_operation("query", 750); telemetry.record_operation("query", 2500); 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}