From 93926e0d33509e7ca332dae6189d8c39437f543e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 18:27:50 +0000 Subject: [PATCH 1/9] Initial plan 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 2/9] .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 ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activit 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 async Task GetResponseAsync( // 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 async IAsyncEnumerable GetStreamingResponseAsync( // 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 AIAgentBuilder UseOpenTelemetry( 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 void UseOpenTelemetry_WithAllParameters_WorksCorrectly() 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 @@ async static IAsyncEnumerable CallbackAsync( } 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 } From 535af00aa219886c07927778875ed191a2e7b2ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 18:57:25 +0000 Subject: [PATCH 3/9] Address review: remove extension overload; honor UseProvidedChatClientAsIs; drop redundant check Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6ac3f75d-eeb7-4811-8043-9a27511b0a8b Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Microsoft.Agents.AI/OpenTelemetryAgent.cs | 10 +++- .../OpenTelemetryAgentBuilderExtensions.cs | 33 +---------- ...penTelemetryAgentBuilderExtensionsTests.cs | 55 ------------------- .../OpenTelemetryAgentTests.cs | 21 +++++++ 4 files changed, 29 insertions(+), 90 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index 567742102f..d51414769f 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -197,6 +197,12 @@ public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activit return options; } + // Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out. + if (this.InnerAgent.GetService()?.UseProvidedChatClientAsIs is true) + { + 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) @@ -206,9 +212,7 @@ public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activit string? sourceName = this._sourceName; IChatClient WrapWithOpenTelemetry(IChatClient cc) => - cc.GetService(typeof(OpenTelemetryChatClient)) is not null - ? cc - : cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(); + cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(); if (options is ChatClientAgentRunOptions ccOptions) { diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs index 1ccf5b84a5..8f83a8dda1 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -49,39 +48,9 @@ public static AIAgentBuilder UseOpenTelemetry( 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, autoWireChatClient); + var agent = new OpenTelemetryAgent(innerAgent, sourceName); 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 d5a1720f6d..3bee00d014 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentBuilderExtensionsTests.cs @@ -123,59 +123,4 @@ public void UseOpenTelemetry_WithAllParameters_WorksCorrectly() 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 4730cc2a84..28e192bc35 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -717,6 +717,27 @@ public async Task AutoWireChatClient_NonChatClientAgent_NoOp_Async() Assert.Null(observedOptions); } + [Fact] + public async Task AutoWireChatClient_UseProvidedChatClientAsIs_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, new ChatClientAgentOptions { UseProvidedChatClientAsIs = true }); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + _ = await agent.RunAsync("hi"); + + // UseProvidedChatClientAsIs opts out of auto-wiring, so only the invoke_agent span should be emitted. + var activity = Assert.Single(activities); + Assert.StartsWith("invoke_agent", activity.DisplayName); + } + [Fact] public async Task AutoWireChatClient_AlreadyInstrumented_DoesNotDoubleWrap_Async() { From e321802317ccad6425465473f0911e7e15145dd0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 07:36:38 +0000 Subject: [PATCH 4/9] Resolve ChatClientAgent via GetService before checking options/chat client Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/008d914d-8cbb-4e9f-81b6-f8c3c8bd8d04 Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index d51414769f..5eac701958 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -191,20 +191,22 @@ public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activit return options; } - // The auto-wiring only applies when the inner agent is a ChatClientAgent. Otherwise, no-op. - if (this.InnerAgent.GetService() is null) + // The auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op. + // Use GetService rather than a type check so wrapping agents that expose a nested ChatClientAgent are supported. + var chatClientAgent = this.InnerAgent.GetService(); + if (chatClientAgent is null) { return options; } // Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out. - if (this.InnerAgent.GetService()?.UseProvidedChatClientAsIs is true) + if (chatClientAgent.GetService()?.UseProvidedChatClientAsIs is true) { return options; } // Capture the underlying IChatClient and check whether it is already instrumented. - var chatClient = this.InnerAgent.GetService(); + var chatClient = chatClientAgent.GetService(); if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null) { return options; From ca732733d3a962e4b75a97d1ea51e0c99575906e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 07:39:16 +0000 Subject: [PATCH 5/9] Split OpenTelemetryAgent ctor to preserve original (innerAgent, sourceName) signature Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/a890c9a7-0b77-40ab-802c-dfbf09f8c260 Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Microsoft.Agents.AI/OpenTelemetryAgent.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index 5eac701958..35f4287514 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -40,6 +40,22 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable /// private readonly bool _autoWireChatClient; + /// Initializes a new instance of the class. + /// The underlying to be augmented with telemetry capabilities. + /// + /// 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. + /// + /// 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) + : this(innerAgent, sourceName, autoWireChatClient: true) + { + } + /// Initializes a new instance of the class. /// The underlying to be augmented with telemetry capabilities. /// @@ -47,7 +63,7 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable /// If not provided, a default source name will be used for telemetry identification. /// /// - /// When (the default) and the inner agent is a , the underlying + /// When 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. @@ -57,7 +73,7 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable /// 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, bool autoWireChatClient = true) : base(innerAgent) + public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireChatClient) : base(innerAgent) { this._providerName = innerAgent.GetService()?.ProviderName; this._sourceName = sourceName; From 921162425d9a8e540a377118e4aae310cb533f3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 08:06:28 +0000 Subject: [PATCH 6/9] Preserve base AgentRunOptions properties and avoid double-wrap on user factory Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/3afbf18c-de22-4236-a2f2-02ca1e98ae21 Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Microsoft.Agents.AI/OpenTelemetryAgent.cs | 32 +++++++-- .../OpenTelemetryAgentTests.cs | 69 +++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index 35f4287514..c5507c1c62 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -229,23 +229,45 @@ public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activit } string? sourceName = this._sourceName; - IChatClient WrapWithOpenTelemetry(IChatClient cc) => - cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(); + static IChatClient WrapIfNeeded(IChatClient cc, string? sourceName) => + 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. + // If the user factory already returns an OpenTelemetry-instrumented client, don't double-wrap. var clone = (ChatClientAgentRunOptions)ccOptions.Clone(); var userFactory = clone.ChatClientFactory; - clone.ChatClientFactory = cc => WrapWithOpenTelemetry(userFactory is null ? cc : userFactory(cc)); + clone.ChatClientFactory = cc => WrapIfNeeded(userFactory is null ? cc : userFactory(cc), sourceName); return clone; } - return new ChatClientAgentRunOptions + // For a plain AgentRunOptions (or null), create a ChatClientAgentRunOptions and preserve + // any base AgentRunOptions properties from the caller so they reach the inner agent. + var newOptions = new ChatClientAgentRunOptions { - ChatClientFactory = WrapWithOpenTelemetry, + ChatClientFactory = cc => WrapIfNeeded(cc, sourceName), }; + + if (options is not null) + { + CopyBaseAgentRunOptions(options, newOptions); + } + + return newOptions; + } + +#pragma warning disable MEAI001 // ContinuationToken is experimental; copy it through to preserve caller-provided value. + private static void CopyBaseAgentRunOptions(AgentRunOptions source, AgentRunOptions target) + { + target.ContinuationToken = source.ContinuationToken; + target.AllowBackgroundResponses = source.AllowBackgroundResponses; + target.AdditionalProperties = source.AdditionalProperties?.Clone(); + target.ResponseFormat = source.ResponseFormat; } +#pragma warning restore MEAI001 /// The stub used to delegate from the into the inner . /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs index 28e192bc35..4a998f6b8f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -792,6 +792,75 @@ public async Task AutoWireChatClient_PreservesUserChatClientFactory_Async() Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); } + [Fact] + public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async() + { + // Auto-wiring converts a plain AgentRunOptions into a ChatClientAgentRunOptions. The base + // properties (ContinuationToken, AllowBackgroundResponses, AdditionalProperties, ResponseFormat) + // must be preserved so they reach the inner agent. + AgentRunOptions? observedOptions = null; + var fakeChatClient = new AutoWireTestChatClient(); + var innerChatClientAgent = new ChatClientAgent(fakeChatClient); + + // Wrapping agent: surfaces the ChatClientAgent via GetService (so auto-wiring activates), + // but captures the AgentRunOptions passed to RunAsync by the OpenTelemetryAgent. + var wrapper = new TestAIAgent + { + GetServiceFunc = (type, key) => + type == typeof(ChatClientAgent) ? innerChatClientAgent : null, + RunAsyncFunc = (messages, session, options, ct) => + { + observedOptions = options; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + }, + }; + + using var agent = new OpenTelemetryAgent(wrapper); + + var additionalProps = new AdditionalPropertiesDictionary { ["customKey"] = "customValue" }; + var inputOptions = new AgentRunOptions + { + AllowBackgroundResponses = true, + AdditionalProperties = additionalProps, + ResponseFormat = ChatResponseFormat.Json, + }; + + _ = await agent.RunAsync("hi", options: inputOptions); + + Assert.NotNull(observedOptions); + Assert.IsType(observedOptions); + Assert.Equal(true, observedOptions!.AllowBackgroundResponses); + Assert.Same(ChatResponseFormat.Json, observedOptions.ResponseFormat); + Assert.NotNull(observedOptions.AdditionalProperties); + Assert.Equal("customValue", observedOptions.AdditionalProperties!["customKey"]); + } + + [Fact] + public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_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(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + // User factory wraps the chat client with OpenTelemetryChatClient itself. + var runOptions = new ChatClientAgentRunOptions + { + ChatClientFactory = cc => cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(), + }; + + _ = await agent.RunAsync("hi", options: runOptions); + + // Expect 2 activities (invoke_agent + a single chat span). If we double-wrapped, we would see 3. + Assert.Equal(2, activities.Count); + } + private sealed class AutoWireTestChatClient : IChatClient { public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => From 9551f9ebd74ef39527e64c71b58a1b9e923295b3 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 12 May 2026 20:35:51 +0100 Subject: [PATCH 7/9] .NET: OpenTelemetryAgent normalize sourceName once and add OTEL wiring path coverage Normalize the configured source name once in the constructor so the outer OpenTelemetryChatClient and the auto-wired inner OpenTelemetryChatClient always emit spans on the same ActivitySource. A caller passing an empty string previously produced agent-level spans on DefaultSourceName but auto-wired chat spans on the empty source, causing the chat spans to be silently dropped by exporters subscribed to the default source. Tests added to cover the previously unexercised OTEL wiring branches: - Ctor_NullOrEmptySourceName_AutoWiredChatClientUsesDefaultSource_Async (Theory: null and empty) - AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationToken_Async - AutoWireChatClient_ChatClientAgentRunOptions_NoUserFactory_PreservesChatOptions_Async - AutoWireChatClient_StreamingDisabled_DoesNotEmitChatSpan_Async --- .../Microsoft.Agents.AI/OpenTelemetryAgent.cs | 16 ++- .../OpenTelemetryAgentTests.cs | 134 +++++++++++++++++- 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index c5507c1c62..81ef9963dc 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -32,8 +32,8 @@ 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; + /// The resolved source name for telemetry. Always non-empty; defaults to . + private readonly string _sourceName; /// /// Indicates whether the underlying of a inner agent /// should be automatically wrapped with on each invocation. @@ -76,12 +76,16 @@ public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireChatClient) : base(innerAgent) { this._providerName = innerAgent.GetService()?.ProviderName; - this._sourceName = sourceName; + + // Resolve once so the outer OpenTelemetryChatClient and the auto-wired inner + // OpenTelemetryChatClient always emit spans under the same ActivitySource, even when + // the caller passes "" (which neither the outer nor inner client should treat as a real source). + this._sourceName = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; this._autoWireChatClient = autoWireChatClient; this._otelClient = new OpenTelemetryChatClient( new ForwardingChatClient(this), - sourceName: string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!); + sourceName: this._sourceName); } /// @@ -228,8 +232,8 @@ public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activit return options; } - string? sourceName = this._sourceName; - static IChatClient WrapIfNeeded(IChatClient cc, string? sourceName) => + string sourceName = this._sourceName; + static IChatClient WrapIfNeeded(IChatClient cc, string sourceName) => cc.GetService(typeof(OpenTelemetryChatClient)) is not null ? cc : cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs index 4a998f6b8f..d65406b305 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -861,13 +861,143 @@ public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNo Assert.Equal(2, activities.Count); } + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task Ctor_NullOrEmptySourceName_AutoWiredChatClientUsesDefaultSource_Async(string? sourceName) + { + // Both the agent-level invoke_agent span and the auto-wired chat span must be emitted under + // OpenTelemetryConsts.DefaultSourceName when the caller passes null or "" so they reach + // the same ActivitySource and are not silently dropped by the exporter. + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("Experimental.Microsoft.Agents.AI") + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + _ = await agent.RunAsync("hi"); + + Assert.Equal(2, activities.Count); + Assert.All(activities, a => Assert.Equal("Experimental.Microsoft.Agents.AI", a.Source.Name)); + 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)); + } + +#pragma warning disable MEAI001 // ResponseContinuationToken is experimental. + [Fact] + public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationToken_Async() + { + // ContinuationToken is the fourth base AgentRunOptions property copied by CopyBaseAgentRunOptions + // and is not exercised by AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async. + AgentRunOptions? observedOptions = null; + var fakeChatClient = new AutoWireTestChatClient(); + var innerChatClientAgent = new ChatClientAgent(fakeChatClient); + + var wrapper = new TestAIAgent + { + GetServiceFunc = (type, key) => + type == typeof(ChatClientAgent) ? innerChatClientAgent : null, + RunAsyncFunc = (messages, session, options, ct) => + { + observedOptions = options; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + }, + }; + + using var agent = new OpenTelemetryAgent(wrapper); + + var token = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + var inputOptions = new AgentRunOptions + { + ContinuationToken = token, + }; + + _ = await agent.RunAsync("hi", options: inputOptions); + + Assert.NotNull(observedOptions); + Assert.IsType(observedOptions); + Assert.Same(token, observedOptions!.ContinuationToken); + } +#pragma warning restore MEAI001 + + [Fact] + public async Task AutoWireChatClient_ChatClientAgentRunOptions_NoUserFactory_PreservesChatOptions_Async() + { + // When the caller passes a ChatClientAgentRunOptions without a ChatClientFactory, the auto-wiring + // must clone (not mutate) the caller's options, set the factory, and preserve nested ChatOptions. + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + ChatOptions? observedChatOptions = null; + var fakeChatClient = new AutoWireTestChatClient + { + OnGetResponseAsync = (msgs, opts) => observedChatOptions = opts, + }; + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + var inputChatOptions = new ChatOptions { Temperature = 0.42f, ModelId = "test-model" }; + var inputOptions = new ChatClientAgentRunOptions(inputChatOptions); + + _ = await agent.RunAsync("hi", options: inputOptions); + + // Caller's options must not have been mutated (no factory installed on the caller's instance). + Assert.Null(inputOptions.ChatClientFactory); + + // Inner chat client must observe the caller-supplied ChatOptions. + Assert.NotNull(observedChatOptions); + Assert.Equal(0.42f, observedChatOptions!.Temperature); + Assert.Equal("test-model", observedChatOptions.ModelId); + + // Auto-wiring still produces a chat span. + Assert.Equal(2, activities.Count); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + [Fact] + public async Task AutoWireChatClient_StreamingDisabled_DoesNotEmitChatSpan_Async() + { + // Symmetry with AutoWireChatClient_Disabled_DoesNotEmitChatSpan_Async for the streaming path. + 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 foreach (var _ in agent.RunStreamingAsync("hi")) + { + } + + var activity = Assert.Single(activities); + Assert.StartsWith("invoke_agent", activity.DisplayName); + } + 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 Action, ChatOptions?>? OnGetResponseAsync { get; set; } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + this.OnGetResponseAsync?.Invoke(messages, options); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + } public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + this.OnGetResponseAsync?.Invoke(messages, options); await Task.Yield(); yield return new ChatResponseUpdate(ChatRole.Assistant, "ok"); } From 3d4a8c41518bbf554ee4c34765601b89365000e9 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 12 May 2026 20:40:05 +0100 Subject: [PATCH 8/9] .NET: Mark OpenTelemetryAgent autoWireChatClient ctor as [Experimental] Annotate the new 3-arg OpenTelemetryAgent(AIAgent, string?, bool) constructor with [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] (MAAI001) so callers must explicitly opt in to the auto-wire toggle. The original 2-arg constructor stays non-experimental and delegates with autoWireChatClient: true; the delegating call is locally suppressed so the existing source compatibility surface is preserved. --- dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index 81ef9963dc..fb407789a3 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; @@ -52,7 +54,9 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable /// telemetry collection according to OpenTelemetry semantic conventions for AI systems. /// public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) +#pragma warning disable MAAI001 // Delegating to the experimental autoWireChatClient overload with the default-on value preserves the original behavior. : this(innerAgent, sourceName, autoWireChatClient: true) +#pragma warning restore MAAI001 { } @@ -73,6 +77,7 @@ public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) /// The constructor automatically extracts provider metadata from the inner agent and configures /// telemetry collection according to OpenTelemetry semantic conventions for AI systems. /// + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireChatClient) : base(innerAgent) { this._providerName = innerAgent.GetService()?.ProviderName; From 2848b66ebf46dd6386047ab221346d43d15ad73d Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 13 May 2026 12:49:37 +0100 Subject: [PATCH 9/9] .NET: OpenTelemetryAgent address westey-m PR review - Use string.IsNullOrWhiteSpace (not IsNullOrEmpty) when normalizing the constructor sourceName, so callers passing whitespace-only strings still land on OpenTelemetryConsts.DefaultSourceName instead of an unsubscribed ActivitySource. - Fix the misleading pragma comment on the 2-arg ctor delegating call: auto-wiring is the new default, it does not preserve the original (pre-PR) behavior. - Expand the GetRunOptionsWithChatClientWiring XML doc to spell out that a base AgentRunOptions (not ChatClientAgentRunOptions) is also accepted: it is converted to ChatClientAgentRunOptions with the auto-wire factory installed and base properties copied. - Tests: extend the source-name normalization Theory with whitespace cases (' ' and '\t'); add end-to-end coverage for plain AgentRunOptions over a real ChatClientAgent (sync + streaming) asserting the inner chat client is invoked and both invoke_agent + chat spans are emitted. --- .../Microsoft.Agents.AI/OpenTelemetryAgent.cs | 14 ++-- .../OpenTelemetryAgentTests.cs | 73 ++++++++++++++++++- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index fb407789a3..cffde717e4 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -54,7 +54,7 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable /// telemetry collection according to OpenTelemetry semantic conventions for AI systems. /// public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) -#pragma warning disable MAAI001 // Delegating to the experimental autoWireChatClient overload with the default-on value preserves the original behavior. +#pragma warning disable MAAI001 // Auto-wiring is the new default; the experimental opt-out lives on the 3-arg overload. : this(innerAgent, sourceName, autoWireChatClient: true) #pragma warning restore MAAI001 { @@ -84,8 +84,8 @@ public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireC // Resolve once so the outer OpenTelemetryChatClient and the auto-wired inner // OpenTelemetryChatClient always emit spans under the same ActivitySource, even when - // the caller passes "" (which neither the outer nor inner client should treat as a real source). - this._sourceName = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + // the caller passes "" or whitespace (which neither client should treat as a real source). + this._sourceName = string.IsNullOrWhiteSpace(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; this._autoWireChatClient = autoWireChatClient; this._otelClient = new OpenTelemetryChatClient( @@ -206,8 +206,12 @@ public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activit /// /// 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. + /// new with a + /// that wraps the chat client with . When is a + /// plain (the base type, not ), the base + /// properties are copied onto the new so high-level callers that pass + /// the abstract still benefit from auto-wiring and propagate their settings to + /// the inner agent. Otherwise, returns unchanged. /// private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options) { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs index d65406b305..1f04d09ba3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -864,10 +864,12 @@ public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNo [Theory] [InlineData(null)] [InlineData("")] - public async Task Ctor_NullOrEmptySourceName_AutoWiredChatClientUsesDefaultSource_Async(string? sourceName) + [InlineData(" ")] + [InlineData("\t")] + public async Task Ctor_NullOrWhitespaceSourceName_AutoWiredChatClientUsesDefaultSource_Async(string? sourceName) { // Both the agent-level invoke_agent span and the auto-wired chat span must be emitted under - // OpenTelemetryConsts.DefaultSourceName when the caller passes null or "" so they reach + // OpenTelemetryConsts.DefaultSourceName when the caller passes null, "", or whitespace, so they reach // the same ActivitySource and are not silently dropped by the exporter. var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() @@ -985,6 +987,73 @@ public async Task AutoWireChatClient_StreamingDisabled_DoesNotEmitChatSpan_Async Assert.StartsWith("invoke_agent", activity.DisplayName); } + [Fact] + public async Task AutoWireChatClient_PlainAgentRunOptions_RealChatClientAgent_EmitsChatSpan_Async() + { + // High-level callers may pass the abstract base AgentRunOptions (not ChatClientAgentRunOptions) when + // wiring a ChatClientAgent. Auto-wiring must still kick in: convert to ChatClientAgentRunOptions, + // install the OTel-wrapping factory, and produce both the invoke_agent and chat spans end-to-end. + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + ChatOptions? observedChatOptions = null; + var fakeChatClient = new AutoWireTestChatClient + { + OnGetResponseAsync = (_, opts) => observedChatOptions = opts, + }; + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + // Pass the base AgentRunOptions, not ChatClientAgentRunOptions. + var inputOptions = new AgentRunOptions { AllowBackgroundResponses = false }; + + _ = await agent.RunAsync("hi", options: inputOptions); + + // Inner chat client was actually invoked (auto-wired factory ran without breaking the pipeline). + Assert.NotNull(observedChatOptions); + + 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_PlainAgentRunOptions_RealChatClientAgent_StreamingEmitsChatSpan_Async() + { + // Same as the sync test above but for the streaming path so both invocation paths + // are covered when callers pass a base AgentRunOptions. + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + ChatOptions? observedChatOptions = null; + var fakeChatClient = new AutoWireTestChatClient + { + OnGetResponseAsync = (_, opts) => observedChatOptions = opts, + }; + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + var inputOptions = new AgentRunOptions { AllowBackgroundResponses = false }; + + await foreach (var _ in agent.RunStreamingAsync("hi", options: inputOptions)) + { + } + + Assert.NotNull(observedChatOptions); + + 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)); + } + private sealed class AutoWireTestChatClient : IChatClient { public Action, ChatOptions?>? OnGetResponseAsync { get; set; }