From b2f759366ac38d24ea74af1f1bd684a7e7c12d99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 18:36:35 +0000 Subject: [PATCH] .NET: Auto-wire ChatClient with OpenTelemetryChatClient in OpenTelemetryAgent Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/96dd033a-0c48-4d3f-9148-324bfd436b75 Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Microsoft.Agents.AI/OpenTelemetryAgent.cs | 74 +++++++- .../OpenTelemetryAgentBuilderExtensions.cs | 33 +++- ...penTelemetryAgentBuilderExtensionsTests.cs | 55 ++++++ .../OpenTelemetryAgentTests.cs | 162 ++++++++++++++++++ 4 files changed, 320 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index fd1c2fd7f5..567742102f 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -32,6 +32,13 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable private readonly OpenTelemetryChatClient _otelClient; /// The provider name extracted from . private readonly string? _providerName; + /// The configured source name for telemetry. May be to use the default. + private readonly string? _sourceName; + /// + /// Indicates whether the underlying of a inner agent + /// should be automatically wrapped with on each invocation. + /// + private readonly bool _autoWireChatClient; /// Initializes a new instance of the class. /// The underlying to be augmented with telemetry capabilities. @@ -39,14 +46,22 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable /// An optional source name that will be used to identify telemetry data from this agent. /// If not provided, a default source name will be used for telemetry identification. /// + /// + /// When (the default) and the inner agent is a , the underlying + /// is automatically wrapped with for each invocation + /// so that chat-level telemetry flows alongside agent-level telemetry. If the underlying chat client is already + /// instrumented, no additional wrapping is applied. Set to to opt-out of this behavior. + /// /// is . /// /// The constructor automatically extracts provider metadata from the inner agent and configures /// telemetry collection according to OpenTelemetry semantic conventions for AI systems. /// - public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) : base(innerAgent) + public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null, bool autoWireChatClient = true) : base(innerAgent) { this._providerName = innerAgent.GetService()?.ProviderName; + this._sourceName = sourceName; + this._autoWireChatClient = autoWireChatClient; this._otelClient = new OpenTelemetryChatClient( new ForwardingChatClient(this), @@ -163,6 +178,53 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable public Activity? CurrentActivity { get; } } + /// + /// If auto-wiring is enabled and the inner agent is a whose underlying + /// is not already instrumented with , returns a + /// new with a that + /// wraps the chat client with . Otherwise, returns unchanged. + /// + private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options) + { + if (!this._autoWireChatClient) + { + return options; + } + + // The auto-wiring only applies when the inner agent is a ChatClientAgent. Otherwise, no-op. + if (this.InnerAgent.GetService() is null) + { + return options; + } + + // Capture the underlying IChatClient and check whether it is already instrumented. + var chatClient = this.InnerAgent.GetService(); + if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null) + { + return options; + } + + string? sourceName = this._sourceName; + IChatClient WrapWithOpenTelemetry(IChatClient cc) => + cc.GetService(typeof(OpenTelemetryChatClient)) is not null + ? cc + : cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(); + + if (options is ChatClientAgentRunOptions ccOptions) + { + // Don't mutate the caller's options; clone and chain any caller-provided factory. + var clone = (ChatClientAgentRunOptions)ccOptions.Clone(); + var userFactory = clone.ChatClientFactory; + clone.ChatClientFactory = cc => WrapWithOpenTelemetry(userFactory is null ? cc : userFactory(cc)); + return clone; + } + + return new ChatClientAgentRunOptions + { + ChatClientFactory = WrapWithOpenTelemetry, + }; + } + /// The stub used to delegate from the into the inner . /// private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient @@ -175,8 +237,11 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable // Update the current activity to reflect the agent invocation. parentAgent.UpdateCurrentActivity(fo?.CurrentActivity); + // If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory. + var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options); + // Invoke the inner agent. - var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false); + var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false); // Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient. return response.AsChatResponse(); @@ -190,8 +255,11 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable // Update the current activity to reflect the agent invocation. parentAgent.UpdateCurrentActivity(fo?.CurrentActivity); + // If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory. + var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options); + // Invoke the inner agent. - await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false)) + await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false)) { // Wrap the response updates in ChatResponseUpdates so we can pass them back through OpenTelemetryChatClient. yield return update.AsChatResponseUpdate(); diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs index 8f83a8dda1..1ccf5b84a5 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -48,9 +49,39 @@ public static class OpenTelemetryAgentBuilderExtensions this AIAgentBuilder builder, string? sourceName = null, Action? configure = null) => + builder.UseOpenTelemetry(autoWireChatClient: true, sourceName: sourceName, configure: configure); + + /// + /// Adds OpenTelemetry instrumentation to the agent pipeline, with explicit control over whether the underlying + /// of a inner agent is automatically wrapped with + /// . + /// + /// The to which OpenTelemetry support will be added. + /// + /// When , the underlying of a inner agent + /// is automatically wrapped with on each invocation so + /// that chat-level telemetry flows alongside agent-level telemetry. If the underlying chat client is already + /// instrumented, no additional wrapping is applied. If the inner agent is not a , no + /// auto-wiring is performed. Set to to opt out of this behavior. + /// + /// + /// An optional source name that will be used to identify telemetry data from this agent. + /// If not specified, a default source name will be used. + /// + /// + /// An optional callback that provides additional configuration of the instance. + /// This allows for fine-tuning telemetry behavior such as enabling sensitive data collection. + /// + /// The with OpenTelemetry instrumentation added, enabling method chaining. + /// is . + public static AIAgentBuilder UseOpenTelemetry( + this AIAgentBuilder builder, + bool autoWireChatClient, + string? sourceName = null, + Action? configure = null) => Throw.IfNull(builder).Use((innerAgent, services) => { - var agent = new OpenTelemetryAgent(innerAgent, sourceName); + var agent = new OpenTelemetryAgent(innerAgent, sourceName, autoWireChatClient); configure?.Invoke(agent); return agent; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentBuilderExtensionsTests.cs index 3bee00d014..d5a1720f6d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentBuilderExtensionsTests.cs @@ -123,4 +123,59 @@ public class OpenTelemetryAgentBuilderExtensionsTests Assert.True(configureWasCalled); Assert.IsType(result); } + + /// + /// Verify that UseOpenTelemetry with autoWireChatClient parameter works correctly. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void UseOpenTelemetry_WithAutoWireChatClientFlag_ReturnsOpenTelemetryAgent(bool autoWireChatClient) + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.UseOpenTelemetry(autoWireChatClient: autoWireChatClient).Build(); + + // Assert + Assert.IsType(result); + } + + /// + /// Verify that UseOpenTelemetry with autoWireChatClient and all parameters works correctly. + /// + [Fact] + public void UseOpenTelemetry_WithAutoWireChatClientAndAllParameters_CallsConfigureAction() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + var configureWasCalled = false; + + // Act + var result = builder.UseOpenTelemetry( + autoWireChatClient: false, + sourceName: "TestSource", + configure: agent => + { + configureWasCalled = true; + Assert.NotNull(agent); + }).Build(); + + // Assert + Assert.True(configureWasCalled); + Assert.IsType(result); + } + + /// + /// Verify that UseOpenTelemetry with autoWireChatClient throws ArgumentNullException when builder is null. + /// + [Fact] + public void UseOpenTelemetry_WithAutoWireChatClient_WithNullBuilder_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws("builder", () => ((AIAgentBuilder)null!).UseOpenTelemetry(autoWireChatClient: true)); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs index b8ddd2feaa..4730cc2a84 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -627,4 +627,166 @@ public class OpenTelemetryAgentTests } private static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", "").Trim(); + + #region AutoWireChatClient + + [Fact] + public async Task AutoWireChatClient_DefaultsToEnabled_EmitsChatSpan_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + _ = await agent.RunAsync("hi"); + + // Expect 2 activities: the inner chat span (from auto-wired OpenTelemetryChatClient) and the invoke_agent span. + Assert.Equal(2, activities.Count); + Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal)); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + [Fact] + public async Task AutoWireChatClient_Streaming_EmitsChatSpan_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + await foreach (var _ in agent.RunStreamingAsync("hi")) + { + } + + Assert.Equal(2, activities.Count); + Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal)); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + [Fact] + public async Task AutoWireChatClient_Disabled_DoesNotEmitChatSpan_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName, autoWireChatClient: false); + + _ = await agent.RunAsync("hi"); + + // Only the invoke_agent activity should be emitted; no chat span. + var activity = Assert.Single(activities); + Assert.StartsWith("invoke_agent", activity.DisplayName); + } + + [Fact] + public async Task AutoWireChatClient_NonChatClientAgent_NoOp_Async() + { + // Inner is not a ChatClientAgent — auto-wiring must be a no-op and options must remain null. + AgentRunOptions? observedOptions = null; + var inner = new TestAIAgent + { + RunAsyncFunc = (messages, session, options, ct) => + { + observedOptions = options; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + }, + }; + + using var agent = new OpenTelemetryAgent(inner); + + _ = await agent.RunAsync("hi"); + + Assert.Null(observedOptions); + } + + [Fact] + public async Task AutoWireChatClient_AlreadyInstrumented_DoesNotDoubleWrap_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + // Pre-wrap with OpenTelemetryChatClient on the same source so spans flow through the tracer. + IChatClient preWrapped = fakeChatClient.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(); + var inner = new ChatClientAgent(preWrapped); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + _ = await agent.RunAsync("hi"); + + // Expect exactly 2 activities (one invoke_agent + one chat from the pre-existing wrapper). If we had double-wrapped, we would see 3. + Assert.Equal(2, activities.Count); + } + + [Fact] + public async Task AutoWireChatClient_PreservesUserChatClientFactory_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + bool userFactoryCalled = false; + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + var runOptions = new ChatClientAgentRunOptions + { + ChatClientFactory = cc => + { + userFactoryCalled = true; + return cc; + }, + }; + + _ = await agent.RunAsync("hi", options: runOptions); + + Assert.True(userFactoryCalled); + // Auto-wiring should still produce a chat span on top of the user's factory. + Assert.Equal(2, activities.Count); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + private sealed class AutoWireTestChatClient : IChatClient + { + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "ok"); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType?.IsInstanceOfType(this) == true ? this : null; + + public void Dispose() { } + } + + #endregion }