Skip to main content

prometheus_exposition/
lib.rs

1//! Prometheus exposition format rendering.
2//!
3//! Provides a reusable [`PrometheusRenderer`] type that renders metrics in the [Prometheus text exposition
4//! format][prom-text]. The renderer owns internal buffers that are reused across calls, amortizing allocation costs.
5//!
6//! [prom-text]: https://prometheus.io/docs/instrumenting/exposition_formats/#text-based-format
7
8use std::fmt::Write as _;
9
10/// Prometheus metric type, used in the `# TYPE` header.
11#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub enum MetricType {
13    /// A counter metric.
14    Counter,
15
16    /// A gauge metric.
17    Gauge,
18
19    /// A histogram metric.
20    Histogram,
21
22    /// A summary metric.
23    Summary,
24}
25
26impl MetricType {
27    /// Returns the Prometheus type string.
28    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
38/// A renderer for the Prometheus text exposition format.
39///
40/// The renderer owns internal buffers that are reused across calls, amortizing allocation costs when rendering
41/// multiple payloads over time.
42///
43/// # Usage
44///
45/// There are two ways to use the renderer:
46///
47/// **High-level**: Use [`render_group`][Self::render_group] for counter/gauge groups where all series are simple
48/// label/value pairs. This handles the TYPE/HELP headers and all series lines in one call.
49///
50/// **Low-level**: Use [`begin_group`][Self::begin_group] to start a new metric group (writes TYPE/HELP headers), then
51/// call individual series writing methods like [`write_gauge_or_counter_series`][Self::write_gauge_or_counter_series],
52/// [`write_histogram_series`][Self::write_histogram_series], or
53/// [`write_summary_series`][Self::write_summary_series] for each tag combination. Finally, call
54/// [`finish_group`][Self::finish_group] to append the group to the output. This is useful when you have multiple series
55/// with complex types (histograms/summaries) sharing a single TYPE header.
56pub 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    /// Creates a new `PrometheusRenderer`.
66    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    /// Clears the output buffer to start a fresh payload, retaining allocated capacity.
77    pub fn clear(&mut self) {
78        self.output.clear();
79        self.groups_written = 0;
80    }
81
82    /// Returns the accumulated output as a string slice.
83    pub fn output(&self) -> &str {
84        &self.output
85    }
86
87    /// Normalizes a metric name to a valid Prometheus metric name, returning a reference to the
88    /// internal name buffer.
89    ///
90    /// Periods (`.`) are converted to double underscores (`__`). Other invalid characters are
91    /// converted to a single underscore (`_`).
92    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    /// Renders a counter or gauge metric group: `# TYPE` header, optional `# HELP` header, and all series lines.
98    ///
99    /// The metric name is automatically normalized to a valid Prometheus name.
100    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    // --- Low-level API ---
116
117    /// Begins a new metric group by writing the `# TYPE` and optional `# HELP` headers.
118    ///
119    /// The metric name is automatically normalized to a valid Prometheus name and stored internally
120    /// for use by subsequent series writing methods.
121    ///
122    /// After calling this, write individual series using [`write_gauge_or_counter_series`][Self::write_gauge_or_counter_series],
123    /// [`write_histogram_series`][Self::write_histogram_series], or
124    /// [`write_summary_series`][Self::write_summary_series]. When done, call [`finish_group`][Self::finish_group].
125    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    /// Writes a single counter or gauge series line to the current metric group.
141    ///
142    /// Uses the metric name set by the most recent [`begin_group`][Self::begin_group] call.
143    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        // SAFETY: We need to index into `name_buffer` while pushing to `metric_buffer`. Since these
152        // are separate fields, this is fine — we just need to avoid re-borrowing `self`.
153        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    /// Writes a single histogram series (one set of labels) to the current metric group.
164    ///
165    /// Uses the metric name set by the most recent [`begin_group`][Self::begin_group] call.
166    ///
167    /// Each bucket is `(upper_bound_str, cumulative_count)`. A `+Inf` bucket is automatically
168    /// appended using the total `count`.
169    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        // Write bucket lines.
179        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        // Write +Inf bucket.
192        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        // Write sum and count.
203        self.write_suffixed_value("_sum", has_labels, sum);
204        self.write_suffixed_value("_count", has_labels, count as f64);
205    }
206
207    /// Writes a single summary series (one set of labels) to the current metric group.
208    ///
209    /// Uses the metric name set by the most recent [`begin_group`][Self::begin_group] call.
210    ///
211    /// Each quantile is `(quantile_value, observed_value)`.
212    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        // Write quantile lines.
222        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        // Write sum and count.
231        self.write_suffixed_value("_sum", has_labels, sum);
232        self.write_suffixed_value("_count", has_labels, count as f64);
233    }
234
235    /// Finishes the current metric group, appending the metric buffer to the output.
236    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    /// Formats labels into the labels buffer, returning whether any labels were written.
245    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    /// Writes a `{name_buffer}{suffix}{labels} {value}` line.
266    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    /// Normalizes a metric name into the internal name buffer.
275    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                // Convert periods to a set of two underscores, and anything else to a single
283                // underscore. This lets us ensure that the normal separators we use in metrics
284                // (periods) are converted in a way where they can be distinguished on the collector
285                // side to potentially reconstitute them back to their original form.
286                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        // One TYPE header, two sets of series.
408        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}