Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changesets/feat_otel_metrics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### Implement metrics for mcp tool and operation counts and durations - @swcollard PR #297

This PR adds metrics to count and measure request duration to events throughout the MCP server

* apollo.mcp.operation.duration
* apollo.mcp.operation.count
* apollo.mcp.tool.duration
* apollo.mcp.tool.count
* apollo.mcp.initialize.count
* apollo.mcp.list_tools.count
* apollo.mcp.get_info.count
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/apollo-mcp-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ apollo-mcp-registry = { path = "../apollo-mcp-registry" }
apollo-schema-index = { path = "../apollo-schema-index" }
axum = "0.8.4"
axum-extra = { version = "0.10.1", features = ["typed-header"] }
axum-otel-metrics = "0.12.0"
axum-tracing-opentelemetry = "0.29.0"
bon = "3.6.3"
clap = { version = "4.5.36", features = ["derive", "env"] }
Expand Down Expand Up @@ -61,6 +62,7 @@ chrono = { version = "0.4.41", default-features = false, features = ["now"] }
figment = { version = "0.10.19", features = ["test"] }
insta.workspace = true
mockito = "1.7.0"
opentelemetry_sdk = { version = "0.30.0", features = ["testing"] }
rstest.workspace = true
tokio.workspace = true
tracing-test = "0.2.5"
Expand Down
116 changes: 113 additions & 3 deletions crates/apollo-mcp-server/src/graphql.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Execute GraphQL operations from an MCP tool

use crate::errors::McpError;
use crate::{errors::McpError, meter::get_meter};
use opentelemetry::KeyValue;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest_middleware::{ClientBuilder, Extension};
use reqwest_tracing::{OtelName, TracingMiddleware};
Expand Down Expand Up @@ -38,6 +39,9 @@ pub trait Executable {
/// Execute as a GraphQL operation using the endpoint and headers
#[tracing::instrument(skip(self))]
async fn execute(&self, request: Request<'_>) -> Result<CallToolResult, McpError> {
let meter = get_meter();
let start = std::time::Instant::now();
let mut op_id: Option<String> = None;
let client_metadata = serde_json::json!({
"name": "mcp",
"version": std::env!("CARGO_PKG_VERSION")
Expand All @@ -59,6 +63,7 @@ pub trait Executable {
"clientLibrary": client_metadata,
}),
);
op_id = Some(id.to_string());
} else {
let OperationDetails {
query,
Expand All @@ -74,6 +79,7 @@ pub trait Executable {
);

if let Some(op_name) = operation_name {
op_id = Some(op_name.clone());
request_body.insert(String::from("operationName"), Value::String(op_name));
}
}
Expand All @@ -83,7 +89,7 @@ pub trait Executable {
.with(TracingMiddleware::default())
.build();

client
let result = client
.post(request.endpoint.as_str())
.headers(self.headers(&request.headers))
.body(Value::Object(request_body).to_string())
Expand Down Expand Up @@ -116,7 +122,34 @@ pub trait Executable {
.filter(|value| !matches!(value, Value::Null))
.is_none(),
),
})
});

// Record response metrics
let attributes = vec![
KeyValue::new(
"success",
result.as_ref().is_ok_and(|r| r.is_error != Some(true)),
),
KeyValue::new("operation.id", op_id.unwrap_or("unknown".to_string())),
KeyValue::new(
"operation.type",
if self.persisted_query_id().is_some() {
"persisted_query"
} else {
"operation"
},
),
];
meter
.f64_histogram("apollo.mcp.operation.duration")
.build()
.record(start.elapsed().as_millis() as f64, &attributes);
meter
.u64_counter("apollo.mcp.operation.count")
.build()
.add(1, &attributes);

result
}
}

Expand All @@ -125,6 +158,11 @@ mod test {
use crate::errors::McpError;
use crate::graphql::{Executable, OperationDetails, Request};
use http::{HeaderMap, HeaderValue};
use opentelemetry::global;
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData};
use opentelemetry_sdk::metrics::{
InMemoryMetricExporter, MeterProviderBuilder, PeriodicReader,
};
use serde_json::{Map, Value, json};
use url::Url;

Expand Down Expand Up @@ -364,4 +402,76 @@ mod test {
assert!(result.is_error.is_some());
assert!(result.is_error.unwrap());
}

#[tokio::test]
async fn validate_metric_attributes_success_false() {
// given
let exporter = InMemoryMetricExporter::default();
let meter_provider = MeterProviderBuilder::default()
.with_reader(PeriodicReader::builder(exporter.clone()).build())
.build();
global::set_meter_provider(meter_provider.clone());

let mut server = mockito::Server::new_async().await;
let url = Url::parse(server.url().as_str()).unwrap();
let mock_request = Request {
input: json!({}),
endpoint: &url,
headers: HeaderMap::new(),
};

server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({ "data": null, "errors": ["an error"] }).to_string())
.expect(1)
.create_async()
.await;

// when
let test_executable = TestExecutableWithPersistedQueryId {};
let result = test_executable.execute(mock_request).await.unwrap();

// then
assert!(result.is_error.is_some());
assert!(result.is_error.unwrap());

// Retrieve the finished metrics from the exporter
let finished_metrics = exporter.get_finished_metrics().unwrap();

// validate the attributes of the apollo.mcp.operation.count counter
for resource_metrics in finished_metrics {
if let Some(scope_metrics) = resource_metrics
.scope_metrics()
.find(|scope_metrics| scope_metrics.scope().name() == "apollo.mcp")
{
for metric in scope_metrics.metrics() {
if metric.name() == "apollo.mcp.operation.count" {
if let AggregatedMetrics::U64(MetricData::Sum(data)) = metric.data() {
for point in data.data_points() {
let attributes = point.attributes();
let mut attr_map = std::collections::HashMap::new();
for kv in attributes {
attr_map.insert(kv.key.as_str(), kv.value.as_str());
}
assert_eq!(
attr_map.get("operation.id").map(|s| s.as_ref()),
Some("mock_operation")
);
assert_eq!(
attr_map.get("operation.type").map(|s| s.as_ref()),
Some("persisted_query")
);
assert_eq!(
attr_map.get("success"),
Some(&std::borrow::Cow::Borrowed("false"))
);
}
}
}
}
}
}
}
}
1 change: 1 addition & 0 deletions crates/apollo-mcp-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod graphql;
pub mod health;
mod introspection;
pub mod json_schema;
pub(crate) mod meter;
pub mod operations;
pub mod sanitize;
pub(crate) mod schema_tree_shake;
Expand Down
8 changes: 8 additions & 0 deletions crates/apollo-mcp-server/src/meter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use opentelemetry::{global, metrics::Meter};
use std::sync::OnceLock;

static METER: OnceLock<Meter> = OnceLock::new();

pub fn get_meter() -> &'static Meter {
METER.get_or_init(|| global::meter(env!("CARGO_PKG_NAME")))
}
1 change: 1 addition & 0 deletions crates/apollo-mcp-server/src/runtime/trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use opentelemetry_sdk::{
metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider},
trace::{RandomIdGenerator, SdkTracerProvider},
};

use opentelemetry_semantic_conventions::{
SCHEMA_URL,
attribute::{DEPLOYMENT_ENVIRONMENT_NAME, SERVICE_VERSION},
Expand Down
Loading