.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
This commit is contained in:
Roger Barreto
2026-05-12 20:35:51 +01:00
Unverified
parent 921162425d
commit 9551f9ebd7
2 changed files with 142 additions and 8 deletions
@@ -32,8 +32,8 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
private readonly OpenTelemetryChatClient _otelClient;
/// <summary>The provider name extracted from <see cref="AIAgentMetadata"/>.</summary>
private readonly string? _providerName;
/// <summary>The configured source name for telemetry. May be <see langword="null"/> to use the default.</summary>
private readonly string? _sourceName;
/// <summary>The resolved source name for telemetry. Always non-empty; defaults to <see cref="OpenTelemetryConsts.DefaultSourceName"/>.</summary>
private readonly string _sourceName;
/// <summary>
/// Indicates whether the underlying <see cref="IChatClient"/> of a <see cref="ChatClientAgent"/> inner agent
/// should be automatically wrapped with <see cref="OpenTelemetryChatClient"/> 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<AIAgentMetadata>()?.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);
}
/// <inheritdoc/>
@@ -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();
@@ -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<Activity>();
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<ChatClientAgentRunOptions>(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<Activity>();
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<Activity>();
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<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "ok")));
public Action<IEnumerable<ChatMessage>, ChatOptions?>? OnGetResponseAsync { get; set; }
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> 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<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
this.OnGetResponseAsync?.Invoke(messages, options);
await Task.Yield();
yield return new ChatResponseUpdate(ChatRole.Assistant, "ok");
}