1use std::fmt::Write as _;
9
10#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub enum MetricType {
13 Counter,
15
16 Gauge,
18
19 Histogram,
21
22 Summary,
24}
25
26impl MetricType {
27 pub const fn as_str(self) -> &'static str {
29 match self {
30 Self::Counter => "counter",
31 Self::Gauge => "gauge",
32 Self::Histogram => "histogram",
33 Self::Summary => "summary",
34 }
35 }
36}
37
38pub struct PrometheusRenderer {
57 output: String,
58 metric_buffer: String,
59 labels_buffer: String,
60 name_buffer: String,
61 groups_written: usize,
62}
63
64impl PrometheusRenderer {
65 pub fn new() -> Self {
67 Self {
68 output: String::new(),
69 metric_buffer: String::new(),
70 labels_buffer: String::new(),
71 name_buffer: String::new(),
72 groups_written: 0,
73 }
74 }
75
76 pub fn clear(&mut self) {
78 self.output.clear();
79 self.groups_written = 0;
80 }
81
82 pub fn output(&self) -> &str {
84 &self.output
85 }
86
87 pub fn normalize_metric_name<'a>(&'a mut self, name: &str) -> &'a str {
93 self.normalize_name_internal(name);
94 &self.name_buffer
95 }
96
97 pub fn render_scalar_group<S, L, K, V>(
101 &mut self, metric_name: &str, metric_type: MetricType, help_text: Option<&str>, series: S,
102 ) where
103 S: IntoIterator<Item = (L, f64)>,
104 L: IntoIterator<Item = (K, V)>,
105 K: AsRef<str>,
106 V: AsRef<str>,
107 {
108 self.begin_group(metric_name, metric_type, help_text);
109 for (labels, value) in series {
110 self.write_gauge_or_counter_series(labels, value);
111 }
112 self.finish_group();
113 }
114
115 pub fn begin_group(&mut self, metric_name: &str, metric_type: MetricType, help_text: Option<&str>) {
126 self.normalize_name_internal(metric_name);
127 self.metric_buffer.clear();
128
129 if let Some(help) = help_text {
130 let _ = writeln!(self.metric_buffer, "# HELP {} {}", self.name_buffer, help);
131 }
132 let _ = writeln!(
133 self.metric_buffer,
134 "# TYPE {} {}",
135 self.name_buffer,
136 metric_type.as_str()
137 );
138 }
139
140 pub fn write_gauge_or_counter_series<L, K, V>(&mut self, labels: L, value: f64)
144 where
145 L: IntoIterator<Item = (K, V)>,
146 K: AsRef<str>,
147 V: AsRef<str>,
148 {
149 let has_labels = self.format_labels(labels);
150
151 let name_len = self.name_buffer.len();
154 self.metric_buffer.push_str(&self.name_buffer[..name_len]);
155 if has_labels {
156 self.metric_buffer.push('{');
157 self.metric_buffer.push_str(&self.labels_buffer);
158 self.metric_buffer.push('}');
159 }
160 let _ = writeln!(self.metric_buffer, " {}", value);
161 }
162
163 pub fn write_histogram_series<L, K, V, B>(&mut self, labels: L, buckets: B, sum: f64, count: u64)
170 where
171 L: IntoIterator<Item = (K, V)>,
172 K: AsRef<str>,
173 V: AsRef<str>,
174 B: IntoIterator<Item = (&'static str, u64)>,
175 {
176 let has_labels = self.format_labels(labels);
177
178 for (upper_bound_str, cumulative_count) in buckets {
180 let _ = write!(
181 self.metric_buffer,
182 "{}_bucket{{{}",
183 self.name_buffer, self.labels_buffer
184 );
185 if has_labels {
186 self.metric_buffer.push(',');
187 }
188 let _ = writeln!(self.metric_buffer, "le=\"{}\"}} {}", upper_bound_str, cumulative_count);
189 }
190
191 let _ = write!(
193 self.metric_buffer,
194 "{}_bucket{{{}",
195 self.name_buffer, self.labels_buffer
196 );
197 if has_labels {
198 self.metric_buffer.push(',');
199 }
200 let _ = writeln!(self.metric_buffer, "le=\"+Inf\"}} {}", count);
201
202 self.write_suffixed_value("_sum", has_labels, sum);
204 self.write_suffixed_value("_count", has_labels, count as f64);
205 }
206
207 pub fn write_summary_series<L, K, V, Q>(&mut self, labels: L, quantiles: Q, sum: f64, count: u64)
213 where
214 L: IntoIterator<Item = (K, V)>,
215 K: AsRef<str>,
216 V: AsRef<str>,
217 Q: IntoIterator<Item = (f64, f64)>,
218 {
219 let has_labels = self.format_labels(labels);
220
221 for (quantile, value) in quantiles {
223 let _ = write!(self.metric_buffer, "{}{{{}", self.name_buffer, self.labels_buffer);
224 if has_labels {
225 self.metric_buffer.push(',');
226 }
227 let _ = writeln!(self.metric_buffer, "quantile=\"{}\"}} {}", quantile, value);
228 }
229
230 self.write_suffixed_value("_sum", has_labels, sum);
232 self.write_suffixed_value("_count", has_labels, count as f64);
233 }
234
235 pub fn finish_group(&mut self) {
237 if self.groups_written > 0 {
238 self.output.push('\n');
239 }
240 self.output.push_str(&self.metric_buffer);
241 self.groups_written += 1;
242 }
243
244 fn format_labels<L, K, V>(&mut self, labels: L) -> bool
246 where
247 L: IntoIterator<Item = (K, V)>,
248 K: AsRef<str>,
249 V: AsRef<str>,
250 {
251 self.labels_buffer.clear();
252 let mut has_labels = false;
253
254 for (key, val) in labels {
255 if has_labels {
256 self.labels_buffer.push(',');
257 }
258 let _ = write!(self.labels_buffer, "{}=\"{}\"", key.as_ref(), val.as_ref());
259 has_labels = true;
260 }
261
262 has_labels
263 }
264
265 fn write_suffixed_value(&mut self, suffix: &str, has_labels: bool, value: f64) {
267 let _ = write!(self.metric_buffer, "{}{}", self.name_buffer, suffix);
268 if has_labels {
269 let _ = write!(self.metric_buffer, "{{{}}}", self.labels_buffer);
270 }
271 let _ = writeln!(self.metric_buffer, " {}", value);
272 }
273
274 fn normalize_name_internal(&mut self, name: &str) {
276 self.name_buffer.clear();
277
278 for (i, c) in name.chars().enumerate() {
279 if i == 0 && is_valid_name_start_char(c) || i != 0 && is_valid_name_char(c) {
280 self.name_buffer.push(c);
281 } else {
282 self.name_buffer.push_str(if c == '.' { "__" } else { "_" });
287 }
288 }
289 }
290}
291
292impl Default for PrometheusRenderer {
293 fn default() -> Self {
294 Self::new()
295 }
296}
297
298#[inline]
299const fn is_valid_name_start_char(c: char) -> bool {
300 c.is_ascii_alphabetic() || c == '_' || c == ':'
301}
302
303#[inline]
304const fn is_valid_name_char(c: char) -> bool {
305 c.is_ascii_alphanumeric() || c == '_' || c == ':'
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 const fn empty_tags() -> [(&'static str, &'static str); 0] {
313 []
314 }
315
316 #[test]
317 fn normalize_metric_name_basic() {
318 let mut renderer = PrometheusRenderer::new();
319 assert_eq!(renderer.normalize_metric_name("foo.bar.baz"), "foo__bar__baz");
320 assert_eq!(renderer.normalize_metric_name("valid_name"), "valid_name");
321 assert_eq!(renderer.normalize_metric_name("has-dash"), "has_dash");
322 assert_eq!(renderer.normalize_metric_name("a.b-c"), "a__b_c");
323 }
324
325 #[test]
326 fn render_counter_group() {
327 let mut renderer = PrometheusRenderer::new();
328 renderer.render_scalar_group(
329 "requests_total",
330 MetricType::Counter,
331 Some("Total requests"),
332 vec![([("method", "GET")], 42.0), ([("method", "POST")], 17.0)],
333 );
334
335 let output = renderer.output();
336 assert!(output.contains("# HELP requests_total Total requests\n"));
337 assert!(output.contains("# TYPE requests_total counter\n"));
338 assert!(output.contains("requests_total{method=\"GET\"} 42\n"));
339 assert!(output.contains("requests_total{method=\"POST\"} 17\n"));
340 }
341
342 #[test]
343 fn render_gauge_no_labels() {
344 let mut renderer = PrometheusRenderer::new();
345 renderer.render_scalar_group("temperature", MetricType::Gauge, None, vec![(empty_tags(), 23.5)]);
346
347 let output = renderer.output();
348 assert!(!output.contains("# HELP"));
349 assert!(output.contains("# TYPE temperature gauge\n"));
350 assert!(output.contains("temperature 23.5\n"));
351 }
352
353 #[test]
354 fn render_multiple_groups_separated() {
355 let mut renderer = PrometheusRenderer::new();
356 renderer.render_scalar_group("metric_a", MetricType::Counter, None, vec![(empty_tags(), 1.0)]);
357 renderer.render_scalar_group("metric_b", MetricType::Gauge, None, vec![(empty_tags(), 2.0)]);
358
359 let output = renderer.output();
360 assert!(output.contains("metric_a 1\n\n# TYPE metric_b"));
361 }
362
363 #[test]
364 fn clear_retains_capacity() {
365 let mut renderer = PrometheusRenderer::new();
366 renderer.render_scalar_group(
367 "big_metric",
368 MetricType::Counter,
369 Some("A metric with help text"),
370 vec![([("tag", "value")], 100.0)],
371 );
372 let cap_before = renderer.output.capacity();
373 renderer.clear();
374 assert!(renderer.output().is_empty());
375 assert_eq!(renderer.output.capacity(), cap_before);
376 }
377
378 #[test]
379 fn render_histogram_low_level() {
380 let mut renderer = PrometheusRenderer::new();
381 renderer.begin_group(
382 "request_duration_seconds",
383 MetricType::Histogram,
384 Some("Request duration"),
385 );
386 renderer.write_histogram_series([("method", "GET")], vec![("0.1", 10), ("0.5", 25), ("1", 30)], 45.0, 30);
387 renderer.finish_group();
388
389 let output = renderer.output();
390 assert!(output.contains("# HELP request_duration_seconds Request duration\n"));
391 assert!(output.contains("# TYPE request_duration_seconds histogram\n"));
392 assert!(output.contains("request_duration_seconds_bucket{method=\"GET\",le=\"0.1\"} 10\n"));
393 assert!(output.contains("request_duration_seconds_bucket{method=\"GET\",le=\"+Inf\"} 30\n"));
394 assert!(output.contains("request_duration_seconds_sum{method=\"GET\"} 45\n"));
395 assert!(output.contains("request_duration_seconds_count{method=\"GET\"} 30\n"));
396 }
397
398 #[test]
399 fn render_histogram_multiple_series() {
400 let mut renderer = PrometheusRenderer::new();
401 renderer.begin_group("http_duration_seconds", MetricType::Histogram, None);
402 renderer.write_histogram_series([("method", "GET")], vec![("1", 5)], 10.0, 5);
403 renderer.write_histogram_series([("method", "POST")], vec![("1", 3)], 6.0, 3);
404 renderer.finish_group();
405
406 let output = renderer.output();
407 assert_eq!(output.matches("# TYPE").count(), 1);
409 assert!(output.contains("http_duration_seconds_bucket{method=\"GET\",le=\"1\"} 5\n"));
410 assert!(output.contains("http_duration_seconds_bucket{method=\"POST\",le=\"1\"} 3\n"));
411 }
412
413 #[test]
414 fn render_summary_low_level() {
415 let mut renderer = PrometheusRenderer::new();
416 renderer.begin_group("rpc_duration_seconds", MetricType::Summary, None);
417 renderer.write_summary_series(empty_tags(), vec![(0.5, 0.05), (0.99, 0.1)], 100.0, 1000);
418 renderer.finish_group();
419
420 let output = renderer.output();
421 assert!(output.contains("# TYPE rpc_duration_seconds summary\n"));
422 assert!(output.contains("rpc_duration_seconds{quantile=\"0.5\"} 0.05\n"));
423 assert!(output.contains("rpc_duration_seconds{quantile=\"0.99\"} 0.1\n"));
424 assert!(output.contains("rpc_duration_seconds_sum 100\n"));
425 assert!(output.contains("rpc_duration_seconds_count 1000\n"));
426 }
427}