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] .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 sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable 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 sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable 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 class OpenTelemetryAgentTests 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"); }