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