1use crate::cache::{cache_dir, cache_path};
2use crate::model::ToolInfo;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::PathBuf;
6use tokio::sync::Mutex;
7
8#[derive(Debug, Default, Serialize, Deserialize)]
13pub struct StatsData {
14 #[serde(default)]
16 pub meta_bytes: u64,
17 #[serde(default)]
19 pub meta_calls: BTreeMap<String, u64>,
20 #[serde(default)]
22 pub schema_bytes_served: u64,
23 #[serde(default)]
25 pub tool_calls_proxied: u64,
26}
27
28pub struct Stats {
29 data: Mutex<StatsData>,
30 path: PathBuf,
31}
32
33impl Stats {
34 pub fn new(meta_bytes: u64) -> std::sync::Arc<Stats> {
35 let path = cache_dir().join("stats.json");
36 let mut data: StatsData = std::fs::read_to_string(&path)
37 .ok()
38 .and_then(|t| serde_json::from_str(&t).ok())
39 .unwrap_or_default();
40 data.meta_bytes = meta_bytes;
41 let s = std::sync::Arc::new(Stats {
42 data: Mutex::new(data),
43 path,
44 });
45 s.persist_sync();
46 s
47 }
48
49 fn persist_sync(&self) {
50 let data = match self.data.try_lock() {
51 Ok(d) => d,
52 Err(_) => return, };
54 if let Some(dir) = self.path.parent() {
55 let _ = std::fs::create_dir_all(dir);
56 }
57 let tmp = self.path.with_extension("json.tmp");
58 if let Ok(json) = serde_json::to_string(&*data) {
59 let _ = std::fs::write(&tmp, json);
60 let _ = std::fs::rename(&tmp, &self.path);
61 }
62 }
63
64 pub async fn record_meta(&self, tool: &str) {
65 {
66 let mut d = self.data.lock().await;
67 *d.meta_calls.entry(tool.to_string()).or_insert(0) += 1;
68 }
69 self.persist_sync();
70 }
71
72 pub async fn record_served(&self, schema_bytes: u64) {
73 {
74 let mut d = self.data.lock().await;
75 d.schema_bytes_served += schema_bytes;
76 }
77 self.persist_sync();
78 }
79
80 pub async fn record_proxied(&self) {
81 {
82 let mut d = self.data.lock().await;
83 d.tool_calls_proxied += 1;
84 }
85 self.persist_sync();
86 }
87}
88
89fn json_len<T: Serialize>(v: &T) -> u64 {
90 serde_json::to_string(v)
91 .map(|s| s.len() as u64)
92 .unwrap_or(0)
93}
94
95pub fn report() -> String {
98 let index_text = std::fs::read_to_string(cache_path()).ok();
99 let tools: Vec<ToolInfo> = index_text
100 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
101 .and_then(|v| v.get("servers")?.as_object().cloned())
102 .map(|servers| {
103 servers
104 .values()
105 .filter_map(|ts| serde_json::from_value::<Vec<ToolInfo>>(ts.clone()).ok())
106 .flatten()
107 .collect()
108 })
109 .unwrap_or_default();
110 let stats: StatsData = std::fs::read_to_string(cache_dir().join("stats.json"))
111 .ok()
112 .and_then(|t| serde_json::from_str(&t).ok())
113 .unwrap_or_default();
114
115 let mut out = String::from("mcp-multiplexer stats\n\n");
116 if tools.is_empty() {
117 out.push_str("No cached index yet — start the mux once to build it.\n");
118 } else {
119 let withheld: u64 = tools.iter().map(json_len).sum();
120 let saved = withheld.saturating_sub(stats.meta_bytes);
121 let pct = (saved * 100).checked_div(withheld).unwrap_or(0);
122 out.push_str(&format!(
123 "Startup context per session:\n without mux: ~{} tokens ({} tools)\n with mux: ~{} tokens (meta-tools)\n saved: ~{} tokens ({}%)\n",
124 withheld / 4,
125 tools.len(),
126 stats.meta_bytes / 4,
127 saved / 4,
128 pct
129 ));
130 }
131 let total_meta: u64 = stats.meta_calls.values().sum();
132 out.push_str(&format!(
133 "\nOn-demand schemas served: ~{} tokens\nTool calls proxied: {}\nMeta-tool calls: {}\n",
134 stats.schema_bytes_served / 4,
135 stats.tool_calls_proxied,
136 total_meta
137 ));
138 if total_meta > 0 {
139 for (name, n) in &stats.meta_calls {
140 out.push_str(&format!(" {name}: {n}\n"));
141 }
142 }
143 out.push_str("\nTokens ~ bytes/4 (heuristic, not a real tokenizer).\n");
144 out
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[tokio::test]
152 async fn stats_roundtrip_and_counters() {
153 let dir = std::env::temp_dir().join(format!("mcpmux-stats-{}", std::process::id()));
154 std::fs::create_dir_all(&dir).unwrap();
155 let path = dir.join("stats.json");
156 let s = Stats {
157 data: Mutex::new(StatsData::default()),
158 path: path.clone(),
159 };
160 s.record_meta("search_tools").await;
161 s.record_meta("search_tools").await;
162 s.record_served(500).await;
163 s.record_proxied().await;
164 let loaded: StatsData =
165 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
166 assert_eq!(loaded.meta_calls["search_tools"], 2);
167 assert_eq!(loaded.schema_bytes_served, 500);
168 assert_eq!(loaded.tool_calls_proxied, 1);
169 std::fs::remove_dir_all(&dir).ok();
170 }
171}