Skip to main content

ralph_core/diagnostics/
mod.rs

1//! Diagnostic logging system for Ralph orchestration.
2//!
3//! Captures agent output, orchestration decisions, traces, performance metrics,
4//! and errors to structured JSONL files when `RALPH_DIAGNOSTICS=1` is set.
5
6mod agent_output;
7mod errors;
8mod log_rotation;
9mod orchestration;
10mod performance;
11mod stream_handler;
12mod trace_layer;
13
14#[cfg(test)]
15mod integration_tests;
16
17pub use agent_output::{AgentOutputContent, AgentOutputEntry, AgentOutputLogger};
18pub use errors::{DiagnosticError, ErrorLogger};
19pub use log_rotation::{create_log_file, rotate_logs};
20pub use orchestration::{OrchestrationEvent, OrchestrationLogger};
21pub use performance::{PerformanceLogger, PerformanceMetric};
22pub use stream_handler::DiagnosticStreamHandler;
23pub use trace_layer::{DiagnosticTraceLayer, TraceEntry};
24
25use chrono::Local;
26use std::fs;
27use std::path::{Path, PathBuf};
28use std::sync::{Arc, Mutex};
29
30/// Central coordinator for diagnostic logging.
31///
32/// Checks `RALPH_DIAGNOSTICS` environment variable and creates a timestamped
33/// session directory if enabled.
34pub struct DiagnosticsCollector {
35    enabled: bool,
36    session_dir: Option<PathBuf>,
37    orchestration_logger: Option<Arc<Mutex<orchestration::OrchestrationLogger>>>,
38    performance_logger: Option<Arc<Mutex<performance::PerformanceLogger>>>,
39    error_logger: Option<Arc<Mutex<errors::ErrorLogger>>>,
40}
41
42impl DiagnosticsCollector {
43    /// Creates a new diagnostics collector.
44    ///
45    /// If `RALPH_DIAGNOSTICS=1`, creates `.ralph/diagnostics/<timestamp>/` directory.
46    pub fn new(base_path: &Path) -> std::io::Result<Self> {
47        let enabled = std::env::var("RALPH_DIAGNOSTICS")
48            .map(|v| v == "1")
49            .unwrap_or(false);
50
51        Self::with_enabled(base_path, enabled)
52    }
53
54    /// Creates a diagnostics collector with explicit enabled flag (for testing).
55    pub fn with_enabled(base_path: &Path, enabled: bool) -> std::io::Result<Self> {
56        let (session_dir, orchestration_logger, performance_logger, error_logger) = if enabled {
57            let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S");
58            let dir = base_path
59                .join(".ralph")
60                .join("diagnostics")
61                .join(timestamp.to_string());
62            fs::create_dir_all(&dir)?;
63
64            let orch_logger = orchestration::OrchestrationLogger::new(&dir)?;
65            let perf_logger = performance::PerformanceLogger::new(&dir)?;
66            let err_logger = errors::ErrorLogger::new(&dir)?;
67            (
68                Some(dir),
69                Some(Arc::new(Mutex::new(orch_logger))),
70                Some(Arc::new(Mutex::new(perf_logger))),
71                Some(Arc::new(Mutex::new(err_logger))),
72            )
73        } else {
74            (None, None, None, None)
75        };
76
77        Ok(Self {
78            enabled,
79            session_dir,
80            orchestration_logger,
81            performance_logger,
82            error_logger,
83        })
84    }
85
86    /// Creates a disabled diagnostics collector without any I/O (for testing).
87    pub fn disabled() -> Self {
88        Self {
89            enabled: false,
90            session_dir: None,
91            orchestration_logger: None,
92            performance_logger: None,
93            error_logger: None,
94        }
95    }
96
97    /// Returns whether diagnostics are enabled.
98    pub fn is_enabled(&self) -> bool {
99        self.enabled
100    }
101
102    /// Returns the session directory if diagnostics are enabled.
103    pub fn session_dir(&self) -> Option<&Path> {
104        self.session_dir.as_deref()
105    }
106
107    /// Wraps a stream handler with diagnostic logging.
108    ///
109    /// Returns the original handler if diagnostics are disabled.
110    pub fn wrap_stream_handler<H>(&self, handler: H) -> Result<DiagnosticStreamHandler<H>, H> {
111        if let Some(session_dir) = &self.session_dir {
112            match AgentOutputLogger::new(session_dir) {
113                Ok(logger) => {
114                    let logger = Arc::new(Mutex::new(logger));
115                    Ok(DiagnosticStreamHandler::new(handler, logger))
116                }
117                Err(_) => Err(handler), // Return original handler on error
118            }
119        } else {
120            Err(handler) // Diagnostics disabled, return original
121        }
122    }
123
124    /// Logs an orchestration event.
125    ///
126    /// Does nothing if diagnostics are disabled.
127    pub fn log_orchestration(&self, iteration: u32, hat: &str, event: OrchestrationEvent) {
128        if let Some(logger) = &self.orchestration_logger
129            && let Ok(mut logger) = logger.lock()
130        {
131            let _ = logger.log(iteration, hat, event);
132        }
133    }
134
135    /// Logs a performance metric.
136    ///
137    /// Does nothing if diagnostics are disabled.
138    pub fn log_performance(&self, iteration: u32, hat: &str, metric: PerformanceMetric) {
139        if let Some(logger) = &self.performance_logger
140            && let Ok(mut logger) = logger.lock()
141        {
142            let _ = logger.log(iteration, hat, metric);
143        }
144    }
145
146    /// Logs an error.
147    ///
148    /// Does nothing if diagnostics are disabled.
149    pub fn log_error(&self, iteration: u32, hat: &str, error: DiagnosticError) {
150        if let Some(logger) = &self.error_logger
151            && let Ok(mut logger) = logger.lock()
152        {
153            logger.set_context(iteration, hat);
154            logger.log(error);
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use tempfile::TempDir;
163
164    #[test]
165    fn test_diagnostics_disabled_by_default() {
166        let temp = TempDir::new().unwrap();
167
168        let collector = DiagnosticsCollector::with_enabled(temp.path(), false).unwrap();
169
170        assert!(!collector.is_enabled());
171        assert!(collector.session_dir().is_none());
172    }
173
174    #[test]
175    fn test_diagnostics_enabled_creates_directory() {
176        let temp = TempDir::new().unwrap();
177
178        let collector = DiagnosticsCollector::with_enabled(temp.path(), true).unwrap();
179
180        assert!(collector.is_enabled());
181        assert!(collector.session_dir().is_some());
182        assert!(collector.session_dir().unwrap().exists());
183    }
184
185    #[test]
186    fn test_session_directory_format() {
187        let temp = TempDir::new().unwrap();
188
189        let collector = DiagnosticsCollector::with_enabled(temp.path(), true).unwrap();
190
191        let session_dir = collector.session_dir().unwrap();
192        let dir_name = session_dir.file_name().unwrap().to_str().unwrap();
193
194        // Verify format: YYYY-MM-DDTHH-MM-SS
195        assert!(dir_name.len() == 19); // 2024-01-21T08-49-56
196        assert!(dir_name.chars().nth(4) == Some('-'));
197        assert!(dir_name.chars().nth(7) == Some('-'));
198        assert!(dir_name.chars().nth(10) == Some('T'));
199        assert!(dir_name.chars().nth(13) == Some('-'));
200        assert!(dir_name.chars().nth(16) == Some('-'));
201    }
202
203    #[test]
204    fn test_performance_logger_integration() {
205        let temp = TempDir::new().unwrap();
206        let collector = DiagnosticsCollector::with_enabled(temp.path(), true).unwrap();
207
208        // Log some performance metrics
209        collector.log_performance(
210            1,
211            "ralph",
212            PerformanceMetric::IterationDuration { duration_ms: 1500 },
213        );
214        collector.log_performance(
215            1,
216            "builder",
217            PerformanceMetric::AgentLatency { duration_ms: 800 },
218        );
219        collector.log_performance(
220            1,
221            "builder",
222            PerformanceMetric::TokenCount {
223                input: 1000,
224                output: 500,
225            },
226        );
227
228        // Verify file exists
229        let perf_file = collector.session_dir().unwrap().join("performance.jsonl");
230        assert!(perf_file.exists(), "performance.jsonl should exist");
231
232        // Verify content
233        let content = std::fs::read_to_string(perf_file).unwrap();
234        let lines: Vec<_> = content.lines().collect();
235        assert_eq!(lines.len(), 3, "Should have 3 performance entries");
236
237        // Verify each line is valid JSON
238        for line in lines {
239            let _: performance::PerformanceEntry = serde_json::from_str(line).unwrap();
240        }
241    }
242
243    #[test]
244    fn test_error_logger_integration() {
245        let temp = TempDir::new().unwrap();
246        let collector = DiagnosticsCollector::with_enabled(temp.path(), true).unwrap();
247
248        // Log some errors
249        collector.log_error(
250            1,
251            "ralph",
252            DiagnosticError::ParseError {
253                source: "agent_output".to_string(),
254                message: "Invalid JSON".to_string(),
255                input: "{invalid".to_string(),
256            },
257        );
258        collector.log_error(
259            2,
260            "builder",
261            DiagnosticError::ValidationFailure {
262                rule: "tests_required".to_string(),
263                message: "Missing test evidence".to_string(),
264                evidence: "tests: missing".to_string(),
265            },
266        );
267
268        // Verify file exists
269        let error_file = collector.session_dir().unwrap().join("errors.jsonl");
270        assert!(error_file.exists(), "errors.jsonl should exist");
271
272        // Verify content
273        let content = std::fs::read_to_string(error_file).unwrap();
274        let lines: Vec<_> = content.lines().collect();
275        assert_eq!(lines.len(), 2, "Should have 2 error entries");
276
277        // Verify each line is valid JSON
278        for line in lines {
279            let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
280            assert!(parsed.get("error_type").is_some());
281            assert!(parsed.get("message").is_some());
282            assert!(parsed.get("context").is_some());
283        }
284    }
285}