1use serde::Serialize;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use crate::telemetry::perf::PerfSpan;
8use crate::utils::async_line_writer::AsyncLineWriter;
9
10const TRAJECTORY_PREFIX: &str = "trajectory-";
11const TRAJECTORY_EXTENSION: &str = "jsonl";
12use super::SECONDS_PER_DAY;
13const BYTES_PER_MB: u64 = 1024 * 1024;
14
15#[derive(Clone)]
16pub struct TrajectoryLogger {
17 enabled: bool,
18 writer: Option<Arc<AsyncLineWriter>>,
19}
20
21#[derive(Debug, Clone, Copy)]
22pub struct TrajectoryRetention {
23 pub max_files: usize,
24 pub max_age_days: u64,
25 pub max_total_size_bytes: u64,
26}
27
28impl Default for TrajectoryRetention {
29 fn default() -> Self {
30 use vtcode_config::constants::defaults;
31 Self {
32 max_files: defaults::DEFAULT_TRAJECTORY_MAX_FILES,
33 max_age_days: defaults::DEFAULT_TRAJECTORY_MAX_AGE_DAYS,
34 max_total_size_bytes: defaults::DEFAULT_TRAJECTORY_MAX_SIZE_MB
35 .saturating_mul(BYTES_PER_MB),
36 }
37 }
38}
39
40impl TrajectoryLogger {
41 pub fn new(workspace: &Path) -> Self {
42 Self::with_retention(workspace, TrajectoryRetention::default())
43 }
44
45 pub fn with_retention(workspace: &Path, retention: TrajectoryRetention) -> Self {
46 let dir = workspace.join(".vtcode").join("logs");
47 rotate_current_trajectory(&dir);
48 prune_trajectory_logs_best_effort(&dir, retention);
49 let path = dir.join("trajectory.jsonl");
50 let writer = AsyncLineWriter::new(path).ok().map(Arc::new);
51 let enabled = writer.is_some();
52 Self { enabled, writer }
53 }
54
55 pub fn disabled() -> Self {
56 Self {
57 enabled: false,
58 writer: None,
59 }
60 }
61
62 pub fn log<T: Serialize>(&self, record: &T) {
63 if !self.enabled {
64 return;
65 }
66 let mut perf = PerfSpan::new("vtcode.perf.trajectory_log_ms");
67 perf.tag("mode", "async");
68 if let Ok(line) = serde_json::to_string(record)
69 && let Some(writer) = self.writer.as_ref()
70 {
71 writer.write_line(line);
72 }
73 }
74
75 #[cfg(test)]
76 pub fn flush(&self) {
77 if let Some(writer) = self.writer.as_ref() {
78 writer.flush();
79 }
80 }
81
82 pub fn log_route(&self, turn: usize, selected_model: &str, class: &str, input_preview: &str) {
83 #[derive(Serialize)]
84 struct RouteRec<'a> {
85 kind: &'static str,
86 turn: usize,
87 selected_model: &'a str,
88 class: &'a str,
89 input_preview: &'a str,
90 ts: i64,
91 }
92 let rec = RouteRec {
93 kind: "route",
94 turn,
95 selected_model,
96 class,
97 input_preview,
98 ts: chrono::Utc::now().timestamp(),
99 };
100 self.log(&rec);
101 }
102
103 pub fn log_tool_call(&self, turn: usize, name: &str, args: &serde_json::Value, ok: bool) {
104 #[derive(Serialize)]
105 struct ToolRec<'a> {
106 kind: &'static str,
107 turn: usize,
108 name: &'a str,
109 args: serde_json::Value,
110 ok: bool,
111 ts: i64,
112 }
113 let rec = ToolRec {
114 kind: "tool",
115 turn,
116 name,
117 args: args.clone(),
118 ok,
119 ts: chrono::Utc::now().timestamp(),
120 };
121 self.log(&rec);
122 }
123}
124
125fn rotate_current_trajectory(dir: &Path) {
126 let current = dir.join("trajectory.jsonl");
127 if !current.exists() {
128 return;
129 }
130 let metadata = match fs::metadata(¤t) {
131 Ok(m) => m,
132 Err(_) => return,
133 };
134 if metadata.len() == 0 {
135 return;
136 }
137 let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
138 let rotated_name = format!("{TRAJECTORY_PREFIX}{timestamp}.{TRAJECTORY_EXTENSION}");
139 let rotated_path = dir.join(rotated_name);
140 let _ = fs::rename(¤t, &rotated_path);
141}
142
143fn is_trajectory_file(path: &Path) -> bool {
144 let name = match path.file_name().and_then(|n| n.to_str()) {
145 Some(n) => n,
146 None => return false,
147 };
148 name.starts_with(TRAJECTORY_PREFIX) && name.ends_with(&format!(".{TRAJECTORY_EXTENSION}"))
149}
150
151struct FileEntry {
152 path: PathBuf,
153 modified: SystemTime,
154 size: u64,
155}
156
157fn prune_trajectory_logs_best_effort(dir: &Path, limits: TrajectoryRetention) {
158 if let Err(err) = prune_trajectory_logs(dir, limits) {
159 tracing::debug!(
160 "Failed to prune trajectory logs in {}: {}",
161 dir.display(),
162 err
163 );
164 }
165}
166
167fn prune_trajectory_logs(dir: &Path, limits: TrajectoryRetention) -> anyhow::Result<()> {
168 if !dir.exists() {
169 return Ok(());
170 }
171
172 let mut entries = Vec::new();
173 for entry in fs::read_dir(dir)? {
174 let entry = match entry {
175 Ok(e) => e,
176 Err(_) => continue,
177 };
178 let path = entry.path();
179 if !is_trajectory_file(&path) {
180 continue;
181 }
182 let metadata = match entry.metadata() {
183 Ok(m) if m.is_file() => m,
184 _ => continue,
185 };
186 entries.push(FileEntry {
187 path,
188 modified: metadata.modified().unwrap_or(UNIX_EPOCH),
189 size: metadata.len(),
190 });
191 }
192
193 if entries.is_empty() {
194 return Ok(());
195 }
196
197 let now = SystemTime::now();
198 let age_cutoff = if limits.max_age_days == 0 {
199 now
200 } else {
201 now.checked_sub(Duration::from_secs(
202 limits.max_age_days.saturating_mul(SECONDS_PER_DAY),
203 ))
204 .unwrap_or(UNIX_EPOCH)
205 };
206
207 let (expired, mut retained): (Vec<_>, Vec<_>) = entries
208 .into_iter()
209 .partition(|entry| entry.modified <= age_cutoff);
210 remove_files(expired);
211
212 retained.sort_by(|a, b| b.modified.cmp(&a.modified));
213
214 if limits.max_files > 0 && retained.len() > limits.max_files {
215 let overflow = retained.split_off(limits.max_files);
216 remove_files(overflow);
217 }
218
219 if limits.max_total_size_bytes == 0 || retained.is_empty() {
220 return Ok(());
221 }
222
223 let mut total_size = 0u64;
224 let mut size_overflow = Vec::new();
225 let mut keep = Vec::with_capacity(retained.len());
226 for entry in retained {
227 let projected = total_size.saturating_add(entry.size);
228 if keep.is_empty() || projected <= limits.max_total_size_bytes {
229 total_size = projected;
230 keep.push(entry);
231 } else {
232 size_overflow.push(entry);
233 }
234 }
235 remove_files(size_overflow);
236
237 Ok(())
238}
239
240fn remove_files(entries: Vec<FileEntry>) {
241 for entry in entries {
242 if let Err(err) = fs::remove_file(&entry.path) {
243 tracing::debug!(
244 "Failed to remove trajectory log {}: {}",
245 entry.path.display(),
246 err
247 );
248 }
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use tempfile::TempDir;
256
257 #[test]
258 fn test_trajectory_logger_log_route_integration() {
259 let temp_dir = TempDir::new().unwrap();
260 let logger = TrajectoryLogger::new(temp_dir.path());
261
262 logger.log_route(
263 1,
264 "gemini-3-flash-preview",
265 "standard",
266 "test user input for logging",
267 );
268 logger.flush();
269
270 let log_path = temp_dir.path().join(".vtcode/logs/trajectory.jsonl");
271 assert!(log_path.exists());
272
273 let content = fs::read_to_string(log_path).unwrap();
274 let lines: Vec<&str> = content.lines().collect();
275 assert_eq!(lines.len(), 1);
276
277 let record: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
278 assert_eq!(record["kind"], "route");
279 assert_eq!(record["turn"], 1);
280 assert_eq!(record["selected_model"], "gemini-3-flash-preview");
281 assert_eq!(record["class"], "standard");
282 assert_eq!(record["input_preview"], "test user input for logging");
283 assert!(record["ts"].is_number());
284 }
285
286 #[test]
287 fn test_rotation_renames_existing_log() {
288 let temp_dir = TempDir::new().unwrap();
289 let logs_dir = temp_dir.path().join(".vtcode").join("logs");
290 fs::create_dir_all(&logs_dir).unwrap();
291
292 let current = logs_dir.join("trajectory.jsonl");
293 fs::write(¤t, r#"{"kind":"route","turn":1}"#).unwrap();
294
295 let _logger = TrajectoryLogger::new(temp_dir.path());
296
297 let rotated: Vec<_> = fs::read_dir(&logs_dir)
298 .unwrap()
299 .filter_map(|e| e.ok())
300 .filter(|e| is_trajectory_file(&e.path()))
301 .collect();
302 assert_eq!(rotated.len(), 1, "Old log should be rotated");
303 assert!(current.exists(), "New current file should be created");
304 }
305
306 #[test]
307 fn test_prune_removes_old_files() {
308 let temp_dir = TempDir::new().unwrap();
309 let logs_dir = temp_dir.path().join(".vtcode").join("logs");
310 fs::create_dir_all(&logs_dir).unwrap();
311
312 for i in 0..5 {
313 let name = format!("trajectory-2024010{}T000000Z.jsonl", i);
314 fs::write(logs_dir.join(name), "data").unwrap();
315 }
316
317 let limits = TrajectoryRetention {
318 max_files: 3,
319 max_age_days: 0,
320 max_total_size_bytes: 100 * BYTES_PER_MB,
321 };
322
323 prune_trajectory_logs(&logs_dir, limits).unwrap();
324
325 let remaining: Vec<_> = fs::read_dir(&logs_dir)
326 .unwrap()
327 .filter_map(|e| e.ok())
328 .filter(|e| is_trajectory_file(&e.path()))
329 .collect();
330 assert!(remaining.len() <= 3, "Should keep at most 3 files");
331 }
332
333 #[test]
334 fn test_empty_trajectory_not_rotated() {
335 let temp_dir = TempDir::new().unwrap();
336 let logs_dir = temp_dir.path().join(".vtcode").join("logs");
337 fs::create_dir_all(&logs_dir).unwrap();
338
339 let current = logs_dir.join("trajectory.jsonl");
340 fs::write(¤t, "").unwrap();
341
342 let _logger = TrajectoryLogger::new(temp_dir.path());
343
344 let rotated: Vec<_> = fs::read_dir(&logs_dir)
345 .unwrap()
346 .filter_map(|e| e.ok())
347 .filter(|e| is_trajectory_file(&e.path()))
348 .collect();
349 assert_eq!(rotated.len(), 0, "Empty file should not be rotated");
350 }
351}