.NET: Add OpenTelemetry Chat Client to harness stack (#6961)

* Add OpenTelemetry Chat Client to harness stack

* Address PR comments
This commit is contained in:
westey
2026-07-07 15:14:26 +01:00
committed by GitHub
parent cc20093da1
commit 757a832dbf
2 changed files with 103 additions and 8 deletions
@@ -190,6 +190,11 @@ public sealed class HarnessAgent : DelegatingAIAgent
}
}
CompactionProvider? compactionProvider = compactionStrategy is not null
? new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory)
: null;
// Build ChatHistoryProvider
ChatHistoryProvider chatHistoryProvider = options?.ChatHistoryProvider
?? (compactionStrategy is not null
? new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
@@ -198,6 +203,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
})
: new InMemoryChatHistoryProvider());
// Build instructions
string harnessInstructions = options?.HarnessInstructions ?? DefaultInstructions;
string? agentInstructions = options?.ChatOptions?.Instructions;
@@ -209,14 +215,11 @@ public sealed class HarnessAgent : DelegatingAIAgent
(false, false) => $"{harnessInstructions}\n\n{agentInstructions}",
};
// Build Chat Options & context providers
ChatOptions chatOptions = BuildChatOptions(options, instructions, options?.MaxOutputTokens);
CompactionProvider? compactionProvider = compactionStrategy is not null
? new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory)
: null;
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options, loggerFactory);
// Build ChatClient stack
ChatClientBuilder chatClientBuilder = chatClient.AsBuilder();
if (options?.DisableNonApprovalRequiredFunctionBypassing is not true)
@@ -224,7 +227,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
chatClientBuilder.UseNonApprovalRequiredFunctionBypassing();
}
ChatClientBuilder pipeline = chatClientBuilder
chatClientBuilder = chatClientBuilder
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
: null)
@@ -233,10 +236,16 @@ public sealed class HarnessAgent : DelegatingAIAgent
if (compactionProvider is not null)
{
pipeline = pipeline.UseAIContextProviders(compactionProvider);
chatClientBuilder = chatClientBuilder.UseAIContextProviders(compactionProvider);
}
return pipeline
if (options?.DisableOpenTelemetry is not true)
{
chatClientBuilder = chatClientBuilder.UseOpenTelemetry(sourceName: options?.OpenTelemetrySourceName);
}
// Build Chat Client Agent
return chatClientBuilder
.BuildAIAgent(new ChatClientAgentOptions
{
Id = options?.Id,
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -844,6 +846,90 @@ public class HarnessAgentTests
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
}
/// <summary>
/// Verify that the inner agent's ChatClient pipeline includes OpenTelemetryChatClient when
/// OpenTelemetry is enabled, so model calls are traced in addition to the agent-level
/// <see cref="OpenTelemetryAgent"/> wrapper.
/// </summary>
[Fact]
public void Pipeline_IncludesOpenTelemetryChatClientWhenEnabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableOpenTelemetry = false;
// Act
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.NotNull(innerAgent!.ChatClient.GetService<OpenTelemetryChatClient>());
}
/// <summary>
/// Verify that the inner agent's ChatClient pipeline excludes OpenTelemetryChatClient when
/// OpenTelemetry is disabled.
/// </summary>
[Fact]
public void Pipeline_ExcludesOpenTelemetryChatClientWhenDisabled()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
Assert.NotNull(innerAgent);
Assert.Null(innerAgent!.ChatClient.GetService<OpenTelemetryChatClient>());
}
/// <summary>
/// Verify that the chat-client-level OpenTelemetry instrumentation emits a chat span under the
/// configured <see cref="HarnessAgentOptions.OpenTelemetrySourceName"/>, proving both that the
/// ChatClient pipeline is instrumented and that the source name is propagated.
/// </summary>
[Fact]
public async Task OpenTelemetry_ChatClientEmitsChatSpanUnderConfiguredSourceNameAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new ConcurrentQueue<Activity>();
using var listener = new ActivityListener
{
ShouldListenTo = source => source.Name == sourceName,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = activities.Enqueue,
};
ActivitySource.AddActivityListener(listener);
var mockClient = new Mock<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello!")));
var options = CreateAllDisabledOptions();
options.DisableOpenTelemetry = false;
options.OpenTelemetrySourceName = sourceName;
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert
Assert.Contains(
activities,
a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
}
#endregion
#region Feature: WebSearch