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