Skip to main content

code_analyze_mcp/
metrics.rs

1//! Metrics collection and daily-rotating JSONL emission.
2//!
3//! Provides a channel-based pipeline: callers emit [`MetricEvent`] values via [`MetricsSender`],
4//! and [`MetricsWriter`] drains the channel and appends events to a daily-rotated JSONL file
5//! under the XDG data directory (`~/.local/share/code-analyze-mcp/metrics-YYYY-MM-DD.jsonl`).
6//! Files older than 30 days are deleted on startup.
7
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11use tokio::io::AsyncWriteExt;
12use tokio::sync::mpsc;
13
14/// A single metric event emitted by a tool invocation.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct MetricEvent {
17    pub ts: u64,
18    pub tool: &'static str,
19    pub duration_ms: u64,
20    pub output_chars: usize,
21    pub param_path_depth: usize,
22    pub max_depth: Option<u32>,
23    pub result: &'static str,
24    pub error_type: Option<String>,
25    #[serde(default)]
26    pub session_id: Option<String>,
27    #[serde(default)]
28    pub seq: Option<u32>,
29}
30
31/// Sender half of the metrics channel; cloned and passed to tools for event emission.
32#[derive(Clone)]
33pub struct MetricsSender(pub tokio::sync::mpsc::UnboundedSender<MetricEvent>);
34
35impl MetricsSender {
36    pub fn send(&self, event: MetricEvent) {
37        let _ = self.0.send(event);
38    }
39}
40
41/// Receiver half of the metrics channel; drains events and writes them to daily-rotated JSONL files.
42pub struct MetricsWriter {
43    rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
44    base_dir: PathBuf,
45}
46
47impl MetricsWriter {
48    pub fn new(
49        rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
50        base_dir: Option<PathBuf>,
51    ) -> Self {
52        let dir = base_dir.unwrap_or_else(xdg_metrics_dir);
53        Self { rx, base_dir: dir }
54    }
55
56    pub async fn run(mut self) {
57        cleanup_old_files(&self.base_dir).await;
58        let mut current_date = current_date_str();
59        let mut current_file: Option<PathBuf> = None;
60
61        loop {
62            let mut batch = Vec::new();
63            if let Some(event) = self.rx.recv().await {
64                batch.push(event);
65                for _ in 0..99 {
66                    match self.rx.try_recv() {
67                        Ok(e) => batch.push(e),
68                        Err(mpsc::error::TryRecvError::Empty) => break,
69                        Err(mpsc::error::TryRecvError::Disconnected) => break,
70                    }
71                }
72            } else {
73                break;
74            }
75
76            let new_date = current_date_str();
77            if new_date != current_date {
78                current_date = new_date;
79                current_file = None;
80            }
81
82            if current_file.is_none() {
83                current_file = Some(rotate_path(&self.base_dir, &current_date));
84            }
85
86            let path = current_file.as_ref().unwrap();
87
88            // Create directory once per batch
89            if let Some(parent) = path.parent()
90                && !parent.as_os_str().is_empty()
91            {
92                tokio::fs::create_dir_all(parent).await.ok();
93            }
94
95            // Open file once per batch
96            let file = tokio::fs::OpenOptions::new()
97                .create(true)
98                .append(true)
99                .open(path)
100                .await;
101
102            if let Ok(mut file) = file {
103                for event in batch {
104                    if let Ok(mut json) = serde_json::to_string(&event) {
105                        json.push('\n');
106                        let _ = file.write_all(json.as_bytes()).await;
107                    }
108                }
109                let _ = file.flush().await;
110            }
111        }
112    }
113}
114
115/// Returns the current UNIX timestamp in milliseconds.
116pub fn unix_ms() -> u64 {
117    SystemTime::now()
118        .duration_since(UNIX_EPOCH)
119        .unwrap_or_default()
120        .as_millis() as u64
121}
122
123/// Counts the number of path segments in a file path.
124pub fn path_component_count(path: &str) -> usize {
125    Path::new(path).components().count()
126}
127
128/// Maps an MCP error code to a short string representation for metrics.
129pub fn error_code_to_type(code: rmcp::model::ErrorCode) -> &'static str {
130    match code {
131        rmcp::model::ErrorCode::PARSE_ERROR => "parse",
132        rmcp::model::ErrorCode::INVALID_PARAMS => "invalid_params",
133        _ => "unknown",
134    }
135}
136
137fn xdg_metrics_dir() -> PathBuf {
138    if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
139        && !xdg_data_home.is_empty()
140    {
141        return PathBuf::from(xdg_data_home).join("code-analyze-mcp");
142    }
143
144    if let Ok(home) = std::env::var("HOME") {
145        PathBuf::from(home)
146            .join(".local")
147            .join("share")
148            .join("code-analyze-mcp")
149    } else {
150        PathBuf::from(".")
151    }
152}
153
154fn rotate_path(base_dir: &Path, date_str: &str) -> PathBuf {
155    base_dir.join(format!("metrics-{}.jsonl", date_str))
156}
157
158async fn cleanup_old_files(base_dir: &Path) {
159    let now_days = (unix_ms() / 86_400_000) as u32;
160
161    let Ok(mut entries) = tokio::fs::read_dir(base_dir).await else {
162        return;
163    };
164
165    loop {
166        match entries.next_entry().await {
167            Ok(Some(entry)) => {
168                let path = entry.path();
169                let file_name = match path.file_name() {
170                    Some(n) => n.to_string_lossy().into_owned(),
171                    None => continue,
172                };
173
174                // Expected format: metrics-YYYY-MM-DD.jsonl
175                if !file_name.starts_with("metrics-") || !file_name.ends_with(".jsonl") {
176                    continue;
177                }
178                let date_part = &file_name[8..file_name.len() - 6];
179                if date_part.len() != 10
180                    || date_part.as_bytes().get(4) != Some(&b'-')
181                    || date_part.as_bytes().get(7) != Some(&b'-')
182                {
183                    continue;
184                }
185                let Ok(year) = date_part[0..4].parse::<u32>() else {
186                    continue;
187                };
188                let Ok(month) = date_part[5..7].parse::<u32>() else {
189                    continue;
190                };
191                let Ok(day) = date_part[8..10].parse::<u32>() else {
192                    continue;
193                };
194                if month == 0 || month > 12 || day == 0 || day > 31 {
195                    continue;
196                }
197
198                let file_days = date_to_days_since_epoch(year, month, day);
199                if now_days > file_days && (now_days - file_days) > 30 {
200                    let _ = tokio::fs::remove_file(&path).await;
201                }
202            }
203            Ok(None) => break,
204            Err(e) => {
205                tracing::warn!("error reading metrics directory entry: {}", e);
206                continue;
207            }
208        }
209    }
210}
211
212fn date_to_days_since_epoch(y: u32, m: u32, d: u32) -> u32 {
213    // Shift year so March is month 0
214    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
215    let era = y / 400;
216    let yoe = y - era * 400;
217    let doy = (153 * m + 2) / 5 + d - 1;
218    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
219    era * 146_097
220        + doe // this is the Proleptic Gregorian day number
221            .saturating_sub(719_468) // subtract the epoch offset to get days since 1970-01-01
222}
223
224/// Returns the current UTC date as a string in YYYY-MM-DD format.
225pub fn current_date_str() -> String {
226    let days = (unix_ms() / 86_400_000) as u32;
227    let z = days + 719_468;
228    let era = z / 146_097;
229    let doe = z - era * 146_097;
230    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
231    let y = yoe + era * 400;
232    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
233    let mp = (5 * doy + 2) / 153;
234    let d = doy - (153 * mp + 2) / 5 + 1;
235    let m = if mp < 10 { mp + 3 } else { mp - 9 };
236    let y = if m <= 2 { y + 1 } else { y };
237    format!("{:04}-{:02}-{:02}", y, m, d)
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_metric_event_serialization() {
246        let event = MetricEvent {
247            ts: 1_700_000_000_000,
248            tool: "analyze_directory",
249            duration_ms: 42,
250            output_chars: 100,
251            param_path_depth: 3,
252            max_depth: Some(2),
253            result: "ok",
254            error_type: None,
255            session_id: None,
256            seq: None,
257        };
258        let json = serde_json::to_string(&event).unwrap();
259        assert!(json.contains("analyze_directory"));
260        assert!(json.contains(r#""result":"ok""#));
261        assert!(json.contains(r#""output_chars":100"#));
262    }
263
264    #[test]
265    fn test_metric_event_serialization_error() {
266        let event = MetricEvent {
267            ts: 1_700_000_000_000,
268            tool: "analyze_directory",
269            duration_ms: 5,
270            output_chars: 0,
271            param_path_depth: 3,
272            max_depth: Some(3),
273            result: "error",
274            error_type: Some("invalid_params".to_string()),
275            session_id: None,
276            seq: None,
277        };
278        let json = serde_json::to_string(&event).unwrap();
279        assert!(json.contains(r#""result":"error""#));
280        assert!(json.contains(r#""error_type":"invalid_params""#));
281        assert!(json.contains(r#""output_chars":0"#));
282        assert!(json.contains(r#""max_depth":3"#));
283    }
284
285    #[test]
286    fn test_metric_event_new_fields_round_trip() {
287        let event = MetricEvent {
288            ts: 1_700_000_000_000,
289            tool: "analyze_file",
290            duration_ms: 100,
291            output_chars: 500,
292            param_path_depth: 2,
293            max_depth: Some(3),
294            result: "ok",
295            error_type: None,
296            session_id: Some("1742468880123-42".to_string()),
297            seq: Some(5),
298        };
299        let serialized = serde_json::to_string(&event).unwrap();
300        let json_str = r#"{"ts":1700000000000,"tool":"analyze_file","duration_ms":100,"output_chars":500,"param_path_depth":2,"max_depth":3,"result":"ok","error_type":null,"session_id":"1742468880123-42","seq":5}"#;
301        assert_eq!(serialized, json_str);
302        let parsed: MetricEvent = serde_json::from_str(json_str).unwrap();
303        assert_eq!(parsed.session_id, Some("1742468880123-42".to_string()));
304        assert_eq!(parsed.seq, Some(5));
305    }
306
307    #[test]
308    fn test_metric_event_backward_compat_parse() {
309        let old_jsonl = r#"{"ts":1700000000000,"tool":"analyze_directory","duration_ms":42,"output_chars":100,"param_path_depth":3,"max_depth":2,"result":"ok","error_type":null}"#;
310        let parsed: MetricEvent = serde_json::from_str(old_jsonl).unwrap();
311        assert_eq!(parsed.tool, "analyze_directory");
312        assert_eq!(parsed.session_id, None);
313        assert_eq!(parsed.seq, None);
314    }
315
316    #[test]
317    fn test_session_id_format() {
318        let event = MetricEvent {
319            ts: 1_700_000_000_000,
320            tool: "analyze_symbol",
321            duration_ms: 20,
322            output_chars: 50,
323            param_path_depth: 1,
324            max_depth: None,
325            result: "ok",
326            error_type: None,
327            session_id: Some("1742468880123-0".to_string()),
328            seq: Some(0),
329        };
330        let sid = event.session_id.unwrap();
331        assert!(sid.contains('-'), "session_id should contain a dash");
332        let parts: Vec<&str> = sid.split('-').collect();
333        assert_eq!(parts.len(), 2, "session_id should have exactly 2 parts");
334        assert!(parts[0].len() == 13, "millis part should be 13 digits");
335    }
336}