Skip to main content

sentinel_core/score/scaphandre/
parser.rs

1//! Prometheus text-exposition parser for Scaphandre's per-process
2//! power metric.
3//!
4//! The parser is deliberately forgiving: malformed lines are
5//! silently skipped rather than returning an error, so a single bad
6//! line can't break the entire scrape. perf-sentinel treats
7//! Scaphandre as best-effort telemetry; invalid data falls back to
8//! the proxy model automatically.
9//!
10//! Only the `scaph_process_power_consumption_microwatts` metric is
11//! extracted. Host and socket metrics, comments, and any other
12//! Scaphandre or Prometheus scrape-level metric are skipped.
13
14/// One parsed line of the Scaphandre `/metrics` exposition.
15///
16/// Carries `exe` (absolute path emitted by Scaphandre, e.g.
17/// `/usr/lib/jvm/temurin-25-jdk-amd64/bin/java`) and `cmdline` (argv
18/// concatenated without separators, e.g. `java-jar/tmp/svc-a.jar`).
19/// Both are needed because multiple co-located services sharing a
20/// runtime (several JVMs, several .NET assemblies) collide on `exe`
21/// and only `cmdline` discriminates them. `cmdline` may be empty if
22/// the label was absent on the wire.
23///
24/// The `pid` label is intentionally NOT retained: PIDs are unstable
25/// across restarts and serve no purpose for service-level attribution.
26use crate::score::prom_parser::{
27    find_label_block_end, parse_next_label, unescape_prometheus_value,
28};
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct ProcessPower {
32    pub exe: String,
33    pub cmdline: String,
34    pub power_microwatts: f64,
35}
36
37/// Parse a Scaphandre `/metrics` exposition body, extracting the
38/// per-process power-consumption entries.
39///
40/// Only lines for the metric
41/// `scaph_process_power_consumption_microwatts` are returned. Other
42/// metrics (`scaph_host_power_microwatts`, `scaph_socket_power_microwatts`,
43/// go_*, process_*, etc.) are skipped. Comments (lines starting with
44/// `#`) are skipped. Label values may contain escaped quotes (`\"`) and
45/// escaped backslashes (`\\`), which are unescaped into the returned
46/// string, this is rare but can occur for JVM processes with quoted
47/// args in their `cmdline` label. Real Scaphandre also concatenates
48/// argv without separators: `java -jar /tmp/svc.jar` is emitted as
49/// `cmdline="java-jar/tmp/svc.jar"`, which downstream matchers must
50/// account for.
51#[must_use]
52pub fn parse_scaphandre_metrics(body: &str) -> Vec<ProcessPower> {
53    const METRIC_NAME: &str = "scaph_process_power_consumption_microwatts";
54    let mut out = Vec::new();
55    for line in body.lines() {
56        let line = line.trim();
57        if line.is_empty() || line.starts_with('#') {
58            continue;
59        }
60        // A valid line is of the form:
61        //   metric_name{label="value",...} 42.5[ timestamp]
62        // We look for the metric_name followed by '{' (labels) or ' '
63        // (no labels, shouldn't happen for per-process power but
64        // handle defensively).
65        let Some(rest) = line.strip_prefix(METRIC_NAME) else {
66            continue;
67        };
68        let (labels_str, value_str) = match rest.as_bytes().first() {
69            Some(b'{') => {
70                // Find the matching closing '}' by walking the bytes
71                // and respecting escape sequences inside label values.
72                match find_label_block_end(rest) {
73                    Some(end) => (&rest[1..end], rest[end + 1..].trim_start()),
74                    None => continue, // unmatched '{' → skip
75                }
76            }
77            Some(b' ') => ("", rest.trim_start()),
78            _ => continue, // not a matching metric (prefix collision)
79        };
80        // value_str now starts with the numeric value, optionally
81        // followed by a trailing timestamp. Split on whitespace and
82        // take the first token.
83        let value_token = value_str.split_whitespace().next().unwrap_or("");
84        let Ok(value) = value_token.parse::<f64>() else {
85            continue;
86        };
87        let (exe, cmdline) = extract_exe_and_cmdline(labels_str);
88        let Some(exe) = exe else {
89            continue;
90        };
91        out.push(ProcessPower {
92            exe,
93            cmdline,
94            power_microwatts: value,
95        });
96    }
97    out
98}
99
100/// Extract `exe` and `cmdline` label values from a labels string in a
101/// single pass (the part between `{` and `}`, excluding the braces).
102///
103/// Returns `(exe, cmdline)` where `exe` is `Some` when the label is
104/// present (lines without `exe` are dropped upstream) and `cmdline`
105/// defaults to the empty string when absent. Unescapes `\"` and `\\`
106/// in both values lazily. Stops walking labels as soon as both are
107/// found.
108fn extract_exe_and_cmdline(labels: &str) -> (Option<String>, String) {
109    let bytes = labels.as_bytes();
110    let mut i = 0;
111    let mut exe: Option<String> = None;
112    let mut cmdline: Option<String> = None;
113    while i < bytes.len() {
114        let Some(parsed) = parse_next_label(labels, bytes, i) else {
115            break;
116        };
117        let materialize = || {
118            if parsed.needs_unescape {
119                unescape_prometheus_value(parsed.value)
120            } else {
121                parsed.value.to_string()
122            }
123        };
124        match parsed.name.trim() {
125            "exe" if exe.is_none() => exe = Some(materialize()),
126            "cmdline" if cmdline.is_none() => cmdline = Some(materialize()),
127            _ => {}
128        }
129        if exe.is_some() && cmdline.is_some() {
130            break;
131        }
132        i = parsed.next_index;
133    }
134    (exe, cmdline.unwrap_or_default())
135}