1use std::collections::HashMap;
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct PromSample {
30 pub label_value: String,
31 pub value: f64,
32}
33
34#[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#[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
97pub(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
128fn 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
147pub(crate) struct ParsedLabel<'a> {
150 pub(crate) name: &'a str,
151 pub(crate) value: &'a str,
152 pub(crate) needs_unescape: bool,
153 pub(crate) next_index: usize,
156}
157
158pub(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
222pub(crate) fn unescape_prometheus_value(raw: &str) -> String {
227 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 assert!(out.is_empty());
341 }
342
343 #[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 #[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}