Skip to content

Commit c1cfddf

Browse files
swcollardDaleSeo
authored andcommitted
Implement metrics for mcp tool and operation counts and durations (#297)
* Implement metrics for mcp tool and operation counts and durations * Changeset * Unit test attribute setting in graphql.rs * Add axum_otel_metrics for emitting basic http metrics about requests * Lazy load singleton Meter for metrics * Alphabetize * Simplify result.is_error checking
1 parent 48fcdfa commit c1cfddf

File tree

9 files changed

+203
-12
lines changed

9 files changed

+203
-12
lines changed

.changesets/feat_otel_metrics.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
### Implement metrics for mcp tool and operation counts and durations - @swcollard PR #297
2+
3+
This PR adds metrics to count and measure request duration to events throughout the MCP server
4+
5+
* apollo.mcp.operation.duration
6+
* apollo.mcp.operation.count
7+
* apollo.mcp.tool.duration
8+
* apollo.mcp.tool.count
9+
* apollo.mcp.initialize.count
10+
* apollo.mcp.list_tools.count
11+
* apollo.mcp.get_info.count

Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/apollo-mcp-server/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ apollo-mcp-registry = { path = "../apollo-mcp-registry" }
1717
apollo-schema-index = { path = "../apollo-schema-index" }
1818
axum = "0.8.4"
1919
axum-extra = { version = "0.10.1", features = ["typed-header"] }
20+
axum-otel-metrics = "0.12.0"
2021
axum-tracing-opentelemetry = "0.29.0"
2122
bon = "3.6.3"
2223
clap = { version = "4.5.36", features = ["derive", "env"] }
@@ -65,6 +66,7 @@ chrono = { version = "0.4.41", default-features = false, features = ["now"] }
6566
figment = { version = "0.10.19", features = ["test"] }
6667
insta.workspace = true
6768
mockito = "1.7.0"
69+
opentelemetry_sdk = { version = "0.30.0", features = ["testing"] }
6870
rstest.workspace = true
6971
tokio.workspace = true
7072
tower = "0.5.2"

crates/apollo-mcp-server/src/graphql.rs

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Execute GraphQL operations from an MCP tool
22
3-
use crate::errors::McpError;
3+
use crate::{errors::McpError, meter::get_meter};
4+
use opentelemetry::KeyValue;
45
use reqwest::header::{HeaderMap, HeaderValue};
56
use reqwest_middleware::{ClientBuilder, Extension};
67
use reqwest_tracing::{OtelName, TracingMiddleware};
@@ -38,6 +39,9 @@ pub trait Executable {
3839
/// Execute as a GraphQL operation using the endpoint and headers
3940
#[tracing::instrument(skip(self))]
4041
async fn execute(&self, request: Request<'_>) -> Result<CallToolResult, McpError> {
42+
let meter = get_meter();
43+
let start = std::time::Instant::now();
44+
let mut op_id: Option<String> = None;
4145
let client_metadata = serde_json::json!({
4246
"name": "mcp",
4347
"version": std::env!("CARGO_PKG_VERSION")
@@ -59,6 +63,7 @@ pub trait Executable {
5963
"clientLibrary": client_metadata,
6064
}),
6165
);
66+
op_id = Some(id.to_string());
6267
} else {
6368
let OperationDetails {
6469
query,
@@ -74,6 +79,7 @@ pub trait Executable {
7479
);
7580

7681
if let Some(op_name) = operation_name {
82+
op_id = Some(op_name.clone());
7783
request_body.insert(String::from("operationName"), Value::String(op_name));
7884
}
7985
}
@@ -83,7 +89,7 @@ pub trait Executable {
8389
.with(TracingMiddleware::default())
8490
.build();
8591

86-
client
92+
let result = client
8793
.post(request.endpoint.as_str())
8894
.headers(self.headers(&request.headers))
8995
.body(Value::Object(request_body).to_string())
@@ -118,7 +124,34 @@ pub trait Executable {
118124
),
119125
meta: None,
120126
structured_content: Some(json),
121-
})
127+
});
128+
129+
// Record response metrics
130+
let attributes = vec![
131+
KeyValue::new(
132+
"success",
133+
result.as_ref().is_ok_and(|r| r.is_error != Some(true)),
134+
),
135+
KeyValue::new("operation.id", op_id.unwrap_or("unknown".to_string())),
136+
KeyValue::new(
137+
"operation.type",
138+
if self.persisted_query_id().is_some() {
139+
"persisted_query"
140+
} else {
141+
"operation"
142+
},
143+
),
144+
];
145+
meter
146+
.f64_histogram("apollo.mcp.operation.duration")
147+
.build()
148+
.record(start.elapsed().as_millis() as f64, &attributes);
149+
meter
150+
.u64_counter("apollo.mcp.operation.count")
151+
.build()
152+
.add(1, &attributes);
153+
154+
result
122155
}
123156
}
124157

@@ -127,6 +160,11 @@ mod test {
127160
use crate::errors::McpError;
128161
use crate::graphql::{Executable, OperationDetails, Request};
129162
use http::{HeaderMap, HeaderValue};
163+
use opentelemetry::global;
164+
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData};
165+
use opentelemetry_sdk::metrics::{
166+
InMemoryMetricExporter, MeterProviderBuilder, PeriodicReader,
167+
};
130168
use serde_json::{Map, Value, json};
131169
use url::Url;
132170

@@ -366,4 +404,76 @@ mod test {
366404
assert!(result.is_error.is_some());
367405
assert!(result.is_error.unwrap());
368406
}
407+
408+
#[tokio::test]
409+
async fn validate_metric_attributes_success_false() {
410+
// given
411+
let exporter = InMemoryMetricExporter::default();
412+
let meter_provider = MeterProviderBuilder::default()
413+
.with_reader(PeriodicReader::builder(exporter.clone()).build())
414+
.build();
415+
global::set_meter_provider(meter_provider.clone());
416+
417+
let mut server = mockito::Server::new_async().await;
418+
let url = Url::parse(server.url().as_str()).unwrap();
419+
let mock_request = Request {
420+
input: json!({}),
421+
endpoint: &url,
422+
headers: HeaderMap::new(),
423+
};
424+
425+
server
426+
.mock("POST", "/")
427+
.with_status(200)
428+
.with_header("content-type", "application/json")
429+
.with_body(json!({ "data": null, "errors": ["an error"] }).to_string())
430+
.expect(1)
431+
.create_async()
432+
.await;
433+
434+
// when
435+
let test_executable = TestExecutableWithPersistedQueryId {};
436+
let result = test_executable.execute(mock_request).await.unwrap();
437+
438+
// then
439+
assert!(result.is_error.is_some());
440+
assert!(result.is_error.unwrap());
441+
442+
// Retrieve the finished metrics from the exporter
443+
let finished_metrics = exporter.get_finished_metrics().unwrap();
444+
445+
// validate the attributes of the apollo.mcp.operation.count counter
446+
for resource_metrics in finished_metrics {
447+
if let Some(scope_metrics) = resource_metrics
448+
.scope_metrics()
449+
.find(|scope_metrics| scope_metrics.scope().name() == "apollo.mcp")
450+
{
451+
for metric in scope_metrics.metrics() {
452+
if metric.name() == "apollo.mcp.operation.count" {
453+
if let AggregatedMetrics::U64(MetricData::Sum(data)) = metric.data() {
454+
for point in data.data_points() {
455+
let attributes = point.attributes();
456+
let mut attr_map = std::collections::HashMap::new();
457+
for kv in attributes {
458+
attr_map.insert(kv.key.as_str(), kv.value.as_str());
459+
}
460+
assert_eq!(
461+
attr_map.get("operation.id").map(|s| s.as_ref()),
462+
Some("mock_operation")
463+
);
464+
assert_eq!(
465+
attr_map.get("operation.type").map(|s| s.as_ref()),
466+
Some("persisted_query")
467+
);
468+
assert_eq!(
469+
attr_map.get("success"),
470+
Some(&std::borrow::Cow::Borrowed("false"))
471+
);
472+
}
473+
}
474+
}
475+
}
476+
}
477+
}
478+
}
369479
}

crates/apollo-mcp-server/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod graphql;
88
pub mod health;
99
mod introspection;
1010
pub mod json_schema;
11+
pub(crate) mod meter;
1112
pub mod operations;
1213
pub mod sanitize;
1314
pub(crate) mod schema_tree_shake;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
use opentelemetry::{global, metrics::Meter};
2+
use std::sync::OnceLock;
3+
4+
static METER: OnceLock<Meter> = OnceLock::new();
5+
6+
pub fn get_meter() -> &'static Meter {
7+
METER.get_or_init(|| global::meter(env!("CARGO_PKG_NAME")))
8+
}

crates/apollo-mcp-server/src/runtime/trace.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use opentelemetry_sdk::{
44
metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider},
55
trace::{RandomIdGenerator, SdkTracerProvider},
66
};
7+
78
use opentelemetry_semantic_conventions::{
89
SCHEMA_URL,
910
attribute::{DEPLOYMENT_ENVIRONMENT_NAME, SERVICE_VERSION},

0 commit comments

Comments
 (0)