1use {
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
19pub 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 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 pub async fn query_scalar_sum(&self, query: &str) -> Result<Option<f64>> {
49 self.query_scalar_sum_at(query, None).await
50 }
51
52 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 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 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 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 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 let pod_re = format!("{}.*", stack.broker_pod_regex().trim_start_matches('^'));
234 let pod_re = pod_re.as_str();
235 let win = window.secs_i64().max(15);
238
239 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 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 msgs_per_cpu_second: event_rate(msgs_produced.0, broker_cpu),
287 };
288
289 if matches!(stack, Stack::Kafka) {
290 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 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
324fn 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#[derive(Debug, Clone, Copy, PartialEq)]
337pub struct PromPoint {
338 pub unix_s: f64,
340 pub value: f64,
342}
343
344fn 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 #[serde(default)]
409 value: Option<(f64, String)>,
410 #[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 #[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 #[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}