Files
agent-framework/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/OpenTelemetryAgentTests.cs
T
westey ff3e13c2aa .Net: Add support for 3rd party thread storage and thread serialization (#203)
* Add thread storage and serialization POC

* Switch to using JsonElement and add unit tests

* Add additional unit tests.

* Exclude private debugger properties from CodeCoverage.

* Rename IChatMessagesStorable to IChatMessageStore

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Improve xml doc.

* Update the message storing thread to always use external store for both local and remote storage.

* Remove threadid from the IChatMessageStore interface, since the store should own the thread id itself, if it requires one.

* Switch GetMessages to IEnumerable

* Address pr comments.

* Make jsonserializer options default consistent on DeserializeThreadAsync

* Move message storing thread functionality into AgentThread and simplify AgentThread behavior.

* Remove embedding generation from VectorStore chat history sample.

* Remove unecessary code and fix formatting.

* Make GetNewThread and DeserializeThread virtual with default implementations.
Remove unsued json utilities.

* Fix formatting

* Remove problem test.

* Add more unit tests

* Remove unused using clause.

* Address pr feedback.

* Address PR comments.

* Make InMemory store internal

* Switch InMemoryChatMessageStore to implement IList instead of inheriting from List.

* Rename store deserialize param.

* Update serialization based on PR comments.

* Remove confusing comment.

* Address Deserialization PR comments in the same way as Serialization

* Add State to IChatMessageStore Serialize and Deserialize names.
Make Thread Deserialize internal.
Make AgentThread type switching fobidden.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-08-05 17:24:25 +00:00

1059 lines
40 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Moq;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.AI.Agents.UnitTests;
public class OpenTelemetryAgentTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task RunAsync_ExpectedTelemetryData_CollectedAsync(bool withError)
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockAgent = CreateMockAgent(withError);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "What's the weather like?")
};
var thread = new Mock<AgentThread>().Object;
// Act & Assert
if (withError)
{
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => telemetryAgent.RunAsync(messages, thread));
Assert.Equal("Test error", exception.Message);
}
else
{
var response = await telemetryAgent.RunAsync(messages, thread);
Assert.NotNull(response);
Assert.Equal("Test response", response.Messages.First().Text);
}
// Verify activity was created
var activity = Assert.Single(activities);
Assert.NotNull(activity.Id);
Assert.NotEmpty(activity.Id);
Assert.Equal($"{AgentOpenTelemetryConsts.GenAI.Operations.InvokeAgent} TestAgent", activity.DisplayName);
Assert.Equal(ActivityKind.Client, activity.Kind);
// Verify activity tags
Assert.Equal(AgentOpenTelemetryConsts.GenAI.Operations.InvokeAgent, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.OperationName));
Assert.Equal(AgentOpenTelemetryConsts.GenAI.Systems.MicrosoftExtensionsAI, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.System));
Assert.Equal("test-agent-id", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Id));
Assert.Equal("TestAgent", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Name));
Assert.Equal("Test Description", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Description));
Assert.Equal(1, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Request.MessageCount));
if (withError)
{
Assert.Equal("System.InvalidOperationException", activity.GetTagItem(AgentOpenTelemetryConsts.ErrorInfo.Type));
Assert.Equal(ActivityStatusCode.Error, activity.Status);
Assert.Equal("Test error", activity.StatusDescription);
}
else
{
Assert.Equal(1, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Response.MessageCount));
Assert.Equal("test-response-id", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Response.Id));
Assert.Equal(10, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Usage.InputTokens));
Assert.Equal(20, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Usage.OutputTokens));
}
Assert.True(activity.Duration.TotalMilliseconds > 0);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task RunStreamingAsync_ExpectedTelemetryData_CollectedAsync(bool withError)
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockAgent = CreateMockStreamingAgent(withError);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Tell me a story")
};
var thread = new Mock<AgentThread>().Object;
// Act & Assert
if (withError)
{
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in telemetryAgent.RunStreamingAsync(messages, thread))
{
// Should not reach here
}
});
Assert.Equal("Streaming error", exception.Message);
}
else
{
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in telemetryAgent.RunStreamingAsync(messages, thread))
{
updates.Add(update);
}
Assert.NotEmpty(updates);
}
// Verify activity was created
var activity = Assert.Single(activities);
Assert.NotNull(activity.Id);
Assert.NotEmpty(activity.Id);
Assert.Equal($"{AgentOpenTelemetryConsts.GenAI.Operations.InvokeAgent} TestAgent", activity.DisplayName);
Assert.Equal(ActivityKind.Client, activity.Kind);
// Verify activity tags
Assert.Equal(AgentOpenTelemetryConsts.GenAI.Operations.InvokeAgent, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.OperationName));
Assert.Equal(AgentOpenTelemetryConsts.GenAI.Systems.MicrosoftExtensionsAI, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.System));
Assert.Equal("test-agent-id", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Id));
Assert.Equal("TestAgent", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Name));
Assert.Equal("Test Description", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Description));
Assert.Equal(1, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Request.MessageCount));
if (withError)
{
Assert.Equal("System.InvalidOperationException", activity.GetTagItem(AgentOpenTelemetryConsts.ErrorInfo.Type));
Assert.Equal(ActivityStatusCode.Error, activity.Status);
Assert.Equal("Streaming error", activity.StatusDescription);
}
else
{
Assert.Equal(1, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Response.MessageCount));
Assert.Equal("stream-response-id", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Response.Id));
Assert.Equal(15, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Usage.InputTokens));
Assert.Equal(25, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Usage.OutputTokens));
}
Assert.True(activity.Duration.TotalMilliseconds > 0);
}
[Fact]
public async Task RunAsync_WithChatClientAgent_IncludesInstructionsAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response")));
var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Id = "chat-agent-id",
Name = "ChatAgent",
Instructions = "You are a helpful assistant."
});
using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Assert
var activity = Assert.Single(activities);
Assert.Equal("You are a helpful assistant.", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Request.Instructions));
// Should use default system when ChatClientMetadata is not available
Assert.Equal(AgentOpenTelemetryConsts.GenAI.Systems.MicrosoftExtensionsAI, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.System));
}
[Fact]
public async Task RunAsync_WithChatClientAgent_WithMetadata_UsesProviderNameAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response")));
// Setup ChatClientMetadata to return a specific provider name
var metadata = new ChatClientMetadata("openai");
mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null))
.Returns(metadata);
var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Id = "chat-agent-id",
Name = "ChatAgent",
Instructions = "You are a helpful assistant."
});
using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Assert
var activity = Assert.Single(activities);
Assert.Equal("You are a helpful assistant.", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Request.Instructions));
// Should use the provider name from ChatClientMetadata
Assert.Equal("openai", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.System));
}
[Fact]
public async Task RunAsync_WithNonChatClientAgent_UsesDefaultSystemAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockAgent = CreateMockAgent(false);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Assert
var activity = Assert.Single(activities);
// Should use default system when agent is not a ChatClientAgent
Assert.Equal(AgentOpenTelemetryConsts.GenAI.Systems.MicrosoftExtensionsAI, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.System));
}
[Theory]
[InlineData("azure")]
[InlineData("openai")]
[InlineData("custom-provider")]
public async Task RunAsync_WithChatClientAgent_WithDifferentProviders_UsesCorrectSystemAsync(string providerName)
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response")));
// Setup ChatClientMetadata to return the specified provider name
var metadata = new ChatClientMetadata(providerName);
mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null))
.Returns(metadata);
var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Id = "chat-agent-id",
Name = "ChatAgent"
});
using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Assert
var activity = Assert.Single(activities);
// Should use the provider name from ChatClientMetadata
Assert.Equal(providerName, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.System));
}
[Fact]
public async Task RunStreamingAsync_WithChatClientAgent_WithMetadata_UsesProviderNameAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockChatClient = new Mock<IChatClient>();
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Stream response")
];
mockChatClient.Setup(c => c.GetStreamingResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Returns(returnUpdates.ToAsyncEnumerable());
// Setup ChatClientMetadata to return a specific provider name
var metadata = new ChatClientMetadata("azure");
mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null))
.Returns(metadata);
var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Id = "chat-agent-id",
Name = "ChatAgent",
Instructions = "You are a helpful assistant."
});
using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await foreach (var update in telemetryAgent.RunStreamingAsync(messages))
{
// Consume the stream
}
// Assert
var activity = Assert.Single(activities);
Assert.Equal("You are a helpful assistant.", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Request.Instructions));
// Should use the provider name from ChatClientMetadata
Assert.Equal("azure", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.System));
}
[Fact]
public async Task RunAsync_WithThreadId_IncludesThreadIdAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockAgent = CreateMockAgent(false);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
var thread = new AgentThread { ConversationId = "thread-123" };
// Act
await telemetryAgent.RunAsync(messages, thread);
// Assert
var activity = Assert.Single(activities);
Assert.Equal("thread-123", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.ConversationId));
}
[Fact]
public void WithOpenTelemetry_ExtensionMethod_CreatesOpenTelemetryAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Id).Returns("test-id");
mockAgent.Setup(a => a.Name).Returns("TestAgent");
// Act
using var telemetryAgent = mockAgent.Object.WithOpenTelemetry();
// Assert
Assert.IsType<OpenTelemetryAgent>(telemetryAgent);
Assert.Equal("test-id", telemetryAgent.Id);
Assert.Equal("TestAgent", telemetryAgent.Name);
}
[Fact]
public async Task RunAsync_NoListeners_NoActivitiesCreatedAsync()
{
// Arrange - No tracer provider, so no listeners
var mockAgent = CreateMockAgent(false);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: "test-source");
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Assert - Should complete without creating activities
mockAgent.Verify(a => a.RunAsync(messages, null, null, It.IsAny<CancellationToken>()), Times.Once);
}
private static Mock<AIAgent> CreateMockAgent(bool throwError)
{
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Id).Returns("test-agent-id");
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test Description");
if (throwError)
{
mockAgent.Setup(a => a.RunAsync(It.IsAny<IReadOnlyCollection<ChatMessage>>(), It.IsAny<AgentThread>(), It.IsAny<AgentRunOptions>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Test error"));
}
else
{
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"))
{
ResponseId = "test-response-id",
Usage = new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = 20
}
};
mockAgent.Setup(a => a.RunAsync(It.IsAny<IReadOnlyCollection<ChatMessage>>(), It.IsAny<AgentThread>(), It.IsAny<AgentRunOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
}
return mockAgent;
}
private static Mock<AIAgent> CreateMockStreamingAgent(bool throwError)
{
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Id).Returns("test-agent-id");
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test Description");
if (throwError)
{
mockAgent.Setup(a => a.RunStreamingAsync(It.IsAny<IReadOnlyCollection<ChatMessage>>(), It.IsAny<AgentThread>(), It.IsAny<AgentRunOptions>(), It.IsAny<CancellationToken>()))
.Returns(ThrowingAsyncEnumerable());
}
else
{
mockAgent.Setup(a => a.RunStreamingAsync(It.IsAny<IReadOnlyCollection<ChatMessage>>(), It.IsAny<AgentThread>(), It.IsAny<AgentRunOptions>(), It.IsAny<CancellationToken>()))
.Returns(CreateStreamingResponse());
}
return mockAgent;
static async IAsyncEnumerable<AgentRunResponseUpdate> ThrowingAsyncEnumerable([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
throw new InvalidOperationException("Streaming error");
#pragma warning disable CS0162 // Unreachable code detected
yield break;
#pragma warning restore CS0162 // Unreachable code detected
}
static async IAsyncEnumerable<AgentRunResponseUpdate> CreateStreamingResponse([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield return new AgentRunResponseUpdate(ChatRole.Assistant, "Hello")
{
ResponseId = "stream-response-id"
};
yield return new AgentRunResponseUpdate(ChatRole.Assistant, " there!")
{
ResponseId = "stream-response-id"
};
yield return new AgentRunResponseUpdate
{
ResponseId = "stream-response-id",
Contents = [new UsageContent(new UsageDetails
{
InputTokenCount = 15,
OutputTokenCount = 25
})]
};
}
}
[Fact]
public void Constructor_NullAgent_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new OpenTelemetryAgent(null!));
}
[Fact]
public void Constructor_WithParameters_SetsProperties()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Id).Returns("test-id");
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test Description");
var logger = new Mock<ILogger>().Object;
var sourceName = "custom-source";
// Act
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName);
// Assert
Assert.Equal("test-id", telemetryAgent.Id);
Assert.Equal("TestAgent", telemetryAgent.Name);
Assert.Equal("Test Description", telemetryAgent.Description);
}
[Fact]
public void GetNewThread_DelegatesToInnerAgent()
{
// Arrange
var mockThread = new Mock<AgentThread>().Object;
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.GetNewThread()).Returns(mockThread);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object);
// Act
var result = telemetryAgent.GetNewThread();
// Assert
Assert.Same(mockThread, result);
mockAgent.Verify(a => a.GetNewThread(), Times.Once);
}
[Fact]
public void Dispose_DisposesResources()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object);
// Act & Assert - Should not throw
telemetryAgent.Dispose();
telemetryAgent.Dispose(); // Should be safe to call multiple times
}
[Fact]
public async Task RunAsync_WithNullResponseId_HandlesGracefullyAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Id).Returns("test-agent-id");
mockAgent.Setup(a => a.Name).Returns("TestAgent");
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"))
{
ResponseId = null, // Null response ID
Usage = null // Null usage
};
mockAgent.Setup(a => a.RunAsync(It.IsAny<IReadOnlyCollection<ChatMessage>>(), It.IsAny<AgentThread>(), It.IsAny<AgentRunOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Assert
var activity = Assert.Single(activities);
Assert.Equal(1, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Response.MessageCount));
Assert.Null(activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Response.Id));
Assert.Null(activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Usage.InputTokens));
Assert.Null(activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Usage.OutputTokens));
}
[Fact]
public async Task RunAsync_WithEmptyAgentName_UsesOperationNameOnlyAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Id).Returns("test-agent-id");
mockAgent.Setup(a => a.Name).Returns((string?)null); // Null name
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
mockAgent.Setup(a => a.RunAsync(It.IsAny<IReadOnlyCollection<ChatMessage>>(), It.IsAny<AgentThread>(), It.IsAny<AgentRunOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Assert
var activity = Assert.Single(activities);
Assert.Equal(AgentOpenTelemetryConsts.GenAI.Operations.InvokeAgent, activity.DisplayName);
}
[Fact]
public async Task RunStreamingAsync_WithPartialUpdates_CombinesCorrectlyAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Id).Returns("test-agent-id");
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.RunStreamingAsync(It.IsAny<IReadOnlyCollection<ChatMessage>>(), It.IsAny<AgentThread>(), It.IsAny<AgentRunOptions>(), It.IsAny<CancellationToken>()))
.Returns(CreatePartialStreamingResponse());
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Tell me a story")
};
// Act
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in telemetryAgent.RunStreamingAsync(messages))
{
updates.Add(update);
}
// Assert
Assert.Equal(4, updates.Count); // 3 content updates + 1 final update
var activity = Assert.Single(activities);
Assert.Equal(1, activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Response.MessageCount));
Assert.Equal("partial-response-id", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Response.Id));
static async IAsyncEnumerable<AgentRunResponseUpdate> CreatePartialStreamingResponse([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield return new AgentRunResponseUpdate(ChatRole.Assistant, "Once")
{
ResponseId = "partial-response-id"
};
yield return new AgentRunResponseUpdate(ChatRole.Assistant, " upon")
{
ResponseId = "partial-response-id"
};
yield return new AgentRunResponseUpdate(ChatRole.Assistant, " a time...")
{
ResponseId = "partial-response-id"
};
yield return new AgentRunResponseUpdate
{
ResponseId = "partial-response-id"
};
}
}
[Fact]
public async Task RunAsync_DefaultSourceName_UsesCorrectSourceAsync()
{
// Arrange
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(AgentOpenTelemetryConsts.DefaultSourceName)
.AddInMemoryExporter(activities)
.Build();
var mockAgent = CreateMockAgent(false);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); // No custom source name
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Assert
var activity = Assert.Single(activities);
Assert.NotNull(activity);
Assert.Equal(AgentOpenTelemetryConsts.DefaultSourceName, activity.Source.Name);
}
[Fact]
public async Task RunAsync_WithMetricsEnabled_RecordsMetricsAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
var exportedMetrics = new List<Metric>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder()
.AddMeter(sourceName)
.AddInMemoryExporter(exportedMetrics)
.Build();
var mockAgent = CreateMockAgent(false);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Force metric collection
meterProvider.ForceFlush(5000);
// Assert - Verify metrics were recorded
Assert.NotEmpty(exportedMetrics);
// Check for operation duration metric
var durationMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.OperationDuration.Name);
Assert.NotNull(durationMetric);
// Check for request count metric
var requestCountMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.RequestCount.Name);
Assert.NotNull(requestCountMetric);
// Check for token usage metric
var tokenUsageMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.TokenUsage.Name);
Assert.NotNull(tokenUsageMetric);
}
[Fact]
public async Task RunAsync_WithMetricsEnabledAndError_RecordsErrorMetricsAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
var exportedMetrics = new List<Metric>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder()
.AddMeter(sourceName)
.AddInMemoryExporter(exportedMetrics)
.Build();
var mockAgent = CreateMockAgent(true); // With error
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => telemetryAgent.RunAsync(messages));
// Force metric collection
meterProvider.ForceFlush(5000);
// Assert - Verify error metrics were recorded
Assert.NotEmpty(exportedMetrics);
// Check for operation duration metric with error tag
var durationMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.OperationDuration.Name);
Assert.NotNull(durationMetric);
}
[Fact]
public async Task RunStreamingAsync_WithMetricsEnabled_RecordsMetricsAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
var exportedMetrics = new List<Metric>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder()
.AddMeter(sourceName)
.AddInMemoryExporter(exportedMetrics)
.Build();
var mockAgent = CreateMockStreamingAgent(false);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Tell me a story")
};
// Act
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in telemetryAgent.RunStreamingAsync(messages))
{
updates.Add(update);
}
// Force metric collection
meterProvider.ForceFlush(5000);
// Assert - Verify metrics were recorded
Assert.NotEmpty(exportedMetrics);
Assert.NotEmpty(updates);
// Check for operation duration metric
var durationMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.OperationDuration.Name);
Assert.NotNull(durationMetric);
// Check for request count metric
var requestCountMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.RequestCount.Name);
Assert.NotNull(requestCountMetric);
// Check for token usage metric
var tokenUsageMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.TokenUsage.Name);
Assert.NotNull(tokenUsageMetric);
}
[Fact]
public async Task RunAsync_WithNullUsage_SkipsTokenMetricsAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var exportedMetrics = new List<Metric>();
using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder()
.AddMeter(sourceName)
.AddInMemoryExporter(exportedMetrics)
.Build();
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Id).Returns("test-agent-id");
mockAgent.Setup(a => a.Name).Returns("TestAgent");
// Response with null usage
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"))
{
ResponseId = "test-response-id",
Usage = null // Null usage
};
mockAgent.Setup(a => a.RunAsync(It.IsAny<IReadOnlyCollection<ChatMessage>>(), It.IsAny<AgentThread>(), It.IsAny<AgentRunOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Force metric collection
meterProvider.ForceFlush(5000);
// Assert - Should have duration and request count metrics, but no token usage metrics
var durationMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.OperationDuration.Name);
Assert.NotNull(durationMetric);
var requestCountMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.RequestCount.Name);
Assert.NotNull(requestCountMetric);
// Token usage metric should not be recorded when usage is null
var tokenUsageMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.TokenUsage.Name);
Assert.Null(tokenUsageMetric);
}
[Fact]
public async Task RunAsync_WithMetricsDisabled_SkipsMetricRecordingAsync()
{
// Arrange - No meter provider, so metrics are disabled
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockAgent = CreateMockAgent(false);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Assert - Should complete without recording metrics (since no meter provider)
var activity = Assert.Single(activities);
Assert.NotNull(activity);
// Verify the agent was called
mockAgent.Verify(a => a.RunAsync(messages, null, null, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RunAsync_WithPartialTokenUsage_RecordsAvailableTokensAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var exportedMetrics = new List<Metric>();
using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder()
.AddMeter(sourceName)
.AddInMemoryExporter(exportedMetrics)
.Build();
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Id).Returns("test-agent-id");
mockAgent.Setup(a => a.Name).Returns("TestAgent");
// Response with only input tokens (no output tokens)
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"))
{
ResponseId = "test-response-id",
Usage = new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = null // No output tokens
}
};
mockAgent.Setup(a => a.RunAsync(It.IsAny<IReadOnlyCollection<ChatMessage>>(), It.IsAny<AgentThread>(), It.IsAny<AgentRunOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Force metric collection
meterProvider.ForceFlush(5000);
// Assert - Should record input tokens but not output tokens
var tokenUsageMetric = exportedMetrics.FirstOrDefault(m => m.Name == AgentOpenTelemetryConsts.GenAI.Agent.Client.TokenUsage.Name);
Assert.NotNull(tokenUsageMetric);
}
[Fact]
public async Task RunAsync_WithNullDescription_SkipsDescriptionAttributeAsync()
{
// Arrange
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Id).Returns("test-agent-id");
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns((string?)null); // Null description
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
mockAgent.Setup(a => a.RunAsync(It.IsAny<IReadOnlyCollection<ChatMessage>>(), It.IsAny<AgentThread>(), It.IsAny<AgentRunOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
// Act
await telemetryAgent.RunAsync(messages);
// Assert
var activity = Assert.Single(activities);
Assert.Equal("test-agent-id", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Id));
Assert.Equal("TestAgent", activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Name));
// Description should not be present when null
Assert.Null(activity.GetTagItem(AgentOpenTelemetryConsts.GenAI.Agent.Description));
}
}