Skip to main content

symbi_runtime/metrics/
file.rs

1//! File-based metrics exporter.
2//!
3//! Writes JSON snapshots atomically using `tempfile` + rename to prevent
4//! partial reads by monitoring tools.
5
6use super::{FileMetricsConfig, MetricsError, MetricsExporter, MetricsSnapshot};
7use async_trait::async_trait;
8use std::path::PathBuf;
9
10/// Exports metrics snapshots as JSON files using atomic writes.
11pub struct FileExporter {
12    path: PathBuf,
13    pretty_print: bool,
14}
15
16impl FileExporter {
17    /// Create a new file exporter, ensuring the parent directory exists.
18    pub fn new(config: FileMetricsConfig) -> Result<Self, MetricsError> {
19        if let Some(parent) = config.path.parent() {
20            std::fs::create_dir_all(parent).map_err(|e| {
21                MetricsError::ConfigError(format!(
22                    "Failed to create metrics directory {}: {}",
23                    parent.display(),
24                    e
25                ))
26            })?;
27        }
28        Ok(Self {
29            path: config.path,
30            pretty_print: config.pretty_print,
31        })
32    }
33}
34
35#[async_trait]
36impl MetricsExporter for FileExporter {
37    async fn export(&self, snapshot: &MetricsSnapshot) -> Result<(), MetricsError> {
38        let json = if self.pretty_print {
39            serde_json::to_string_pretty(snapshot)?
40        } else {
41            serde_json::to_string(snapshot)?
42        };
43
44        let path = self.path.clone();
45
46        // Perform the atomic write on a blocking thread to avoid blocking the runtime.
47        tokio::task::spawn_blocking(move || -> Result<(), MetricsError> {
48            use std::io::Write;
49
50            let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
51            let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
52            tmp.write_all(json.as_bytes())?;
53            tmp.flush()?;
54            tmp.persist(&path).map_err(|e| {
55                MetricsError::ExportFailed(format!(
56                    "Failed to persist metrics file {}: {}",
57                    path.display(),
58                    e
59                ))
60            })?;
61            Ok(())
62        })
63        .await
64        .map_err(|e| MetricsError::ExportFailed(format!("Blocking task panicked: {}", e)))??;
65
66        tracing::debug!("Metrics snapshot written to {}", self.path.display());
67        Ok(())
68    }
69
70    async fn shutdown(&self) -> Result<(), MetricsError> {
71        Ok(())
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::metrics::{
79        LoadBalancerMetrics, SchedulerMetrics, SystemResourceMetrics, TaskManagerMetrics,
80    };
81
82    fn sample_snapshot() -> MetricsSnapshot {
83        MetricsSnapshot {
84            timestamp: 1700000000,
85            scheduler: SchedulerMetrics {
86                total_scheduled: 10,
87                uptime_seconds: 3600,
88                running_agents: 5,
89                queued_agents: 3,
90                suspended_agents: 2,
91                max_capacity: 1000,
92                load_factor: 0.005,
93            },
94            task_manager: TaskManagerMetrics {
95                total_tasks: 5,
96                healthy_tasks: 4,
97                average_uptime_seconds: 1800.0,
98                total_memory_usage: 1024,
99            },
100            load_balancer: LoadBalancerMetrics {
101                total_allocations: 100,
102                active_allocations: 5,
103                memory_utilization: 0.45,
104                cpu_utilization: 0.30,
105                allocation_failures: 2,
106                average_allocation_time_ms: 1.5,
107            },
108            system: SystemResourceMetrics {
109                memory_usage_mb: 512.0,
110                cpu_usage_percent: 30.0,
111            },
112            compaction: None,
113        }
114    }
115
116    #[tokio::test]
117    async fn test_file_exporter_write_and_read() {
118        let dir = tempfile::tempdir().unwrap();
119        let path = dir.path().join("metrics.json");
120
121        let exporter = FileExporter::new(FileMetricsConfig {
122            path: path.clone(),
123            pretty_print: true,
124        })
125        .unwrap();
126
127        let snapshot = sample_snapshot();
128        exporter.export(&snapshot).await.unwrap();
129
130        let content = std::fs::read_to_string(&path).unwrap();
131        let loaded: MetricsSnapshot = serde_json::from_str(&content).unwrap();
132        assert_eq!(loaded.timestamp, 1700000000);
133        assert_eq!(loaded.scheduler.running_agents, 5);
134    }
135
136    #[tokio::test]
137    async fn test_file_exporter_creates_parent_dirs() {
138        let dir = tempfile::tempdir().unwrap();
139        let path = dir.path().join("nested").join("deep").join("metrics.json");
140
141        let exporter = FileExporter::new(FileMetricsConfig {
142            path: path.clone(),
143            pretty_print: false,
144        })
145        .unwrap();
146
147        let snapshot = sample_snapshot();
148        exporter.export(&snapshot).await.unwrap();
149        assert!(path.exists());
150    }
151
152    #[tokio::test]
153    async fn test_file_exporter_compact_json() {
154        let dir = tempfile::tempdir().unwrap();
155        let path = dir.path().join("compact.json");
156
157        let exporter = FileExporter::new(FileMetricsConfig {
158            path: path.clone(),
159            pretty_print: false,
160        })
161        .unwrap();
162
163        let snapshot = sample_snapshot();
164        exporter.export(&snapshot).await.unwrap();
165
166        let content = std::fs::read_to_string(&path).unwrap();
167        // Compact JSON contains no newlines.
168        assert!(!content.trim().contains('\n'));
169    }
170
171    #[tokio::test]
172    async fn test_file_exporter_shutdown() {
173        let dir = tempfile::tempdir().unwrap();
174        let path = dir.path().join("shutdown.json");
175
176        let exporter = FileExporter::new(FileMetricsConfig {
177            path,
178            pretty_print: true,
179        })
180        .unwrap();
181
182        assert!(exporter.shutdown().await.is_ok());
183    }
184
185    #[tokio::test]
186    async fn test_file_exporter_overwrite() {
187        let dir = tempfile::tempdir().unwrap();
188        let path = dir.path().join("overwrite.json");
189
190        let exporter = FileExporter::new(FileMetricsConfig {
191            path: path.clone(),
192            pretty_print: false,
193        })
194        .unwrap();
195
196        let mut snapshot = sample_snapshot();
197        exporter.export(&snapshot).await.unwrap();
198
199        // Overwrite with different data.
200        snapshot.timestamp = 1700000001;
201        snapshot.scheduler.running_agents = 42;
202        exporter.export(&snapshot).await.unwrap();
203
204        let content = std::fs::read_to_string(&path).unwrap();
205        let loaded: MetricsSnapshot = serde_json::from_str(&content).unwrap();
206        assert_eq!(loaded.timestamp, 1700000001);
207        assert_eq!(loaded.scheduler.running_agents, 42);
208    }
209}