Skip to main content

netget/server/prometheus/
exposition.rs

1//! Prometheus exposition rendering: structured metric families in, text format 0.0.4 or
2//! OpenMetrics 1.0.0 out.
3//!
4//! The model never writes a byte of the exposition. It hands over families — name, type,
5//! help, samples with labels — and [`MetricFamilies::parse`] refuses anything a scraper would
6//! reject or mis-read, with a message naming what is wrong. [`MetricFamilies::render`] then
7//! produces the wire text. The split is what makes "the model cannot emit an invalid
8//! exposition" true rather than hoped for: every check lives on the parse side, so a value
9//! that reaches `render` has already been accepted.
10//!
11//! What is enforced, and why each one exists:
12//!
13//! - **Names.** Metric names match `[a-zA-Z_:][a-zA-Z0-9_:]*`, label names
14//!   `[a-zA-Z_][a-zA-Z0-9_]*`, and label names beginning `__` are refused — Prometheus reserves
15//!   them, and a scrape carrying one is rejected outright.
16//! - **Suffixes per type.** A histogram sample is `_bucket` (with `le`), `_sum` or `_count`; a
17//!   summary sample is a quantile (with `quantile`), `_sum` or `_count`; a gauge or untyped
18//!   sample has no suffix. `le` and `quantile` are refused where they do not belong.
19//! - **Counters end in `_total`.** Text format 0.0.4 names the family by its sample, so the
20//!   `_total` goes on the `# TYPE` line too; OpenMetrics names the family without it. A counter
21//!   named without `_total` has it appended, which is what the exposition guidelines require
22//!   and what `promtool check metrics` lints for. Counter values may not be negative.
23//! - **Histograms are well-formed.** Buckets are sorted by `le`, must be cumulative
24//!   (non-decreasing), and always end in a `+Inf` bucket: one is synthesised from `_count`, or
25//!   from the largest bucket when there is no `_count`. A `_count` that disagrees with the
26//!   `+Inf` bucket is refused, because both formats define them as the same number.
27//! - **No duplicate series.** Two samples with the same name and label set are refused — a
28//!   Prometheus scrape rejects the whole target for that — and so are two families whose
29//!   sample names collide (a gauge `x_count` beside a histogram `x`).
30//! - **Escaping.** Label values escape `\`, `"` and line feed; help text escapes `\` and line
31//!   feed (and `"` in OpenMetrics, whose grammar requires it).
32//!
33//! Recursion: none. Inputs are flat JSON arrays; every loop is bounded by [`MAX_SAMPLES`].
34
35use serde_json::Value;
36use std::collections::{BTreeMap, BTreeSet};
37
38/// Most samples one `send_metrics` may carry, across all families.
39///
40/// A real node_exporter exposes around a thousand; fifty thousand is well past any honest
41/// answer and still renders in a few megabytes. The bound exists so a runaway model or script
42/// cannot make one scrape allocate without limit.
43pub const MAX_SAMPLES: usize = 50_000;
44
45/// Longest metric or label name accepted.
46pub const MAX_NAME_LEN: usize = 256;
47
48/// Longest label value or help text accepted, in bytes.
49pub const MAX_TEXT_LEN: usize = 16 * 1024;
50
51/// The wire format a scrape negotiated.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum Format {
54    /// `text/plain; version=0.0.4` — the classic Prometheus text format.
55    Text,
56    /// `application/openmetrics-text; version=1.0.0`.
57    OpenMetrics,
58}
59
60impl Format {
61    /// The `Content-Type` this format is served with.
62    pub fn content_type(self) -> &'static str {
63        match self {
64            Format::Text => "text/plain; version=0.0.4; charset=utf-8",
65            Format::OpenMetrics => "application/openmetrics-text; version=1.0.0; charset=utf-8",
66        }
67    }
68
69    /// The name used in event data and logs.
70    pub fn as_str(self) -> &'static str {
71        match self {
72            Format::Text => "text",
73            Format::OpenMetrics => "openmetrics",
74        }
75    }
76}
77
78/// Choose the format from an `Accept` header.
79///
80/// OpenMetrics is chosen only when the client lists `application/openmetrics-text` with a
81/// strictly higher q-value than every text/plain or wildcard alternative. That is what
82/// Prometheus 2.5+ and 3.x send by default; `curl` and anything that sends no `Accept`, or
83/// `*/*`, gets the text format, which is what every Prometheus-compatible parser accepts.
84pub fn negotiate(accept: Option<&str>) -> Format {
85    let Some(accept) = accept else {
86        return Format::Text;
87    };
88    let mut best_openmetrics: f32 = -1.0;
89    let mut best_other: f32 = -1.0;
90    for entry in accept.split(',') {
91        let mut parts = entry.split(';');
92        let media = parts.next().unwrap_or("").trim().to_ascii_lowercase();
93        let mut q: f32 = 1.0;
94        for param in parts {
95            if let Some((k, v)) = param.split_once('=') {
96                if k.trim().eq_ignore_ascii_case("q") {
97                    q = v.trim().parse().unwrap_or(0.0);
98                }
99            }
100        }
101        if !q.is_finite() || q <= 0.0 {
102            continue;
103        }
104        if media == "application/openmetrics-text" {
105            best_openmetrics = best_openmetrics.max(q);
106        } else if media == "text/plain" || media == "*/*" || media == "text/*" {
107            best_other = best_other.max(q);
108        }
109    }
110    if best_openmetrics > best_other {
111        Format::OpenMetrics
112    } else {
113        Format::Text
114    }
115}
116
117/// A metric type as the model names it.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum MetricType {
120    Counter,
121    Gauge,
122    Histogram,
123    Summary,
124    Untyped,
125}
126
127impl MetricType {
128    fn parse(s: &str) -> Option<Self> {
129        match s.to_ascii_lowercase().as_str() {
130            "counter" => Some(Self::Counter),
131            "gauge" => Some(Self::Gauge),
132            "histogram" => Some(Self::Histogram),
133            "summary" => Some(Self::Summary),
134            "untyped" | "unknown" => Some(Self::Untyped),
135            _ => None,
136        }
137    }
138
139    fn type_word(self, format: Format) -> &'static str {
140        match (self, format) {
141            (Self::Counter, _) => "counter",
142            (Self::Gauge, _) => "gauge",
143            (Self::Histogram, _) => "histogram",
144            (Self::Summary, _) => "summary",
145            (Self::Untyped, Format::Text) => "untyped",
146            (Self::Untyped, Format::OpenMetrics) => "unknown",
147        }
148    }
149}
150
151/// Which series of a family a sample belongs to.
152#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
153enum Suffix {
154    /// No suffix: a gauge/untyped value, a counter's `_total`, a summary quantile.
155    None,
156    Bucket,
157    Sum,
158    Count,
159}
160
161impl Suffix {
162    fn text(self, mtype: MetricType) -> &'static str {
163        match (self, mtype) {
164            (Suffix::None, MetricType::Counter) => "_total",
165            (Suffix::None, _) => "",
166            (Suffix::Bucket, _) => "_bucket",
167            (Suffix::Sum, _) => "_sum",
168            (Suffix::Count, _) => "_count",
169        }
170    }
171}
172
173#[derive(Clone, Debug)]
174struct Sample {
175    suffix: Suffix,
176    /// Labels other than `le` / `quantile`, sorted by name.
177    labels: BTreeMap<String, String>,
178    /// `le` for a bucket, `quantile` for a summary quantile.
179    bound: Option<f64>,
180    value: f64,
181    timestamp_ms: Option<i64>,
182}
183
184#[derive(Clone, Debug)]
185struct Family {
186    /// The family name without `_total` for a counter; the name as given otherwise.
187    base: String,
188    mtype: MetricType,
189    help: Option<String>,
190    samples: Vec<Sample>,
191}
192
193/// A validated, normalised set of metric families, ready to render in either format.
194#[derive(Clone, Debug)]
195pub struct MetricFamilies {
196    families: Vec<Family>,
197}
198
199fn valid_metric_name(name: &str) -> bool {
200    let mut chars = name.chars();
201    match chars.next() {
202        Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
203        _ => return false,
204    }
205    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
206}
207
208fn valid_label_name(name: &str) -> bool {
209    let mut chars = name.chars();
210    match chars.next() {
211        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
212        _ => return false,
213    }
214    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
215}
216
217/// A sample value: a JSON number, or one of the strings Prometheus spells specially.
218fn parse_value(v: &Value) -> Option<f64> {
219    match v {
220        Value::Number(n) => n.as_f64(),
221        Value::String(s) => match s.trim() {
222            "NaN" | "nan" => Some(f64::NAN),
223            "+Inf" | "Inf" | "inf" | "+inf" => Some(f64::INFINITY),
224            "-Inf" | "-inf" => Some(f64::NEG_INFINITY),
225            other => other.parse::<f64>().ok(),
226        },
227        _ => None,
228    }
229}
230
231/// Format a sample value the way both parsers read it back exactly.
232fn format_value(v: f64) -> String {
233    if v.is_nan() {
234        "NaN".to_string()
235    } else if v == f64::INFINITY {
236        "+Inf".to_string()
237    } else if v == f64::NEG_INFINITY {
238        "-Inf".to_string()
239    } else {
240        // Rust's Display for f64 is the shortest string that round-trips, and never uses
241        // exponent notation — both parsers accept it.
242        format!("{v}")
243    }
244}
245
246/// Format an `le` / `quantile` label value. Integral bounds carry `.0` (`"1.0"`, not `"1"`),
247/// which is OpenMetrics' canonical form and is equally valid in the text format.
248fn format_bound(v: f64) -> String {
249    if v == f64::INFINITY {
250        "+Inf".to_string()
251    } else if v == f64::NEG_INFINITY {
252        "-Inf".to_string()
253    } else if v.fract() == 0.0 && v.abs() < 1e15 {
254        format!("{v:.1}")
255    } else {
256        format!("{v}")
257    }
258}
259
260fn escape_label_value(s: &str) -> String {
261    let mut out = String::with_capacity(s.len());
262    for c in s.chars() {
263        match c {
264            '\\' => out.push_str("\\\\"),
265            '"' => out.push_str("\\\""),
266            '\n' => out.push_str("\\n"),
267            other => out.push(other),
268        }
269    }
270    out
271}
272
273fn escape_help(s: &str, format: Format) -> String {
274    let mut out = String::with_capacity(s.len());
275    for c in s.chars() {
276        match c {
277            '\\' => out.push_str("\\\\"),
278            '\n' => out.push_str("\\n"),
279            '"' if format == Format::OpenMetrics => out.push_str("\\\""),
280            other => out.push(other),
281        }
282    }
283    out
284}
285
286impl MetricFamilies {
287    /// Validate the `metrics` array of a `send_metrics` action.
288    ///
289    /// Every refusal names the metric and what is wrong with it, because this message is what
290    /// the operator (and, through the access log, the model) reads to fix the answer.
291    pub fn parse(metrics: &Value) -> Result<Self, String> {
292        let list = metrics
293            .as_array()
294            .ok_or("'metrics' must be an array of metric families")?;
295
296        let mut families = Vec::with_capacity(list.len());
297        let mut seen_bases: BTreeSet<String> = BTreeSet::new();
298        let mut total_samples = 0usize;
299
300        for (i, raw) in list.iter().enumerate() {
301            let name = raw
302                .get("name")
303                .and_then(Value::as_str)
304                .ok_or_else(|| format!("metrics[{i}] is missing a string 'name'"))?;
305            if name.len() > MAX_NAME_LEN {
306                return Err(format!(
307                    "metric name '{}…' is longer than {MAX_NAME_LEN} characters",
308                    &name[..name
309                        .char_indices()
310                        .nth(32)
311                        .map(|(b, _)| b)
312                        .unwrap_or(name.len())]
313                ));
314            }
315            if !valid_metric_name(name) {
316                return Err(format!(
317                    "metric name '{name}' is invalid: it must match [a-zA-Z_:][a-zA-Z0-9_:]*"
318                ));
319            }
320            let type_str = raw.get("type").and_then(Value::as_str).unwrap_or("untyped");
321            let mtype = MetricType::parse(type_str).ok_or_else(|| {
322                format!(
323                    "metric '{name}' has type '{type_str}'; it must be one of counter, gauge, \
324                     histogram, summary, untyped"
325                )
326            })?;
327            let help = match raw.get("help") {
328                None | Some(Value::Null) => None,
329                Some(Value::String(s)) if s.len() > MAX_TEXT_LEN => {
330                    return Err(format!(
331                        "metric '{name}' help text is longer than {MAX_TEXT_LEN} bytes"
332                    ))
333                }
334                Some(Value::String(s)) => Some(s.clone()),
335                Some(_) => return Err(format!("metric '{name}' help must be a string")),
336            };
337
338            let base = match mtype {
339                MetricType::Counter => name.strip_suffix("_total").unwrap_or(name).to_string(),
340                _ => name.to_string(),
341            };
342            if base.is_empty() {
343                return Err(format!("metric name '{name}' is only a suffix"));
344            }
345            if !seen_bases.insert(base.clone()) {
346                return Err(format!(
347                    "metric family '{name}' appears twice; put all of its samples in one entry"
348                ));
349            }
350
351            let raw_samples = match raw.get("samples") {
352                None | Some(Value::Null) => Vec::new(),
353                Some(Value::Array(a)) => a.clone(),
354                Some(_) => return Err(format!("metric '{name}' samples must be an array")),
355            };
356            total_samples = total_samples.saturating_add(raw_samples.len());
357            if total_samples > MAX_SAMPLES {
358                return Err(format!(
359                    "more than {MAX_SAMPLES} samples in one answer; send fewer series"
360                ));
361            }
362
363            let mut samples = Vec::with_capacity(raw_samples.len());
364            for (j, s) in raw_samples.iter().enumerate() {
365                samples.push(parse_sample(name, mtype, j, s)?);
366            }
367
368            let mut family = Family {
369                base,
370                mtype,
371                help,
372                samples,
373            };
374            normalise(&mut family, name)?;
375            families.push(family);
376        }
377
378        // Two families whose rendered sample names collide would be read as one by a scraper.
379        let mut owner: BTreeMap<String, String> = BTreeMap::new();
380        for f in &families {
381            for series in series_names(f) {
382                if let Some(other) = owner.insert(series.clone(), f.base.clone()) {
383                    if other != f.base {
384                        return Err(format!(
385                            "sample name '{series}' is produced by both '{other}' and '{}'; \
386                             rename one of them",
387                            f.base
388                        ));
389                    }
390                }
391            }
392        }
393
394        Ok(Self { families })
395    }
396
397    /// How many families this answer carries.
398    pub fn family_count(&self) -> usize {
399        self.families.len()
400    }
401
402    /// How many samples, after any `+Inf` / `_count` synthesis.
403    pub fn sample_count(&self) -> usize {
404        self.families.iter().map(|f| f.samples.len()).sum()
405    }
406
407    /// Render in the negotiated format.
408    pub fn render(&self, format: Format) -> String {
409        let mut out = String::new();
410        for f in &self.families {
411            let family_name = match (f.mtype, format) {
412                (MetricType::Counter, Format::Text) => format!("{}_total", f.base),
413                _ => f.base.clone(),
414            };
415            if let Some(help) = &f.help {
416                out.push_str("# HELP ");
417                out.push_str(&family_name);
418                out.push(' ');
419                out.push_str(&escape_help(help, format));
420                out.push('\n');
421            }
422            out.push_str("# TYPE ");
423            out.push_str(&family_name);
424            out.push(' ');
425            out.push_str(f.mtype.type_word(format));
426            out.push('\n');
427
428            for s in &f.samples {
429                out.push_str(&f.base);
430                out.push_str(s.suffix.text(f.mtype));
431                let mut labels: Vec<(String, String)> = s
432                    .labels
433                    .iter()
434                    .map(|(k, v)| (k.clone(), escape_label_value(v)))
435                    .collect();
436                if let Some(bound) = s.bound {
437                    let key = if f.mtype == MetricType::Histogram {
438                        "le"
439                    } else {
440                        "quantile"
441                    };
442                    labels.push((key.to_string(), format_bound(bound)));
443                }
444                if !labels.is_empty() {
445                    out.push('{');
446                    for (idx, (k, v)) in labels.iter().enumerate() {
447                        if idx > 0 {
448                            out.push(',');
449                        }
450                        out.push_str(k);
451                        out.push_str("=\"");
452                        out.push_str(v);
453                        out.push('"');
454                    }
455                    out.push('}');
456                }
457                out.push(' ');
458                out.push_str(&format_value(s.value));
459                if let Some(ts) = s.timestamp_ms {
460                    out.push(' ');
461                    match format {
462                        Format::Text => out.push_str(&ts.to_string()),
463                        // OpenMetrics timestamps are seconds.
464                        Format::OpenMetrics => {
465                            let secs = ts.div_euclid(1000);
466                            let millis = ts.rem_euclid(1000);
467                            out.push_str(&format!("{secs}.{millis:03}"));
468                        }
469                    }
470                }
471                out.push('\n');
472            }
473        }
474        if format == Format::OpenMetrics {
475            out.push_str("# EOF\n");
476        }
477        out
478    }
479}
480
481/// Every rendered sample name a family produces.
482fn series_names(f: &Family) -> Vec<String> {
483    let suffixes: &[&str] = match f.mtype {
484        MetricType::Counter => &["_total"],
485        MetricType::Gauge | MetricType::Untyped => &[""],
486        MetricType::Histogram => &["_bucket", "_sum", "_count"],
487        MetricType::Summary => &["", "_sum", "_count"],
488    };
489    suffixes
490        .iter()
491        .map(|s| format!("{}{}", f.base, s))
492        .collect()
493}
494
495fn parse_sample(name: &str, mtype: MetricType, j: usize, s: &Value) -> Result<Sample, String> {
496    let where_ = || format!("metric '{name}' sample {j}");
497
498    let value = s
499        .get("value")
500        .and_then(parse_value)
501        .ok_or_else(|| format!("{} has no numeric 'value'", where_()))?;
502
503    let suffix_str = s.get("suffix").and_then(Value::as_str).unwrap_or("");
504    let suffix = match (mtype, suffix_str) {
505        (MetricType::Counter, "" | "_total") => Suffix::None,
506        (MetricType::Gauge | MetricType::Untyped, "") => Suffix::None,
507        (MetricType::Histogram, "_bucket") => Suffix::Bucket,
508        (MetricType::Histogram | MetricType::Summary, "_sum") => Suffix::Sum,
509        (MetricType::Histogram | MetricType::Summary, "_count") => Suffix::Count,
510        (MetricType::Summary, "") => Suffix::None,
511        (MetricType::Histogram, "") => {
512            return Err(format!(
513                "{} needs a suffix: a histogram sample is _bucket (with an 'le' label), _sum or \
514                 _count",
515                where_()
516            ))
517        }
518        (_, other) => {
519            return Err(format!(
520                "{} has suffix '{other}', which a {} does not have",
521                where_(),
522                mtype.type_word(Format::Text)
523            ))
524        }
525    };
526
527    let mut labels = BTreeMap::new();
528    let mut bound: Option<f64> = None;
529    match s.get("labels") {
530        None | Some(Value::Null) => {}
531        Some(Value::Object(map)) => {
532            for (k, v) in map {
533                if k.len() > MAX_NAME_LEN || !valid_label_name(k) {
534                    return Err(format!(
535                        "{}: label name '{k}' is invalid: it must match [a-zA-Z_][a-zA-Z0-9_]*",
536                        where_()
537                    ));
538                }
539                if k.starts_with("__") {
540                    return Err(format!(
541                        "{}: label name '{k}' begins with '__', which Prometheus reserves",
542                        where_()
543                    ));
544                }
545                let text = match v {
546                    Value::String(s) => s.clone(),
547                    Value::Number(n) => n.to_string(),
548                    Value::Bool(b) => b.to_string(),
549                    _ => {
550                        return Err(format!(
551                            "{}: label '{k}' must be a string, number or boolean",
552                            where_()
553                        ))
554                    }
555                };
556                if text.len() > MAX_TEXT_LEN {
557                    return Err(format!(
558                        "{}: label '{k}' is longer than {MAX_TEXT_LEN} bytes",
559                        where_()
560                    ));
561                }
562                let is_le = k == "le" && mtype == MetricType::Histogram;
563                let is_quantile = k == "quantile" && mtype == MetricType::Summary;
564                if is_le || is_quantile {
565                    let b = parse_value(v).filter(|b| !b.is_nan()).ok_or_else(|| {
566                        format!("{}: '{k}' must be a number or \"+Inf\"", where_())
567                    })?;
568                    bound = Some(b);
569                } else if k == "le" || k == "quantile" {
570                    // Harmless on a gauge in the text format, but it collides with the reserved
571                    // meaning the moment anyone aggregates across types; refuse it everywhere
572                    // it is not the histogram/summary label.
573                    return Err(format!(
574                        "{}: '{k}' is reserved for {} samples",
575                        where_(),
576                        if k == "le" {
577                            "histogram _bucket"
578                        } else {
579                            "summary quantile"
580                        }
581                    ));
582                } else {
583                    labels.insert(k.clone(), text);
584                }
585            }
586        }
587        Some(_) => return Err(format!("{}: 'labels' must be an object", where_())),
588    }
589
590    match (mtype, suffix) {
591        (MetricType::Histogram, Suffix::Bucket) if bound.is_none() => {
592            return Err(format!("{} is a _bucket without an 'le' label", where_()))
593        }
594        (MetricType::Histogram, Suffix::Sum | Suffix::Count) if bound.is_some() => {
595            return Err(format!(
596                "{}: 'le' belongs on _bucket samples only",
597                where_()
598            ))
599        }
600        (MetricType::Summary, Suffix::None) => match bound {
601            None => {
602                return Err(format!(
603                    "{} is a summary quantile without a 'quantile' label",
604                    where_()
605                ))
606            }
607            Some(q) if !(0.0..=1.0).contains(&q) => {
608                return Err(format!("{}: quantile {q} is outside 0..1", where_()))
609            }
610            _ => {}
611        },
612        (MetricType::Summary, Suffix::Sum | Suffix::Count) if bound.is_some() => {
613            return Err(format!(
614                "{}: 'quantile' belongs on quantile samples only",
615                where_()
616            ))
617        }
618        _ => {}
619    }
620
621    let counts_things = matches!(
622        (mtype, suffix),
623        (MetricType::Counter, _) | (_, Suffix::Bucket) | (_, Suffix::Count)
624    );
625    if counts_things && (value.is_nan() || value < 0.0) {
626        return Err(format!(
627            "{}: value {} is not a valid count; counters, buckets and _count are non-negative",
628            where_(),
629            format_value(value)
630        ));
631    }
632
633    let timestamp_ms = match s.get("timestamp_ms") {
634        None | Some(Value::Null) => None,
635        Some(v) => Some(v.as_i64().ok_or_else(|| {
636            format!(
637                "{}: timestamp_ms must be an integer (milliseconds since the epoch)",
638                where_()
639            )
640        })?),
641    };
642
643    Ok(Sample {
644        suffix,
645        labels,
646        bound,
647        value,
648        timestamp_ms,
649    })
650}
651
652/// Sort, deduplicate-check and complete one family.
653fn normalise(f: &mut Family, name: &str) -> Result<(), String> {
654    // Duplicate series: same suffix, same labels, same bound.
655    let mut seen = BTreeSet::new();
656    for s in &f.samples {
657        let key = (s.suffix, s.labels.clone(), s.bound.map(|b| b.to_bits()));
658        if !seen.insert(key) {
659            return Err(format!(
660                "metric '{name}' has two samples with the same labels{}; each series may \
661                 appear once",
662                match s.bound {
663                    Some(b) => format!(" and bound {}", format_bound(b)),
664                    None => String::new(),
665                }
666            ));
667        }
668    }
669
670    if f.mtype != MetricType::Histogram && f.mtype != MetricType::Summary {
671        return Ok(());
672    }
673
674    // Group by label set; within a group, order is quantiles/buckets ascending, then _sum,
675    // then _count — the order every client library writes and every parser expects.
676    let mut groups: BTreeMap<BTreeMap<String, String>, Vec<Sample>> = BTreeMap::new();
677    let mut order: Vec<BTreeMap<String, String>> = Vec::new();
678    for s in f.samples.drain(..) {
679        if !groups.contains_key(&s.labels) {
680            order.push(s.labels.clone());
681        }
682        groups.entry(s.labels.clone()).or_default().push(s);
683    }
684
685    let mut out = Vec::new();
686    for labels in order {
687        let mut group = groups.remove(&labels).unwrap_or_default();
688        group.sort_by(|a, b| {
689            a.suffix.cmp(&b.suffix).then(
690                a.bound
691                    .unwrap_or(0.0)
692                    .partial_cmp(&b.bound.unwrap_or(0.0))
693                    .unwrap_or(std::cmp::Ordering::Equal),
694            )
695        });
696
697        if f.mtype == MetricType::Histogram {
698            let buckets: Vec<&Sample> = group
699                .iter()
700                .filter(|s| s.suffix == Suffix::Bucket)
701                .collect();
702            let count = group.iter().find(|s| s.suffix == Suffix::Count).cloned();
703            let mut previous = 0.0f64;
704            for b in &buckets {
705                if b.value < previous {
706                    return Err(format!(
707                        "histogram '{name}' bucket le={} holds {} but a smaller bucket holds \
708                         {}; buckets are cumulative, so each must be at least the one before",
709                        format_bound(b.bound.unwrap_or(0.0)),
710                        format_value(b.value),
711                        format_value(previous)
712                    ));
713                }
714                previous = b.value;
715            }
716            let has_inf = buckets
717                .last()
718                .is_some_and(|b| b.bound == Some(f64::INFINITY));
719            let inf_value = if has_inf {
720                previous
721            } else {
722                let v = count.as_ref().map(|c| c.value).unwrap_or(previous);
723                if v < previous {
724                    return Err(format!(
725                        "histogram '{name}' _count {} is smaller than its largest bucket {}",
726                        format_value(v),
727                        format_value(previous)
728                    ));
729                }
730                let template = group.iter().find(|s| s.suffix == Suffix::Bucket).cloned();
731                if let Some(mut inf) = template.or_else(|| count.clone()) {
732                    inf.suffix = Suffix::Bucket;
733                    inf.bound = Some(f64::INFINITY);
734                    inf.value = v;
735                    let insert_at = group
736                        .iter()
737                        .position(|s| s.suffix != Suffix::Bucket)
738                        .unwrap_or(group.len());
739                    group.insert(insert_at, inf);
740                    v
741                } else {
742                    // Only a _sum: nothing to anchor a bucket to, and a histogram whose only
743                    // series is a sum is not one a scraper can use.
744                    return Err(format!(
745                        "histogram '{name}' has a _sum but no buckets and no _count"
746                    ));
747                }
748            };
749            match count {
750                Some(c) if c.value != inf_value => {
751                    return Err(format!(
752                        "histogram '{name}' _count is {} but its +Inf bucket is {}; they are \
753                         the same number by definition",
754                        format_value(c.value),
755                        format_value(inf_value)
756                    ))
757                }
758                Some(_) => {}
759                None => {
760                    let mut c = group
761                        .iter()
762                        .find(|s| s.suffix == Suffix::Bucket)
763                        .cloned()
764                        .expect("a +Inf bucket was just ensured");
765                    c.suffix = Suffix::Count;
766                    c.bound = None;
767                    c.value = inf_value;
768                    group.push(c);
769                }
770            }
771        }
772        out.extend(group);
773    }
774    f.samples = out;
775    Ok(())
776}