Skip to content

Commit 131d764

Browse files
feat!: telemetry metrics updates as per semantic convention (#2566)
## Description This PR updates MCP telemetry to align with [OTel semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#metrics) by removing all existing transport-specific and HTTP-API metrics and introducing a unified, future-proof set. It adds 1. `mcp.server.operation.duration` to capture per-method request rate, latency, and errors across all MCP transports; 2. `mcp.server.session.duration` to track session lifecycles for stdio and SSE; 3. `toolbox.server.mcp.active_sessions` for real-time capacity, saturation, and leak detection. 4. `toolbox.tool.operation.duration` to isolate backend tool execution time from MCP/protocol overhead, enabling clearer attribution of performance bottlenecks between the MCP layer and tool runtime. Together, these changes simplify the metrics surface while significantly improving observability and debuggability with minimal runtime overhead. ## PR Checklist - [x] Make sure you reviewed [CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md) - [x] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/genai-toolbox/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [x] Ensure the tests and linter pass - [x] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) - [x] Make sure to add `!` if this involve a breaking change 🛠️ Fixes #2222 --------- Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com>
1 parent a136e16 commit 131d764

8 files changed

Lines changed: 337 additions & 110 deletions

File tree

internal/server/api.go

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import (
2828
"github.com/googleapis/genai-toolbox/internal/util/parameters"
2929
"go.opentelemetry.io/otel/attribute"
3030
"go.opentelemetry.io/otel/codes"
31-
"go.opentelemetry.io/otel/metric"
3231
)
3332

3433
// apiRouter creates a router that represents the routes under /api
@@ -64,17 +63,6 @@ func toolsetHandler(s *Server, w http.ResponseWriter, r *http.Request) {
6463
span.SetStatus(codes.Error, err.Error())
6564
}
6665
span.End()
67-
68-
status := "success"
69-
if err != nil {
70-
status = "error"
71-
}
72-
s.instrumentation.ToolsetGet.Add(
73-
r.Context(),
74-
1,
75-
metric.WithAttributes(attribute.String("toolbox.name", toolsetName)),
76-
metric.WithAttributes(attribute.String("toolbox.operation.status", status)),
77-
)
7866
}()
7967

8068
toolset, ok := s.ResourceMgr.GetToolset(toolsetName)
@@ -101,18 +89,8 @@ func toolGetHandler(s *Server, w http.ResponseWriter, r *http.Request) {
10189
span.SetStatus(codes.Error, err.Error())
10290
}
10391
span.End()
104-
105-
status := "success"
106-
if err != nil {
107-
status = "error"
108-
}
109-
s.instrumentation.ToolGet.Add(
110-
r.Context(),
111-
1,
112-
metric.WithAttributes(attribute.String("toolbox.name", toolName)),
113-
metric.WithAttributes(attribute.String("toolbox.operation.status", status)),
114-
)
11592
}()
93+
11694
tool, ok := s.ResourceMgr.GetTool(toolName)
11795
if !ok {
11896
err = fmt.Errorf("invalid tool name: tool with name %q does not exist", toolName)
@@ -146,17 +124,6 @@ func toolInvokeHandler(s *Server, w http.ResponseWriter, r *http.Request) {
146124
span.SetStatus(codes.Error, err.Error())
147125
}
148126
span.End()
149-
150-
status := "success"
151-
if err != nil {
152-
status = "error"
153-
}
154-
s.instrumentation.ToolInvoke.Add(
155-
r.Context(),
156-
1,
157-
metric.WithAttributes(attribute.String("toolbox.name", toolName)),
158-
metric.WithAttributes(attribute.String("toolbox.operation.status", status)),
159-
)
160127
}()
161128

162129
tool, ok := s.ResourceMgr.GetTool(toolName)

internal/server/mcp.go

Lines changed: 113 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,45 @@ func (s *stdioSession) Start(ctx context.Context) error {
184184

185185
// readInputStream reads requests/notifications from MCP clients through stdin
186186
func (s *stdioSession) readInputStream(ctx context.Context) error {
187+
sessionStart := time.Now()
188+
189+
// Define attributes for session metrics
190+
// Note: mcp.protocol.version is added dynamically after protocol negotiation
191+
sessionAttrs := []attribute.KeyValue{
192+
attribute.String("network.transport", "pipe"),
193+
attribute.String("network.protocol.name", "stdio"),
194+
}
195+
196+
s.server.instrumentation.McpActiveSessions.Add(ctx, 1, metric.WithAttributes(sessionAttrs...))
197+
198+
var err error
199+
defer func() {
200+
// Build full attributes including mcp.protocol.version if negotiated
201+
fullAttrs := sessionAttrs
202+
if s.protocol != "" {
203+
fullAttrs = append(fullAttrs, attribute.String("mcp.protocol.version", s.protocol))
204+
}
205+
206+
// Decrement active sessions counter
207+
s.server.instrumentation.McpActiveSessions.Add(ctx, -1, metric.WithAttributes(fullAttrs...))
208+
209+
// Record session duration
210+
sessionDuration := time.Since(sessionStart).Seconds()
211+
durationAttrs := make([]attribute.KeyValue, len(fullAttrs))
212+
copy(durationAttrs, fullAttrs)
213+
if err != nil && err != io.EOF {
214+
durationAttrs = append(durationAttrs, attribute.String("error.type", err.Error()))
215+
}
216+
s.server.instrumentation.McpSessionDuration.Record(ctx, sessionDuration, metric.WithAttributes(durationAttrs...))
217+
}()
218+
187219
for {
188-
if err := ctx.Err(); err != nil {
220+
if err = ctx.Err(); err != nil {
189221
return err
190222
}
191-
line, err := s.readLine(ctx)
223+
224+
var line string
225+
line, err = s.readLine(ctx)
192226
if err != nil {
193227
if err == io.EOF {
194228
return nil
@@ -206,7 +240,9 @@ func (s *stdioSession) readInputStream(ctx context.Context) error {
206240
)
207241
defer span.End()
208242

209-
v, res, err := processMcpMessage(msgCtx, []byte(line), s.server, s.protocol, "", "", nil, "")
243+
var v string
244+
var res any
245+
v, res, err = processMcpMessage(msgCtx, []byte(line), s.server, s.protocol, "", "", nil, "")
210246
if err != nil {
211247
// errors during the processing of message will generate a valid MCP Error response.
212248
// server can continue to run.
@@ -309,6 +345,7 @@ func mcpRouter(s *Server) (chi.Router, error) {
309345

310346
// sseHandler handles sse initialization and message.
311347
func sseHandler(s *Server, w http.ResponseWriter, r *http.Request) {
348+
sessionStart := time.Now()
312349
ctx, span := s.instrumentation.Tracer.Start(r.Context(), "toolbox/server/mcp/sse",
313350
trace.WithSpanKind(trace.SpanKindServer),
314351
)
@@ -325,23 +362,34 @@ func sseHandler(s *Server, w http.ResponseWriter, r *http.Request) {
325362
w.Header().Set("Connection", "keep-alive")
326363
w.Header().Set("Access-Control-Allow-Origin", "*")
327364

365+
// Define attributes for session metrics
366+
networkProtocolVersion := fmt.Sprintf("%d.%d", r.ProtoMajor, r.ProtoMinor)
367+
sessionAttrs := []attribute.KeyValue{
368+
attribute.String("network.transport", "tcp"),
369+
attribute.String("network.protocol.name", "http"),
370+
attribute.String("network.protocol.version", networkProtocolVersion),
371+
attribute.String("mcp.protocol.version", "2024-11-05"),
372+
attribute.String("toolset.name", toolsetName),
373+
}
374+
375+
// Increment active sessions counter
376+
s.instrumentation.McpActiveSessions.Add(ctx, 1, metric.WithAttributes(sessionAttrs...))
377+
328378
var err error
329379
defer func() {
380+
// Decrement active sessions counter
381+
s.instrumentation.McpActiveSessions.Add(ctx, -1, metric.WithAttributes(sessionAttrs...))
382+
383+
// Record session duration
384+
sessionDuration := time.Since(sessionStart).Seconds()
385+
durationAttrs := make([]attribute.KeyValue, len(sessionAttrs))
386+
copy(durationAttrs, sessionAttrs)
330387
if err != nil {
331388
span.SetStatus(codes.Error, err.Error())
389+
durationAttrs = append(durationAttrs, attribute.String("error.type", err.Error()))
332390
}
391+
s.instrumentation.McpSessionDuration.Record(ctx, sessionDuration, metric.WithAttributes(durationAttrs...))
333392
span.End()
334-
status := "success"
335-
if err != nil {
336-
status = "error"
337-
}
338-
s.instrumentation.McpSse.Add(
339-
r.Context(),
340-
1,
341-
metric.WithAttributes(attribute.String("toolbox.toolset.name", toolsetName)),
342-
metric.WithAttributes(attribute.String("toolbox.sse.sessionId", sessionId)),
343-
metric.WithAttributes(attribute.String("toolbox.operation.status", status)),
344-
)
345393
}()
346394

347395
flusher, ok := w.(http.Flusher)
@@ -474,17 +522,6 @@ func httpHandler(s *Server, w http.ResponseWriter, r *http.Request) {
474522
span.SetStatus(codes.Error, err.Error())
475523
}
476524
span.End()
477-
478-
status := "success"
479-
if err != nil {
480-
status = "error"
481-
}
482-
s.instrumentation.McpPost.Add(
483-
r.Context(),
484-
1,
485-
metric.WithAttributes(attribute.String("toolbox.sse.sessionId", sessionId)),
486-
metric.WithAttributes(attribute.String("toolbox.operation.status", status)),
487-
)
488525
}()
489526

490527
networkProtocolVersion := fmt.Sprintf("%d.%d", r.ProtoMajor, r.ProtoMinor)
@@ -540,6 +577,8 @@ func httpHandler(s *Server, w http.ResponseWriter, r *http.Request) {
540577

541578
// processMcpMessage process the messages received from clients
542579
func processMcpMessage(ctx context.Context, body []byte, s *Server, protocolVersion string, toolsetName string, promptsetName string, header http.Header, networkProtocolVersion string) (string, any, error) {
580+
operationStart := time.Now()
581+
543582
logger, err := util.LoggerFromContext(ctx)
544583
if err != nil {
545584
return "", jsonrpc.NewError("", jsonrpc.INTERNAL_ERROR, err.Error(), nil), err
@@ -590,6 +629,44 @@ func processMcpMessage(ctx context.Context, body []byte, s *Server, protocolVers
590629
networkProtocolName = "http"
591630
}
592631

632+
var metricErrorType string
633+
genAIAttrs := &util.GenAIMetricAttrs{
634+
NetworkProtocolName: networkProtocolName,
635+
NetworkProtocolVersion: networkProtocolVersion,
636+
}
637+
ctx = util.WithGenAIMetricAttrs(ctx, genAIAttrs)
638+
639+
// Record operation duration metric on function exit
640+
defer func() {
641+
operationDuration := time.Since(operationStart).Seconds()
642+
durationAttrs := []attribute.KeyValue{
643+
attribute.String("mcp.method.name", baseMessage.Method),
644+
attribute.String("network.transport", networkTransport),
645+
attribute.String("network.protocol.name", networkProtocolName),
646+
attribute.String("toolset.name", toolsetName),
647+
}
648+
if protocolVersion != "" {
649+
durationAttrs = append(durationAttrs, attribute.String("mcp.protocol.version", protocolVersion))
650+
}
651+
if networkProtocolVersion != "" {
652+
durationAttrs = append(durationAttrs, attribute.String("network.protocol.version", networkProtocolVersion))
653+
}
654+
// Add gen_ai attributes populated by method handlers
655+
if genAIAttrs.OperationName != "" {
656+
durationAttrs = append(durationAttrs, attribute.String("gen_ai.operation.name", genAIAttrs.OperationName))
657+
}
658+
if genAIAttrs.ToolName != "" {
659+
durationAttrs = append(durationAttrs, attribute.String("gen_ai.tool.name", genAIAttrs.ToolName))
660+
}
661+
if genAIAttrs.PromptName != "" {
662+
durationAttrs = append(durationAttrs, attribute.String("gen_ai.prompt.name", genAIAttrs.PromptName))
663+
}
664+
if metricErrorType != "" {
665+
durationAttrs = append(durationAttrs, attribute.String("error.type", metricErrorType))
666+
}
667+
s.instrumentation.McpOperationDuration.Record(ctx, operationDuration, metric.WithAttributes(durationAttrs...))
668+
}()
669+
593670
// Set required semantic attributes for span according to OTEL MCP semcov
594671
// ref: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#server
595672
span.SetAttributes(
@@ -625,14 +702,18 @@ func processMcpMessage(ctx context.Context, body []byte, s *Server, protocolVers
625702
return "", nil, err
626703
}
627704

705+
// Add instrumentation to context for use in method handlers
706+
ctx = util.WithInstrumentation(ctx, s.instrumentation)
707+
628708
// Process the method
629709
switch baseMessage.Method {
630710
case mcputil.INITIALIZE:
631711
result, version, err := mcp.InitializeResponse(ctx, baseMessage.Id, body, s.version)
632712
if err != nil {
633713
span.SetStatus(codes.Error, err.Error())
634714
if rpcErr, ok := result.(jsonrpc.JSONRPCError); ok {
635-
span.SetAttributes(attribute.String("error.type", rpcErr.Error.String()))
715+
metricErrorType = rpcErr.Error.String()
716+
span.SetAttributes(attribute.String("error.type", metricErrorType))
636717
}
637718
return "", result, err
638719
}
@@ -643,25 +724,28 @@ func processMcpMessage(ctx context.Context, body []byte, s *Server, protocolVers
643724
if !ok {
644725
err := fmt.Errorf("toolset does not exist")
645726
rpcErr := jsonrpc.NewError(baseMessage.Id, jsonrpc.INVALID_REQUEST, err.Error(), nil)
727+
metricErrorType = rpcErr.Error.String()
646728
span.SetStatus(codes.Error, err.Error())
647-
span.SetAttributes(attribute.String("error.type", rpcErr.Error.String()))
729+
span.SetAttributes(attribute.String("error.type", metricErrorType))
648730
return "", rpcErr, err
649731
}
650732
promptset, ok := s.ResourceMgr.GetPromptset(promptsetName)
651733
if !ok {
652734
err := fmt.Errorf("promptset does not exist")
653735
rpcErr := jsonrpc.NewError(baseMessage.Id, jsonrpc.INVALID_REQUEST, err.Error(), nil)
736+
metricErrorType = rpcErr.Error.String()
654737
span.SetStatus(codes.Error, err.Error())
655-
span.SetAttributes(attribute.String("error.type", rpcErr.Error.String()))
738+
span.SetAttributes(attribute.String("error.type", metricErrorType))
656739
return "", rpcErr, err
657740
}
658741
result, err := mcp.ProcessMethod(ctx, protocolVersion, baseMessage.Id, baseMessage.Method, toolset, promptset, s.ResourceMgr, body, header)
659742
if err != nil {
660743
span.SetStatus(codes.Error, err.Error())
661744
// Set error.type based on JSON-RPC error code
662745
if rpcErr, ok := result.(jsonrpc.JSONRPCError); ok {
746+
metricErrorType = rpcErr.Error.String()
663747
span.SetAttributes(attribute.Int("jsonrpc.error.code", rpcErr.Error.Code))
664-
span.SetAttributes(attribute.String("error.type", rpcErr.Error.String()))
748+
span.SetAttributes(attribute.String("error.type", metricErrorType))
665749
}
666750
}
667751
return "", result, err

internal/server/mcp/v20241105/method.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"errors"
2222
"fmt"
2323
"net/http"
24+
"time"
2425

2526
"github.com/googleapis/genai-toolbox/internal/prompts"
2627
"github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
@@ -29,6 +30,7 @@ import (
2930
"github.com/googleapis/genai-toolbox/internal/util"
3031
"github.com/googleapis/genai-toolbox/internal/util/parameters"
3132
"go.opentelemetry.io/otel/attribute"
33+
"go.opentelemetry.io/otel/metric"
3234
"go.opentelemetry.io/otel/trace"
3335
)
3436

@@ -111,12 +113,19 @@ func toolsCallHandler(ctx context.Context, id jsonrpc.RequestId, resourceMgr *re
111113
attribute.String("gen_ai.tool.name", toolName),
112114
attribute.String("gen_ai.operation.name", "execute_tool"),
113115
)
116+
114117
tool, ok := resourceMgr.GetTool(toolName)
115118
if !ok {
116119
err = fmt.Errorf("invalid tool name: tool with name %q does not exist", toolName)
117120
return jsonrpc.NewError(id, jsonrpc.INVALID_PARAMS, err.Error(), nil), err
118121
}
119122

123+
// Populate gen_ai attributes for operation duration metric
124+
if genAIAttrs := util.GenAIMetricAttrsFromContext(ctx); genAIAttrs != nil {
125+
genAIAttrs.OperationName = "execute_tool"
126+
genAIAttrs.ToolName = toolName
127+
}
128+
120129
// Get access token
121130
authTokenHeadername, err := tool.GetAuthTokenHeaderName(resourceMgr)
122131
if err != nil {
@@ -209,8 +218,34 @@ func toolsCallHandler(ctx context.Context, id jsonrpc.RequestId, resourceMgr *re
209218
return jsonrpc.NewError(id, jsonrpc.INVALID_PARAMS, err.Error(), nil), err
210219
}
211220

221+
// Get instrumentation for recording tool execution duration
222+
instrumentation, instrumentationErr := util.InstrumentationFromContext(ctx)
223+
212224
// run tool invocation and generate response.
225+
executionStart := time.Now()
213226
results, err := tool.Invoke(ctx, resourceMgr, params, accessToken)
227+
executionDuration := time.Since(executionStart).Seconds()
228+
229+
// Record tool execution duration metric
230+
if instrumentationErr == nil {
231+
execAttrs := []attribute.KeyValue{
232+
attribute.String("gen_ai.tool.name", toolName),
233+
}
234+
// Add network protocol attributes from context
235+
if genAIAttrs := util.GenAIMetricAttrsFromContext(ctx); genAIAttrs != nil {
236+
if genAIAttrs.NetworkProtocolName != "" {
237+
execAttrs = append(execAttrs, attribute.String("network.protocol.name", genAIAttrs.NetworkProtocolName))
238+
}
239+
if genAIAttrs.NetworkProtocolVersion != "" {
240+
execAttrs = append(execAttrs, attribute.String("network.protocol.version", genAIAttrs.NetworkProtocolVersion))
241+
}
242+
}
243+
if err != nil {
244+
execAttrs = append(execAttrs, attribute.String("error.type", err.Error()))
245+
}
246+
instrumentation.ToolExecutionDuration.Record(ctx, executionDuration, metric.WithAttributes(execAttrs...))
247+
}
248+
214249
if err != nil {
215250
var tbErr util.ToolboxError
216251

@@ -325,12 +360,19 @@ func promptsGetHandler(ctx context.Context, id jsonrpc.RequestId, resourceMgr *r
325360
span := trace.SpanFromContext(ctx)
326361
span.SetName(fmt.Sprintf("%s %s", PROMPTS_GET, promptName))
327362
span.SetAttributes(attribute.String("gen_ai.prompt.name", promptName))
363+
328364
prompt, ok := resourceMgr.GetPrompt(promptName)
329365
if !ok {
330366
err := fmt.Errorf("prompt with name %q does not exist", promptName)
331367
return jsonrpc.NewError(id, jsonrpc.INVALID_PARAMS, err.Error(), nil), err
332368
}
333369

370+
// Populate gen_ai attributes for operation duration metric
371+
if genAIAttrs := util.GenAIMetricAttrsFromContext(ctx); genAIAttrs != nil {
372+
genAIAttrs.OperationName = "get_prompt"
373+
genAIAttrs.PromptName = promptName
374+
}
375+
334376
// Parse the arguments provided in the request.
335377
argValues, err := prompt.ParseArgs(req.Params.Arguments, nil)
336378
if err != nil {

0 commit comments

Comments
 (0)