1use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::fs::{self, File};
5use std::io::{BufRead as _, BufReader};
6use std::path::{Path, PathBuf};
7
8use serde::Serialize;
9use serde_json::Value;
10use time::{format_description::well_known::Rfc3339, Duration, OffsetDateTime};
11
12use crate::errors::{Result, WinxError};
13
14const MAX_REPORT_EVENTS: usize = 100_000;
15
16#[derive(Debug, Serialize)]
17#[serde(rename_all = "camelCase")]
18pub struct UsageReport {
19 pub base_path: String,
20 pub source_files: usize,
21 pub lines_seen: u64,
22 pub invalid_lines: u64,
23 pub events: usize,
24 pub first_timestamp: Option<String>,
25 pub last_timestamp: Option<String>,
26 pub tool_calls: usize,
27 pub http_requests: usize,
28 pub correlated_requests: usize,
29 pub tool_calls_without_http: usize,
30 pub http_without_tool_call: usize,
31 pub duplicate_tool_request_ids: usize,
32 pub http_statuses: BTreeMap<String, u64>,
33 pub result_statuses: BTreeMap<String, u64>,
34 pub error_codes: BTreeMap<String, u64>,
35 pub command_kinds: BTreeMap<String, u64>,
36 pub build_identities: BTreeMap<String, u64>,
37 pub workspace_coherence: BTreeMap<String, u64>,
38 pub tools: BTreeMap<String, ToolUsage>,
39 pub latency: LatencySummary,
40 pub http_overhead: LatencySummary,
41 pub response_bytes: u64,
42 pub initialize: InitializeUsage,
43 pub recovery: RecoveryUsage,
44 pub trace_audit: TraceAudit,
45}
46
47#[derive(Debug, Default, Serialize)]
48#[serde(rename_all = "camelCase")]
49pub struct ToolUsage {
50 pub calls: u64,
51 pub error_results: u64,
52 pub response_bytes: u64,
53 pub latency: LatencySummary,
54}
55
56#[derive(Clone, Debug, Default, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct LatencySummary {
59 pub samples: usize,
60 pub average_ms: u64,
61 pub p50_ms: u64,
62 pub p95_ms: u64,
63 pub max_ms: u64,
64}
65
66#[derive(Debug, Default, Serialize)]
67#[serde(rename_all = "camelCase")]
68pub struct InitializeUsage {
69 pub calls: u64,
70 pub reused: u64,
71 pub response_bytes: u64,
72 pub transitions: BTreeMap<String, u64>,
73}
74
75#[derive(Debug, Default, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct RecoveryUsage {
78 pub next_actions: u64,
79 pub required_reads: u64,
80 pub repeated_attempts: u64,
81 pub escalated: u64,
82 pub mutation_receipts_persisted: u64,
83}
84
85#[derive(Debug, Default, Serialize)]
86#[serde(rename_all = "camelCase")]
87pub struct TraceAudit {
88 pub healthy: bool,
89 pub violations: Vec<TraceViolation>,
90 pub workspace_coherence_rejections: u64,
91 pub audited_tool_events: u64,
92 pub legacy_unverifiable_tool_events: u64,
93}
94
95#[derive(Debug, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct TraceViolation {
98 pub request_id: String,
99 pub rule: &'static str,
100}
101
102#[derive(Debug)]
103struct UsageEvent {
104 timestamp: Option<String>,
105 fields: serde_json::Map<String, Value>,
106}
107
108#[derive(Default)]
109struct ToolAccumulator {
110 calls: u64,
111 error_results: u64,
112 response_bytes: u64,
113 durations: Vec<u64>,
114}
115
116pub fn usage_report(
119 path: Option<&Path>,
120 last_events: usize,
121 since_minutes: u64,
122 build_filter: Option<&str>,
123) -> Result<UsageReport> {
124 if last_events == 0 || last_events > MAX_REPORT_EVENTS {
125 return Err(WinxError::InvalidInput(format!(
126 "--last must be between 1 and {MAX_REPORT_EVENTS}"
127 )));
128 }
129 let base = path.map_or_else(default_usage_path, Path::to_path_buf);
130 let sources = usage_sources(&base)?;
131 let cutoff = (since_minutes > 0)
132 .then(|| OffsetDateTime::now_utc() - Duration::minutes(i64_saturating(since_minutes)));
133 let mut recent = VecDeque::with_capacity(last_events.min(4096));
134 let mut lines_seen = 0_u64;
135 let mut invalid_lines = 0_u64;
136 for source in &sources {
137 let reader =
138 BufReader::new(File::open(source).map_err(|error| WinxError::FileAccessError {
139 path: source.clone(),
140 message: format!("opening usage log: {error}"),
141 })?);
142 for line in reader.lines() {
143 lines_seen = lines_seen.saturating_add(1);
144 let Ok(line) = line else {
145 invalid_lines = invalid_lines.saturating_add(1);
146 continue;
147 };
148 let Some(event) = parse_event(&line) else {
149 invalid_lines = invalid_lines.saturating_add(1);
150 continue;
151 };
152 if cutoff.is_some_and(|cutoff| {
153 event
154 .timestamp
155 .as_deref()
156 .and_then(parse_timestamp)
157 .is_some_and(|timestamp| timestamp < cutoff)
158 }) {
159 continue;
160 }
161 if build_filter.is_some_and(|filter| {
162 ![string(&event.fields, "build"), string(&event.fields, "build_version")]
163 .contains(&filter)
164 }) {
165 continue;
166 }
167 if recent.len() == last_events {
168 recent.pop_front();
169 }
170 recent.push_back(event);
171 }
172 }
173 Ok(aggregate(&base, sources.len(), lines_seen, invalid_lines, &recent))
174}
175
176#[derive(Default)]
177struct ReportAccumulator {
178 result_statuses: BTreeMap<String, u64>,
179 error_codes: BTreeMap<String, u64>,
180 command_kinds: BTreeMap<String, u64>,
181 build_identities: BTreeMap<String, u64>,
182 workspace_coherence: BTreeMap<String, u64>,
183 http_statuses: BTreeMap<String, u64>,
184 tools: BTreeMap<String, ToolAccumulator>,
185 initialize: InitializeUsage,
186 recovery: RecoveryUsage,
187 trace_audit: TraceAudit,
188 all_durations: Vec<u64>,
189 tool_request_ids: BTreeSet<String>,
190 duplicate_tool_request_ids: usize,
191 http_request_ids: BTreeSet<String>,
192 tool_durations: BTreeMap<String, u64>,
193 http_durations: BTreeMap<String, u64>,
194 tool_calls: usize,
195 http_requests: usize,
196 response_bytes: u64,
197}
198
199impl ReportAccumulator {
200 fn record(&mut self, fields: &serde_json::Map<String, Value>) {
201 match string(fields, "event") {
202 "tool_call" => self.record_tool(fields),
203 "http_request" => self.record_http(fields),
204 _ => {}
205 }
206 }
207
208 fn record_tool(&mut self, fields: &serde_json::Map<String, Value>) {
209 self.tool_calls += 1;
210 let tool = string(fields, "tool").to_string();
211 increment_nonempty(&mut self.result_statuses, string(fields, "result_status"));
212 increment_nonempty(&mut self.error_codes, string(fields, "error_code"));
213 increment_nonempty(&mut self.command_kinds, string(fields, "command_kind"));
214 increment_nonempty(&mut self.build_identities, string(fields, "build"));
215 increment_nonempty(&mut self.workspace_coherence, string(fields, "workspace_coherence"));
216 let bytes = number(fields, "response_bytes");
217 let duration = number(fields, "duration_ms");
218 self.response_bytes = self.response_bytes.saturating_add(bytes);
219 self.all_durations.push(duration);
220
221 let accumulator = self.tools.entry(tool.clone()).or_default();
222 accumulator.calls = accumulator.calls.saturating_add(1);
223 accumulator.error_results = accumulator
224 .error_results
225 .saturating_add(u64::from(!string(fields, "error_code").is_empty()));
226 accumulator.response_bytes = accumulator.response_bytes.saturating_add(bytes);
227 accumulator.durations.push(duration);
228
229 let request_id = string(fields, "request_id");
230 if !request_id.is_empty() {
231 if !self.tool_request_ids.insert(request_id.to_string()) {
232 self.duplicate_tool_request_ids += 1;
233 }
234 self.tool_durations.insert(request_id.to_string(), duration);
235 }
236 if tool == "Initialize" {
237 self.initialize.calls = self.initialize.calls.saturating_add(1);
238 self.initialize.response_bytes = self.initialize.response_bytes.saturating_add(bytes);
239 self.initialize.reused = self
240 .initialize
241 .reused
242 .saturating_add(u64::from(boolean(fields, "initialize_reused")));
243 increment_nonempty(
244 &mut self.initialize.transitions,
245 string(fields, "initialize_transition"),
246 );
247 }
248 self.record_recovery(fields);
249 audit_tool_event(fields, &mut self.trace_audit);
250 }
251
252 fn record_recovery(&mut self, fields: &serde_json::Map<String, Value>) {
253 self.recovery.next_actions = self
254 .recovery
255 .next_actions
256 .saturating_add(u64::from(!string(fields, "next_action_tool").is_empty()));
257 self.recovery.required_reads =
258 self.recovery.required_reads.saturating_add(number(fields, "required_read_count"));
259 self.recovery.repeated_attempts = self
260 .recovery
261 .repeated_attempts
262 .saturating_add(u64::from(number(fields, "recovery_attempt") > 1));
263 self.recovery.escalated = self
264 .recovery
265 .escalated
266 .saturating_add(u64::from(string(fields, "recovery_level") == "escalated"));
267 self.recovery.mutation_receipts_persisted = self
268 .recovery
269 .mutation_receipts_persisted
270 .saturating_add(u64::from(string(fields, "mutation_receipt_state") == "persisted"));
271 }
272
273 fn record_http(&mut self, fields: &serde_json::Map<String, Value>) {
274 self.http_requests += 1;
275 let status = fields.get("status").map(value_text).unwrap_or_default();
276 increment_nonempty(&mut self.http_statuses, &status);
277 let request_id = string(fields, "request_id");
278 if !request_id.is_empty() {
279 self.http_request_ids.insert(request_id.to_string());
280 self.http_durations.insert(request_id.to_string(), number(fields, "duration_ms"));
281 }
282 }
283
284 fn finish(mut self, window: ReportWindow<'_>) -> UsageReport {
285 let correlated_requests =
286 self.tool_request_ids.intersection(&self.http_request_ids).count();
287 let overhead = self
288 .tool_durations
289 .iter()
290 .filter_map(|(id, tool)| {
291 self.http_durations.get(id).map(|http| http.saturating_sub(*tool))
292 })
293 .collect::<Vec<_>>();
294 let tools = self
295 .tools
296 .into_iter()
297 .map(|(name, accumulator)| {
298 (
299 name,
300 ToolUsage {
301 calls: accumulator.calls,
302 error_results: accumulator.error_results,
303 response_bytes: accumulator.response_bytes,
304 latency: latency(accumulator.durations),
305 },
306 )
307 })
308 .collect();
309 self.trace_audit.healthy = self.trace_audit.violations.is_empty();
310
311 UsageReport {
312 base_path: window.base.to_string_lossy().into_owned(),
313 source_files: window.source_files,
314 lines_seen: window.lines_seen,
315 invalid_lines: window.invalid_lines,
316 events: window.events,
317 first_timestamp: window.first_timestamp,
318 last_timestamp: window.last_timestamp,
319 tool_calls: self.tool_calls,
320 http_requests: self.http_requests,
321 correlated_requests,
322 tool_calls_without_http: self
323 .tool_request_ids
324 .len()
325 .saturating_sub(correlated_requests),
326 http_without_tool_call: self.http_request_ids.len().saturating_sub(correlated_requests),
327 duplicate_tool_request_ids: self.duplicate_tool_request_ids,
328 http_statuses: self.http_statuses,
329 result_statuses: self.result_statuses,
330 error_codes: self.error_codes,
331 command_kinds: self.command_kinds,
332 build_identities: self.build_identities,
333 workspace_coherence: self.workspace_coherence,
334 tools,
335 latency: latency(self.all_durations),
336 http_overhead: latency(overhead),
337 response_bytes: self.response_bytes,
338 initialize: self.initialize,
339 recovery: self.recovery,
340 trace_audit: self.trace_audit,
341 }
342 }
343}
344
345struct ReportWindow<'a> {
346 base: &'a Path,
347 source_files: usize,
348 lines_seen: u64,
349 invalid_lines: u64,
350 events: usize,
351 first_timestamp: Option<String>,
352 last_timestamp: Option<String>,
353}
354
355fn aggregate(
356 base: &Path,
357 source_files: usize,
358 lines_seen: u64,
359 invalid_lines: u64,
360 events: &VecDeque<UsageEvent>,
361) -> UsageReport {
362 let mut accumulator = ReportAccumulator::default();
363 for event in events {
364 accumulator.record(&event.fields);
365 }
366 accumulator.finish(ReportWindow {
367 base,
368 source_files,
369 lines_seen,
370 invalid_lines,
371 events: events.len(),
372 first_timestamp: events.front().and_then(|event| event.timestamp.clone()),
373 last_timestamp: events.back().and_then(|event| event.timestamp.clone()),
374 })
375}
376
377fn audit_tool_event(fields: &serde_json::Map<String, Value>, audit: &mut TraceAudit) {
378 if matches!(
379 string(fields, "workspace_coherence"),
380 "mismatch" | "rejected" | "binding_mismatch" | "thread_mismatch"
381 ) {
382 audit.workspace_coherence_rejections =
383 audit.workspace_coherence_rejections.saturating_add(1);
384 }
385 if number(fields, "usage_schema") == 0 {
386 audit.legacy_unverifiable_tool_events =
389 audit.legacy_unverifiable_tool_events.saturating_add(1);
390 return;
391 }
392 audit.audited_tool_events = audit.audited_tool_events.saturating_add(1);
393 let request_id = string(fields, "request_id");
394 let mut violation = |rule| {
395 if audit.violations.len() < 100 {
396 audit.violations.push(TraceViolation { request_id: request_id.to_string(), rule });
397 }
398 };
399 if boolean(fields, "retry_same_call") {
400 violation("retry_same_call_must_be_false");
401 }
402 if boolean(fields, "edit_applied") && boolean(fields, "retry_same_call") {
403 violation("committed_edit_must_never_be_retried");
404 }
405 if string(fields, "result_status") == "needs_read"
406 && string(fields, "next_action_tool") != "ReadFiles"
407 {
408 violation("needs_read_must_point_to_read_files");
409 }
410 if matches!(string(fields, "error_code"), "search_block_not_found" | "search_block_ambiguous")
411 && !boolean(fields, "fresh_read_required")
412 {
413 violation("search_conflict_must_invalidate_read_evidence");
414 }
415}
416
417fn usage_sources(base: &Path) -> Result<Vec<PathBuf>> {
418 let parent = base.parent().unwrap_or_else(|| Path::new("."));
419 let name = base.file_name().and_then(|name| name.to_str()).ok_or_else(|| {
420 WinxError::InvalidInput(format!("usage log path is not UTF-8: {}", base.display()))
421 })?;
422 let prefix = format!("{name}.");
423 let mut sources = fs::read_dir(parent)
424 .map_err(|error| WinxError::FileAccessError {
425 path: parent.to_path_buf(),
426 message: format!("listing usage logs: {error}"),
427 })?
428 .filter_map(std::result::Result::ok)
429 .filter_map(|entry| {
430 let entry_name = entry.file_name();
431 let entry_name = entry_name.to_str()?;
432 let metadata = entry.metadata().ok()?;
433 (metadata.is_file() && (entry_name == name || entry_name.starts_with(&prefix)))
434 .then_some(entry.path())
435 })
436 .collect::<Vec<_>>();
437 sources.sort();
438 if sources.is_empty() {
439 return Err(WinxError::FileNotFound { path: base.to_path_buf() });
440 }
441 Ok(sources)
442}
443
444fn default_usage_path() -> PathBuf {
445 std::env::var_os("WINX_USAGE_LOG").map_or_else(
446 || {
447 home::home_dir()
448 .unwrap_or_else(|| PathBuf::from("."))
449 .join(".local/state/winx/usage.jsonl")
450 },
451 PathBuf::from,
452 )
453}
454
455fn parse_event(line: &str) -> Option<UsageEvent> {
456 let value: Value = serde_json::from_str(line).ok()?;
457 (value.get("target").and_then(Value::as_str) == Some("winx::usage")).then_some(())?;
458 Some(UsageEvent {
459 timestamp: value.get("timestamp").and_then(Value::as_str).map(ToString::to_string),
460 fields: value.get("fields")?.as_object()?.clone(),
461 })
462}
463
464fn parse_timestamp(value: &str) -> Option<OffsetDateTime> {
465 OffsetDateTime::parse(value, &Rfc3339).ok()
466}
467
468fn latency(mut samples: Vec<u64>) -> LatencySummary {
469 if samples.is_empty() {
470 return LatencySummary::default();
471 }
472 samples.sort_unstable();
473 let sum = samples.iter().fold(0_u128, |sum, value| sum.saturating_add(u128::from(*value)));
474 let average = sum / samples.len() as u128;
475 LatencySummary {
476 samples: samples.len(),
477 average_ms: u64::try_from(average).unwrap_or(u64::MAX),
478 p50_ms: percentile(&samples, 50),
479 p95_ms: percentile(&samples, 95),
480 max_ms: samples.last().copied().unwrap_or_default(),
481 }
482}
483
484fn percentile(sorted: &[u64], percentile: usize) -> u64 {
485 let index = sorted.len().saturating_mul(percentile).div_ceil(100).saturating_sub(1);
486 sorted[index.min(sorted.len() - 1)]
487}
488
489fn increment_nonempty(counts: &mut BTreeMap<String, u64>, value: &str) {
490 if !value.is_empty() {
491 counts.entry(value.to_string()).and_modify(|count| *count += 1).or_insert(1);
492 }
493}
494
495fn string<'a>(fields: &'a serde_json::Map<String, Value>, key: &str) -> &'a str {
496 fields.get(key).and_then(Value::as_str).unwrap_or_default()
497}
498
499fn number(fields: &serde_json::Map<String, Value>, key: &str) -> u64 {
500 fields.get(key).and_then(Value::as_u64).unwrap_or_default()
501}
502
503fn boolean(fields: &serde_json::Map<String, Value>, key: &str) -> bool {
504 fields.get(key).and_then(Value::as_bool).unwrap_or_default()
505}
506
507fn value_text(value: &Value) -> String {
508 value.as_str().map_or_else(|| value.to_string(), ToString::to_string)
509}
510
511fn i64_saturating(value: u64) -> i64 {
512 i64::try_from(value).unwrap_or(i64::MAX)
513}
514
515#[cfg(test)]
516mod tests {
517 use std::io::Write as _;
518
519 use super::*;
520
521 #[test]
522 fn aggregates_correlated_trace_without_reading_sensitive_arguments() -> Result<()> {
523 let temp = tempfile::tempdir()?;
524 let base = temp.path().join("usage.jsonl");
525 let path = temp.path().join("usage.jsonl.2026-08-25");
526 let mut file = File::create(path)?;
527 writeln!(
528 file,
529 "{}",
530 serde_json::json!({
531 "timestamp": "2026-08-25T12:00:00Z",
532 "target": "winx::usage",
533 "fields": {
534 "event":"tool_call", "usage_schema":1,
535 "tool":"BashCommand", "request_id":"req",
536 "result_status":"completed", "duration_ms":10, "response_bytes":20,
537 "command_kind":"rust_toolchain", "build":"0.2.343+abc"
538 }
539 })
540 )?;
541 writeln!(
542 file,
543 "{}",
544 serde_json::json!({
545 "timestamp": "2026-08-25T12:00:00Z",
546 "target": "winx::usage",
547 "fields": {
548 "event":"http_request", "request_id":"req", "status":200,
549 "duration_ms":12
550 }
551 })
552 )?;
553 let report = usage_report(Some(&base), 100, 0, None)?;
554 assert_eq!(report.tool_calls, 1);
555 assert_eq!(report.http_requests, 1);
556 assert_eq!(report.correlated_requests, 1);
557 assert_eq!(report.http_overhead.p95_ms, 2);
558 assert_eq!(report.command_kinds["rust_toolchain"], 1);
559 Ok(())
560 }
561
562 #[test]
563 fn legacy_missing_recovery_fields_are_unknown_not_violations() -> Result<()> {
564 let temp = tempfile::tempdir()?;
565 let base = temp.path().join("usage.jsonl");
566 let mut file = File::create(&base)?;
567 writeln!(
568 file,
569 "{}",
570 serde_json::json!({
571 "timestamp": "2026-08-25T12:00:00Z",
572 "target": "winx::usage",
573 "fields": {
574 "event": "tool_call",
575 "tool": "MultiFileEdit",
576 "request_id": "legacy",
577 "result_status": "conflict",
578 "error_code": "search_block_not_found"
579 }
580 })
581 )?;
582
583 let report = usage_report(Some(&base), 100, 0, None)?;
584 assert!(report.trace_audit.healthy);
585 assert_eq!(report.trace_audit.audited_tool_events, 0);
586 assert_eq!(report.trace_audit.legacy_unverifiable_tool_events, 1);
587 assert!(report.trace_audit.violations.is_empty());
588 Ok(())
589 }
590}