Skip to main content

taba_cli/tui/prom/
mod.rs

1//! Reader of the Prometheus text a node serves at `GET /nodes/{id}/metrics` (doc 34, section 7): names,
2//! labels and values, plus the `# TYPE` of each family so counters can be told from gauges.
3
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct Sample {
8    pub name: String,
9    pub labels: Vec<(String, String)>,
10    pub value: f64,
11}
12
13impl Sample {
14    pub fn label(&self, name: &str) -> Option<&str> {
15        self.labels
16            .iter()
17            .find(|(k, _)| k == name)
18            .map(|(_, v)| v.as_str())
19    }
20
21    /// `name{a="b",c="d"}`, the way a person reads a series.
22    pub fn series(&self) -> String {
23        if self.labels.is_empty() {
24            return self.name.clone();
25        }
26        let labels: Vec<String> = self
27            .labels
28            .iter()
29            .map(|(k, v)| format!("{k}=\"{v}\""))
30            .collect();
31        format!("{}{{{}}}", self.name, labels.join(","))
32    }
33}
34
35#[derive(Debug, Clone, PartialEq, Default)]
36pub struct Metrics {
37    pub samples: Vec<Sample>,
38    types: HashMap<String, String>,
39}
40
41impl Metrics {
42    pub fn parse(text: &str) -> Metrics {
43        let mut metrics = Metrics::default();
44        for line in text.lines() {
45            let line = line.trim();
46            if let Some(rest) = line.strip_prefix("# TYPE ") {
47                if let Some((name, kind)) = rest.split_once(' ') {
48                    metrics
49                        .types
50                        .insert(name.to_owned(), kind.trim().to_owned());
51                }
52            } else if !line.is_empty() && !line.starts_with('#') {
53                if let Some(sample) = parse_sample(line) {
54                    metrics.samples.push(sample);
55                }
56            }
57        }
58        metrics
59    }
60
61    /// The family's type (`counter`, `gauge`, `histogram`...), if the text declared it.
62    pub fn kind(&self, name: &str) -> Option<&str> {
63        self.types.get(name).map(String::as_str)
64    }
65
66    /// `true` for a counter, including the `_total` series of a family declared without the suffix.
67    pub fn is_counter(&self, name: &str) -> bool {
68        self.kind(name) == Some("counter")
69            || name
70                .strip_suffix("_total")
71                .is_some_and(|base| self.kind(base) == Some("counter"))
72    }
73
74    /// The sum of all the series with this name (a family split by labels).
75    pub fn sum(&self, name: &str) -> Option<f64> {
76        let mut found = false;
77        let mut total = 0.0;
78        for sample in self.samples.iter().filter(|s| s.name == name) {
79            found = true;
80            total += sample.value;
81        }
82        found.then_some(total)
83    }
84}
85
86/// Events per second between two readings of a counter. A counter that went down means the node restarted:
87/// there is no rate for that interval (never a negative one).
88pub fn rate(previous: f64, now: f64, seconds: f64) -> Option<f64> {
89    (seconds > 0.0 && now >= previous).then(|| (now - previous) / seconds)
90}
91
92fn parse_sample(line: &str) -> Option<Sample> {
93    let (head, rest) = match line.find('{') {
94        Some(open) => {
95            let close = closing_brace(line, open)?;
96            (&line[..=close], &line[close + 1..])
97        }
98        None => line.split_once(' ')?,
99    };
100    let (name, labels) = match head.find('{') {
101        Some(open) => (&head[..open], parse_labels(&head[open + 1..head.len() - 1])),
102        None => (head, Vec::new()),
103    };
104    let value = rest.split_whitespace().next()?;
105    Some(Sample {
106        name: name.to_owned(),
107        labels,
108        value: parse_value(value)?,
109    })
110}
111
112fn parse_value(text: &str) -> Option<f64> {
113    match text {
114        "+Inf" | "Inf" => Some(f64::INFINITY),
115        "-Inf" => Some(f64::NEG_INFINITY),
116        "NaN" => Some(f64::NAN),
117        other => other.parse().ok(),
118    }
119}
120
121/// The index of the `}` that closes the labels, skipping braces inside quoted values.
122fn closing_brace(line: &str, open: usize) -> Option<usize> {
123    let mut quoted = false;
124    let mut escaped = false;
125    for (i, c) in line.char_indices().skip_while(|(i, _)| *i < open) {
126        match c {
127            _ if escaped => escaped = false,
128            '\\' if quoted => escaped = true,
129            '"' => quoted = !quoted,
130            '}' if !quoted => return Some(i),
131            _ => {}
132        }
133    }
134    None
135}
136
137fn parse_labels(text: &str) -> Vec<(String, String)> {
138    let mut labels = Vec::new();
139    let mut rest = text;
140    while let Some((name, after)) = rest.split_once("=\"") {
141        let mut value = String::new();
142        let mut chars = after.char_indices();
143        let mut end = after.len();
144        while let Some((i, c)) = chars.next() {
145            match c {
146                '\\' => match chars.next() {
147                    Some((_, 'n')) => value.push('\n'),
148                    Some((_, other)) => value.push(other),
149                    None => break,
150                },
151                '"' => {
152                    end = i + 1;
153                    break;
154                }
155                other => value.push(other),
156            }
157        }
158        labels.push((name.trim_start_matches(',').trim().to_owned(), value));
159        rest = after.get(end..).unwrap_or("");
160    }
161    labels
162}
163
164#[cfg(test)]
165mod tests;