Skip to main content

metriken_exposition/
prometheus.rs

1use std::collections::HashMap;
2use std::fmt::Write;
3
4use metriken::{MetricEntry, Value};
5
6/// Options for Prometheus text format rendering.
7pub struct PrometheusOptions {
8    /// Include `# HELP` lines when metric descriptions are available.
9    pub help_text: bool,
10    /// Percentiles to compute for histogram summaries (0.0-1.0 scale).
11    /// If empty, full cumulative bucket exposition is used instead.
12    pub percentiles: Vec<f64>,
13}
14
15impl Default for PrometheusOptions {
16    fn default() -> Self {
17        Self {
18            help_text: true,
19            percentiles: Vec::new(),
20        }
21    }
22}
23
24impl PrometheusOptions {
25    /// Use summary-style histogram exposition with these percentiles.
26    pub fn with_percentiles(mut self, percentiles: Vec<f64>) -> Self {
27        self.percentiles = percentiles;
28        self
29    }
30
31    /// Disable `# HELP` lines.
32    pub fn without_help(mut self) -> Self {
33        self.help_text = false;
34        self
35    }
36}
37
38/// Render all registered metriken metrics in Prometheus text exposition format.
39///
40/// This walks the global metric registry and produces a complete Prometheus
41/// response body. Group metrics are exploded into individual labeled series.
42///
43/// # Example
44/// ```no_run
45/// use metriken_exposition::prometheus_text;
46/// use metriken_exposition::PrometheusOptions;
47///
48/// let body = prometheus_text(&PrometheusOptions::default());
49/// ```
50pub fn prometheus_text(options: &PrometheusOptions) -> String {
51    let mut output = String::new();
52
53    for metric in &metriken::metrics() {
54        let name = sanitize_name(metric.name());
55
56        match metric.value() {
57            Some(Value::Counter(value)) => {
58                write_type_help(&mut output, &name, "counter", metric, options);
59                write_metric_line(&mut output, &name, None, &value.to_string());
60            }
61            Some(Value::Gauge(value)) => {
62                write_type_help(&mut output, &name, "gauge", metric, options);
63                write_metric_line(&mut output, &name, None, &value.to_string());
64            }
65            Some(Value::Histogram(h)) => {
66                if let Some(snapshot) = h.load() {
67                    write_histogram(&mut output, &name, None, &snapshot, metric, options);
68                }
69            }
70            Some(Value::CounterGroup(g)) => {
71                let base_metadata = entry_metadata(metric);
72                let active = g.metadata_snapshot();
73                if !active.is_empty() {
74                    write_type_help(&mut output, &name, "counter", metric, options);
75                    for (idx, entry_meta) in active {
76                        if let Some(value) = g.counter_value(idx) {
77                            let labels = merge_labels(&base_metadata, Some(entry_meta));
78                            write_metric_line(
79                                &mut output,
80                                &name,
81                                Some(&labels),
82                                &value.to_string(),
83                            );
84                        }
85                    }
86                }
87            }
88            Some(Value::GaugeGroup(g)) => {
89                let base_metadata = entry_metadata(metric);
90                let active = g.metadata_snapshot();
91                if !active.is_empty() {
92                    write_type_help(&mut output, &name, "gauge", metric, options);
93                    for (idx, entry_meta) in active {
94                        if let Some(value) = g.gauge_value(idx) {
95                            let labels = merge_labels(&base_metadata, Some(entry_meta));
96                            write_metric_line(
97                                &mut output,
98                                &name,
99                                Some(&labels),
100                                &value.to_string(),
101                            );
102                        }
103                    }
104                }
105            }
106            Some(Value::HistogramGroup(g)) => {
107                let base_metadata = entry_metadata(metric);
108                let active = g.metadata_snapshot();
109                for (idx, entry_meta) in active {
110                    if let Some(snapshot) = g.load_histogram(idx) {
111                        let labels = merge_labels(&base_metadata, Some(entry_meta));
112                        write_histogram(
113                            &mut output,
114                            &name,
115                            Some(&labels),
116                            &snapshot,
117                            metric,
118                            options,
119                        );
120                    }
121                }
122            }
123            _ => {}
124        }
125    }
126
127    output
128}
129
130fn write_type_help(
131    output: &mut String,
132    name: &str,
133    kind: &str,
134    metric: &MetricEntry,
135    options: &PrometheusOptions,
136) {
137    if options.help_text {
138        if let Some(description) = metric.description() {
139            let _ = writeln!(output, "# HELP {name} {description}");
140        }
141    }
142    let _ = writeln!(output, "# TYPE {name} {kind}");
143}
144
145fn write_metric_line(output: &mut String, name: &str, labels: Option<&str>, value: &str) {
146    match labels {
147        Some(l) if !l.is_empty() => {
148            let _ = writeln!(output, "{name}{{{l}}} {value}");
149        }
150        _ => {
151            let _ = writeln!(output, "{name} {value}");
152        }
153    }
154}
155
156fn write_histogram(
157    output: &mut String,
158    name: &str,
159    labels: Option<&str>,
160    snapshot: &histogram::Histogram,
161    metric: &MetricEntry,
162    options: &PrometheusOptions,
163) {
164    if !options.percentiles.is_empty() {
165        // Summary-style: emit percentile gauges
166        write_type_help(output, name, "summary", metric, options);
167
168        if let Ok(Some(results)) = snapshot.percentiles(&options.percentiles) {
169            for (percentile, bucket) in results {
170                let value = bucket.end();
171                let quantile_label = format!("quantile=\"{percentile}\"");
172                let combined = match labels {
173                    Some(l) if !l.is_empty() => format!("{l}, {quantile_label}"),
174                    _ => quantile_label,
175                };
176                write_metric_line(output, name, Some(&combined), &value.to_string());
177            }
178        }
179
180        // count and sum
181        let mut count: u64 = 0;
182        let mut sum: u128 = 0;
183        for bucket in snapshot {
184            let c = bucket.count();
185            count += c;
186            sum += c as u128 * ((bucket.start() as u128 + bucket.end() as u128) / 2);
187        }
188        write_metric_line(output, &format!("{name}_count"), labels, &count.to_string());
189        write_metric_line(output, &format!("{name}_sum"), labels, &sum.to_string());
190    } else {
191        // Full cumulative bucket exposition
192        write_type_help(output, name, "histogram", metric, options);
193
194        let mut count: u64 = 0;
195        let mut sum: u128 = 0;
196        for bucket in snapshot {
197            let c = bucket.count();
198            sum += c as u128 * bucket.end() as u128;
199            count += c;
200            let le_label = format!("le=\"{}\"", bucket.end());
201            let combined = match labels {
202                Some(l) if !l.is_empty() => format!("{l}, {le_label}"),
203                _ => le_label,
204            };
205            write_metric_line(
206                output,
207                &format!("{name}_bucket"),
208                Some(&combined),
209                &count.to_string(),
210            );
211        }
212        let inf_label = "le=\"+Inf\"".to_string();
213        let combined = match labels {
214            Some(l) if !l.is_empty() => format!("{l}, {inf_label}"),
215            _ => inf_label,
216        };
217        write_metric_line(
218            output,
219            &format!("{name}_bucket"),
220            Some(&combined),
221            &count.to_string(),
222        );
223        write_metric_line(output, &format!("{name}_count"), labels, &count.to_string());
224        write_metric_line(output, &format!("{name}_sum"), labels, &sum.to_string());
225    }
226}
227
228/// Extract metadata key-value pairs from a metric entry.
229fn entry_metadata(metric: &MetricEntry) -> HashMap<String, String> {
230    metric
231        .metadata()
232        .into_iter()
233        .map(|(k, v)| (k.to_string(), v.to_string()))
234        .collect()
235}
236
237/// Merge base metadata from the metric entry with per-index group metadata,
238/// and format as a Prometheus label string.
239fn merge_labels(
240    base: &HashMap<String, String>,
241    index_meta: Option<HashMap<String, String>>,
242) -> String {
243    let mut all = base.clone();
244    if let Some(meta) = index_meta {
245        all.extend(meta);
246    }
247    format_labels(&all)
248}
249
250/// Format a metadata map as a sorted Prometheus label string.
251fn format_labels(metadata: &HashMap<String, String>) -> String {
252    let mut pairs: Vec<String> = metadata
253        .iter()
254        .map(|(k, v)| format!("{k}=\"{v}\""))
255        .collect();
256    pairs.sort();
257    pairs.join(", ")
258}
259
260/// Sanitize a metric name for Prometheus compatibility.
261///
262/// Prometheus metric names must match `[a-zA-Z_:][a-zA-Z0-9_:]*`.
263fn sanitize_name(name: &str) -> String {
264    let mut result = String::with_capacity(name.len());
265    for c in name.chars() {
266        if c.is_ascii_alphanumeric() || c == '_' || c == ':' {
267            result.push(c);
268        } else {
269            result.push('_');
270        }
271    }
272    result
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_sanitize_name() {
281        assert_eq!(sanitize_name("simple"), "simple");
282        assert_eq!(sanitize_name("with/slash"), "with_slash");
283        assert_eq!(sanitize_name("with.dots"), "with_dots");
284        assert_eq!(sanitize_name("ok_under_score"), "ok_under_score");
285        assert_eq!(sanitize_name("has:colon"), "has:colon");
286    }
287
288    #[test]
289    fn test_format_labels() {
290        let mut meta = HashMap::new();
291        meta.insert("b".into(), "2".into());
292        meta.insert("a".into(), "1".into());
293        assert_eq!(format_labels(&meta), "a=\"1\", b=\"2\"");
294    }
295
296    #[test]
297    fn test_format_labels_empty() {
298        let meta = HashMap::new();
299        assert_eq!(format_labels(&meta), "");
300    }
301
302    #[test]
303    fn test_write_metric_line_no_labels() {
304        let mut out = String::new();
305        write_metric_line(&mut out, "my_counter", None, "42");
306        assert_eq!(out, "my_counter 42\n");
307    }
308
309    #[test]
310    fn test_write_metric_line_with_labels() {
311        let mut out = String::new();
312        write_metric_line(&mut out, "my_counter", Some("cpu=\"0\""), "42");
313        assert_eq!(out, "my_counter{cpu=\"0\"} 42\n");
314    }
315
316    #[test]
317    fn test_write_metric_line_empty_labels() {
318        let mut out = String::new();
319        write_metric_line(&mut out, "my_counter", Some(""), "42");
320        assert_eq!(out, "my_counter 42\n");
321    }
322}