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