Skip to main content

dol/
collector.rs

1//! Background telemetry collection loop.
2//!
3//! Periodically collects Docker snapshots, metrics, and events and persists
4//! them into a `TelemetryStore`. Runs as a background tokio task.
5
6use std::sync::{Arc, Mutex};
7
8use tokio::sync::watch;
9
10use crate::{
11    docker::DockerClient,
12    metrics::MetricsCollector,
13    storage::{TelemetrySnapshot, TelemetryStore},
14};
15
16/// Configuration for the background collector.
17#[derive(Debug, Clone)]
18pub struct CollectorConfig {
19    /// How often to take a full snapshot (containers, images, networks, volumes), in seconds.
20    pub snapshot_interval_secs: u64,
21    /// How often to collect container metrics (CPU, memory, etc.), in seconds.
22    pub metrics_interval_secs: u64,
23}
24
25impl Default for CollectorConfig {
26    fn default() -> Self {
27        Self {
28            snapshot_interval_secs: crate::DEFAULT_SNAPSHOT_INTERVAL,
29            metrics_interval_secs: crate::DEFAULT_METRICS_INTERVAL,
30        }
31    }
32}
33
34/// Result from a single collection tick.
35#[derive(Debug, Default)]
36pub struct CollectionStats {
37    pub snapshots_written: u64,
38    pub metrics_written: u64,
39    pub errors: Vec<String>,
40}
41
42/// Spawn a background metrics collector that periodically writes to the store.
43///
44/// Returns a `watch::Receiver<CollectionStats>` that can be polled for progress
45/// and a `JoinHandle` to the background task.
46#[allow(clippy::needless_pass_by_value)]
47pub fn spawn_metrics_collector<S, C, M>(
48    store: Arc<Mutex<S>>,
49    docker: C,
50    metrics_collector: M,
51    config: CollectorConfig,
52    mut shutdown: watch::Receiver<bool>,
53) -> tokio::task::JoinHandle<()>
54where
55    S: TelemetryStore + Send + 'static,
56    C: DockerClient + Send + Sync + 'static,
57    M: MetricsCollector + Send + Sync + 'static,
58{
59    tokio::spawn(async move {
60        let mut metrics_interval =
61            tokio::time::interval(std::time::Duration::from_secs(config.metrics_interval_secs));
62        let mut snapshot_interval = tokio::time::interval(std::time::Duration::from_secs(
63            config.snapshot_interval_secs,
64        ));
65
66        // Tick immediately on start for the first collection.
67        metrics_interval.tick().await;
68        snapshot_interval.tick().await;
69
70        // Take initial snapshot.
71        collect_snapshot(&store, &docker).await;
72
73        loop {
74            tokio::select! {
75                _ = shutdown.changed(), if *shutdown.borrow() => {
76                    break;
77                }
78                _ = metrics_interval.tick() => {
79                    collect_metrics(&store, &metrics_collector).await;
80                }
81                _ = snapshot_interval.tick() => {
82                    collect_snapshot(&store, &docker).await;
83                }
84            }
85        }
86    })
87}
88
89async fn collect_metrics<S, M>(store: &Arc<Mutex<S>>, metrics_collector: &M)
90where
91    S: TelemetryStore,
92    M: MetricsCollector + ?Sized,
93{
94    match metrics_collector.collect().await {
95        Ok(samples) => {
96            if let Ok(mut store) = store.lock() {
97                for sample in samples {
98                    if let Err(e) = store.write_metric(sample) {
99                        eprintln!("[collector] metric write error: {e}");
100                    }
101                }
102            }
103        }
104        Err(e) => {
105            eprintln!("[collector] metrics collection error: {e}");
106        }
107    }
108}
109
110async fn collect_snapshot<S, C>(store: &Arc<Mutex<S>>, docker: &C)
111where
112    S: TelemetryStore,
113    C: DockerClient,
114{
115    let timestamp = chrono::Utc::now().to_rfc3339();
116    let containers = docker.list_containers().await.unwrap_or_default();
117    let images = docker.list_images().await.unwrap_or_default();
118    let networks = docker.list_networks().await.unwrap_or_default();
119    let volumes = docker.list_volumes().await.unwrap_or_default();
120
121    let snapshot = TelemetrySnapshot {
122        timestamp,
123        containers,
124        images,
125        networks,
126        volumes,
127    };
128
129    if let Ok(mut store) = store.lock()
130        && let Err(e) = store.write_snapshot(snapshot)
131    {
132        eprintln!("[collector] snapshot write error: {e}");
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::{
140        docker::MockDockerClient, metrics::MockMetricsCollector, storage::InMemoryTelemetryStore,
141    };
142
143    /// How long to wait for the background collector to run before checking results.
144    const COLLECTOR_WAIT_DURATION: std::time::Duration = std::time::Duration::from_millis(1500);
145
146    #[tokio::test]
147    async fn collector_writes_snapshot_and_metrics() {
148        let store = Arc::new(Mutex::new(InMemoryTelemetryStore::default()));
149        let docker = MockDockerClient {
150            containers: vec![crate::docker::Container {
151                id: "abc".to_owned(),
152                name: "api".to_owned(),
153                image: "api:latest".to_owned(),
154                status: "Up".to_owned(),
155                state: "running".to_owned(),
156                ports: Vec::new(),
157                labels: Vec::new(),
158                created_at: None,
159                started_at: None,
160                finished_at: None,
161                restart_count: Some(0),
162                health: None,
163            }],
164            ..Default::default()
165        };
166        let metrics = MockMetricsCollector {
167            samples: vec![crate::docker::MetricSample {
168                container_id: "abc".to_owned(),
169                container_name: "api".to_owned(),
170                timestamp: "2026-01-01T12:00:00Z".to_owned(),
171                cpu_percent: Some(50.0),
172                memory_usage_bytes: Some(128),
173                memory_limit_bytes: Some(1024),
174                network_rx_bytes: None,
175                network_tx_bytes: None,
176                disk_read_bytes: None,
177                disk_write_bytes: None,
178            }],
179        };
180
181        let (shutdown_tx, shutdown_rx) = watch::channel(false);
182        let config = CollectorConfig {
183            snapshot_interval_secs: 1,
184            metrics_interval_secs: 1,
185        };
186
187        let handle =
188            spawn_metrics_collector(Arc::clone(&store), docker, metrics, config, shutdown_rx);
189
190        // Wait a bit for collector to run.
191        tokio::time::sleep(COLLECTOR_WAIT_DURATION).await;
192
193        // Signal shutdown.
194        shutdown_tx.send(true).unwrap();
195        handle.await.unwrap();
196
197        // Verify data was written.
198        let store = store.lock().unwrap();
199        let latest = store.latest_metrics().unwrap();
200        assert!(!latest.is_empty());
201    }
202}