Skip to main content

sentinel_core/score/
prom_parser.rs

1//! Shared Prometheus text-exposition parser for the label-keyed energy
2//! scrapers.
3//!
4//! Generic over the metric name and the routing label key, since each
5//! backend exposes a different pair: Kepler exports a per-container
6//! series (`kepler_container_cpu_joules_total`, keyed by
7//! `container_name`) and a per-process series
8//! (`kepler_process_cpu_joules_total`, keyed by `comm`), while Alumet
9//! lets the operator name both through its exporter config.
10//!
11//! Forgiving by contract, like `scaphandre::parser`: a malformed line is
12//! skipped, never an error. This is best-effort telemetry.
13//!
14//! `scaphandre::parser` stays separate on purpose: it extracts two
15//! labels (`exe` and `cmdline`) in a single pass, so its scan loop
16//! legitimately diverges from the single-label shape here.
17
18/// One parsed sample of a Prometheus exposition line.
19///
20/// `label_value` is whatever the configured `label_key` resolved to (a
21/// container name, a kernel `comm` string, a pod name). `value` is the
22/// raw reading at the moment of the scrape, its unit and semantics are
23/// the caller's business: Kepler reads it as a cumulative joule counter
24/// and derives a delta, Alumet reads it as the energy of one poll
25/// interval.
26use std::collections::HashMap;
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct PromSample {
30    pub label_value: String,
31    pub value: f64,
32}
33
34/// Parse a Prometheus `/metrics` exposition body, extracting samples for
35/// the requested `metric_name`, keyed on `label_key`.
36///
37/// Only lines matching `metric_name` are returned. Comments, blank
38/// lines, and all other scrape-level metrics are skipped. Lines without
39/// the configured label or with an unparseable numeric value are also
40/// skipped.
41#[must_use]
42pub fn parse_metric_samples(body: &str, metric_name: &str, label_key: &str) -> Vec<PromSample> {
43    let mut out = Vec::new();
44    for line in body.lines() {
45        let line = line.trim();
46        if line.is_empty() || line.starts_with('#') {
47            continue;
48        }
49        let Some(rest) = line.strip_prefix(metric_name) else {
50            continue;
51        };
52        let (labels_str, value_str) = match rest.as_bytes().first() {
53            Some(b'{') => match find_label_block_end(rest) {
54                Some(end) => (&rest[1..end], rest[end + 1..].trim_start()),
55                None => continue,
56            },
57            Some(b' ') => ("", rest.trim_start()),
58            _ => continue,
59        };
60        let value_token = value_str.split_whitespace().next().unwrap_or("");
61        let Ok(value) = value_token.parse::<f64>() else {
62            continue;
63        };
64        let Some(label_value) = extract_label(labels_str, label_key) else {
65            continue;
66        };
67        out.push(PromSample { label_value, value });
68    }
69    out
70}
71
72/// Sum samples per `label_value`, with per-row validation.
73///
74/// Values sharing a label value are SUMMED, not overwritten: energy is
75/// additive and label collisions are normal for both consumers (one
76/// container name repeated across pods for Kepler, one row per RAPL
77/// domain or per socket for Alumet). A last-write-wins read would keep
78/// whichever row the exposition emitted last and silently understate
79/// the figure. Per-row validation happens HERE, not only on the sum:
80/// the Prometheus text format legitimately carries NaN, and one NaN row
81/// must not poison every row sharing its label, while a negative row
82/// must not subtract from an otherwise valid sum. Rejected rows still
83/// create the entry, so the label counts as present on the wire (the
84/// series exists, a mapping pointing at it is not the problem).
85#[must_use]
86pub fn sum_by_label(samples: &[PromSample]) -> HashMap<&str, f64> {
87    let mut by_label: HashMap<&str, f64> = HashMap::with_capacity(samples.len());
88    for s in samples {
89        let slot = by_label.entry(s.label_value.as_str()).or_insert(0.0);
90        if s.value.is_finite() && s.value > 0.0 {
91            *slot += s.value;
92        }
93    }
94    by_label
95}
96
97/// Find the index of the closing `}` that matches the leading `{` in a
98/// Prometheus labels block. Handles escape sequences inside label
99/// values so a backslash followed by a quote does not prematurely end
100/// the block. Advancing by 2 bytes over an escape is UTF-8-safe:
101/// Prometheus escape sequences are single-byte ASCII, so the byte after
102/// the backslash cannot split a multi-byte codepoint.
103///
104/// Returns `None` if the `{` is unmatched within the slice.
105pub(crate) fn find_label_block_end(s: &str) -> Option<usize> {
106    let bytes = s.as_bytes();
107    if bytes.first() != Some(&b'{') {
108        return None;
109    }
110    let mut i = 1;
111    let mut in_value = false;
112    while i < bytes.len() {
113        let b = bytes[i];
114        match b {
115            b'"' => in_value = !in_value,
116            b'\\' if in_value => {
117                i += 2;
118                continue;
119            }
120            b'}' if !in_value => return Some(i),
121            _ => {}
122        }
123        i += 1;
124    }
125    None
126}
127
128/// Extract a single label value by key from a Prometheus labels block.
129/// Returns the unescaped value, or `None` if the key is absent.
130fn extract_label(labels: &str, target_key: &str) -> Option<String> {
131    let bytes = labels.as_bytes();
132    let mut i = 0;
133    while i < bytes.len() {
134        let parsed = parse_next_label(labels, bytes, i)?;
135        if parsed.name.trim() == target_key {
136            return Some(if parsed.needs_unescape {
137                unescape_prometheus_value(parsed.value)
138            } else {
139                parsed.value.to_string()
140            });
141        }
142        i = parsed.next_index;
143    }
144    None
145}
146
147/// One parsed Prometheus label, as returned by [`parse_next_label`].
148/// All string slices borrow from the outer `labels` buffer.
149pub(crate) struct ParsedLabel<'a> {
150    pub(crate) name: &'a str,
151    pub(crate) value: &'a str,
152    pub(crate) needs_unescape: bool,
153    /// Byte offset in `labels.as_bytes()` just past the trailing
154    /// comma / whitespace, i.e. the start of the next label.
155    pub(crate) next_index: usize,
156}
157
158/// Parse a single `name="value"` label starting at byte offset `i`.
159///
160/// Returns `None` if the buffer is truncated or the shape is invalid
161/// (missing `=`, missing opening `"`, unterminated value). On success,
162/// returns a [`ParsedLabel`] with the three components plus the offset
163/// just past the following separator, ready for the next iteration.
164pub(crate) fn parse_next_label<'a>(
165    labels: &'a str,
166    bytes: &[u8],
167    i: usize,
168) -> Option<ParsedLabel<'a>> {
169    let (name, after_eq) = read_label_name(labels, bytes, i)?;
170    let (value, needs_unescape, after_close_quote) = read_label_value(labels, bytes, after_eq)?;
171    let next_index = advance_past_separators(bytes, after_close_quote);
172    Some(ParsedLabel {
173        name,
174        value,
175        needs_unescape,
176        next_index,
177    })
178}
179
180fn read_label_name<'a>(labels: &'a str, bytes: &[u8], i: usize) -> Option<(&'a str, usize)> {
181    let name_start = i;
182    let mut pos = i;
183    while pos < bytes.len() && bytes[pos] != b'=' {
184        pos += 1;
185    }
186    if pos >= bytes.len() {
187        return None;
188    }
189    Some((&labels[name_start..pos], pos + 1))
190}
191
192fn read_label_value<'a>(labels: &'a str, bytes: &[u8], i: usize) -> Option<(&'a str, bool, usize)> {
193    if i >= bytes.len() || bytes[i] != b'"' {
194        return None;
195    }
196    let value_start = i + 1;
197    let mut pos = value_start;
198    let mut needs_unescape = false;
199    while pos < bytes.len() {
200        match bytes[pos] {
201            b'\\' if pos + 1 < bytes.len() => {
202                needs_unescape = true;
203                pos += 2;
204            }
205            b'"' => break,
206            _ => pos += 1,
207        }
208    }
209    if pos >= bytes.len() {
210        return None;
211    }
212    Some((&labels[value_start..pos], needs_unescape, pos + 1))
213}
214
215fn advance_past_separators(bytes: &[u8], mut i: usize) -> usize {
216    while i < bytes.len() && (bytes[i] == b',' || bytes[i] == b' ') {
217        i += 1;
218    }
219    i
220}
221
222/// Unescape a Prometheus label value. Handles `\"`, `\\`, and `\n`
223/// per the exposition format spec. Other backslash sequences are
224/// passed through literally. UTF-8-safe: walks the string by
225/// character, not by byte.
226pub(crate) fn unescape_prometheus_value(raw: &str) -> String {
227    // Fast path: no backslashes means no allocation-per-char walk.
228    if !raw.contains('\\') {
229        return raw.to_string();
230    }
231    let mut out = String::with_capacity(raw.len());
232    let mut chars = raw.chars();
233    while let Some(c) = chars.next() {
234        if c == '\\' {
235            match chars.next() {
236                Some('"') => out.push('"'),
237                Some('n') => out.push('\n'),
238                Some('\\') | None => out.push('\\'),
239                Some(other) => {
240                    out.push('\\');
241                    out.push(other);
242                }
243            }
244        } else {
245            out.push(c);
246        }
247    }
248    out
249}
250
251#[cfg(test)]
252mod tests {
253    use super::{PromSample, parse_metric_samples};
254
255    #[test]
256    fn parse_empty_body() {
257        assert!(
258            parse_metric_samples("", "kepler_container_cpu_joules_total", "container_name")
259                .is_empty()
260        );
261    }
262
263    #[test]
264    fn parse_comments_only() {
265        let body = "# HELP kepler_container_cpu_joules_total ...\n# TYPE kepler_container_cpu_joules_total counter\n";
266        assert!(
267            parse_metric_samples(body, "kepler_container_cpu_joules_total", "container_name")
268                .is_empty()
269        );
270    }
271
272    #[test]
273    fn parse_single_container_sample() {
274        let body = "kepler_container_cpu_joules_total{container_name=\"order-svc\",pod_name=\"p1\"} 1234.5\n";
275        let out = parse_metric_samples(body, "kepler_container_cpu_joules_total", "container_name");
276        assert_eq!(out.len(), 1);
277        assert_eq!(out[0].label_value, "order-svc");
278        assert!((out[0].value - 1234.5).abs() < f64::EPSILON);
279    }
280
281    #[test]
282    fn parse_multiple_containers() {
283        let body = "kepler_container_cpu_joules_total{container_name=\"a\"} 100.0\n\
284                    kepler_container_cpu_joules_total{container_name=\"b\"} 250.5\n\
285                    kepler_container_cpu_joules_total{container_name=\"c\"} 999.9\n";
286        let out = parse_metric_samples(body, "kepler_container_cpu_joules_total", "container_name");
287        assert_eq!(out.len(), 3);
288        let names: Vec<&str> = out.iter().map(|s| s.label_value.as_str()).collect();
289        assert!(names.contains(&"a"));
290        assert!(names.contains(&"b"));
291        assert!(names.contains(&"c"));
292    }
293
294    #[test]
295    fn parse_skips_unrelated_metrics() {
296        let body = "kepler_node_info{name=\"foo\"} 1\n\
297                    kepler_container_cpu_joules_total{container_name=\"order-svc\"} 42.0\n\
298                    kepler_host_joules{name=\"bar\"} 7.0\n";
299        let out = parse_metric_samples(body, "kepler_container_cpu_joules_total", "container_name");
300        assert_eq!(out.len(), 1);
301        assert_eq!(out[0].label_value, "order-svc");
302    }
303
304    #[test]
305    fn parse_process_cpu_with_comm_label() {
306        let body = "kepler_process_cpu_joules_total{comm=\"java\",pid=\"42\"} 88.0\n";
307        let out = parse_metric_samples(body, "kepler_process_cpu_joules_total", "comm");
308        assert_eq!(out.len(), 1);
309        assert_eq!(out[0].label_value, "java");
310    }
311
312    #[test]
313    fn parse_skips_invalid_value() {
314        let body = "kepler_container_cpu_joules_total{container_name=\"order-svc\"} not_a_number\n";
315        let out = parse_metric_samples(body, "kepler_container_cpu_joules_total", "container_name");
316        assert!(out.is_empty());
317    }
318
319    #[test]
320    fn parse_skips_missing_label() {
321        let body = "kepler_container_cpu_joules_total{pod_name=\"only-pod\"} 5.0\n";
322        let out = parse_metric_samples(body, "kepler_container_cpu_joules_total", "container_name");
323        assert!(out.is_empty());
324    }
325
326    #[test]
327    fn parse_handles_escaped_quote_in_label() {
328        let body =
329            "kepler_container_cpu_joules_total{container_name=\"with \\\"quotes\\\"\"} 1.0\n";
330        let out = parse_metric_samples(body, "kepler_container_cpu_joules_total", "container_name");
331        assert_eq!(out.len(), 1);
332        assert_eq!(out[0].label_value, "with \"quotes\"");
333    }
334
335    #[test]
336    fn parse_handles_no_labels() {
337        let body = "kepler_container_cpu_joules_total 99.0\n";
338        let out = parse_metric_samples(body, "kepler_container_cpu_joules_total", "container_name");
339        // No label block means no label_value, sample is skipped.
340        assert!(out.is_empty());
341    }
342
343    // Alumet's exporter emits every attribute as a label alongside four
344    // fixed `resource_*` labels, and OpenMetrics framing adds `# EOF`.
345    // Shape taken from the upstream user-book exposition excerpt.
346    #[test]
347    fn parse_alumet_attributed_energy_by_pod_name() {
348        let body = "# HELP attributed_energy_cpu_alumet attributed energy.\n\
349                    # TYPE attributed_energy_cpu_alumet gauge\n\
350                    attributed_energy_cpu_alumet{domain=\"package\",name=\"checkout-pod\",namespace=\"prod\",resource_consumer_id=\"\",resource_consumer_kind=\"cgroup\",resource_id=\"\",resource_kind=\"local_machine\"} 12.5\n\
351                    # EOF\n";
352        let out = parse_metric_samples(body, "attributed_energy_cpu_alumet", "name");
353        assert_eq!(out.len(), 1);
354        assert_eq!(out[0].label_value, "checkout-pod");
355        assert!((out[0].value - 12.5).abs() < f64::EPSILON);
356    }
357
358    // A shorter metric name must not match a longer one that starts with
359    // it: `strip_prefix` alone would let `rapl_consumed_energy_alumet`
360    // swallow a line for `rapl_consumed_energy_alumet_joules`.
361    #[test]
362    fn parse_does_not_match_longer_metric_name_prefix() {
363        let body = "rapl_consumed_energy_alumet_joules{domain=\"package\"} 7.0\n";
364        let out = parse_metric_samples(body, "rapl_consumed_energy_alumet", "domain");
365        assert!(out.is_empty());
366    }
367
368    #[test]
369    fn prom_sample_is_constructible_for_callers() {
370        let s = PromSample {
371            label_value: "svc".to_string(),
372            value: 1.0,
373        };
374        assert_eq!(s.label_value, "svc");
375    }
376}