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] .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 sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
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 sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
// 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 sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
// 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 class OpenTelemetryAgentBuilderExtensions
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 class OpenTelemetryAgentBuilderExtensionsTests
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 @@ public class OpenTelemetryAgentTests
}
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
}