1
1
//! Execute GraphQL operations from an MCP tool
2
2
3
- use crate :: errors:: McpError ;
3
+ use crate :: { errors:: McpError , meter:: get_meter} ;
4
+ use opentelemetry:: KeyValue ;
4
5
use reqwest:: header:: { HeaderMap , HeaderValue } ;
5
6
use reqwest_middleware:: { ClientBuilder , Extension } ;
6
7
use reqwest_tracing:: { OtelName , TracingMiddleware } ;
@@ -38,6 +39,9 @@ pub trait Executable {
38
39
/// Execute as a GraphQL operation using the endpoint and headers
39
40
#[ tracing:: instrument( skip( self ) ) ]
40
41
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 ;
41
45
let client_metadata = serde_json:: json!( {
42
46
"name" : "mcp" ,
43
47
"version" : std:: env!( "CARGO_PKG_VERSION" )
@@ -59,6 +63,7 @@ pub trait Executable {
59
63
"clientLibrary" : client_metadata,
60
64
} ) ,
61
65
) ;
66
+ op_id = Some ( id. to_string ( ) ) ;
62
67
} else {
63
68
let OperationDetails {
64
69
query,
@@ -74,6 +79,7 @@ pub trait Executable {
74
79
) ;
75
80
76
81
if let Some ( op_name) = operation_name {
82
+ op_id = Some ( op_name. clone ( ) ) ;
77
83
request_body. insert ( String :: from ( "operationName" ) , Value :: String ( op_name) ) ;
78
84
}
79
85
}
@@ -83,7 +89,7 @@ pub trait Executable {
83
89
. with ( TracingMiddleware :: default ( ) )
84
90
. build ( ) ;
85
91
86
- client
92
+ let result = client
87
93
. post ( request. endpoint . as_str ( ) )
88
94
. headers ( self . headers ( & request. headers ) )
89
95
. body ( Value :: Object ( request_body) . to_string ( ) )
@@ -118,7 +124,34 @@ pub trait Executable {
118
124
) ,
119
125
meta : None ,
120
126
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
122
155
}
123
156
}
124
157
@@ -127,6 +160,11 @@ mod test {
127
160
use crate :: errors:: McpError ;
128
161
use crate :: graphql:: { Executable , OperationDetails , Request } ;
129
162
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
+ } ;
130
168
use serde_json:: { Map , Value , json} ;
131
169
use url:: Url ;
132
170
@@ -366,4 +404,76 @@ mod test {
366
404
assert ! ( result. is_error. is_some( ) ) ;
367
405
assert ! ( result. is_error. unwrap( ) ) ;
368
406
}
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
+ }
369
479
}
0 commit comments