1pub use crate::metrics_export::MetricsWriter;
12pub use crate::metrics_export::migrate_legacy_metrics_dir;
13pub(crate) use crate::metrics_export::{
15 path_component_count, path_file_ext, path_language, unix_ms,
16};
17
18use opentelemetry::metrics::{Counter, Histogram};
19use opentelemetry::{KeyValue, global};
20use rmcp::model::ProtocolVersion;
21use serde::{Deserialize, Serialize};
22use std::sync::OnceLock;
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26#[serde(default)]
27pub struct MetricEvent {
28 pub ts: u64,
29 pub tool: &'static str,
30 pub duration_ms: u64,
31 pub output_chars: usize,
32 pub param_path_depth: usize,
33 pub max_depth: Option<u32>,
34 pub result: &'static str,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub error_type: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub error_subtype: Option<String>,
39 #[serde(default)]
40 pub session_id: Option<String>,
41 #[serde(default)]
42 pub seq: Option<u32>,
43 #[serde(default)]
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub cache_hit: Option<bool>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub cache_tier: Option<&'static str>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub cache_write_failure: Option<bool>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub exit_code: Option<i32>,
54 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
55 pub timed_out: bool,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub output_truncated: Option<bool>,
58 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
62 pub chars_threshold_breach: bool,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub file_ext: Option<&'static str>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub filter_applied: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub language: Option<String>,
78 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
81 pub git_ref_used: bool,
82 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
85 pub summary_mode: bool,
86 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
89 pub is_paginated: bool,
90 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
93 pub fields_projected: bool,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub match_mode: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub follow_depth: Option<u32>,
101 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
103 pub import_lookup: bool,
104 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
106 pub def_use: bool,
107 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
109 pub impl_only: bool,
110 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112 pub stdin_provided: bool,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub timeout_configured_ms: Option<i64>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub drain_timeout_ms: Option<i64>,
119 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
122 pub working_dir_used: bool,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub l1_eviction_count: Option<u64>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub l2_entry_count: Option<u64>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub l2_size_bytes: Option<u64>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub stdout_bytes_raw: Option<u64>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub stderr_bytes_raw: Option<u64>,
142}
143
144#[derive(Debug, Default)]
146pub(crate) struct MetricEventBuilder {
147 ts: u64,
148 tool: &'static str,
149 duration_ms: u64,
150 output_chars: usize,
151 param_path_depth: usize,
152 max_depth: Option<u32>,
153 result: &'static str,
154 error_type: Option<String>,
155 error_subtype: Option<String>,
156 session_id: Option<String>,
157 seq: Option<u32>,
158 cache_hit: Option<bool>,
159 cache_write_failure: Option<bool>,
160 cache_tier: Option<&'static str>,
161 exit_code: Option<i32>,
162 timed_out: bool,
163 output_truncated: Option<bool>,
164 chars_threshold_breach: bool,
165 file_ext: Option<&'static str>,
166 filter_applied: Option<String>,
167 language: Option<String>,
168 git_ref_used: bool,
169 summary_mode: bool,
170 is_paginated: bool,
171 fields_projected: bool,
172 match_mode: Option<String>,
173 follow_depth: Option<u32>,
174 import_lookup: bool,
175 def_use: bool,
176 impl_only: bool,
177 stdin_provided: bool,
178 timeout_configured_ms: Option<i64>,
179 drain_timeout_ms: Option<i64>,
180 working_dir_used: bool,
181 l1_eviction_count: Option<u64>,
182 l2_entry_count: Option<u64>,
183 l2_size_bytes: Option<u64>,
184 stdout_bytes_raw: Option<u64>,
185 stderr_bytes_raw: Option<u64>,
186}
187
188#[allow(clippy::too_many_arguments)]
189impl MetricEventBuilder {
190 #[must_use]
191 pub(crate) fn new(tool: &'static str, result: &'static str, duration_ms: u64) -> Self {
192 Self {
193 ts: unix_ms(),
194 tool,
195 result,
196 duration_ms,
197 ..Self::default()
198 }
199 }
200
201 #[must_use]
202 pub(crate) fn output_chars(mut self, v: usize) -> Self {
203 self.output_chars = v;
204 self
205 }
206 #[must_use]
207 pub(crate) fn param_path_depth(mut self, v: usize) -> Self {
208 self.param_path_depth = v;
209 self
210 }
211 #[must_use]
212 pub(crate) fn max_depth(mut self, v: Option<u32>) -> Self {
213 self.max_depth = v;
214 self
215 }
216 #[must_use]
217 pub(crate) fn error_type(mut self, v: Option<String>) -> Self {
218 self.error_type = v;
219 self
220 }
221 #[must_use]
222 pub(crate) fn error_subtype(mut self, v: Option<String>) -> Self {
223 self.error_subtype = v;
224 self
225 }
226 #[must_use]
227 pub(crate) fn session_id(mut self, v: Option<String>) -> Self {
228 self.session_id = v;
229 self
230 }
231 #[must_use]
232 pub(crate) fn seq(mut self, v: Option<u32>) -> Self {
233 self.seq = v;
234 self
235 }
236 #[must_use]
237 pub(crate) fn cache_hit(mut self, v: Option<bool>) -> Self {
238 self.cache_hit = v;
239 self
240 }
241 #[must_use]
242 pub(crate) fn cache_tier(mut self, v: Option<&'static str>) -> Self {
243 self.cache_tier = v;
244 self
245 }
246 #[must_use]
247 pub(crate) fn cache_write_failure(mut self, v: Option<bool>) -> Self {
248 self.cache_write_failure = v;
249 self
250 }
251 #[must_use]
252 pub(crate) fn exit_code(mut self, v: Option<i32>) -> Self {
253 self.exit_code = v;
254 self
255 }
256 #[must_use]
257 pub(crate) fn timed_out(mut self, v: bool) -> Self {
258 self.timed_out = v;
259 self
260 }
261 #[must_use]
262 pub(crate) fn output_truncated(mut self, v: Option<bool>) -> Self {
263 self.output_truncated = v;
264 self
265 }
266 #[must_use]
267 pub(crate) fn chars_threshold_breach(mut self, v: bool) -> Self {
268 self.chars_threshold_breach = v;
269 self
270 }
271 #[must_use]
272 pub(crate) fn file_ext(mut self, v: Option<&'static str>) -> Self {
273 self.file_ext = v;
274 self
275 }
276 #[must_use]
277 pub(crate) fn filter_applied(mut self, v: Option<String>) -> Self {
278 self.filter_applied = v;
279 self
280 }
281 #[must_use]
282 pub(crate) fn language(mut self, v: Option<String>) -> Self {
283 self.language = v;
284 self
285 }
286 #[must_use]
287 pub(crate) fn git_ref_used(mut self, v: bool) -> Self {
288 self.git_ref_used = v;
289 self
290 }
291 #[must_use]
292 pub(crate) fn summary_mode(mut self, v: bool) -> Self {
293 self.summary_mode = v;
294 self
295 }
296 #[must_use]
297 #[allow(clippy::wrong_self_convention)]
298 pub(crate) fn is_paginated(mut self, v: bool) -> Self {
299 self.is_paginated = v;
300 self
301 }
302 #[must_use]
303 pub(crate) fn fields_projected(mut self, v: bool) -> Self {
304 self.fields_projected = v;
305 self
306 }
307 #[must_use]
308 pub(crate) fn match_mode(mut self, v: Option<String>) -> Self {
309 self.match_mode = v;
310 self
311 }
312 #[must_use]
313 pub(crate) fn follow_depth(mut self, v: Option<u32>) -> Self {
314 self.follow_depth = v;
315 self
316 }
317 #[must_use]
318 pub(crate) fn import_lookup(mut self, v: bool) -> Self {
319 self.import_lookup = v;
320 self
321 }
322 #[must_use]
323 pub(crate) fn def_use(mut self, v: bool) -> Self {
324 self.def_use = v;
325 self
326 }
327 #[must_use]
328 pub(crate) fn impl_only(mut self, v: bool) -> Self {
329 self.impl_only = v;
330 self
331 }
332 #[must_use]
333 pub(crate) fn stdin_provided(mut self, v: bool) -> Self {
334 self.stdin_provided = v;
335 self
336 }
337 #[must_use]
338 pub(crate) fn timeout_configured_ms(mut self, v: Option<i64>) -> Self {
339 self.timeout_configured_ms = v;
340 self
341 }
342 #[must_use]
343 pub(crate) fn drain_timeout_ms(mut self, v: Option<i64>) -> Self {
344 self.drain_timeout_ms = v;
345 self
346 }
347 #[must_use]
348 pub(crate) fn working_dir_used(mut self, v: bool) -> Self {
349 self.working_dir_used = v;
350 self
351 }
352 #[must_use]
353 pub(crate) fn l1_eviction_count(mut self, v: Option<u64>) -> Self {
354 self.l1_eviction_count = v;
355 self
356 }
357 #[must_use]
358 pub(crate) fn l2_entry_count(mut self, v: Option<u64>) -> Self {
359 self.l2_entry_count = v;
360 self
361 }
362 #[must_use]
363 pub(crate) fn l2_size_bytes(mut self, v: Option<u64>) -> Self {
364 self.l2_size_bytes = v;
365 self
366 }
367 #[must_use]
368 pub(crate) fn stdout_bytes_raw(mut self, v: u64) -> Self {
369 self.stdout_bytes_raw = Some(v);
370 self
371 }
372 #[must_use]
373 pub(crate) fn stderr_bytes_raw(mut self, v: u64) -> Self {
374 self.stderr_bytes_raw = Some(v);
375 self
376 }
377 #[must_use]
378 pub(crate) fn build(self) -> MetricEvent {
379 MetricEvent {
380 ts: self.ts,
381 tool: self.tool,
382 duration_ms: self.duration_ms,
383 output_chars: self.output_chars,
384 param_path_depth: self.param_path_depth,
385 max_depth: self.max_depth,
386 result: self.result,
387 error_type: self.error_type,
388 error_subtype: self.error_subtype,
389 session_id: self.session_id,
390 seq: self.seq,
391 cache_hit: self.cache_hit,
392 cache_write_failure: self.cache_write_failure,
393 cache_tier: self.cache_tier,
394 exit_code: self.exit_code,
395 timed_out: self.timed_out,
396 output_truncated: self.output_truncated,
397 chars_threshold_breach: self.chars_threshold_breach,
398 file_ext: self.file_ext,
399 filter_applied: self.filter_applied,
400 language: self.language,
401 git_ref_used: self.git_ref_used,
402 summary_mode: self.summary_mode,
403 is_paginated: self.is_paginated,
404 fields_projected: self.fields_projected,
405 match_mode: self.match_mode,
406 follow_depth: self.follow_depth,
407 import_lookup: self.import_lookup,
408 def_use: self.def_use,
409 impl_only: self.impl_only,
410 stdin_provided: self.stdin_provided,
411 timeout_configured_ms: self.timeout_configured_ms,
412 drain_timeout_ms: self.drain_timeout_ms,
413 working_dir_used: self.working_dir_used,
414 l1_eviction_count: self.l1_eviction_count,
415 l2_entry_count: self.l2_entry_count,
416 l2_size_bytes: self.l2_size_bytes,
417 stdout_bytes_raw: self.stdout_bytes_raw,
418 stderr_bytes_raw: self.stderr_bytes_raw,
419 }
420 }
421}
422
423#[derive(Clone)]
425pub struct MetricsSender(pub tokio::sync::mpsc::UnboundedSender<MetricEvent>);
426
427impl MetricsSender {
428 pub fn send(&self, event: MetricEvent) {
429 let _ = self.0.send(event);
430 }
431}
432
433#[derive(Default, Debug)]
435pub(crate) struct ToolMetrics {
436 pub(crate) count: u64,
437 pub(crate) duration_ms: u64,
438 pub(crate) output_chars: u64,
439}
440
441#[allow(dead_code)]
444pub(crate) struct MetricsLockGuard(pub(crate) std::fs::File);
445
446pub(crate) fn record_otel_metrics(event: &MetricEvent) {
456 if event.result == "received" {
458 return;
459 }
460
461 static DURATION_HISTOGRAM: OnceLock<Histogram<f64>> = OnceLock::new();
462 static CALL_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
463 static CACHE_HITS_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
464 static CACHE_WRITE_FAILURES_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
465
466 let histogram = DURATION_HISTOGRAM.get_or_init(|| {
467 global::meter("aptu-coder")
468 .f64_histogram("mcp.server.operation.duration")
469 .with_unit("s")
470 .with_boundaries(vec![
471 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0,
472 ])
473 .build()
474 });
475
476 let counter = CALL_COUNTER.get_or_init(|| {
477 global::meter("aptu-coder")
478 .u64_counter("mcp.server.tool.calls")
479 .build()
480 });
481
482 let cache_hits_counter = CACHE_HITS_COUNTER.get_or_init(|| {
483 global::meter("aptu-coder")
484 .u64_counter("mcp.server.tool.cache_hits_total")
485 .with_description("Number of tool responses served from cache (l1_memory or l2_disk)")
486 .build()
487 });
488
489 let cache_write_failures_counter = CACHE_WRITE_FAILURES_COUNTER.get_or_init(|| {
490 global::meter("aptu-coder")
491 .u64_counter("mcp.server.tool.cache_write_failures_total")
492 .with_description(
493 "Number of L2 disk cache write failures (dir, tempfile, write, rename)",
494 )
495 .build()
496 });
497
498 let error_type = event.error_type.as_deref().unwrap_or("success");
499 let attributes = [
500 KeyValue::new("gen_ai.tool.name", event.tool),
501 KeyValue::new("error.type", error_type.to_string()),
502 KeyValue::new("mcp.method.name", "tools/call"),
503 KeyValue::new("mcp.protocol.version", ProtocolVersion::LATEST.as_str()),
504 KeyValue::new("network.transport", "pipe"),
505 ];
506
507 histogram.record(event.duration_ms as f64 / 1000.0, &attributes);
508 counter.add(1, &attributes);
509
510 if event.cache_hit == Some(true) {
511 let tier = event.cache_tier.unwrap_or("unknown");
512 cache_hits_counter.add(
513 1,
514 &[
515 KeyValue::new("gen_ai.tool.name", event.tool),
516 KeyValue::new("cache_tier", tier),
517 ],
518 );
519 }
520
521 if event.cache_write_failure == Some(true) {
522 cache_write_failures_counter.add(1, &[KeyValue::new("gen_ai.tool.name", event.tool)]);
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn test_metric_event_serialization() {
532 let event = MetricEvent {
533 ts: 1_700_000_000_000,
534 tool: "analyze_directory",
535 duration_ms: 100,
536 output_chars: 500,
537 param_path_depth: 1,
538 max_depth: None,
539 result: "ok",
540 error_type: None,
541 error_subtype: None,
542 session_id: Some("1742468880123-42".to_string()),
543 seq: Some(5),
544 cache_hit: None,
545 cache_write_failure: None,
546 cache_tier: None,
547 exit_code: Some(0),
548 timed_out: false,
549 output_truncated: None,
550 chars_threshold_breach: false,
551 file_ext: None,
552 ..Default::default()
553 };
554 let serialized = serde_json::to_string(&event).unwrap();
555 assert!(serialized.contains(r#""ts":1700000000000"#));
556 assert!(serialized.contains(r#""tool":"analyze_directory""#));
557 assert!(serialized.contains(r#""session_id":"1742468880123-42""#));
558 assert!(serialized.contains(r#""exit_code":0"#));
559 }
560
561 #[test]
562 fn test_metric_event_serialization_error() {
563 let event = MetricEvent {
564 ts: 1_700_000_000_000,
565 tool: "edit_replace",
566 duration_ms: 10,
567 output_chars: 0,
568 param_path_depth: 2,
569 max_depth: None,
570 result: "error",
571 error_type: Some("invalid_params".to_string()),
572 session_id: None,
573 seq: None,
574 cache_hit: None,
575 cache_write_failure: None,
576 exit_code: None,
577 timed_out: false,
578 cache_tier: None,
579 output_truncated: None,
580 chars_threshold_breach: false,
581 file_ext: None,
582 ..Default::default()
583 };
584 let json = serde_json::to_string(&event).unwrap();
585 assert!(json.contains(r#""error_type":"invalid_params""#));
586 }
587
588 #[test]
589 fn test_metric_event_error_subtype_some_serializes() {
590 let event = MetricEvent {
591 ts: 1_700_000_000_000,
592 tool: "edit_replace",
593 duration_ms: 10,
594 output_chars: 0,
595 param_path_depth: 2,
596 max_depth: None,
597 result: "error",
598 error_type: Some("invalid_params".to_string()),
599 error_subtype: Some("not_found".to_string()),
600 session_id: None,
601 seq: None,
602 cache_hit: None,
603 cache_write_failure: None,
604 exit_code: None,
605 timed_out: false,
606 cache_tier: None,
607 output_truncated: None,
608 chars_threshold_breach: false,
609 file_ext: None,
610 ..Default::default()
611 };
612 let json = serde_json::to_string(&event).unwrap();
613 assert!(json.contains(r#""error_subtype":"not_found""#));
614 }
615
616 #[test]
617 fn test_metric_event_error_subtype_ambiguous() {
618 let event = MetricEvent {
619 ts: 1_700_000_000_000,
620 tool: "edit_replace",
621 duration_ms: 10,
622 output_chars: 0,
623 param_path_depth: 2,
624 max_depth: None,
625 result: "error",
626 error_type: Some("invalid_params".to_string()),
627 error_subtype: Some("ambiguous".to_string()),
628 session_id: None,
629 seq: None,
630 cache_hit: None,
631 cache_write_failure: None,
632 exit_code: None,
633 timed_out: false,
634 cache_tier: None,
635 output_truncated: None,
636 chars_threshold_breach: false,
637 file_ext: None,
638 ..Default::default()
639 };
640 let json = serde_json::to_string(&event).unwrap();
641 assert!(json.contains(r#""error_subtype":"ambiguous""#));
642 }
643
644 #[test]
645 fn test_metric_event_new_fields_round_trip() {
646 let event = MetricEvent {
647 ts: 1_700_000_000_000,
648 tool: "analyze_file",
649 duration_ms: 100,
650 output_chars: 500,
651 param_path_depth: 2,
652 max_depth: Some(3),
653 result: "ok",
654 error_type: None,
655 error_subtype: None,
656 session_id: Some("1742468880123-42".to_string()),
657 seq: Some(5),
658 cache_hit: None,
659 cache_write_failure: None,
660 exit_code: None,
661 timed_out: false,
662 cache_tier: None,
663 output_truncated: None,
664 chars_threshold_breach: false,
665 file_ext: None,
666 filter_applied: None,
667 language: None,
668 git_ref_used: false,
669 summary_mode: false,
670 is_paginated: false,
671 fields_projected: false,
672 match_mode: None,
673 follow_depth: None,
674 import_lookup: false,
675 def_use: false,
676 impl_only: false,
677 stdin_provided: false,
678 timeout_configured_ms: None,
679 drain_timeout_ms: None,
680 working_dir_used: false,
681 l1_eviction_count: None,
682 l2_entry_count: None,
683 l2_size_bytes: None,
684 stdout_bytes_raw: None,
685 stderr_bytes_raw: None,
686 };
687 let serialized = serde_json::to_string(&event).unwrap();
688 let json_str = r#"{"ts":1700000000000,"tool":"analyze_file","duration_ms":100,"output_chars":500,"param_path_depth":2,"max_depth":3,"result":"ok","session_id":"1742468880123-42","seq":5}"#;
689 assert_eq!(serialized, json_str);
690 }
691}
692
693#[test]
694fn test_metric_event_builder_raw_bytes_serialize() {
695 let event = MetricEventBuilder::new("exec_command", "ok", 100)
698 .stdout_bytes_raw(12345)
699 .stderr_bytes_raw(6789)
700 .build();
701 let json = serde_json::to_string(&event).unwrap();
702 assert!(json.contains(r#""stdout_bytes_raw":12345"#));
703 assert!(json.contains(r#""stderr_bytes_raw":6789"#));
704}
705
706#[test]
707fn test_metric_event_builder_raw_bytes_skip_when_none() {
708 let event = MetricEventBuilder::new("exec_command", "ok", 100).build();
711 let json = serde_json::to_string(&event).unwrap();
712 assert!(!json.contains("stdout_bytes_raw"));
713 assert!(!json.contains("stderr_bytes_raw"));
714}