Skip to main content

liminal/metrics/
export.rs

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/// Render a point-in-time metrics snapshot as Prometheus 0.0.4 text.
24#[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        // A leading digit is valid in later positions but not as the first
172        // character of a Prometheus name ([a-zA-Z_:][a-zA-Z0-9_:]*).
173        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        // A leading digit is invalid as the first character of a Prometheus
200        // label name ([a-zA-Z_][a-zA-Z0-9_]*).
201        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        // Prometheus names/label names must not start with a digit; a digit is
384        // only valid in later positions.
385        assert_eq!(sanitize_metric_name("5xx_responses"), "_5xx_responses");
386        assert_eq!(sanitize_label_name("2nd_zone"), "_2nd_zone");
387        // Valid leading characters are left untouched.
388        assert_eq!(sanitize_metric_name("http:requests"), "http:requests");
389        assert_eq!(sanitize_label_name("zone"), "zone");
390        // Empty input still yields a single underscore.
391        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        // Both the metric name and the label name must be prefixed with '_'
409        // (these prefixed forms cannot appear unless the first-char fix runs).
410        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}