1use std::collections::{HashMap, VecDeque};
17use std::sync::Arc;
18use std::time::Duration;
19
20use thiserror::Error;
21use tokio::time::timeout;
22
23use crate::config::DolConfig;
24use crate::docker::{DockerClient, DockerError, MetricSample};
25
26pub trait MetricsCollector {
27 fn collect(
28 &self,
29 ) -> impl std::future::Future<Output = Result<Vec<MetricSample>, MetricsError>> + Send;
30}
31
32#[derive(Debug, Error)]
33pub enum MetricsError {
34 #[error("{0}")]
35 Docker(#[from] DockerError),
36 #[error("invalid docker stats field `{field}`: {value}")]
37 InvalidStatsField { field: &'static str, value: String },
38 #[error("tokio runtime error: {0}")]
39 Runtime(String),
40}
41
42#[derive(Debug, Clone)]
47pub struct BollardMetricsCollector<C> {
48 docker: Arc<C>,
49 stats_timeout: Duration,
50}
51
52impl<C> BollardMetricsCollector<C>
53where
54 C: DockerClient + Send + Sync + 'static,
55{
56 pub const fn new(docker: Arc<C>) -> Self {
58 Self {
59 docker,
60 stats_timeout: Duration::from_secs(10),
61 }
62 }
63
64 #[must_use]
66 pub fn with_config(docker: Arc<C>, config: &DolConfig) -> Self {
67 Self {
68 docker,
69 stats_timeout: Duration::from_secs(config.stats_timeout.unwrap_or(10)),
70 }
71 }
72
73 pub fn set_stats_timeout(&mut self, timeout: Duration) {
75 self.stats_timeout = timeout;
76 }
77}
78
79impl<C> MetricsCollector for BollardMetricsCollector<C>
80where
81 C: DockerClient + Send + Sync + 'static,
82{
83 async fn collect(&self) -> Result<Vec<MetricSample>, MetricsError> {
84 let containers = self
85 .docker
86 .list_containers()
87 .await
88 .map_err(MetricsError::Docker)?;
89
90 let mut samples = Vec::new();
91 for container in &containers {
92 let id_short = &container.id[..12.min(container.id.len())];
93 let id = id_short.to_owned();
94 match timeout(self.stats_timeout, self.docker.container_stats(&id)).await {
95 Ok(Ok(sample)) => samples.push(sample),
96 Ok(Err(e)) => {
97 eprintln!("[metrics] failed to get stats for {}: {e}", container.name);
98 }
99 Err(_) => {
100 eprintln!(
101 "[metrics] stats call timed out after {}s for {}",
102 self.stats_timeout.as_secs(),
103 container.name
104 );
105 }
106 }
107 }
108 Ok(samples)
109 }
110}
111
112#[derive(Debug, Clone, Default)]
113pub struct MockMetricsCollector {
114 pub samples: Vec<MetricSample>,
115}
116
117impl MetricsCollector for MockMetricsCollector {
118 async fn collect(&self) -> Result<Vec<MetricSample>, MetricsError> {
119 Ok(self.samples.clone())
120 }
121}
122
123#[derive(Debug, Clone, Default)]
124pub struct NoopMetricsCollector;
125
126impl MetricsCollector for NoopMetricsCollector {
127 async fn collect(&self) -> Result<Vec<MetricSample>, MetricsError> {
128 Ok(Vec::new())
129 }
130}
131
132#[derive(Debug, Clone, Default)]
133pub struct MetricRingBuffer {
134 samples: VecDeque<MetricSample>,
135 capacity: usize,
136}
137
138impl MetricRingBuffer {
139 #[must_use]
140 pub fn new(capacity: usize) -> Self {
141 Self {
142 samples: VecDeque::with_capacity(capacity),
143 capacity,
144 }
145 }
146
147 pub fn push(&mut self, sample: MetricSample) {
148 if self.capacity == 0 {
149 return;
150 }
151
152 while self.samples.len() >= self.capacity {
153 self.samples.pop_front();
154 }
155 self.samples.push_back(sample);
156 }
157
158 #[must_use]
159 pub fn latest_by_container(&self) -> HashMap<String, MetricSample> {
160 let mut latest = HashMap::new();
161 for sample in &self.samples {
162 latest.insert(sample.container_id.clone(), sample.clone());
163 latest.insert(sample.container_name.clone(), sample.clone());
164 }
165 latest
166 }
167}
168
169#[allow(clippy::result_unit_err)]
171pub fn parse_percent(value: &str) -> Result<f64, ()> {
172 value.trim().trim_end_matches('%').parse().map_err(|_| ())
173}
174
175#[allow(clippy::result_unit_err)]
177pub fn parse_usage_pair(value: &str) -> Result<(Option<u64>, Option<u64>), ()> {
178 let mut parts = value.split('/').map(str::trim);
179 let left = parts.next().ok_or(())?;
180 let right = parts.next();
181
182 Ok((
183 parse_byte_quantity(left)?,
184 right.map(parse_byte_quantity).transpose()?.flatten(),
185 ))
186}
187
188#[allow(clippy::result_unit_err)]
189pub fn parse_byte_quantity(value: &str) -> Result<Option<u64>, ()> {
190 let value = value.trim();
191 if value.is_empty() || value == "--" {
192 return Ok(None);
193 }
194
195 let split_at = value
196 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
197 .unwrap_or(value.len());
198 let number = value[..split_at].trim().parse::<f64>().map_err(|_| ())?;
199 let unit = value[split_at..].trim().to_ascii_lowercase();
200 let multiplier = match unit.as_str() {
201 "" | "b" => 1.0,
202 "kb" => 1_000.0,
203 "mb" => 1_000_000.0,
204 "gb" => 1_000_000_000.0,
205 "tb" => 1_000_000_000_000.0,
206 "kib" => 1024.0,
207 "mib" => 1024.0 * 1024.0,
208 "gib" => 1024.0 * 1024.0 * 1024.0,
209 "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
210 _ => return Err(()),
211 };
212
213 Ok(Some((number * multiplier).round() as u64))
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn parses_percent() {
222 assert_eq!(parse_percent("85.42%"), Ok(85.42));
223 }
224
225 #[test]
226 fn parses_byte_quantities() {
227 assert_eq!(parse_byte_quantity("1KiB"), Ok(Some(1024)));
228 assert_eq!(parse_byte_quantity("1.5MB"), Ok(Some(1_500_000)));
229 assert_eq!(parse_byte_quantity("--"), Ok(None));
230 }
231
232 #[test]
233 fn parses_usage_pairs() {
234 assert_eq!(
235 parse_usage_pair("12.5MiB / 1GiB"),
236 Ok((Some(13_107_200), Some(1_073_741_824)))
237 );
238 }
239
240 #[tokio::test]
241 async fn mock_collector_works() {
242 let collector = MockMetricsCollector {
243 samples: vec![MetricSample {
244 container_id: "abc".to_owned(),
245 container_name: "api".to_owned(),
246 timestamp: "2026-01-01T12:00:00Z".to_owned(),
247 cpu_percent: Some(50.0),
248 memory_usage_bytes: Some(128),
249 memory_limit_bytes: Some(1024),
250 network_rx_bytes: None,
251 network_tx_bytes: None,
252 disk_read_bytes: None,
253 disk_write_bytes: None,
254 }],
255 };
256 let samples = collector.collect().await.unwrap();
257 assert_eq!(samples.len(), 1);
258 assert_eq!(samples[0].container_name, "api");
259 }
260
261 #[tokio::test]
262 async fn noop_collector_returns_empty() {
263 let samples = NoopMetricsCollector.collect().await.unwrap();
264 assert!(samples.is_empty());
265 }
266
267 #[test]
268 fn ring_buffer_keeps_latest_samples_by_container() {
269 let mut buffer = MetricRingBuffer::new(1);
270 buffer.push(MetricSample {
271 container_id: "abc".to_owned(),
272 container_name: "api".to_owned(),
273 timestamp: "t1".to_owned(),
274 cpu_percent: Some(10.0),
275 memory_usage_bytes: None,
276 memory_limit_bytes: None,
277 network_rx_bytes: None,
278 network_tx_bytes: None,
279 disk_read_bytes: None,
280 disk_write_bytes: None,
281 });
282 buffer.push(MetricSample {
283 container_id: "abc".to_owned(),
284 container_name: "api".to_owned(),
285 timestamp: "t2".to_owned(),
286 cpu_percent: Some(20.0),
287 memory_usage_bytes: None,
288 memory_limit_bytes: None,
289 network_rx_bytes: None,
290 network_tx_bytes: None,
291 disk_read_bytes: None,
292 disk_write_bytes: None,
293 });
294
295 let latest = buffer.latest_by_container();
296
297 assert_eq!(latest["abc"].cpu_percent, Some(20.0));
298 assert_eq!(latest["api"].cpu_percent, Some(20.0));
299 }
300}