1mod 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
32pub 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 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 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 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 pub fn is_enabled(&self) -> bool {
107 self.enabled
108 }
109
110 pub fn session_dir(&self) -> Option<&Path> {
112 self.session_dir.as_deref()
113 }
114
115 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), }
127 } else {
128 Err(handler) }
130 }
131
132 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 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 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 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 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 assert!(dir_name.len() == 19); 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 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 let perf_file = collector.session_dir().unwrap().join("performance.jsonl");
266 assert!(perf_file.exists(), "performance.jsonl should exist");
267
268 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 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 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 let error_file = collector.session_dir().unwrap().join("errors.jsonl");
306 assert!(error_file.exists(), "errors.jsonl should exist");
307
308 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 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}