Skip to main content

opendev_runtime/
debug_logger.rs

1//! Per-session structured debug logger.
2//!
3//! Writes JSONL events to `~/.opendev/sessions/{session_id}.debug` when
4//! verbose mode is enabled. Each line is a JSON object:
5//! ```json
6//! {"ts": "...", "elapsed_ms": 123, "event": "llm_call_start", "component": "react", "data": {...}}
7//! ```
8//!
9//! Thread-safe via `Mutex`. Use [`SessionDebugLogger::noop()`] for zero-cost
10//! disabled logging.
11
12use std::path::{Path, PathBuf};
13use std::sync::Mutex;
14use std::time::Instant;
15
16use serde_json::Value;
17
18/// Maximum length for string values in event data.
19const MAX_PREVIEW_LEN: usize = 200;
20
21/// Truncate a serde_json::Value's string fields if too long.
22fn truncate_value(value: &Value) -> Value {
23    match value {
24        Value::String(s) if s.len() > MAX_PREVIEW_LEN => {
25            let total = s.len();
26            Value::String(format!("{}... ({total} chars)", &s[..MAX_PREVIEW_LEN]))
27        }
28        Value::Object(map) => {
29            let truncated: serde_json::Map<String, Value> = map
30                .iter()
31                .map(|(k, v)| (k.clone(), truncate_value(v)))
32                .collect();
33            Value::Object(truncated)
34        }
35        Value::Array(arr) => Value::Array(arr.iter().map(truncate_value).collect()),
36        other => other.clone(),
37    }
38}
39
40/// Per-session structured debug logger.
41pub struct SessionDebugLogger {
42    inner: Option<LoggerInner>,
43}
44
45struct LoggerInner {
46    file_path: PathBuf,
47    start_time: Instant,
48    lock: Mutex<()>,
49}
50
51impl SessionDebugLogger {
52    /// Create a new debug logger writing to `{session_dir}/{session_id}.debug`.
53    pub fn new(session_dir: &Path, session_id: &str) -> Self {
54        let file_path = session_dir.join(format!("{session_id}.debug"));
55
56        // Ensure directory exists
57        if let Some(parent) = file_path.parent() {
58            let _ = std::fs::create_dir_all(parent);
59        }
60
61        Self {
62            inner: Some(LoggerInner {
63                file_path,
64                start_time: Instant::now(),
65                lock: Mutex::new(()),
66            }),
67        }
68    }
69
70    /// Create a no-op logger that discards all events (zero overhead).
71    pub fn noop() -> Self {
72        Self { inner: None }
73    }
74
75    /// Whether this logger is active.
76    pub fn is_enabled(&self) -> bool {
77        self.inner.is_some()
78    }
79
80    /// Path to the debug log file, if active.
81    pub fn file_path(&self) -> Option<&Path> {
82        self.inner.as_ref().map(|i| i.file_path.as_path())
83    }
84
85    /// Log a structured event.
86    ///
87    /// # Arguments
88    /// - `event` — Event type (e.g., `"llm_call_start"`, `"tool_call_end"`)
89    /// - `component` — Component name (e.g., `"react"`, `"tool"`, `"llm"`)
90    /// - `data` — Arbitrary JSON data (string values truncated if too long)
91    pub fn log(&self, event: &str, component: &str, data: Value) {
92        let inner = match &self.inner {
93            Some(i) => i,
94            None => return,
95        };
96
97        let elapsed_ms = inner.start_time.elapsed().as_millis() as u64;
98        let ts = chrono::Utc::now().to_rfc3339();
99
100        let truncated_data = truncate_value(&data);
101
102        let entry = serde_json::json!({
103            "ts": ts,
104            "elapsed_ms": elapsed_ms,
105            "event": event,
106            "component": component,
107            "data": truncated_data,
108        });
109
110        let line = match serde_json::to_string(&entry) {
111            Ok(s) => format!("{s}\n"),
112            Err(_) => return,
113        };
114
115        let _guard = inner.lock.lock().ok();
116        let _ = std::fs::OpenOptions::new()
117            .create(true)
118            .append(true)
119            .open(&inner.file_path)
120            .and_then(|mut f| {
121                use std::io::Write;
122                f.write_all(line.as_bytes())
123            });
124    }
125}
126
127impl std::fmt::Debug for SessionDebugLogger {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("SessionDebugLogger")
130            .field("enabled", &self.is_enabled())
131            .field("file_path", &self.file_path())
132            .finish()
133    }
134}
135
136#[cfg(test)]
137#[path = "debug_logger_tests.rs"]
138mod tests;