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");
}