Skip to main content

dynamo_runtime/metrics/
prom_typed.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Build `MetricFamily` from a typed engine-metrics structure.
5//!
6//! `prometheus_client` already holds metrics typed; today Dynamo flattens them
7//! to exposition text (`generate_latest`) and Rust reconstructs the structure
8//! by parsing. This builds from the structure directly instead, which is where
9//! the type, help and family name arrive authoritative rather than re-inferred.
10//!
11//! Samples remain suffix-encoded even in the typed model (`_bucket` with `le`,
12//! `_sum`, `_count`, `_total`, `_created`), so grouping is still by convention
13//! -- but scoped to a family of known type rather than guessed globally.
14
15use prometheus::proto::{
16    Bucket, Counter, Gauge, Histogram, LabelPair, Metric, MetricFamily, MetricType, Quantile,
17    Summary,
18};
19use std::collections::BTreeMap;
20
21#[cfg(test)]
22use serde::Deserialize;
23
24/// Accept a float as either a JSON number or a string, parsing strings with
25/// `str::parse`, which is correctly rounded.
26#[cfg(test)]
27fn de_f64<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
28    use serde::Deserialize as _;
29    #[derive(Deserialize)]
30    #[serde(untagged)]
31    enum Repr {
32        Text(String),
33        Num(f64),
34    }
35    match Repr::deserialize(d)? {
36        Repr::Text(t) => t.parse().map_err(serde::de::Error::custom),
37        Repr::Num(v) => Ok(v),
38    }
39}
40
41#[derive(Debug)]
42#[cfg_attr(test, derive(Deserialize))]
43pub struct TypedSample {
44    pub name: String,
45    pub labels: BTreeMap<String, String>,
46    /// Fixtures carry this as a string: JSON float parsing can land a ULP off
47    /// the correctly-rounded value, and a fixture must not introduce error the
48    /// real path -- a native f64 across PyO3 -- never has.
49    #[cfg_attr(test, serde(deserialize_with = "de_f64"))]
50    pub value: f64,
51    /// Seconds since the epoch, when the source recorded one. Standard
52    /// `prometheus_client` metrics leave this unset; custom collectors and
53    /// federated sources can populate it.
54    #[cfg_attr(test, serde(default))]
55    pub timestamp: Option<f64>,
56}
57
58#[derive(Debug)]
59#[cfg_attr(test, derive(Deserialize))]
60pub struct TypedFamily {
61    pub name: String,
62    pub help: String,
63    #[cfg_attr(test, serde(rename = "type"))]
64    pub kind: String,
65    /// Prometheus UNIT metadata, as the word the client reports (`seconds`).
66    /// Empty when the source does not declare one.
67    #[cfg_attr(test, serde(default))]
68    pub unit: String,
69    pub samples: Vec<TypedSample>,
70}
71
72/// A built family together with the UNIT metadata that came with it.
73///
74/// The unit rides alongside rather than inside: `prometheus::proto::MetricFamily`
75/// has no unit field, so it would otherwise be dropped at this boundary -- and
76/// the spec requires it be carried to OTLP when the source declares one.
77pub struct BuiltFamily {
78    pub family: MetricFamily,
79    pub unit: String,
80    /// The Prometheus type word as the client reported it (`info`, `counter`).
81    /// The proto model has no Info variant and collapses it to a gauge, so the
82    /// original is kept: the spec converts Info to a non-monotonic Sum, and
83    /// requires the word itself in `prometheus.type`.
84    pub prom_type: String,
85}
86
87/// Convert typed families into `MetricFamily`, sorted by name.
88///
89/// `_created` samples are promoted to standalone gauge families, which is how
90/// `generate_latest` renders them and therefore what the text path already
91/// exports. Dropping them here would silently stop exporting series that ship
92/// today.
93pub fn build_families(typed: Vec<TypedFamily>) -> Vec<BuiltFamily> {
94    let mut out: Vec<BuiltFamily> = Vec::new();
95    for mut family in typed {
96        let unit = family.unit.clone();
97        let prom_type = family.kind.clone();
98        let (created, rest) = family
99            .samples
100            .drain(..)
101            .partition(|s| s.name.ends_with("_created"));
102        family.samples = rest;
103        out.extend(
104            promote_created(&family.help, created)
105                .into_iter()
106                .map(|family| {
107                    // `_created` is a timestamp, not a measurement, so it
108                    // carries no unit and is a plain gauge.
109                    BuiltFamily {
110                        family,
111                        unit: String::new(),
112                        prom_type: "gauge".to_string(),
113                    }
114                }),
115        );
116        if let Some(family) = build_one(family) {
117            out.push(BuiltFamily {
118                family,
119                unit,
120                prom_type,
121            });
122        }
123    }
124    out.sort_by(|a, b| a.family.name().cmp(b.family.name()));
125    out
126}
127
128/// `_created` carries the construction timestamp, not a measurement, and the
129/// typed model nests it inside its parent while the text model gives it its own
130/// family. Emit one gauge family per `_created` sample so both agree.
131fn promote_created(help: &str, samples: Vec<TypedSample>) -> Vec<MetricFamily> {
132    let mut by_name: BTreeMap<String, MetricFamily> = BTreeMap::new();
133    for sample in samples {
134        let entry = by_name.entry(sample.name.clone()).or_insert_with(|| {
135            let mut f = MetricFamily::new();
136            f.set_name(sample.name.clone());
137            f.set_help(help.to_string());
138            f.set_field_type(MetricType::GAUGE);
139            f
140        });
141        let mut metric = Metric::new();
142        metric.set_label(label_pairs(sample.labels.into_iter().collect()));
143        let mut gauge = Gauge::new();
144        gauge.set_value(sample.value);
145        metric.set_gauge(gauge);
146        entry.mut_metric().push(metric);
147    }
148    by_name.into_values().collect()
149}
150
151fn build_one(family: TypedFamily) -> Option<MetricFamily> {
152    // The OTLP name is the Prometheus *family* name, which `collect()` already
153    // reports bare -- so a counter exports as `foo`, not `foo_total`, as the
154    // compatibility spec requires. Info is the exception: its `_info` suffix is
155    // part of the name rather than a rendering artifact.
156    let (metric_type, rendered_suffix) = match family.kind.as_str() {
157        "counter" => (MetricType::COUNTER, None),
158        "gauge" => (MetricType::GAUGE, None),
159        "histogram" => (MetricType::HISTOGRAM, None),
160        "summary" => (MetricType::SUMMARY, None),
161        // Info has no proto counterpart; it renders as a gauge whose value is
162        // always 1 and whose labels carry the payload.
163        "info" => (MetricType::GAUGE, Some("_info")),
164        // "unknown" is the exposition format's untyped, which is a gauge.
165        // Anything else is a type this build does not know: treat it as a
166        // gauge so the metric still ships, but say so.
167        "unknown" => (MetricType::GAUGE, None),
168        other => {
169            tracing::debug!(
170                metric_name = %family.name,
171                kind = %other,
172                "unrecognised metric type; treating as gauge"
173            );
174            (MetricType::GAUGE, None)
175        }
176    };
177
178    let samples = family.samples;
179    if samples.is_empty() {
180        return None;
181    }
182
183    let name = rendered_suffix
184        .and_then(|suffix| samples.iter().find(|s| s.name.ends_with(suffix)))
185        .map_or_else(|| family.name.clone(), |s| s.name.clone());
186
187    let mut out = MetricFamily::new();
188    out.set_name(name);
189    out.set_help(family.help);
190    out.set_field_type(metric_type);
191
192    match metric_type {
193        MetricType::HISTOGRAM | MetricType::SUMMARY => {
194            let point_label = if metric_type == MetricType::HISTOGRAM {
195                "le"
196            } else {
197                "quantile"
198            };
199            // Group by the labels that identify a series; the bucket/quantile
200            // label identifies a point within one.
201            struct Series {
202                labels: Vec<(String, String)>,
203                points: Vec<(f64, f64)>,
204                sum: f64,
205                count: u64,
206                /// "If `_count` is not present, the metric MUST be dropped."
207                /// A count of zero is a real value, so absence is tracked
208                /// separately rather than inferred from it.
209                saw_count: bool,
210                /// A histogram is one proto `Metric` built from several
211                /// samples, so keep the first timestamp any of them carried.
212                timestamp: Option<f64>,
213            }
214            let mut series: Vec<Series> = Vec::new();
215            for sample in samples {
216                let key: Vec<(String, String)> = sample
217                    .labels
218                    .iter()
219                    .filter(|(k, _)| k.as_str() != point_label)
220                    .map(|(k, v)| (k.clone(), v.clone()))
221                    .collect();
222                let entry = match series.iter_mut().position(|s| s.labels == key) {
223                    Some(i) => &mut series[i],
224                    None => {
225                        series.push(Series {
226                            labels: key,
227                            points: Vec::new(),
228                            sum: 0.0,
229                            count: 0,
230                            saw_count: false,
231                            timestamp: None,
232                        });
233                        series.last_mut()?
234                    }
235                };
236                if entry.timestamp.is_none() {
237                    entry.timestamp = sample.timestamp;
238                }
239                match sample.labels.get(point_label) {
240                    Some(point) => {
241                        let bound = parse_bound(point);
242                        // +Inf is implicit: TextEncoder synthesises it from
243                        // sample_count and would render a stored one as `inf`.
244                        if bound.is_finite() {
245                            entry.points.push((bound, sample.value));
246                        }
247                    }
248                    None if sample.name.ends_with("_sum") => entry.sum = sample.value,
249                    None if sample.name.ends_with("_count") => {
250                        entry.count = sample.value as u64;
251                        entry.saw_count = true;
252                    }
253                    None => {}
254                }
255            }
256
257            for Series {
258                labels,
259                mut points,
260                sum,
261                count,
262                saw_count,
263                timestamp,
264            } in series
265            {
266                if !saw_count {
267                    tracing::debug!(
268                        metric_name = %out.name(),
269                        "dropping histogram/summary series with no _count sample"
270                    );
271                    continue;
272                }
273                points.sort_by(|a, b| a.0.total_cmp(&b.0));
274                let mut metric = Metric::new();
275                metric.set_label(label_pairs(labels));
276                if metric_type == MetricType::HISTOGRAM {
277                    let mut h = Histogram::new();
278                    h.set_bucket(
279                        points
280                            .into_iter()
281                            .map(|(bound, cumulative)| {
282                                let mut bucket = Bucket::new();
283                                bucket.set_upper_bound(bound);
284                                bucket.set_cumulative_count(cumulative as u64);
285                                bucket
286                            })
287                            .collect(),
288                    );
289                    h.set_sample_sum(sum);
290                    h.set_sample_count(count);
291                    metric.set_histogram(h);
292                } else {
293                    let mut s = Summary::new();
294                    s.set_quantile(
295                        points
296                            .into_iter()
297                            .map(|(q, value)| {
298                                let mut quantile = Quantile::new();
299                                quantile.set_quantile(q);
300                                quantile.set_value(value);
301                                quantile
302                            })
303                            .collect(),
304                    );
305                    s.set_sample_sum(sum);
306                    s.set_sample_count(count);
307                    metric.set_summary(s);
308                }
309                if let Some(seconds) = timestamp {
310                    metric.set_timestamp_ms((seconds * 1_000.0) as i64);
311                }
312                out.mut_metric().push(metric);
313            }
314        }
315        _ => {
316            for sample in samples {
317                let mut metric = Metric::new();
318                metric.set_label(label_pairs(sample.labels.into_iter().collect::<Vec<_>>()));
319                if metric_type == MetricType::COUNTER {
320                    let mut c = Counter::new();
321                    c.set_value(sample.value);
322                    metric.set_counter(c);
323                } else {
324                    let mut g = Gauge::new();
325                    g.set_value(sample.value);
326                    metric.set_gauge(g);
327                }
328                if let Some(seconds) = sample.timestamp {
329                    metric.set_timestamp_ms((seconds * 1_000.0) as i64);
330                }
331                out.mut_metric().push(metric);
332            }
333        }
334    }
335
336    (!out.get_metric().is_empty()).then_some(out)
337}
338
339fn parse_bound(text: &str) -> f64 {
340    match text {
341        "+Inf" => f64::INFINITY,
342        "-Inf" => f64::NEG_INFINITY,
343        other => other.parse().unwrap_or(f64::NAN),
344    }
345}
346
347fn label_pairs(mut labels: Vec<(String, String)>) -> Vec<LabelPair> {
348    labels.sort();
349    labels
350        .into_iter()
351        .map(|(k, v)| {
352            let mut pair = LabelPair::new();
353            pair.set_name(k);
354            pair.set_value(v);
355            pair
356        })
357        .collect()
358}