Skip to main content

krabka_bench_driver/
prom.rs

1//! Prometheus query client.
2//!
3//! The driver issues a small set of instant queries at scenario end to capture
4//! resource usage on the broker pods. For Strimzi only, it also captures JVM
5//! heap and non-heap from the JMX exporter.
6
7use {
8    crate::{
9        ids::{MessageCount, TimeOffsetMs},
10        numeric::{event_rate, nonnegative_f64_to_u64, to_f64},
11        scenario::{BrokerSample, Resource, Stack},
12    },
13    anyhow::{Context, Result, anyhow},
14    krabka_units::prelude::*,
15    serde::Deserialize,
16    std::collections::BTreeMap,
17};
18
19/// Default HTTP request timeout for Prometheus queries.
20pub const DEFAULT_PROMETHEUS_REQUEST_TIMEOUT: Time = secs(15);
21
22pub struct PromClient {
23    base_url: String,
24    http: reqwest::Client,
25}
26
27impl PromClient {
28    /// `base_url` is the Prometheus root, for example
29    /// `http://prom.monitoring.svc:9090`. A trailing slash is not needed.
30    /// # Errors
31    /// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
32    pub fn new(base_url: impl Into<String>, request_timeout: Time) -> Result<Self> {
33        let http = reqwest::Client::builder()
34            .timeout(request_timeout.to_std())
35            .build()
36            .context("build reqwest client")?;
37        Ok(Self {
38            base_url: base_url.into().trim_end_matches('/').to_owned(),
39            http,
40        })
41    }
42
43    /// Runs a `PromQL` instant query. Returns the sum of the first scalar
44    /// value across all returned series. Returns `None` when the result
45    /// vector is empty.
46    /// # Errors
47    /// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
48    pub async fn query_scalar_sum(&self, query: &str) -> Result<Option<f64>> {
49        self.query_scalar_sum_at(query, None).await
50    }
51
52    /// [`Self::query_scalar_sum`], evaluated at an explicit instant.
53    ///
54    /// `at_unix_s` is the Prometheus `time` parameter. A range selector inside
55    /// the query then looks back from that instant and not from now, which is
56    /// what lets a caller name the window it measured rather than the window
57    /// that happens to end when the client got round to asking.
58    /// # Errors
59    /// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
60    pub async fn query_scalar_sum_at(
61        &self,
62        query: &str,
63        at_unix_s: Option<f64>,
64    ) -> Result<Option<f64>> {
65        let url = format!("{}/api/v1/query", self.base_url);
66        let mut request = self.http.get(&url).query(&[("query", query)]);
67        if let Some(at) = at_unix_s {
68            request = request.query(&[("time", format!("{at}"))]);
69        }
70        let body: PromResp = request
71            .send()
72            .await
73            .with_context(|| format!("GET {url} query={query}"))?
74            .error_for_status()
75            .with_context(|| "prometheus non-2xx")?
76            .json()
77            .await
78            .context("decode prometheus json")?;
79
80        if body.status != "success" {
81            return Err(anyhow!(
82                "prometheus query failed: status={} err={:?}",
83                body.status,
84                body.error
85            ));
86        }
87        let Some(data) = body.data else {
88            return Ok(None);
89        };
90        if data.result.is_empty() {
91            return Ok(None);
92        }
93        let mut sum = 0.0_f64;
94        let mut had = false;
95        for r in &data.result {
96            if let Some((_, v)) = r.value.as_ref()
97                && let Ok(parsed) = v.parse::<f64>()
98            {
99                sum += parsed;
100                had = true;
101            }
102        }
103        Ok(had.then_some(sum))
104    }
105
106    /// Runs a `PromQL` range query and sums across all returned series per
107    /// timestamp. Returns [`PromPoint`]s on the step grid.
108    /// # Errors
109    /// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
110    pub async fn query_range_sum(
111        &self,
112        query: &str,
113        start_s: f64,
114        end_s: f64,
115        step_s: u64,
116    ) -> Result<Vec<PromPoint>> {
117        let url = format!("{}/api/v1/query_range", self.base_url);
118        let start = format!("{start_s}");
119        let end = format!("{end_s}");
120        let step = format!("{step_s}s");
121        let body: PromResp = self
122            .http
123            .get(&url)
124            .query(&[
125                ("query", query),
126                ("start", start.as_str()),
127                ("end", end.as_str()),
128                ("step", step.as_str()),
129            ])
130            .send()
131            .await
132            .with_context(|| format!("GET {url} (range) query={query}"))?
133            .error_for_status()
134            .with_context(|| "prometheus non-2xx (range)")?
135            .json()
136            .await
137            .context("decode prometheus range json")?;
138
139        if body.status != "success" {
140            return Err(anyhow!(
141                "prometheus range query failed: status={} err={:?}",
142                body.status,
143                body.error
144            ));
145        }
146        let Some(data) = body.data else {
147            return Ok(Vec::new());
148        };
149        // Sum across series per timestamp (keyed by ms to dedupe float ts).
150        let mut by_ts: BTreeMap<u64, f64> = BTreeMap::new();
151        for r in &data.result {
152            if let Some(vals) = &r.values {
153                for (ts, v) in vals {
154                    if let Ok(parsed) = v.parse::<f64>() {
155                        *by_ts
156                            .entry(nonnegative_f64_to_u64((ts * 1000.0).round()))
157                            .or_insert(0.0) += parsed;
158                    }
159                }
160            }
161        }
162        Ok(by_ts
163            .into_iter()
164            .map(|(ts_ms, value)| PromPoint {
165                unix_s: to_f64(ts_ms) / 1000.0,
166                value,
167            })
168            .collect())
169    }
170
171    /// Captures a broker CPU and memory **time series** over `[start_s, end_s]`,
172    /// so a graph can show the values over the test. CPU is a 1-minute `rate()`
173    /// in cores, and memory is the summed working set. Both align onto a single
174    /// `step_s` grid. `t_offset_ms` is relative to `start_s`.
175    /// # Errors
176    /// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
177    pub async fn capture_resource_series(
178        &self,
179        stack: Stack,
180        namespace: &str,
181        start_s: f64,
182        end_s: f64,
183        step_s: u64,
184    ) -> Result<Vec<BrokerSample>> {
185        let pod_re = format!("{}.*", stack.broker_pod_regex().trim_start_matches('^'));
186        let cpu_q = format!(
187            "sum(rate(container_cpu_usage_seconds_total{{namespace=\"{namespace}\",pod=~\"{pod_re}\",id=~\".*slice\"}}[1m]))"
188        );
189        let mem_q = format!(
190            "sum(container_memory_working_set_bytes{{namespace=\"{namespace}\",pod=~\"{pod_re}\",id=~\".*slice\"}})"
191        );
192        let cpu = self
193            .query_range_sum(&cpu_q, start_s, end_s, step_s)
194            .await
195            .context("capture broker CPU time series")?;
196        let mem = self
197            .query_range_sum(&mem_q, start_s, end_s, step_s)
198            .await
199            .context("capture broker memory time series")?;
200
201        join_resource_series(cpu, mem, nonnegative_f64_to_u64((start_s * 1000.0).round()))
202    }
203
204    /// Captures broker resource usage for the given stack over a window that
205    /// ends now. Make `window` a little larger than the measured scenario
206    /// duration, so the `rate()` window does not tail off.
207    /// # Errors
208    /// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
209    /// `measured_until_unix_s` is the instant the measurement window closed.
210    /// The queries are evaluated there rather than at call time, so the
211    /// `window`-long lookback covers the window that was measured. Without it,
212    /// the time the driver spends joining its tasks and draining its producers
213    /// shifts the lookback forward by that much: the start of the measurement
214    /// falls out of the window and the drain falls into it, while the
215    /// efficiency figure still divides the measurement-only message count by
216    /// that shifted CPU interval.
217    pub async fn capture_resource(
218        &self,
219        stack: Stack,
220        namespace: &str,
221        window: Time,
222        measured_until_unix_s: f64,
223        msgs_produced: MessageCount,
224    ) -> Result<Resource> {
225        // `broker_pod_regex()` returns a `^`-anchored *prefix* (e.g.
226        // `^demo-broker`) shared with `failover.rs`, which strips the `^` and
227        // uses it via `starts_with`. PromQL `=~` matchers are *fully* anchored
228        // (`^demo-broker` is compiled as `^(?:^demo-broker)$`), so the bare
229        // prefix matches a pod named *exactly* `demo-broker` and never the real
230        // `demo-broker-0-0` — every series came back empty → all resource
231        // numbers 0. Rebuild it as an unanchored prefix-glob: drop the `^` and
232        // append `.*` so `demo-broker.*` matches the ordinal-suffixed pods.
233        let pod_re = format!("{}.*", stack.broker_pod_regex().trim_start_matches('^'));
234        let pod_re = pod_re.as_str();
235        // PromQL range selectors take whole units, and need at least one full
236        // scrape interval to have anything to rate over.
237        let win = window.secs_i64().max(15);
238
239        // GKE's kubelet-cadvisor series carry `pod`/`namespace` but NO
240        // `container` label — so the old `container!=""` filter matched nothing
241        // and every resource number came back 0. Each pod instead has one
242        // pod-level cgroup series (cgroup `id` ends in `.slice`) plus a
243        // per-container series (`id` ends in `.scope`). Match only the pod-level
244        // rollup to get the pod's true total (all containers + pause) without
245        // double-counting. The matcher is fully anchored, so `.*slice` keeps the
246        // `…pod<uid>.slice` rollup and drops the `…/cri-…scope` children
247        // (verified: pod-level ≈ Σ containers, vs ~2× when summing everything).
248
249        let cpu_query = format!(
250            "sum(rate(container_cpu_usage_seconds_total{{namespace=\"{namespace}\",pod=~\"{pod_re}\",id=~\".*slice\"}}[{win}s]) * {win})"
251        );
252        let rss_query = format!(
253            "max_over_time(sum(container_memory_working_set_bytes{{namespace=\"{namespace}\",pod=~\"{pod_re}\",id=~\".*slice\"}})[{win}s:15s])"
254        );
255
256        // An empty result vector is not a zero. It means the selector matched
257        // no series: a scrape that had not started, a renamed label, a wrong
258        // namespace. Recording it as zero publishes 0 bytes of memory as a
259        // real and exceptionally good result, and derives an efficiency figure
260        // from a CPU measurement that does not exist. Both queries below have
261        // already been wrong in exactly that way, as the comments above say, and
262        // the only symptom was a run of suspiciously good numbers. Fail instead:
263        // `workload::capture_resources` turns this into a
264        // `prometheus-capture-failed` note on the run.
265        let at = Some(measured_until_unix_s);
266        let broker_cpu = Time::from_secs_f64(require_series(
267            self.query_scalar_sum_at(&cpu_query, at).await?,
268            "broker CPU",
269            &cpu_query,
270        )?);
271        let mem_working = nonnegative_f64_to_u64(require_series(
272            self.query_scalar_sum_at(&rss_query, at).await?,
273            "broker memory",
274            &rss_query,
275        )?);
276
277        let mut res = Resource {
278            broker_cpu,
279            mem_cgroup_working_set: ByteSize::from_bytes(mem_working),
280            jvm_heap_used: None,
281            jvm_nonheap_used: None,
282            kafka_page_cache_approx: None,
283            // Messages per second of burnt CPU: the same count-over-window shape
284            // as a throughput, with CPU time standing in for wallclock. A run
285            // that burnt no CPU (nothing scraped) reports no rate.
286            msgs_per_cpu_second: event_rate(msgs_produced.0, broker_cpu),
287        };
288
289        if matches!(stack, Stack::Kafka) {
290            // Strimzi 0.46 ships the new Prometheus Java client (1.x), whose
291            // built-in JVM collector publishes `jvm_memory_used_bytes{area=...}`
292            // — note the `used_bytes` suffix order, NOT the legacy simpleclient
293            // `jvm_memory_bytes_used`. (Querying the old name returned empty, so
294            // the JVM heap/non-heap split was silently 0.) The `area` label is
295            // lowercase `heap`/`nonheap`, matching the selectors below.
296            let heap_q = format!(
297                "max_over_time(sum(jvm_memory_used_bytes{{namespace=\"{namespace}\",pod=~\"{pod_re}\",area=\"heap\"}})[{win}s:15s])"
298            );
299            let nonheap_q = format!(
300                "max_over_time(sum(jvm_memory_used_bytes{{namespace=\"{namespace}\",pod=~\"{pod_re}\",area=\"nonheap\"}})[{win}s:15s])"
301            );
302            // These three are already `Option`, so an empty vector has a way to
303            // say "not measured" without inventing a zero. A JVM that exports
304            // no heap series is a real state -- a metrics port that is not
305            // scraped yet, or a collector under another name -- and the page
306            // cache figure is derived from both, so it is unavailable whenever
307            // either half is.
308            let heap = self.query_scalar_sum_at(&heap_q, at).await?;
309            let nonheap = self.query_scalar_sum_at(&nonheap_q, at).await?;
310            res.jvm_heap_used = heap.map(|v| ByteSize::from_bytes(nonnegative_f64_to_u64(v)));
311            res.jvm_nonheap_used = nonheap.map(|v| ByteSize::from_bytes(nonnegative_f64_to_u64(v)));
312            res.kafka_page_cache_approx = heap.zip(nonheap).map(|(heap, nonheap)| {
313                let page_cache = mem_working
314                    .saturating_sub(nonnegative_f64_to_u64(heap))
315                    .saturating_sub(nonnegative_f64_to_u64(nonheap));
316                ByteSize::from_bytes(page_cache)
317            });
318        }
319
320        Ok(res)
321    }
322}
323
324/// The value of a query that must have matched a series, or an error naming
325/// the query that matched none.
326///
327/// This is the seam that keeps "no series" from becoming a zero. It is a free
328/// function so its rule is testable without an HTTP server.
329fn require_series(value: Option<f64>, what: &str, query: &str) -> Result<f64> {
330    value.ok_or_else(|| anyhow!("no series matched the {what} query: {query}"))
331}
332
333// ── Prometheus HTTP API types (minimal) ─────────────────────────────────────
334
335/// One point of a `PromQL` range query result on the step grid.
336#[derive(Debug, Clone, Copy, PartialEq)]
337pub struct PromPoint {
338    /// The point's timestamp in seconds since the Unix epoch.
339    pub unix_s: f64,
340    /// The summed value across the returned series at `unix_s`.
341    pub value: f64,
342}
343
344/// Join CPU and memory observations without inventing values for missing data.
345fn join_resource_series(
346    cpu: Vec<PromPoint>,
347    mem: Vec<PromPoint>,
348    range_start_ms: u64,
349) -> Result<Vec<BrokerSample>> {
350    let cpu: BTreeMap<_, _> = cpu
351        .into_iter()
352        .map(|PromPoint { unix_s, value }| {
353            (nonnegative_f64_to_u64((unix_s * 1000.0).round()), value)
354        })
355        .collect();
356    let mem: BTreeMap<_, _> = mem
357        .into_iter()
358        .map(|PromPoint { unix_s, value }| {
359            (
360                nonnegative_f64_to_u64((unix_s * 1000.0).round()),
361                nonnegative_f64_to_u64(value),
362            )
363        })
364        .collect();
365
366    if cpu.is_empty() {
367        return Err(anyhow!("no observations for broker CPU time series"));
368    }
369    if mem.is_empty() {
370        return Err(anyhow!("no observations for broker memory time series"));
371    }
372    if !cpu.keys().eq(mem.keys()) {
373        return Err(anyhow!(
374            "incomplete broker resource time series: CPU and memory observations are not aligned"
375        ));
376    }
377
378    Ok(cpu
379        .into_iter()
380        .zip(mem.into_values())
381        .map(|((ts_ms, cpu_cores), mem_bytes)| BrokerSample {
382            t_offset_ms: TimeOffsetMs(ts_ms.saturating_sub(range_start_ms)),
383            cpu_cores,
384            mem_working_set: ByteSize::from_bytes(mem_bytes),
385        })
386        .collect())
387}
388
389#[derive(Debug, Deserialize)]
390struct PromResp {
391    status: String,
392    #[serde(default)]
393    error: Option<String>,
394    #[serde(default)]
395    data: Option<PromData>,
396}
397
398#[derive(Debug, Deserialize)]
399struct PromData {
400    #[serde(default)]
401    result: Vec<PromResult>,
402}
403
404#[derive(Debug, Deserialize)]
405struct PromResult {
406    /// `[<unix-ts>, "<string-value>"]`. Prometheus encodes the scalar
407    /// portion as a JSON string. The timestamp is f64 in seconds.
408    #[serde(default)]
409    value: Option<(f64, String)>,
410    /// The matrix payload of a range query: `[[ts, "val"], ...]`.
411    #[serde(default)]
412    values: Option<Vec<(f64, String)>>,
413}
414
415#[cfg(test)]
416mod tests {
417    use {super::*, assert2::assert, tokio::task::JoinHandle};
418
419    async fn local_prometheus(responses: Vec<(u16, &'static str)>) -> (PromClient, JoinHandle<()>) {
420        use tokio::{
421            io::{AsyncReadExt, AsyncWriteExt},
422            net::TcpListener,
423        };
424
425        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
426        let address = listener.local_addr().unwrap();
427        let server = tokio::spawn(async move {
428            for (status, body) in responses {
429                let (mut socket, _) = listener.accept().await.unwrap();
430                let mut request = Vec::new();
431                let mut buffer = [0; 1024];
432                while !request.windows(4).any(|window| window == b"\r\n\r\n") {
433                    let count = socket.read(&mut buffer).await.unwrap();
434                    if count == 0 {
435                        break;
436                    }
437                    request.extend_from_slice(&buffer[..count]);
438                }
439                let reason = if status == 200 {
440                    "OK"
441                } else {
442                    "Internal Server Error"
443                };
444                let response = format!(
445                    "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
446                    body.len()
447                );
448                socket.write_all(response.as_bytes()).await.unwrap();
449            }
450        });
451        let client = PromClient::new(format!("http://{address}"), secs(1)).unwrap();
452        (client, server)
453    }
454
455    const CPU_ONE: &str = r#"{"status":"success","data":{"resultType":"matrix","result":[{"metric":{},"values":[[1000.0,"0"]]}]}}"#;
456    const MEMORY_ONE: &str = r#"{"status":"success","data":{"resultType":"matrix","result":[{"metric":{},"values":[[1000.0,"64"]]}]}}"#;
457    const EMPTY: &str = r#"{"status":"success","data":{"resultType":"matrix","result":[]}}"#;
458
459    #[test]
460    fn prometheus_request_timeout_constructs_prom_client() {
461        assert!(PromClient::new("http://prometheus.example", secs(1)).is_ok());
462    }
463
464    #[tokio::test]
465    async fn range_query_http_failure_is_reported() {
466        let (client, server) = local_prometheus(vec![(500, "")]).await;
467        let error = client
468            .capture_resource_series(Stack::Krabka, "ns", 1.0, 2.0, 15)
469            .await
470            .expect_err("HTTP failures must not become zero samples");
471        assert!(format!("{error:#}").contains("prometheus non-2xx (range)"));
472        server.abort();
473    }
474
475    #[tokio::test]
476    async fn empty_range_result_is_reported_as_missing_observations() {
477        let (client, server) = local_prometheus(vec![(200, EMPTY), (200, MEMORY_ONE)]).await;
478        let error = client
479            .capture_resource_series(Stack::Krabka, "ns", 1.0, 2.0, 15)
480            .await
481            .expect_err("an empty CPU matrix is missing data, not a zero measurement");
482        assert!(error.to_string().contains("no observations for broker CPU"));
483        server.await.unwrap();
484    }
485
486    #[tokio::test]
487    async fn unaligned_cpu_and_memory_timestamps_are_reported() {
488        let memory_at_next_step = r#"{"status":"success","data":{"resultType":"matrix","result":[{"metric":{},"values":[[1015.0,"64"]]}]}}"#;
489        let (client, server) =
490            local_prometheus(vec![(200, CPU_ONE), (200, memory_at_next_step)]).await;
491        let error = client
492            .capture_resource_series(Stack::Krabka, "ns", 1.0, 2.0, 15)
493            .await
494            .expect_err("unaligned observations must not create partial zero samples");
495        assert!(error.to_string().contains("observations are not aligned"));
496        server.await.unwrap();
497    }
498
499    #[tokio::test]
500    async fn aligned_zero_cpu_and_measured_memory_are_preserved() {
501        let (client, server) = local_prometheus(vec![(200, CPU_ONE), (200, MEMORY_ONE)]).await;
502        let samples = client
503            .capture_resource_series(Stack::Krabka, "ns", 1.0, 2.0, 15)
504            .await
505            .expect("aligned observations are complete");
506        assert!(samples.len() == 1);
507        assert2::assert!(samples[0].cpu_cores.to_bits() == 0);
508        assert!(samples[0].mem_working_set.bytes_u64() == 64);
509        server.await.unwrap();
510    }
511
512    /// A matched series keeps its value, whatever that value is. A real zero
513    /// is a measurement and passes through.
514    #[yare::parameterized(nonzero_measurement = { 12.5_f64 }, real_zero_measurement = { 0.0_f64 })]
515    fn require_series_passes_a_matched_value_through(value: f64) {
516        let kept = require_series(Some(value), "broker CPU", "q").expect("a matched series");
517        assert2::assert!((kept - value).abs() < f64::EPSILON);
518    }
519
520    /// An empty vector is not a zero. The error names the query, because the
521    /// cause is nearly always the selector.
522    #[test]
523    fn require_series_rejects_an_empty_vector() {
524        let error = require_series(None, "broker memory", "sum(container_memory{pod=~\"x.*\"})")
525            .expect_err("an empty vector is an error");
526        let message = format!("{error}");
527        assert!(message.contains("no series matched the broker memory query"));
528        assert!(message.contains("container_memory"));
529    }
530
531    #[test]
532    fn parses_success_with_one_result() {
533        let json = r#"{
534          "status": "success",
535          "data": {
536            "resultType": "vector",
537            "result": [
538              { "metric": {}, "value": [1234.0, "12.5"] }
539            ]
540          }
541        }"#;
542        let p: PromResp = serde_json::from_str(json).unwrap();
543        assert2::assert!(p.status == "success");
544        assert2::assert!(p.data.unwrap().result.len() == 1);
545    }
546
547    #[test]
548    fn parses_empty_result_set() {
549        let json = r#"{"status":"success","data":{"resultType":"vector","result":[]}}"#;
550        let p: PromResp = serde_json::from_str(json).unwrap();
551        assert2::assert!(p.data.unwrap().result.is_empty());
552    }
553
554    #[test]
555    fn parses_error_response() {
556        let json = r#"{"status":"error","error":"bad query"}"#;
557        let p: PromResp = serde_json::from_str(json).unwrap();
558        assert2::assert!(p.status == "error");
559        assert2::assert!(p.error.as_deref() == Some("bad query"));
560    }
561}