pub use crate::metrics_export::MetricsWriter;
pub use crate::metrics_export::migrate_legacy_metrics_dir;
pub(crate) use crate::metrics_export::{
path_component_count, path_file_ext, path_language, unix_ms,
};
use opentelemetry::metrics::{Counter, Histogram};
use opentelemetry::{KeyValue, global};
use rmcp::model::ProtocolVersion;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct MetricEvent {
pub ts: u64,
pub tool: &'static str,
pub duration_ms: u64,
pub output_chars: usize,
pub param_path_depth: usize,
pub max_depth: Option<u32>,
pub result: &'static str,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_subtype: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub seq: Option<u32>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_hit: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_tier: Option<&'static str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_failure: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub timed_out: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_truncated: Option<bool>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub chars_threshold_breach: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_ext: Option<&'static str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter_applied: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub git_ref_used: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub summary_mode: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_paginated: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub fields_projected: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub match_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub follow_depth: Option<u32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub import_lookup: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub def_use: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub impl_only: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stdin_provided: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_configured_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drain_timeout_ms: Option<i64>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub working_dir_used: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub l1_eviction_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub l2_entry_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub l2_size_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stdout_bytes_raw: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stderr_bytes_raw: Option<u64>,
}
#[derive(Debug, Default)]
pub(crate) struct MetricEventBuilder {
ts: u64,
tool: &'static str,
duration_ms: u64,
output_chars: usize,
param_path_depth: usize,
max_depth: Option<u32>,
result: &'static str,
error_type: Option<String>,
error_subtype: Option<String>,
session_id: Option<String>,
seq: Option<u32>,
cache_hit: Option<bool>,
cache_write_failure: Option<bool>,
cache_tier: Option<&'static str>,
exit_code: Option<i32>,
timed_out: bool,
output_truncated: Option<bool>,
chars_threshold_breach: bool,
file_ext: Option<&'static str>,
filter_applied: Option<String>,
language: Option<String>,
git_ref_used: bool,
summary_mode: bool,
is_paginated: bool,
fields_projected: bool,
match_mode: Option<String>,
follow_depth: Option<u32>,
import_lookup: bool,
def_use: bool,
impl_only: bool,
stdin_provided: bool,
timeout_configured_ms: Option<i64>,
drain_timeout_ms: Option<i64>,
working_dir_used: bool,
l1_eviction_count: Option<u64>,
l2_entry_count: Option<u64>,
l2_size_bytes: Option<u64>,
stdout_bytes_raw: Option<u64>,
stderr_bytes_raw: Option<u64>,
}
#[allow(clippy::too_many_arguments)]
impl MetricEventBuilder {
#[must_use]
pub(crate) fn new(tool: &'static str, result: &'static str, duration_ms: u64) -> Self {
Self {
ts: unix_ms(),
tool,
result,
duration_ms,
..Self::default()
}
}
#[must_use]
pub(crate) fn output_chars(mut self, v: usize) -> Self {
self.output_chars = v;
self
}
#[must_use]
pub(crate) fn param_path_depth(mut self, v: usize) -> Self {
self.param_path_depth = v;
self
}
#[must_use]
pub(crate) fn max_depth(mut self, v: Option<u32>) -> Self {
self.max_depth = v;
self
}
#[must_use]
pub(crate) fn error_type(mut self, v: Option<String>) -> Self {
self.error_type = v;
self
}
#[must_use]
pub(crate) fn error_subtype(mut self, v: Option<String>) -> Self {
self.error_subtype = v;
self
}
#[must_use]
pub(crate) fn session_id(mut self, v: Option<String>) -> Self {
self.session_id = v;
self
}
#[must_use]
pub(crate) fn seq(mut self, v: Option<u32>) -> Self {
self.seq = v;
self
}
#[must_use]
pub(crate) fn cache_hit(mut self, v: Option<bool>) -> Self {
self.cache_hit = v;
self
}
#[must_use]
pub(crate) fn cache_tier(mut self, v: Option<&'static str>) -> Self {
self.cache_tier = v;
self
}
#[must_use]
pub(crate) fn cache_write_failure(mut self, v: Option<bool>) -> Self {
self.cache_write_failure = v;
self
}
#[must_use]
pub(crate) fn exit_code(mut self, v: Option<i32>) -> Self {
self.exit_code = v;
self
}
#[must_use]
pub(crate) fn timed_out(mut self, v: bool) -> Self {
self.timed_out = v;
self
}
#[must_use]
pub(crate) fn output_truncated(mut self, v: Option<bool>) -> Self {
self.output_truncated = v;
self
}
#[must_use]
pub(crate) fn chars_threshold_breach(mut self, v: bool) -> Self {
self.chars_threshold_breach = v;
self
}
#[must_use]
pub(crate) fn file_ext(mut self, v: Option<&'static str>) -> Self {
self.file_ext = v;
self
}
#[must_use]
pub(crate) fn filter_applied(mut self, v: Option<String>) -> Self {
self.filter_applied = v;
self
}
#[must_use]
pub(crate) fn language(mut self, v: Option<String>) -> Self {
self.language = v;
self
}
#[must_use]
pub(crate) fn git_ref_used(mut self, v: bool) -> Self {
self.git_ref_used = v;
self
}
#[must_use]
pub(crate) fn summary_mode(mut self, v: bool) -> Self {
self.summary_mode = v;
self
}
#[must_use]
#[allow(clippy::wrong_self_convention)]
pub(crate) fn is_paginated(mut self, v: bool) -> Self {
self.is_paginated = v;
self
}
#[must_use]
pub(crate) fn fields_projected(mut self, v: bool) -> Self {
self.fields_projected = v;
self
}
#[must_use]
pub(crate) fn match_mode(mut self, v: Option<String>) -> Self {
self.match_mode = v;
self
}
#[must_use]
pub(crate) fn follow_depth(mut self, v: Option<u32>) -> Self {
self.follow_depth = v;
self
}
#[must_use]
pub(crate) fn import_lookup(mut self, v: bool) -> Self {
self.import_lookup = v;
self
}
#[must_use]
pub(crate) fn def_use(mut self, v: bool) -> Self {
self.def_use = v;
self
}
#[must_use]
pub(crate) fn impl_only(mut self, v: bool) -> Self {
self.impl_only = v;
self
}
#[must_use]
pub(crate) fn stdin_provided(mut self, v: bool) -> Self {
self.stdin_provided = v;
self
}
#[must_use]
pub(crate) fn timeout_configured_ms(mut self, v: Option<i64>) -> Self {
self.timeout_configured_ms = v;
self
}
#[must_use]
pub(crate) fn drain_timeout_ms(mut self, v: Option<i64>) -> Self {
self.drain_timeout_ms = v;
self
}
#[must_use]
pub(crate) fn working_dir_used(mut self, v: bool) -> Self {
self.working_dir_used = v;
self
}
#[must_use]
pub(crate) fn l1_eviction_count(mut self, v: Option<u64>) -> Self {
self.l1_eviction_count = v;
self
}
#[must_use]
pub(crate) fn l2_entry_count(mut self, v: Option<u64>) -> Self {
self.l2_entry_count = v;
self
}
#[must_use]
pub(crate) fn l2_size_bytes(mut self, v: Option<u64>) -> Self {
self.l2_size_bytes = v;
self
}
#[must_use]
pub(crate) fn stdout_bytes_raw(mut self, v: u64) -> Self {
self.stdout_bytes_raw = Some(v);
self
}
#[must_use]
pub(crate) fn stderr_bytes_raw(mut self, v: u64) -> Self {
self.stderr_bytes_raw = Some(v);
self
}
#[must_use]
pub(crate) fn build(self) -> MetricEvent {
MetricEvent {
ts: self.ts,
tool: self.tool,
duration_ms: self.duration_ms,
output_chars: self.output_chars,
param_path_depth: self.param_path_depth,
max_depth: self.max_depth,
result: self.result,
error_type: self.error_type,
error_subtype: self.error_subtype,
session_id: self.session_id,
seq: self.seq,
cache_hit: self.cache_hit,
cache_write_failure: self.cache_write_failure,
cache_tier: self.cache_tier,
exit_code: self.exit_code,
timed_out: self.timed_out,
output_truncated: self.output_truncated,
chars_threshold_breach: self.chars_threshold_breach,
file_ext: self.file_ext,
filter_applied: self.filter_applied,
language: self.language,
git_ref_used: self.git_ref_used,
summary_mode: self.summary_mode,
is_paginated: self.is_paginated,
fields_projected: self.fields_projected,
match_mode: self.match_mode,
follow_depth: self.follow_depth,
import_lookup: self.import_lookup,
def_use: self.def_use,
impl_only: self.impl_only,
stdin_provided: self.stdin_provided,
timeout_configured_ms: self.timeout_configured_ms,
drain_timeout_ms: self.drain_timeout_ms,
working_dir_used: self.working_dir_used,
l1_eviction_count: self.l1_eviction_count,
l2_entry_count: self.l2_entry_count,
l2_size_bytes: self.l2_size_bytes,
stdout_bytes_raw: self.stdout_bytes_raw,
stderr_bytes_raw: self.stderr_bytes_raw,
}
}
}
#[derive(Clone)]
pub struct MetricsSender(pub tokio::sync::mpsc::UnboundedSender<MetricEvent>);
impl MetricsSender {
pub fn send(&self, event: MetricEvent) {
let _ = self.0.send(event);
}
}
#[derive(Default, Debug)]
pub(crate) struct ToolMetrics {
pub(crate) count: u64,
pub(crate) duration_ms: u64,
pub(crate) output_chars: u64,
}
#[allow(dead_code)]
pub(crate) struct MetricsLockGuard(pub(crate) std::fs::File);
pub(crate) fn record_otel_metrics(event: &MetricEvent) {
if event.result == "received" {
return;
}
static DURATION_HISTOGRAM: OnceLock<Histogram<f64>> = OnceLock::new();
static CALL_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
static CACHE_HITS_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
static CACHE_WRITE_FAILURES_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
let histogram = DURATION_HISTOGRAM.get_or_init(|| {
global::meter("aptu-coder")
.f64_histogram("mcp.server.operation.duration")
.with_unit("s")
.with_boundaries(vec![
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,
])
.build()
});
let counter = CALL_COUNTER.get_or_init(|| {
global::meter("aptu-coder")
.u64_counter("mcp.server.tool.calls")
.build()
});
let cache_hits_counter = CACHE_HITS_COUNTER.get_or_init(|| {
global::meter("aptu-coder")
.u64_counter("mcp.server.tool.cache_hits_total")
.with_description("Number of tool responses served from cache (l1_memory or l2_disk)")
.build()
});
let cache_write_failures_counter = CACHE_WRITE_FAILURES_COUNTER.get_or_init(|| {
global::meter("aptu-coder")
.u64_counter("mcp.server.tool.cache_write_failures_total")
.with_description(
"Number of L2 disk cache write failures (dir, tempfile, write, rename)",
)
.build()
});
let error_type = event.error_type.as_deref().unwrap_or("success");
let attributes = [
KeyValue::new("gen_ai.tool.name", event.tool),
KeyValue::new("error.type", error_type.to_string()),
KeyValue::new("mcp.method.name", "tools/call"),
KeyValue::new("mcp.protocol.version", ProtocolVersion::LATEST.as_str()),
KeyValue::new("network.transport", "pipe"),
];
histogram.record(event.duration_ms as f64 / 1000.0, &attributes);
counter.add(1, &attributes);
if event.cache_hit == Some(true) {
let tier = event.cache_tier.unwrap_or("unknown");
cache_hits_counter.add(
1,
&[
KeyValue::new("gen_ai.tool.name", event.tool),
KeyValue::new("cache_tier", tier),
],
);
}
if event.cache_write_failure == Some(true) {
cache_write_failures_counter.add(1, &[KeyValue::new("gen_ai.tool.name", event.tool)]);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metric_event_serialization() {
let event = MetricEvent {
ts: 1_700_000_000_000,
tool: "analyze_directory",
duration_ms: 100,
output_chars: 500,
param_path_depth: 1,
max_depth: None,
result: "ok",
error_type: None,
error_subtype: None,
session_id: Some("1742468880123-42".to_string()),
seq: Some(5),
cache_hit: None,
cache_write_failure: None,
cache_tier: None,
exit_code: Some(0),
timed_out: false,
output_truncated: None,
chars_threshold_breach: false,
file_ext: None,
..Default::default()
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains(r#""ts":1700000000000"#));
assert!(serialized.contains(r#""tool":"analyze_directory""#));
assert!(serialized.contains(r#""session_id":"1742468880123-42""#));
assert!(serialized.contains(r#""exit_code":0"#));
}
#[test]
fn test_metric_event_serialization_error() {
let event = MetricEvent {
ts: 1_700_000_000_000,
tool: "edit_replace",
duration_ms: 10,
output_chars: 0,
param_path_depth: 2,
max_depth: None,
result: "error",
error_type: Some("invalid_params".to_string()),
session_id: None,
seq: None,
cache_hit: None,
cache_write_failure: None,
exit_code: None,
timed_out: false,
cache_tier: None,
output_truncated: None,
chars_threshold_breach: false,
file_ext: None,
..Default::default()
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""error_type":"invalid_params""#));
}
#[test]
fn test_metric_event_error_subtype_some_serializes() {
let event = MetricEvent {
ts: 1_700_000_000_000,
tool: "edit_replace",
duration_ms: 10,
output_chars: 0,
param_path_depth: 2,
max_depth: None,
result: "error",
error_type: Some("invalid_params".to_string()),
error_subtype: Some("not_found".to_string()),
session_id: None,
seq: None,
cache_hit: None,
cache_write_failure: None,
exit_code: None,
timed_out: false,
cache_tier: None,
output_truncated: None,
chars_threshold_breach: false,
file_ext: None,
..Default::default()
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""error_subtype":"not_found""#));
}
#[test]
fn test_metric_event_error_subtype_ambiguous() {
let event = MetricEvent {
ts: 1_700_000_000_000,
tool: "edit_replace",
duration_ms: 10,
output_chars: 0,
param_path_depth: 2,
max_depth: None,
result: "error",
error_type: Some("invalid_params".to_string()),
error_subtype: Some("ambiguous".to_string()),
session_id: None,
seq: None,
cache_hit: None,
cache_write_failure: None,
exit_code: None,
timed_out: false,
cache_tier: None,
output_truncated: None,
chars_threshold_breach: false,
file_ext: None,
..Default::default()
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""error_subtype":"ambiguous""#));
}
#[test]
fn test_metric_event_new_fields_round_trip() {
let event = MetricEvent {
ts: 1_700_000_000_000,
tool: "analyze_file",
duration_ms: 100,
output_chars: 500,
param_path_depth: 2,
max_depth: Some(3),
result: "ok",
error_type: None,
error_subtype: None,
session_id: Some("1742468880123-42".to_string()),
seq: Some(5),
cache_hit: None,
cache_write_failure: None,
exit_code: None,
timed_out: false,
cache_tier: None,
output_truncated: None,
chars_threshold_breach: false,
file_ext: None,
filter_applied: None,
language: None,
git_ref_used: false,
summary_mode: false,
is_paginated: false,
fields_projected: false,
match_mode: None,
follow_depth: None,
import_lookup: false,
def_use: false,
impl_only: false,
stdin_provided: false,
timeout_configured_ms: None,
drain_timeout_ms: None,
working_dir_used: false,
l1_eviction_count: None,
l2_entry_count: None,
l2_size_bytes: None,
stdout_bytes_raw: None,
stderr_bytes_raw: None,
};
let serialized = serde_json::to_string(&event).unwrap();
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}"#;
assert_eq!(serialized, json_str);
}
}
#[test]
fn test_metric_event_builder_raw_bytes_serialize() {
let event = MetricEventBuilder::new("exec_command", "ok", 100)
.stdout_bytes_raw(12345)
.stderr_bytes_raw(6789)
.build();
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""stdout_bytes_raw":12345"#));
assert!(json.contains(r#""stderr_bytes_raw":6789"#));
}
#[test]
fn test_metric_event_builder_raw_bytes_skip_when_none() {
let event = MetricEventBuilder::new("exec_command", "ok", 100).build();
let json = serde_json::to_string(&event).unwrap();
assert!(!json.contains("stdout_bytes_raw"));
assert!(!json.contains("stderr_bytes_raw"));
}