metrics_exporter_prometheus/
protobuf.rs

1//! Protobuf serialization support for Prometheus metrics.
2
3use metrics::Unit;
4use prost::Message;
5use std::collections::HashMap;
6
7use crate::common::{LabelSet, Snapshot};
8use crate::distribution::Distribution;
9use crate::formatting::sanitize_metric_name;
10
11// Include the generated protobuf code
12mod pb {
13    #![allow(missing_docs, clippy::trivially_copy_pass_by_ref, clippy::doc_markdown)]
14    include!(concat!(env!("OUT_DIR"), "/io.prometheus.client.rs"));
15}
16
17#[cfg(feature = "http-listener")]
18pub(crate) const PROTOBUF_CONTENT_TYPE: &str =
19    "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited";
20
21/// Renders a snapshot of metrics into protobuf format using length-delimited encoding.
22///
23/// This function takes a snapshot of metrics and converts them into the Prometheus
24/// protobuf wire format, where each `MetricFamily` message is prefixed with a varint
25/// length header.
26#[allow(clippy::too_many_lines)]
27pub(crate) fn render_protobuf(
28    snapshot: Snapshot,
29    descriptions: &HashMap<String, (metrics::SharedString, Option<Unit>)>,
30    counter_suffix: Option<&'static str>,
31) -> Vec<u8> {
32    let mut output = Vec::new();
33
34    // Process counters
35    for (name, by_labels) in snapshot.counters {
36        let sanitized_name = sanitize_metric_name(&name);
37        let help =
38            descriptions.get(name.as_str()).map(|(desc, _)| desc.to_string()).unwrap_or_default();
39
40        let mut metrics = Vec::new();
41        for (labels, value) in by_labels {
42            let label_pairs = label_set_to_protobuf(labels);
43
44            metrics.push(pb::Metric {
45                label: label_pairs,
46                counter: Some(pb::Counter {
47                    #[allow(clippy::cast_precision_loss)]
48                    value: Some(value as f64),
49
50                    ..Default::default()
51                }),
52
53                ..Default::default()
54            });
55        }
56
57        let metric_family = pb::MetricFamily {
58            name: Some(add_suffix_to_name(&sanitized_name, counter_suffix)),
59            help: if help.is_empty() { None } else { Some(help) },
60            r#type: Some(pb::MetricType::Counter as i32),
61            metric: metrics,
62            unit: None,
63        };
64
65        metric_family.encode_length_delimited(&mut output).unwrap();
66    }
67
68    // Process gauges
69    for (name, by_labels) in snapshot.gauges {
70        let sanitized_name = sanitize_metric_name(&name);
71        let help =
72            descriptions.get(name.as_str()).map(|(desc, _)| desc.to_string()).unwrap_or_default();
73
74        let mut metrics = Vec::new();
75        for (labels, value) in by_labels {
76            let label_pairs = label_set_to_protobuf(labels);
77
78            metrics.push(pb::Metric {
79                label: label_pairs,
80                gauge: Some(pb::Gauge { value: Some(value) }),
81
82                ..Default::default()
83            });
84        }
85
86        let metric_family = pb::MetricFamily {
87            name: Some(sanitized_name),
88            help: if help.is_empty() { None } else { Some(help) },
89            r#type: Some(pb::MetricType::Gauge as i32),
90            metric: metrics,
91            unit: None,
92        };
93
94        metric_family.encode_length_delimited(&mut output).unwrap();
95    }
96
97    // Process distributions (histograms and summaries)
98    for (name, by_labels) in snapshot.distributions {
99        let sanitized_name = sanitize_metric_name(&name);
100        let help =
101            descriptions.get(name.as_str()).map(|(desc, _)| desc.to_string()).unwrap_or_default();
102
103        let mut metrics = Vec::new();
104        let mut metric_type = None;
105        for (labels, distribution) in by_labels {
106            let label_pairs = label_set_to_protobuf(labels);
107
108            let metric = match distribution {
109                Distribution::Summary(summary, quantiles, sum) => {
110                    use quanta::Instant;
111                    metric_type = Some(pb::MetricType::Summary);
112                    let snapshot = summary.snapshot(Instant::now());
113                    let quantile_values: Vec<pb::Quantile> = quantiles
114                        .iter()
115                        .map(|q| pb::Quantile {
116                            quantile: Some(q.value()),
117                            value: Some(snapshot.quantile(q.value()).unwrap_or(0.0)),
118                        })
119                        .collect();
120
121                    pb::Metric {
122                        label: label_pairs,
123                        summary: Some(pb::Summary {
124                            sample_count: Some(summary.count() as u64),
125                            sample_sum: Some(sum),
126                            quantile: quantile_values,
127
128                            created_timestamp: None,
129                        }),
130
131                        ..Default::default()
132                    }
133                }
134                Distribution::Histogram(histogram) => {
135                    metric_type = Some(pb::MetricType::Histogram);
136                    let mut buckets = Vec::new();
137                    for (le, count) in histogram.buckets() {
138                        buckets.push(pb::Bucket {
139                            cumulative_count: Some(count),
140                            upper_bound: Some(le),
141
142                            ..Default::default()
143                        });
144                    }
145                    // Add +Inf bucket
146                    buckets.push(pb::Bucket {
147                        cumulative_count: Some(histogram.count()),
148                        upper_bound: Some(f64::INFINITY),
149
150                        ..Default::default()
151                    });
152
153                    pb::Metric {
154                        label: label_pairs,
155                        histogram: Some(pb::Histogram {
156                            sample_count: Some(histogram.count()),
157                            sample_sum: Some(histogram.sum()),
158                            bucket: buckets,
159
160                            ..Default::default()
161                        }),
162
163                        ..Default::default()
164                    }
165                }
166                Distribution::NativeHistogram(native_hist) => {
167                    // Convert our native histogram into Prometheus native histogram format
168                    let positive_buckets = native_hist.positive_buckets();
169                    let negative_buckets = native_hist.negative_buckets();
170
171                    // Get the current schema being used by the histogram
172                    let schema = native_hist.schema();
173
174                    // Convert positive buckets to spans and deltas (matches Go makeBuckets function)
175                    let (positive_spans, positive_deltas) = make_buckets(&positive_buckets);
176                    let (negative_spans, negative_deltas) = make_buckets(&negative_buckets);
177
178                    // Match Go Write() method output exactly
179                    let mut histogram = pb::Histogram {
180                        sample_count: Some(native_hist.count()),
181                        sample_sum: Some(native_hist.sum()),
182
183                        // Native histogram fields from Go implementation
184                        zero_threshold: Some(native_hist.config().zero_threshold()),
185                        schema: Some(schema),
186                        zero_count: Some(native_hist.zero_count()),
187
188                        positive_span: positive_spans,
189                        positive_delta: positive_deltas,
190
191                        negative_span: negative_spans,
192                        negative_delta: negative_deltas,
193
194                        ..Default::default()
195                    };
196
197                    // Add a no-op span if histogram is empty (matches Go implementation)
198                    if histogram.zero_threshold == Some(0.0)
199                        && histogram.zero_count == Some(0)
200                        && histogram.positive_span.is_empty()
201                        && histogram.negative_span.is_empty()
202                    {
203                        histogram.positive_span =
204                            vec![pb::BucketSpan { offset: Some(0), length: Some(0) }];
205                    }
206
207                    pb::Metric {
208                        label: label_pairs,
209                        histogram: Some(histogram),
210                        ..Default::default()
211                    }
212                }
213            };
214
215            metrics.push(metric);
216        }
217
218        let Some(metric_type) = metric_type else {
219            // Skip empty metric families
220            continue;
221        };
222
223        let metric_family = pb::MetricFamily {
224            name: Some(sanitized_name),
225            help: if help.is_empty() { None } else { Some(help) },
226            r#type: Some(metric_type as i32),
227            metric: metrics,
228            unit: None,
229        };
230
231        metric_family.encode_length_delimited(&mut output).unwrap();
232    }
233
234    output
235}
236
237fn label_set_to_protobuf(labels: LabelSet) -> Vec<pb::LabelPair> {
238    let mut label_pairs = Vec::new();
239
240    for (key, value) in labels.labels {
241        label_pairs.push(pb::LabelPair { name: Some(key), value: Some(value) });
242    }
243
244    label_pairs
245}
246
247fn add_suffix_to_name(name: &str, suffix: Option<&'static str>) -> String {
248    match suffix {
249        Some(suffix) if !name.ends_with(suffix) => format!("{name}_{suffix}"),
250        _ => name.to_string(),
251    }
252}
253
254/// Convert a `BTreeMap` of bucket indices to counts into Prometheus native histogram
255/// spans and deltas format. This follows the Go `makeBucketsFromMap` function.
256fn make_buckets(buckets: &std::collections::BTreeMap<i32, u64>) -> (Vec<pb::BucketSpan>, Vec<i64>) {
257    if buckets.is_empty() {
258        return (vec![], vec![]);
259    }
260
261    // Get sorted bucket indices (similar to Go's sorting)
262    let mut indices: Vec<i32> = buckets.keys().copied().collect();
263    indices.sort_unstable();
264
265    let mut spans = Vec::new();
266    let mut deltas = Vec::new();
267    let mut prev_count = 0i64;
268    let mut next_i = 0i32;
269
270    for (n, &i) in indices.iter().enumerate() {
271        #[allow(clippy::cast_possible_wrap)]
272        let count = buckets[&i] as i64;
273
274        // Multiple spans with only small gaps in between are probably
275        // encoded more efficiently as one larger span with a few empty buckets.
276        // Following Go: gaps of one or two buckets should not create a new span.
277        let i_delta = i - next_i;
278
279        if n == 0 || i_delta > 2 {
280            // Create a new span - either first bucket or gap > 2
281            spans.push(pb::BucketSpan { offset: Some(i_delta), length: Some(0) });
282        } else {
283            // Small gap (or no gap) - insert empty buckets as needed
284            for _ in 0..i_delta {
285                if let Some(last_span) = spans.last_mut() {
286                    *last_span.length.as_mut().unwrap() += 1;
287                }
288                deltas.push(-prev_count);
289                prev_count = 0;
290            }
291        }
292
293        // Add the current bucket
294        if let Some(last_span) = spans.last_mut() {
295            *last_span.length.as_mut().unwrap() += 1;
296        }
297        deltas.push(count - prev_count);
298        prev_count = count;
299        next_i = i + 1;
300    }
301
302    (spans, deltas)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::common::Snapshot;
309    use indexmap::IndexMap;
310    use metrics::SharedString;
311    use prost::Message;
312    use std::collections::HashMap;
313
314    #[test]
315    fn test_render_protobuf_counters() {
316        let mut counters = HashMap::new();
317        let mut counter_labels = HashMap::new();
318        let labels = LabelSet::from_key_and_global(
319            &metrics::Key::from_parts("", vec![metrics::Label::new("method", "GET")]),
320            &IndexMap::new(),
321        );
322        counter_labels.insert(labels, 42u64);
323        counters.insert("http_requests".to_string(), counter_labels);
324
325        let snapshot = Snapshot { counters, gauges: HashMap::new(), distributions: HashMap::new() };
326
327        let descriptions = HashMap::new();
328
329        let protobuf_data = render_protobuf(snapshot, &descriptions, Some("total"));
330
331        assert!(!protobuf_data.is_empty(), "Protobuf data should not be empty");
332
333        // Parse the protobuf response to verify it's correct
334        let metric_family = pb::MetricFamily::decode_length_delimited(&protobuf_data[..]).unwrap();
335
336        assert_eq!(metric_family.name.as_ref().unwrap(), "http_requests_total");
337        assert_eq!(metric_family.r#type.unwrap(), pb::MetricType::Counter as i32);
338        assert_eq!(metric_family.metric.len(), 1);
339
340        let metric = &metric_family.metric[0];
341        assert!(metric.counter.is_some());
342        let counter_value = metric.counter.as_ref().unwrap().value.unwrap();
343        assert!((counter_value - 42.0).abs() < f64::EPSILON);
344    }
345
346    #[test]
347    fn test_render_protobuf_gauges() {
348        let mut gauges = HashMap::new();
349        let mut gauge_labels = HashMap::new();
350        let labels = LabelSet::from_key_and_global(
351            &metrics::Key::from_parts("", vec![metrics::Label::new("instance", "localhost")]),
352            &IndexMap::new(),
353        );
354        gauge_labels.insert(labels, 0.75f64);
355        gauges.insert("cpu_usage".to_string(), gauge_labels);
356
357        let snapshot = Snapshot { counters: HashMap::new(), gauges, distributions: HashMap::new() };
358
359        let mut descriptions = HashMap::new();
360        descriptions.insert(
361            "cpu_usage".to_string(),
362            (SharedString::const_str("CPU usage percentage"), None),
363        );
364
365        let protobuf_data = render_protobuf(snapshot, &descriptions, None);
366
367        assert!(!protobuf_data.is_empty(), "Protobuf data should not be empty");
368
369        // Parse the protobuf response to verify it's correct
370        let metric_family = pb::MetricFamily::decode_length_delimited(&protobuf_data[..]).unwrap();
371
372        assert_eq!(metric_family.name.as_ref().unwrap(), "cpu_usage");
373        assert_eq!(metric_family.r#type.unwrap(), pb::MetricType::Gauge as i32);
374        assert_eq!(metric_family.help.as_ref().unwrap(), "CPU usage percentage");
375
376        let metric = &metric_family.metric[0];
377        assert!(metric.gauge.is_some());
378        let gauge_value = metric.gauge.as_ref().unwrap().value.unwrap();
379        assert!((gauge_value - 0.75).abs() < f64::EPSILON);
380    }
381
382    #[test]
383    fn test_add_suffix_to_name() {
384        assert_eq!(add_suffix_to_name("requests", Some("total")), "requests_total");
385        assert_eq!(add_suffix_to_name("requests_total", Some("total")), "requests_total");
386        assert_eq!(add_suffix_to_name("requests", None), "requests");
387    }
388}