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}