1use std::path::Path;
17
18use rusqlite::{Connection, params};
19use serde_json;
20
21use crate::{
22 docker::{DockerEvent, MetricSample},
23 storage::{TelemetryError, TelemetrySnapshot, TelemetryStore},
24};
25
26#[derive(Debug, Clone)]
28pub struct RetentionPolicy {
29 pub metric_max_age_secs: u64,
31 pub event_max_age_secs: u64,
33 pub snapshot_max_age_secs: u64,
35}
36
37impl Default for RetentionPolicy {
38 fn default() -> Self {
39 Self {
40 metric_max_age_secs: 7 * 24 * 3600, event_max_age_secs: 30 * 24 * 3600, snapshot_max_age_secs: 30 * 24 * 3600, }
44 }
45}
46
47pub struct SqliteTelemetryStore {
53 conn: Connection,
54 retention: RetentionPolicy,
55}
56
57impl SqliteTelemetryStore {
58 pub fn open(path: impl AsRef<Path>) -> Result<Self, TelemetryError> {
60 Self::open_with_retention(path, RetentionPolicy::default())
61 }
62
63 pub fn open_with_retention(
65 path: impl AsRef<Path>,
66 retention: RetentionPolicy,
67 ) -> Result<Self, TelemetryError> {
68 let conn = Connection::open(path).map_err(|e| TelemetryError::Storage(e.to_string()))?;
69 let store = Self { conn, retention };
70 store.init_schema()?;
71 Ok(store)
72 }
73
74 pub fn in_memory() -> Result<Self, TelemetryError> {
76 let conn =
77 Connection::open_in_memory().map_err(|e| TelemetryError::Storage(e.to_string()))?;
78 let store = Self {
79 conn,
80 retention: RetentionPolicy::default(),
81 };
82 store.init_schema()?;
83 Ok(store)
84 }
85
86 pub const fn set_retention(&mut self, retention: RetentionPolicy) {
88 self.retention = retention;
89 }
90
91 pub fn apply_retention(&mut self) -> Result<RetentionStats, TelemetryError> {
93 let now = chrono::Utc::now();
94 let metric_cutoff =
95 now - chrono::Duration::seconds(self.retention.metric_max_age_secs as i64);
96 let event_cutoff =
97 now - chrono::Duration::seconds(self.retention.event_max_age_secs as i64);
98 let snapshot_cutoff =
99 now - chrono::Duration::seconds(self.retention.snapshot_max_age_secs as i64);
100
101 let metric_cutoff_str = metric_cutoff.to_rfc3339();
102 let event_cutoff_str = event_cutoff.to_rfc3339();
103 let snapshot_cutoff_str = snapshot_cutoff.to_rfc3339();
104
105 let metrics_deleted = self
106 .conn
107 .execute(
108 "DELETE FROM metrics WHERE timestamp < ?1",
109 params![metric_cutoff_str],
110 )
111 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
112
113 let events_deleted = self
114 .conn
115 .execute(
116 "DELETE FROM events WHERE time < ?1",
117 params![event_cutoff_str],
118 )
119 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
120
121 let snapshots_deleted = self
122 .conn
123 .execute(
124 "DELETE FROM snapshots WHERE timestamp < ?1",
125 params![snapshot_cutoff_str],
126 )
127 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
128
129 Ok(RetentionStats {
130 metrics_deleted,
131 events_deleted,
132 snapshots_deleted,
133 })
134 }
135
136 pub fn stats(&self) -> Result<StoreStats, TelemetryError> {
138 let metric_count: i64 = self
139 .conn
140 .query_row("SELECT COUNT(*) FROM metrics", [], |row| row.get(0))
141 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
142 let event_count: i64 = self
143 .conn
144 .query_row("SELECT COUNT(*) FROM events", [], |row| row.get(0))
145 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
146 let snapshot_count: i64 = self
147 .conn
148 .query_row("SELECT COUNT(*) FROM snapshots", [], |row| row.get(0))
149 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
150
151 Ok(StoreStats {
152 metric_count: metric_count as u64,
153 event_count: event_count as u64,
154 snapshot_count: snapshot_count as u64,
155 })
156 }
157
158 fn init_schema(&self) -> Result<(), TelemetryError> {
159 self.conn
160 .execute_batch(
161 "
162 CREATE TABLE IF NOT EXISTS metrics (
163 id INTEGER PRIMARY KEY AUTOINCREMENT,
164 container_id TEXT NOT NULL,
165 container_name TEXT NOT NULL,
166 timestamp TEXT NOT NULL,
167 cpu_percent REAL,
168 memory_usage INTEGER,
169 memory_limit INTEGER,
170 network_rx INTEGER,
171 network_tx INTEGER,
172 disk_read INTEGER,
173 disk_write INTEGER
174 );
175
176 CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp);
177 CREATE INDEX IF NOT EXISTS idx_metrics_container ON metrics(container_id);
178
179 CREATE TABLE IF NOT EXISTS events (
180 id INTEGER PRIMARY KEY AUTOINCREMENT,
181 time TEXT NOT NULL,
182 event_type TEXT NOT NULL,
183 action TEXT NOT NULL,
184 actor_id TEXT NOT NULL,
185 container TEXT,
186 image TEXT,
187 attributes TEXT NOT NULL
188 );
189
190 CREATE INDEX IF NOT EXISTS idx_events_time ON events(time);
191 CREATE INDEX IF NOT EXISTS idx_events_action ON events(action);
192
193 CREATE TABLE IF NOT EXISTS snapshots (
194 id INTEGER PRIMARY KEY AUTOINCREMENT,
195 timestamp TEXT NOT NULL,
196 data TEXT NOT NULL
197 );
198
199 CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON snapshots(timestamp);
200
201 CREATE TABLE IF NOT EXISTS alert_history (
202 id INTEGER PRIMARY KEY AUTOINCREMENT,
203 timestamp TEXT NOT NULL,
204 container_id TEXT NOT NULL,
205 container_name TEXT NOT NULL,
206 rule_condition TEXT NOT NULL,
207 action_type TEXT NOT NULL,
208 action_detail TEXT NOT NULL,
209 success INTEGER NOT NULL DEFAULT 1
210 );
211
212 CREATE INDEX IF NOT EXISTS idx_alert_history_time ON alert_history(timestamp);
213 CREATE INDEX IF NOT EXISTS idx_alert_history_container ON alert_history(container_id);
214 ",
215 )
216 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
217 Ok(())
218 }
219}
220
221impl TelemetryStore for SqliteTelemetryStore {
222 fn write_metric(&mut self, sample: MetricSample) -> Result<(), TelemetryError> {
223 self.conn
224 .execute(
225 "INSERT INTO metrics (container_id, container_name, timestamp, cpu_percent, memory_usage, memory_limit, network_rx, network_tx, disk_read, disk_write)
226 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
227 params![
228 sample.container_id,
229 sample.container_name,
230 sample.timestamp,
231 sample.cpu_percent,
232 sample.memory_usage_bytes.map(|v| v as i64),
233 sample.memory_limit_bytes.map(|v| v as i64),
234 sample.network_rx_bytes.map(|v| v as i64),
235 sample.network_tx_bytes.map(|v| v as i64),
236 sample.disk_read_bytes.map(|v| v as i64),
237 sample.disk_write_bytes.map(|v| v as i64),
238 ],
239 )
240 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
241 Ok(())
242 }
243
244 fn latest_metrics(&self) -> Result<Vec<MetricSample>, TelemetryError> {
245 let mut stmt = self
246 .conn
247 .prepare(
248 "SELECT m.container_id, m.container_name, m.timestamp,
249 m.cpu_percent, m.memory_usage, m.memory_limit,
250 m.network_rx, m.network_tx, m.disk_read, m.disk_write
251 FROM metrics m
252 INNER JOIN (
253 SELECT container_id, MAX(timestamp) as max_ts
254 FROM metrics
255 GROUP BY container_id
256 ) latest ON m.container_id = latest.container_id AND m.timestamp = latest.max_ts",
257 )
258 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
259
260 let rows = stmt
261 .query_map([], |row| {
262 Ok(MetricSample {
263 container_id: row.get(0)?,
264 container_name: row.get(1)?,
265 timestamp: row.get(2)?,
266 cpu_percent: row.get(3)?,
267 memory_usage_bytes: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
268 memory_limit_bytes: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
269 network_rx_bytes: row.get::<_, Option<i64>>(6)?.map(|v| v as u64),
270 network_tx_bytes: row.get::<_, Option<i64>>(7)?.map(|v| v as u64),
271 disk_read_bytes: row.get::<_, Option<i64>>(8)?.map(|v| v as u64),
272 disk_write_bytes: row.get::<_, Option<i64>>(9)?.map(|v| v as u64),
273 })
274 })
275 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
276
277 rows.collect::<Result<Vec<_>, _>>()
278 .map_err(|e| TelemetryError::Storage(e.to_string()))
279 }
280
281 fn metrics_between(&self, from: &str, to: &str) -> Result<Vec<MetricSample>, TelemetryError> {
282 let mut stmt = self
283 .conn
284 .prepare(
285 "SELECT container_id, container_name, timestamp,
286 cpu_percent, memory_usage, memory_limit,
287 network_rx, network_tx, disk_read, disk_write
288 FROM metrics
289 WHERE timestamp >= ?1 AND timestamp <= ?2
290 ORDER BY timestamp ASC",
291 )
292 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
293
294 let rows = stmt
295 .query_map(params![from, to], |row| {
296 Ok(MetricSample {
297 container_id: row.get(0)?,
298 container_name: row.get(1)?,
299 timestamp: row.get(2)?,
300 cpu_percent: row.get(3)?,
301 memory_usage_bytes: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
302 memory_limit_bytes: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
303 network_rx_bytes: row.get::<_, Option<i64>>(6)?.map(|v| v as u64),
304 network_tx_bytes: row.get::<_, Option<i64>>(7)?.map(|v| v as u64),
305 disk_read_bytes: row.get::<_, Option<i64>>(8)?.map(|v| v as u64),
306 disk_write_bytes: row.get::<_, Option<i64>>(9)?.map(|v| v as u64),
307 })
308 })
309 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
310
311 rows.collect::<Result<Vec<_>, _>>()
312 .map_err(|e| TelemetryError::Storage(e.to_string()))
313 }
314
315 fn write_event(&mut self, event: DockerEvent) -> Result<(), TelemetryError> {
316 let attributes_json =
317 serde_json::to_string(&event.attributes).unwrap_or_else(|_| "[]".to_owned());
318
319 self.conn
320 .execute(
321 "INSERT INTO events (time, event_type, action, actor_id, container, image, attributes)
322 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
323 params![
324 event.time,
325 event.event_type,
326 event.action,
327 event.actor_id,
328 event.container,
329 event.image,
330 attributes_json,
331 ],
332 )
333 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
334 Ok(())
335 }
336
337 fn events_between(&self, from: &str, to: &str) -> Result<Vec<DockerEvent>, TelemetryError> {
338 let mut stmt = self
339 .conn
340 .prepare(
341 "SELECT time, event_type, action, actor_id, container, image, attributes
342 FROM events
343 WHERE time >= ?1 AND time <= ?2
344 ORDER BY time ASC",
345 )
346 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
347
348 let rows = stmt
349 .query_map(params![from, to], |row| {
350 let attributes_json: String = row.get(6)?;
351 let attributes: Vec<(String, String)> =
352 serde_json::from_str(&attributes_json).unwrap_or_default();
353 Ok(DockerEvent {
354 time: row.get(0)?,
355 event_type: row.get(1)?,
356 action: row.get(2)?,
357 actor_id: row.get(3)?,
358 container: row.get(4)?,
359 image: row.get(5)?,
360 attributes,
361 })
362 })
363 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
364
365 rows.collect::<Result<Vec<_>, _>>()
366 .map_err(|e| TelemetryError::Storage(e.to_string()))
367 }
368
369 fn write_snapshot(&mut self, snapshot: TelemetrySnapshot) -> Result<(), TelemetryError> {
370 let data =
371 serde_json::to_string(&snapshot).map_err(|e| TelemetryError::Storage(e.to_string()))?;
372
373 self.conn
374 .execute(
375 "INSERT INTO snapshots (timestamp, data) VALUES (?1, ?2)",
376 params![snapshot.timestamp, data],
377 )
378 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
379 Ok(())
380 }
381
382 fn snapshot_at_or_before(
383 &self,
384 timestamp: &str,
385 ) -> Result<Option<TelemetrySnapshot>, TelemetryError> {
386 let result: Option<String> = self
387 .conn
388 .query_row(
389 "SELECT data FROM snapshots WHERE timestamp <= ?1 ORDER BY timestamp DESC LIMIT 1",
390 params![timestamp],
391 |row| row.get(0),
392 )
393 .optional()
394 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
395
396 match result {
397 Some(data) => {
398 let snapshot: TelemetrySnapshot = serde_json::from_str(&data)
399 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
400 Ok(Some(snapshot))
401 }
402 None => Ok(None),
403 }
404 }
405
406 fn all_snapshots(&self) -> Result<Vec<TelemetrySnapshot>, TelemetryError> {
407 let mut stmt = self
408 .conn
409 .prepare("SELECT data FROM snapshots ORDER BY timestamp ASC")
410 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
411
412 let rows = stmt
413 .query_map([], |row| {
414 let data: String = row.get(0)?;
415 let snapshot: TelemetrySnapshot = serde_json::from_str(&data)
416 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
417 Ok(snapshot)
418 })
419 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
420
421 rows.collect::<Result<Vec<_>, _>>()
422 .map_err(|e| TelemetryError::Storage(e.to_string()))
423 }
424
425 fn write_alert_event(
426 &mut self,
427 event: crate::storage::AlertHistoryEvent,
428 ) -> Result<(), TelemetryError> {
429 self.conn
430 .execute(
431 "INSERT INTO alert_history (timestamp, container_id, container_name, rule_condition, action_type, action_detail, success)
432 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
433 params![
434 event.timestamp,
435 event.container_id,
436 event.container_name,
437 event.rule_condition,
438 event.action_type,
439 event.action_detail,
440 i32::from(event.success),
441 ],
442 )
443 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
444 Ok(())
445 }
446
447 fn alert_history(
448 &self,
449 from: &str,
450 to: &str,
451 ) -> Result<Vec<crate::storage::AlertHistoryEvent>, TelemetryError> {
452 let mut stmt = self
453 .conn
454 .prepare(
455 "SELECT timestamp, container_id, container_name, rule_condition, action_type, action_detail, success
456 FROM alert_history
457 WHERE timestamp >= ?1 AND timestamp <= ?2
458 ORDER BY timestamp DESC",
459 )
460 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
461
462 let rows = stmt
463 .query_map(params![from, to], |row| {
464 let success_int: i32 = row.get(6)?;
465 Ok(crate::storage::AlertHistoryEvent {
466 timestamp: row.get(0)?,
467 container_id: row.get(1)?,
468 container_name: row.get(2)?,
469 rule_condition: row.get(3)?,
470 action_type: row.get(4)?,
471 action_detail: row.get(5)?,
472 success: success_int != 0,
473 })
474 })
475 .map_err(|e| TelemetryError::Storage(e.to_string()))?;
476
477 rows.collect::<Result<Vec<_>, _>>()
478 .map_err(|e| TelemetryError::Storage(e.to_string()))
479 }
480}
481
482#[derive(Debug, Clone)]
484pub struct RetentionStats {
485 pub metrics_deleted: usize,
486 pub events_deleted: usize,
487 pub snapshots_deleted: usize,
488}
489
490#[derive(Debug, Clone)]
492pub struct StoreStats {
493 pub metric_count: u64,
494 pub event_count: u64,
495 pub snapshot_count: u64,
496}
497
498trait OptionalExt<T> {
500 fn optional(self) -> Result<Option<T>, rusqlite::Error>;
501}
502
503impl<T> OptionalExt<T> for Result<T, rusqlite::Error> {
504 fn optional(self) -> Result<Option<T>, rusqlite::Error> {
505 match self {
506 Ok(value) => Ok(Some(value)),
507 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
508 Err(e) => Err(e),
509 }
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::docker::Container;
517
518 #[test]
519 fn sqlite_stores_and_reads_metrics() {
520 let mut store = SqliteTelemetryStore::in_memory().unwrap();
521
522 store
523 .write_metric(sample("abc", "api", "2026-01-01T12:00:00Z", 85.0))
524 .unwrap();
525 store
526 .write_metric(sample("abc", "api", "2026-01-01T12:01:00Z", 90.0))
527 .unwrap();
528 store
529 .write_metric(sample("def", "worker", "2026-01-01T12:00:00Z", 20.0))
530 .unwrap();
531
532 let latest = store.latest_metrics().unwrap();
533 assert_eq!(latest.len(), 2);
534
535 let between = store
536 .metrics_between("2026-01-01T12:00:00Z", "2026-01-01T12:00:30Z")
537 .unwrap();
538 assert_eq!(between.len(), 2); }
540
541 #[test]
542 fn sqlite_stores_and_reads_events() {
543 let mut store = SqliteTelemetryStore::in_memory().unwrap();
544
545 store
546 .write_event(event("2026-01-01T12:00:00Z", "start"))
547 .unwrap();
548 store
549 .write_event(event("2026-01-01T12:05:00Z", "die"))
550 .unwrap();
551 store
552 .write_event(event("2026-01-01T13:00:00Z", "restart"))
553 .unwrap();
554
555 let events = store
556 .events_between("2026-01-01T12:00:00Z", "2026-01-01T12:59:59Z")
557 .unwrap();
558 assert_eq!(events.len(), 2);
559 assert_eq!(events[0].action, "start");
560 assert_eq!(events[1].action, "die");
561 }
562
563 #[test]
564 fn sqlite_stores_and_reads_snapshots() {
565 let mut store = SqliteTelemetryStore::in_memory().unwrap();
566
567 store
568 .write_snapshot(snapshot("2026-01-01 11:59:00", "old-image"))
569 .unwrap();
570 store
571 .write_snapshot(snapshot("2026-01-01 12:00:00", "new-image"))
572 .unwrap();
573
574 let snap = store
576 .snapshot_at_or_before("2026-01-01 12:00:00")
577 .unwrap()
578 .unwrap();
579 assert_eq!(snap.containers[0].image, "new-image");
580
581 let snap = store
583 .snapshot_at_or_before("2026-01-01 12:00:30")
584 .unwrap()
585 .unwrap();
586 assert_eq!(snap.containers[0].image, "new-image");
587
588 let snap = store
590 .snapshot_at_or_before("2026-01-01 11:59:30")
591 .unwrap()
592 .unwrap();
593 assert_eq!(snap.containers[0].image, "old-image");
594
595 let snap = store.snapshot_at_or_before("2026-01-01 11:00:00").unwrap();
597 assert!(snap.is_none());
598 }
599
600 #[test]
601 fn sqlite_inspect_at_works() {
602 let mut store = SqliteTelemetryStore::in_memory().unwrap();
603
604 store
605 .write_snapshot(snapshot("2026-01-01 12:00:00", "api:v2"))
606 .unwrap();
607
608 let query = crate::ast::InspectQuery {
609 target: crate::ast::SingularTarget {
610 kind: crate::ast::SingularTargetKind::Container,
611 value: "api".to_owned(),
612 },
613 at: Some("2026-01-01 12:00:30".to_owned()),
614 };
615
616 let result = crate::storage::inspect_at(&query, &store).unwrap();
617 assert_eq!(result.rows.len(), 1);
618 assert_eq!(
619 result.rows[0].fields["image"],
620 serde_json::Value::String("api:v2".to_owned())
621 );
622 }
623
624 #[test]
625 fn sqlite_historical_events_works() {
626 let mut store = SqliteTelemetryStore::in_memory().unwrap();
627
628 store
629 .write_event(event("2026-01-01T12:00:00Z", "start"))
630 .unwrap();
631 store
632 .write_event(event("2026-01-01T12:05:00Z", "die"))
633 .unwrap();
634
635 let query = crate::ast::EventsQuery {
636 target: crate::ast::CollectionTarget::Containers,
637 time: Some(crate::ast::TimeSelector::Range {
638 from: "2026-01-01T12:00:00Z".to_owned(),
639 to: "2026-01-01T12:10:00Z".to_owned(),
640 }),
641 filter: Some(crate::ast::Expression::Comparison {
642 left: Box::new(crate::ast::Expression::Field("action".to_owned())),
643 operator: crate::ast::Operator::Eq,
644 right: Box::new(crate::ast::Expression::Literal(crate::ast::Value::String(
645 "die".to_owned(),
646 ))),
647 }),
648 pipeline: vec![],
649 };
650
651 let rt = tokio::runtime::Runtime::new().unwrap();
652 let result = rt
653 .block_on(crate::storage::historical_events(&query, &store))
654 .unwrap();
655 assert_eq!(result.rows.len(), 1);
656 assert_eq!(
657 result.rows[0].fields["action"],
658 serde_json::Value::String("die".to_owned())
659 );
660 }
661
662 #[test]
663 fn sqlite_retention_deletes_old_data() {
664 let mut store = SqliteTelemetryStore::in_memory().unwrap();
665 store.set_retention(RetentionPolicy {
666 metric_max_age_secs: 0, event_max_age_secs: 0,
668 snapshot_max_age_secs: 0,
669 });
670
671 store
672 .write_metric(sample("abc", "api", "2020-01-01T00:00:00Z", 50.0))
673 .unwrap();
674 store
675 .write_event(event("2020-01-01T00:00:00Z", "start"))
676 .unwrap();
677 store
678 .write_snapshot(snapshot("2020-01-01 00:00:00", "old"))
679 .unwrap();
680
681 let stats_before = store.stats().unwrap();
682 assert_eq!(stats_before.metric_count, 1);
683 assert_eq!(stats_before.event_count, 1);
684 assert_eq!(stats_before.snapshot_count, 1);
685
686 let retention_stats = store.apply_retention().unwrap();
687 assert_eq!(retention_stats.metrics_deleted, 1);
688 assert_eq!(retention_stats.events_deleted, 1);
689 assert_eq!(retention_stats.snapshots_deleted, 1);
690
691 let stats_after = store.stats().unwrap();
692 assert_eq!(stats_after.metric_count, 0);
693 assert_eq!(stats_after.event_count, 0);
694 assert_eq!(stats_after.snapshot_count, 0);
695 }
696
697 #[test]
698 fn sqlite_store_stats() {
699 let mut store = SqliteTelemetryStore::in_memory().unwrap();
700
701 store
702 .write_metric(sample("abc", "api", "2026-01-01T12:00:00Z", 50.0))
703 .unwrap();
704 store
705 .write_metric(sample("def", "worker", "2026-01-01T12:00:00Z", 30.0))
706 .unwrap();
707 store
708 .write_event(event("2026-01-01T12:00:00Z", "start"))
709 .unwrap();
710
711 let stats = store.stats().unwrap();
712 assert_eq!(stats.metric_count, 2);
713 assert_eq!(stats.event_count, 1);
714 assert_eq!(stats.snapshot_count, 0);
715 }
716
717 #[test]
718 fn sqlite_preserves_event_attributes() {
719 let mut store = SqliteTelemetryStore::in_memory().unwrap();
720
721 let ev = DockerEvent {
722 time: "2026-01-01T12:00:00Z".to_owned(),
723 event_type: "container".to_owned(),
724 action: "start".to_owned(),
725 actor_id: "abc".to_owned(),
726 container: Some("api".to_owned()),
727 image: Some("api:latest".to_owned()),
728 attributes: vec![
729 ("name".to_owned(), "api".to_owned()),
730 ("signal".to_owned(), "15".to_owned()),
731 ],
732 };
733 store.write_event(ev).unwrap();
734
735 let events = store
736 .events_between("2026-01-01T00:00:00Z", "2026-01-01T23:59:59Z")
737 .unwrap();
738 assert_eq!(events.len(), 1);
739 assert_eq!(events[0].attributes.len(), 2);
740 assert_eq!(
741 events[0].attributes[0],
742 ("name".to_owned(), "api".to_owned())
743 );
744 }
745
746 fn sample(id: &str, name: &str, ts: &str, cpu: f64) -> MetricSample {
747 MetricSample {
748 container_id: id.to_owned(),
749 container_name: name.to_owned(),
750 timestamp: ts.to_owned(),
751 cpu_percent: Some(cpu),
752 memory_usage_bytes: Some(128),
753 memory_limit_bytes: Some(1024),
754 network_rx_bytes: Some(10),
755 network_tx_bytes: Some(20),
756 disk_read_bytes: Some(30),
757 disk_write_bytes: Some(40),
758 }
759 }
760
761 fn event(time: &str, action: &str) -> DockerEvent {
762 DockerEvent {
763 time: time.to_owned(),
764 event_type: "container".to_owned(),
765 action: action.to_owned(),
766 actor_id: "abc".to_owned(),
767 container: Some("api".to_owned()),
768 image: Some("api:latest".to_owned()),
769 attributes: vec![("name".to_owned(), "api".to_owned())],
770 }
771 }
772
773 fn snapshot(timestamp: &str, image: &str) -> TelemetrySnapshot {
774 TelemetrySnapshot {
775 timestamp: timestamp.to_owned(),
776 containers: vec![Container {
777 id: "abc".to_owned(),
778 name: "api".to_owned(),
779 image: image.to_owned(),
780 status: "Up".to_owned(),
781 state: "running".to_owned(),
782 ports: Vec::new(),
783 labels: Vec::new(),
784 created_at: None,
785 started_at: None,
786 finished_at: None,
787 restart_count: Some(1),
788 health: None,
789 }],
790 images: Vec::new(),
791 networks: Vec::new(),
792 volumes: Vec::new(),
793 }
794 }
795}