1use std::collections::BTreeSet;
2use std::fmt::{Display, Write as _};
3
4use super::{HistogramSnapshot, MetricValue, MetricsSnapshot};
5
6#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
7enum PrometheusMetricType {
8 Counter,
9 Gauge,
10 Histogram,
11}
12
13impl PrometheusMetricType {
14 const fn as_str(self) -> &'static str {
15 match self {
16 Self::Counter => "counter",
17 Self::Gauge => "gauge",
18 Self::Histogram => "histogram",
19 }
20 }
21}
22
23#[must_use]
25pub fn render(snapshot: &MetricsSnapshot) -> String {
26 let mut output = String::new();
27 let mut emitted_families = BTreeSet::new();
28
29 for metric in snapshot.metrics() {
30 let name = sanitize_metric_name(&metric.name);
31 let metric_type = prometheus_metric_type(&metric.value);
32
33 if emitted_families.insert((name.clone(), metric_type)) {
34 render_family_header(&mut output, &name, metric_type);
35 }
36
37 match &metric.value {
38 MetricValue::Counter(value) => {
39 render_number_sample(&mut output, &name, &metric.labels, *value);
40 }
41 MetricValue::Gauge(value) => {
42 render_number_sample(&mut output, &name, &metric.labels, *value);
43 }
44 MetricValue::Histogram(histogram) => {
45 render_histogram(&mut output, &name, &metric.labels, histogram);
46 }
47 }
48 }
49
50 if output.is_empty() {
51 output.push('\n');
52 }
53
54 output
55}
56
57fn render_family_header(output: &mut String, name: &str, metric_type: PrometheusMetricType) {
58 let _ = writeln!(output, "# HELP {name} {name}");
59 let _ = writeln!(output, "# TYPE {name} {}", metric_type.as_str());
60}
61
62const fn prometheus_metric_type(value: &MetricValue) -> PrometheusMetricType {
63 match value {
64 MetricValue::Counter(_) => PrometheusMetricType::Counter,
65 MetricValue::Gauge(_) => PrometheusMetricType::Gauge,
66 MetricValue::Histogram(_) => PrometheusMetricType::Histogram,
67 }
68}
69
70fn render_number_sample<Value>(
71 output: &mut String,
72 name: &str,
73 labels: &[(String, String)],
74 value: Value,
75) where
76 Value: Display,
77{
78 let labels = render_labels(labels, None);
79 let _ = writeln!(output, "{name}{labels} {value}");
80}
81
82fn render_histogram(
83 output: &mut String,
84 name: &str,
85 labels: &[(String, String)],
86 histogram: &HistogramSnapshot,
87) {
88 let total_count = histogram
89 .buckets
90 .iter()
91 .fold(0_u64, |total, bucket| total.saturating_add(bucket.count));
92 let mut cumulative_count = 0_u64;
93
94 for bucket in &histogram.buckets {
95 let Some(upper_bound) = bucket.upper_bound else {
96 continue;
97 };
98 cumulative_count = cumulative_count.saturating_add(bucket.count);
99 let boundary = format_bucket_bound(upper_bound);
100 render_histogram_bucket(output, name, labels, &boundary, cumulative_count);
101 }
102
103 render_histogram_bucket(output, name, labels, "+Inf", total_count);
104
105 let labels = render_labels(labels, None);
106 let sum = format_sample_float(histogram.sum);
107 let _ = writeln!(output, "{name}_sum{labels} {sum}");
108 let _ = writeln!(output, "{name}_count{labels} {total_count}");
109}
110
111fn render_histogram_bucket(
112 output: &mut String,
113 name: &str,
114 labels: &[(String, String)],
115 upper_bound: &str,
116 count: u64,
117) {
118 let labels = render_labels(labels, Some(("le", upper_bound)));
119 let _ = writeln!(output, "{name}_bucket{labels} {count}");
120}
121
122#[must_use]
123fn render_labels(labels: &[(String, String)], extra_label: Option<(&str, &str)>) -> String {
124 if labels.is_empty() && extra_label.is_none() {
125 return String::new();
126 }
127
128 let mut rendered = String::from("{");
129 let mut first = true;
130
131 for (name, value) in labels {
132 append_label(&mut rendered, &mut first, name, value);
133 }
134
135 if let Some((name, value)) = extra_label {
136 append_label(&mut rendered, &mut first, name, value);
137 }
138
139 rendered.push('}');
140 rendered
141}
142
143fn append_label(output: &mut String, first: &mut bool, name: &str, value: &str) {
144 if *first {
145 *first = false;
146 } else {
147 output.push(',');
148 }
149
150 let name = sanitize_label_name(name);
151 let value = escape_label_value(value);
152 let _ = write!(output, "{name}=\"{value}\"");
153}
154
155#[must_use]
156fn sanitize_metric_name(name: &str) -> String {
157 let mut sanitized = name
158 .chars()
159 .map(|character| {
160 if is_valid_metric_name_char(character) {
161 character
162 } else {
163 '_'
164 }
165 })
166 .collect::<String>();
167
168 if sanitized.is_empty() {
169 sanitized.push('_');
170 } else if sanitized.starts_with(|character: char| character.is_ascii_digit()) {
171 sanitized.insert(0, '_');
174 }
175
176 sanitized
177}
178
179const fn is_valid_metric_name_char(character: char) -> bool {
180 character.is_ascii_alphanumeric() || matches!(character, '_' | ':')
181}
182
183#[must_use]
184fn sanitize_label_name(name: &str) -> String {
185 let mut sanitized = name
186 .chars()
187 .map(|character| {
188 if is_valid_label_name_char(character) {
189 character
190 } else {
191 '_'
192 }
193 })
194 .collect::<String>();
195
196 if sanitized.is_empty() {
197 sanitized.push('_');
198 } else if sanitized.starts_with(|character: char| character.is_ascii_digit()) {
199 sanitized.insert(0, '_');
202 }
203
204 if sanitized.starts_with("__") {
205 let mut prefixed = String::from("label");
206 prefixed.push_str(&sanitized);
207 sanitized = prefixed;
208 }
209
210 sanitized
211}
212
213const fn is_valid_label_name_char(character: char) -> bool {
214 character.is_ascii_alphanumeric() || character == '_'
215}
216
217#[must_use]
218fn escape_label_value(value: &str) -> String {
219 let mut escaped = String::new();
220
221 for character in value.chars() {
222 match character {
223 '\\' => escaped.push_str("\\\\"),
224 '"' => escaped.push_str("\\\""),
225 '\n' => escaped.push_str("\\n"),
226 other => escaped.push(other),
227 }
228 }
229
230 escaped
231}
232
233#[must_use]
234fn format_bucket_bound(bound: f64) -> String {
235 let mut rendered = format_sample_float(bound);
236
237 if !is_non_integral_float_text(&rendered) {
238 rendered.push_str(".0");
239 }
240
241 rendered
242}
243
244fn is_non_integral_float_text(value: &str) -> bool {
245 value.contains('.')
246 || value.contains('e')
247 || value.contains('E')
248 || value == "+Inf"
249 || value == "-Inf"
250 || value == "NaN"
251}
252
253#[must_use]
254fn format_sample_float(value: f64) -> String {
255 if value.is_nan() {
256 String::from("NaN")
257 } else if value.is_infinite() && value.is_sign_positive() {
258 String::from("+Inf")
259 } else if value.is_infinite() {
260 String::from("-Inf")
261 } else {
262 value.to_string()
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::metrics::{HistogramBucketSnapshot, MetricKind, MetricSnapshot, MetricsSnapshot};
270
271 #[test]
272 fn renders_counter_with_type_help_labels_and_sanitized_name() {
273 let snapshot = MetricsSnapshot {
274 metrics: vec![MetricSnapshot {
275 name: String::from("channel-message-rate"),
276 labels: vec![(String::from("channel"), String::from("orders"))],
277 kind: MetricKind::Counter,
278 value: MetricValue::Counter(42),
279 }],
280 };
281
282 let output = render(&snapshot);
283
284 assert!(output.contains("# HELP channel_message_rate channel_message_rate\n"));
285 assert!(output.contains("# TYPE channel_message_rate counter\n"));
286 assert!(output.contains("channel_message_rate{channel=\"orders\"} 42\n"));
287 assert!(output.ends_with('\n'));
288 }
289
290 #[test]
291 fn renders_gauge_values_without_empty_label_blocks() {
292 let snapshot = MetricsSnapshot {
293 metrics: vec![
294 MetricSnapshot {
295 name: String::from("active_conversations"),
296 labels: Vec::new(),
297 kind: MetricKind::Gauge,
298 value: MetricValue::Gauge(7),
299 },
300 MetricSnapshot {
301 name: String::from("conversation_delta"),
302 labels: Vec::new(),
303 kind: MetricKind::Gauge,
304 value: MetricValue::Gauge(-3),
305 },
306 ],
307 };
308
309 let output = render(&snapshot);
310
311 assert!(output.contains("# TYPE active_conversations gauge\n"));
312 assert!(output.contains("active_conversations 7\n"));
313 assert!(output.contains("conversation_delta -3\n"));
314 }
315
316 #[test]
317 fn renders_histogram_buckets_sum_and_count() {
318 let snapshot = MetricsSnapshot {
319 metrics: vec![MetricSnapshot {
320 name: String::from("metric_name"),
321 labels: Vec::new(),
322 kind: MetricKind::Histogram,
323 value: MetricValue::Histogram(HistogramSnapshot {
324 buckets: vec![
325 HistogramBucketSnapshot {
326 upper_bound: Some(0.01),
327 count: 1,
328 },
329 HistogramBucketSnapshot {
330 upper_bound: Some(0.1),
331 count: 1,
332 },
333 HistogramBucketSnapshot {
334 upper_bound: Some(1.0),
335 count: 0,
336 },
337 HistogramBucketSnapshot {
338 upper_bound: None,
339 count: 1,
340 },
341 ],
342 sum: 5.055,
343 }),
344 }],
345 };
346
347 let output = render(&snapshot);
348
349 assert!(output.contains("# TYPE metric_name histogram\n"));
350 assert!(output.contains("metric_name_bucket{le=\"0.01\"} 1\n"));
351 assert!(output.contains("metric_name_bucket{le=\"0.1\"} 2\n"));
352 assert!(output.contains("metric_name_bucket{le=\"1.0\"} 2\n"));
353 assert!(output.contains("metric_name_bucket{le=\"+Inf\"} 3\n"));
354 assert!(output.contains("metric_name_sum 5.055\n"));
355 assert!(output.contains("metric_name_count 3\n"));
356 }
357
358 #[test]
359 fn escapes_label_values_and_sanitizes_label_names() {
360 let snapshot = MetricsSnapshot {
361 metrics: vec![MetricSnapshot {
362 name: String::from("label_escape_total"),
363 labels: vec![
364 (
365 String::from("bad-label"),
366 String::from("quote\" slash\\ newline\n"),
367 ),
368 (String::from("__reserved"), String::from("value")),
369 ],
370 kind: MetricKind::Counter,
371 value: MetricValue::Counter(1),
372 }],
373 };
374
375 let output = render(&snapshot);
376
377 assert!(output.contains("bad_label=\"quote\\\" slash\\\\ newline\\n\""));
378 assert!(output.contains("label__reserved=\"value\""));
379 }
380
381 #[test]
382 fn sanitizers_prefix_leading_digit_to_keep_first_char_valid() {
383 assert_eq!(sanitize_metric_name("5xx_responses"), "_5xx_responses");
386 assert_eq!(sanitize_label_name("2nd_zone"), "_2nd_zone");
387 assert_eq!(sanitize_metric_name("http:requests"), "http:requests");
389 assert_eq!(sanitize_label_name("zone"), "zone");
390 assert_eq!(sanitize_metric_name(""), "_");
392 assert_eq!(sanitize_label_name(""), "_");
393 }
394
395 #[test]
396 fn render_prefixes_digit_leading_metric_and_label_names() {
397 let snapshot = MetricsSnapshot {
398 metrics: vec![MetricSnapshot {
399 name: String::from("5xx_responses"),
400 labels: vec![(String::from("2nd_zone"), String::from("alpha"))],
401 kind: MetricKind::Counter,
402 value: MetricValue::Counter(7),
403 }],
404 };
405
406 let output = render(&snapshot);
407
408 assert!(output.contains("# TYPE _5xx_responses counter\n"));
411 assert!(output.contains("_5xx_responses{_2nd_zone=\"alpha\"} 7\n"));
412 }
413
414 #[test]
415 fn renders_same_snapshot_identically() {
416 let snapshot = MetricsSnapshot {
417 metrics: vec![MetricSnapshot {
418 name: String::from("stable_metric"),
419 labels: vec![(String::from("channel"), String::from("orders"))],
420 kind: MetricKind::Counter,
421 value: MetricValue::Counter(9),
422 }],
423 };
424
425 assert_eq!(render(&snapshot), render(&snapshot));
426 }
427}