Align telemetry with OpenTelemetry MCP semantic conventions (#1139)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
This commit is contained in:
parent
89b67b0e70
commit
d52a7b183d
1 changed files with 226 additions and 77 deletions
|
|
@ -20,13 +20,13 @@ namespace ModelContextProtocol;
|
|||
internal sealed partial class McpSessionHandler : IAsyncDisposable
|
||||
{
|
||||
private static readonly Histogram<double> s_clientSessionDuration = Diagnostics.CreateDurationHistogram(
|
||||
"mcp.client.session.duration", "Measures the duration of a client session.", longBuckets: true);
|
||||
"mcp.client.session.duration", "The duration of the MCP session as observed on the MCP client.");
|
||||
private static readonly Histogram<double> s_serverSessionDuration = Diagnostics.CreateDurationHistogram(
|
||||
"mcp.server.session.duration", "Measures the duration of a server session.", longBuckets: true);
|
||||
"mcp.server.session.duration", "The duration of the MCP session as observed on the MCP server.");
|
||||
private static readonly Histogram<double> s_clientOperationDuration = Diagnostics.CreateDurationHistogram(
|
||||
"mcp.client.operation.duration", "Measures the duration of outbound message.", longBuckets: false);
|
||||
"mcp.client.operation.duration", "The duration of the MCP request or notification as observed on the sender from the time it was sent until the response or ack is received.");
|
||||
private static readonly Histogram<double> s_serverOperationDuration = Diagnostics.CreateDurationHistogram(
|
||||
"mcp.server.operation.duration", "Measures the duration of inbound message processing.", longBuckets: false);
|
||||
"mcp.server.operation.duration", "MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent.");
|
||||
|
||||
/// <summary>The latest version of the protocol supported by this implementation.</summary>
|
||||
internal const string LatestProtocolVersion = "2025-06-18";
|
||||
|
|
@ -59,6 +59,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
// This _sessionId is solely used to identify the session in telemetry and logs.
|
||||
private readonly string _sessionId = Guid.NewGuid().ToString("N");
|
||||
|
||||
private long _lastRequestId;
|
||||
|
||||
private CancellationTokenSource? _messageProcessingCts;
|
||||
|
|
@ -85,11 +86,11 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
_transportKind = transport switch
|
||||
{
|
||||
StdioClientSessionTransport or StdioServerTransport => "stdio",
|
||||
StreamClientSessionTransport or StreamServerTransport => "stream",
|
||||
SseClientSessionTransport or SseResponseStreamTransport => "sse",
|
||||
StreamableHttpClientSessionTransport or StreamableHttpServerTransport or StreamableHttpPostTransport => "http",
|
||||
_ => "unknownTransport"
|
||||
StdioClientSessionTransport or StdioServerTransport => "pipe",
|
||||
StreamClientSessionTransport or StreamServerTransport => "pipe",
|
||||
SseClientSessionTransport or SseResponseStreamTransport => "tcp",
|
||||
StreamableHttpClientSessionTransport or StreamableHttpServerTransport or StreamableHttpPostTransport => "tcp",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
_isServer = isServer;
|
||||
|
|
@ -106,6 +107,11 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
/// </summary>
|
||||
public string EndpointName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the negotiated MCP protocol version for telemetry.
|
||||
/// </summary>
|
||||
public string? NegotiatedProtocolVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts processing messages from the transport. This method will block until the transport is disconnected.
|
||||
/// This is generally started in a background task or thread from the initialization logic of the derived class.
|
||||
|
|
@ -260,13 +266,17 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
|
||||
|
||||
Activity? activity = Diagnostics.ShouldInstrumentMessage(message) ?
|
||||
Diagnostics.ActivitySource.StartActivity(
|
||||
CreateActivityName(method),
|
||||
Activity? activity = null;
|
||||
string? target = null;
|
||||
if (Diagnostics.ShouldInstrumentMessage(message))
|
||||
{
|
||||
target = ExtractTargetFromMessage(message, method);
|
||||
activity = Diagnostics.ActivitySource.StartActivity(
|
||||
CreateActivityName(method, target),
|
||||
ActivityKind.Server,
|
||||
parentContext: _propagator.ExtractActivityContext(message),
|
||||
links: Diagnostics.ActivityLinkFromCurrent()) :
|
||||
null;
|
||||
links: Diagnostics.ActivityLinkFromCurrent());
|
||||
}
|
||||
|
||||
TagList tags = default;
|
||||
bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null;
|
||||
|
|
@ -274,7 +284,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
{
|
||||
if (addTags)
|
||||
{
|
||||
AddTags(ref tags, activity, message, method);
|
||||
AddTags(ref tags, activity, message, method, target);
|
||||
}
|
||||
|
||||
switch (message)
|
||||
|
|
@ -420,9 +430,25 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
string method = request.Method;
|
||||
|
||||
long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
|
||||
using Activity? activity = Diagnostics.ShouldInstrumentMessage(request) ?
|
||||
Diagnostics.ActivitySource.StartActivity(McpSessionHandler.CreateActivityName(method), ActivityKind.Client) :
|
||||
null;
|
||||
|
||||
// If outer GenAI instrumentation is already tracing the tool execution,
|
||||
// add MCP attributes to that activity instead of creating a new one.
|
||||
Activity? activity = null;
|
||||
bool usingOuterActivity = false;
|
||||
string? target = null;
|
||||
if (Diagnostics.ShouldInstrumentMessage(request))
|
||||
{
|
||||
target = ExtractTargetFromMessage(request, method);
|
||||
if (method == RequestMethods.ToolsCall && Diagnostics.TryGetOuterToolExecutionActivity(out var outerActivity))
|
||||
{
|
||||
activity = outerActivity;
|
||||
usingOuterActivity = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
activity = Diagnostics.ActivitySource.StartActivity(CreateActivityName(method, target), ActivityKind.Client);
|
||||
}
|
||||
}
|
||||
|
||||
// Set request ID
|
||||
if (request.Id.Id is null)
|
||||
|
|
@ -441,7 +467,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
{
|
||||
if (addTags)
|
||||
{
|
||||
AddTags(ref tags, activity, request, method);
|
||||
AddTags(ref tags, activity, request, method, target);
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
|
|
@ -502,7 +528,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
finally
|
||||
{
|
||||
_pendingRequests.TryRemove(request.Id, out _);
|
||||
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags);
|
||||
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags, disposeActivity: !usingOuterActivity);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -516,9 +542,14 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
string method = GetMethodName(message);
|
||||
|
||||
long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
|
||||
using Activity? activity = Diagnostics.ShouldInstrumentMessage(message) ?
|
||||
Diagnostics.ActivitySource.StartActivity(McpSessionHandler.CreateActivityName(method), ActivityKind.Client) :
|
||||
null;
|
||||
|
||||
Activity? activity = null;
|
||||
string? target = null;
|
||||
if (Diagnostics.ShouldInstrumentMessage(message))
|
||||
{
|
||||
target = ExtractTargetFromMessage(message, method);
|
||||
activity = Diagnostics.ActivitySource.StartActivity(CreateActivityName(method, target), ActivityKind.Client);
|
||||
}
|
||||
|
||||
TagList tags = default;
|
||||
bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null;
|
||||
|
|
@ -530,7 +561,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
{
|
||||
if (addTags)
|
||||
{
|
||||
AddTags(ref tags, activity, message, method);
|
||||
AddTags(ref tags, activity, message, method, target);
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
|
|
@ -585,6 +616,38 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
private static string CreateActivityName(string method) => method;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a span name according to semantic conventions: "{mcp.method.name} {target}" where
|
||||
/// target is the tool name, prompt name, or resource URI when applicable.
|
||||
/// </summary>
|
||||
private static string CreateActivityName(string method, string? target) =>
|
||||
target is null ? method : $"{method} {target}";
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the target (tool name, prompt name, or resource URI) from a message for use in span naming.
|
||||
/// </summary>
|
||||
private static string? ExtractTargetFromMessage(JsonRpcMessage message, string method)
|
||||
{
|
||||
JsonObject? paramsObj = message switch
|
||||
{
|
||||
JsonRpcRequest request => request.Params as JsonObject,
|
||||
JsonRpcNotification notification => notification.Params as JsonObject,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (paramsObj is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return method switch
|
||||
{
|
||||
RequestMethods.ToolsCall or RequestMethods.PromptsGet => GetStringProperty(paramsObj, "name"),
|
||||
// Note: resource URI is not included in span name by default due to high cardinality per semantic conventions
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetMethodName(JsonRpcMessage message) =>
|
||||
message switch
|
||||
{
|
||||
|
|
@ -593,46 +656,45 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
_ => "unknownMethod"
|
||||
};
|
||||
|
||||
private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage message, string method)
|
||||
private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage message, string method, string? target)
|
||||
{
|
||||
tags.Add("mcp.method.name", method);
|
||||
tags.Add("network.transport", _transportKind);
|
||||
|
||||
// TODO: When using SSE transport, add:
|
||||
// - server.address and server.port on client spans and metrics
|
||||
// - client.address and client.port on server spans (not metrics because of cardinality) when using SSE transport
|
||||
if (_transportKind is "tcp")
|
||||
{
|
||||
tags.Add("network.protocol.name", "http");
|
||||
}
|
||||
|
||||
if (NegotiatedProtocolVersion is not null)
|
||||
{
|
||||
tags.Add("mcp.protocol.version", NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (activity is { IsAllDataRequested: true })
|
||||
{
|
||||
// session and request id have high cardinality, so not applying to metric tags
|
||||
activity.AddTag("mcp.session.id", _sessionId);
|
||||
|
||||
if (message is JsonRpcMessageWithId withId)
|
||||
{
|
||||
activity.AddTag("mcp.request.id", withId.Id.Id?.ToString());
|
||||
activity.AddTag("jsonrpc.request.id", withId.Id.Id?.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
JsonObject? paramsObj = message switch
|
||||
{
|
||||
JsonRpcRequest request => request.Params as JsonObject,
|
||||
JsonRpcNotification notification => notification.Params as JsonObject,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (paramsObj == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? target = null;
|
||||
switch (method)
|
||||
{
|
||||
case RequestMethods.ToolsCall:
|
||||
case RequestMethods.PromptsGet:
|
||||
target = GetStringProperty(paramsObj, "name");
|
||||
if (target is not null)
|
||||
{
|
||||
tags.Add(method == RequestMethods.ToolsCall ? "mcp.tool.name" : "mcp.prompt.name", target);
|
||||
tags.Add("gen_ai.tool.name", target);
|
||||
tags.Add("gen_ai.operation.name", "execute_tool");
|
||||
}
|
||||
break;
|
||||
|
||||
case RequestMethods.PromptsGet:
|
||||
if (target is not null)
|
||||
{
|
||||
tags.Add("gen_ai.prompt.name", target);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -640,18 +702,21 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
case RequestMethods.ResourcesSubscribe:
|
||||
case RequestMethods.ResourcesUnsubscribe:
|
||||
case NotificationMethods.ResourceUpdatedNotification:
|
||||
target = GetStringProperty(paramsObj, "uri");
|
||||
if (target is not null)
|
||||
{
|
||||
tags.Add("mcp.resource.uri", target);
|
||||
JsonObject? paramsObj = message switch
|
||||
{
|
||||
JsonRpcRequest request => request.Params as JsonObject,
|
||||
JsonRpcNotification notification => notification.Params as JsonObject,
|
||||
_ => null
|
||||
};
|
||||
string? uri = paramsObj is not null ? GetStringProperty(paramsObj, "uri") : null;
|
||||
if (uri is not null)
|
||||
{
|
||||
tags.Add("mcp.resource.uri", uri);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (activity is { IsAllDataRequested: true })
|
||||
{
|
||||
activity.DisplayName = target == null ? method : $"{method} {target}";
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddExceptionTags(ref TagList tags, Activity? activity, Exception e)
|
||||
|
|
@ -670,7 +735,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
tags.Add("error.type", errorType);
|
||||
if (intErrorCode is not null)
|
||||
{
|
||||
tags.Add("rpc.jsonrpc.error_code", errorType);
|
||||
tags.Add("rpc.response.status_code", errorType);
|
||||
}
|
||||
|
||||
if (activity is { IsAllDataRequested: true })
|
||||
|
|
@ -701,7 +766,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
}
|
||||
|
||||
private static void FinalizeDiagnostics(
|
||||
Activity? activity, long? startingTimestamp, Histogram<double> durationMetric, ref TagList tags)
|
||||
Activity? activity, long? startingTimestamp, Histogram<double> durationMetric, ref TagList tags, bool disposeActivity = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -720,7 +785,11 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
}
|
||||
finally
|
||||
{
|
||||
activity?.Dispose();
|
||||
// Only dispose the activity if we created it (not when reusing an outer GenAI activity)
|
||||
if (disposeActivity)
|
||||
{
|
||||
activity?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue