diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs
index fd1c2fd7f5..cffde717e4 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;
@@ -32,6 +34,13 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
private readonly OpenTelemetryChatClient _otelClient;
/// The provider name extracted from .
private readonly string? _providerName;
+ /// 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.
+ ///
+ private readonly bool _autoWireChatClient;
/// Initializes a new instance of the class.
/// The underlying to be augmented with telemetry capabilities.
@@ -44,13 +53,44 @@ 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) : base(innerAgent)
+ public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null)
+#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
+ {
+ }
+
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ /// is .
+ ///
+ /// 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;
+ // Resolve once so the outer OpenTelemetryChatClient and the auto-wired inner
+ // OpenTelemetryChatClient always emit spans under the same ActivitySource, even when
+ // 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(
new ForwardingChatClient(this),
- sourceName: string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!);
+ sourceName: this._sourceName);
}
///
@@ -163,6 +203,85 @@ 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 . 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)
+ {
+ if (!this._autoWireChatClient)
+ {
+ return options;
+ }
+
+ // 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 (chatClientAgent.GetService()?.UseProvidedChatClientAsIs is true)
+ {
+ return options;
+ }
+
+ // Capture the underlying IChatClient and check whether it is already instrumented.
+ var chatClient = chatClientAgent.GetService();
+ if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null)
+ {
+ return options;
+ }
+
+ 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();
+
+ 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 => WrapIfNeeded(userFactory is null ? cc : userFactory(cc), sourceName);
+ return clone;
+ }
+
+ // 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 = 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 .
///
private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient
@@ -175,8 +294,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 +312,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/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs
index b8ddd2feaa..1f04d09ba3 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs
@@ -627,4 +627,455 @@ 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_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()
+ {
+ 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));
+ }
+
+ [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);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [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 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()
+ .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);
+ }
+
+ [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; }
+
+ 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");
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null) =>
+ serviceType?.IsInstanceOfType(this) == true ? this : null;
+
+ public void Dispose() { }
+ }
+
+ #endregion
}