.NET: Auto-instrument resolved AIAgents with OpenTelemetry for Foundry Hosted Agents (#5316)

* Auto-instrument resolved AIAgents with OpenTelemetry using Core ResponsesSourceName

* Add OTel telemetry capture tests for Foundry hosted agent handler
This commit is contained in:
Roger Barreto
2026-04-16 21:10:46 +01:00
committed by GitHub
Unverified
parent 8f5a6ec4a1
commit e737ac6a44
6 changed files with 320 additions and 2 deletions
@@ -186,7 +186,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
if (agent is not null)
{
return agent;
return FoundryHostingExtensions.ApplyOpenTelemetry(agent);
}
if (this._logger.IsEnabled(LogLevel.Warning))
@@ -199,7 +199,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
if (defaultAgent is not null)
{
return defaultAgent;
return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent);
}
var errorMessage = string.IsNullOrEmpty(agentName)
@@ -117,6 +117,24 @@ public static class FoundryHostingExtensions
return endpoints;
}
/// <summary>
/// Wraps <paramref name="agent"/> with <see cref="OpenTelemetryAgent"/> instrumentation
/// so that agent invocations emit spans into the pipeline registered by
/// <c>Azure.AI.AgentServer.Core</c>'s <c>AddAgentHostTelemetry()</c>.
/// If the agent is already instrumented the original instance is returned unchanged.
/// </summary>
internal static AIAgent ApplyOpenTelemetry(AIAgent agent)
{
if (agent.GetService<OpenTelemetryAgent>() is not null)
{
return agent;
}
return agent.AsBuilder()
.UseOpenTelemetry(sourceName: AgentHostTelemetry.ResponsesSourceName)
.Build();
}
private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next)
{
private static readonly string s_userAgentValue = CreateUserAgentValue();
@@ -0,0 +1,237 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using OpenTelemetry;
using OpenTelemetry.Trace;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
/// <summary>
/// Tests that verify OTel spans are actually emitted and captured through the
/// <see cref="AgentFrameworkResponseHandler"/> pipeline when
/// <see cref="FoundryHostingExtensions.ApplyOpenTelemetry"/> wraps the resolved agent.
/// </summary>
public class AgentFrameworkResponseHandlerTelemetryTests
{
/// <summary>
/// The ActivitySource name used by ApplyOpenTelemetry() — equals AgentHostTelemetry.ResponsesSourceName.
/// Declared as a constant so the TracerProvider and assertions reference the same literal.
/// </summary>
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
[Fact]
public async Task CreateAsync_DefaultAgent_EmitsInvokeAgentSpanAsync()
{
// Arrange
var activities = new List<Activity>();
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(activities)
.Build();
var agent = new TelemetryTestAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var (request, context) = BuildRequest();
// Act — enumerate all events so the span completes before asserting
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — filter by agent name to isolate this test's span from any parallel test spans
var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
Assert.Equal("invoke_agent", mySpan.GetTagItem("gen_ai.operation.name"));
Assert.NotNull(mySpan.GetTagItem("gen_ai.agent.id"));
}
[Fact]
public async Task CreateAsync_KeyedAgent_EmitsInvokeAgentSpanAsync()
{
// Arrange
var activities = new List<Activity>();
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(activities)
.Build();
var agent = new TelemetryTestAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton<AIAgent>("keyed-agent", agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var (request, context) = BuildRequest(agentKey: "keyed-agent");
// Act
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — filter by agent name to isolate this test's span
var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
Assert.Equal("invoke_agent", mySpan.GetTagItem("gen_ai.operation.name"));
}
[Fact]
public async Task CreateAsync_AlreadyInstrumentedAgent_EmitsSingleSpanPerRunAsync()
{
// Arrange — use a unique source for the pre-wrapped agent distinct from ResponsesSourceName.
// If ApplyOpenTelemetry double-wraps, an extra span would appear on ResponsesSourceName.
// If it correctly skips wrapping, only the pre-wrap's unique source emits spans.
var preWrapSource = Guid.NewGuid().ToString();
var preWrapActivities = new List<Activity>();
var responsesActivities = new List<Activity>();
using var preWrapProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(preWrapSource)
.AddInMemoryExporter(preWrapActivities)
.Build();
using var responsesProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(responsesActivities)
.Build();
var innerAgent = new TelemetryTestAgent();
var preWrapped = innerAgent.AsBuilder()
.UseOpenTelemetry(sourceName: preWrapSource)
.Build();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton(preWrapped);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
// Act
var (request, context) = BuildRequest();
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — pre-wrap source emits exactly 1 span (agent ran)
Assert.Single(preWrapActivities);
Assert.Equal("invoke_agent", preWrapActivities[0].GetTagItem("gen_ai.operation.name"));
// ResponsesSourceName emits 0 spans — ApplyOpenTelemetry skipped wrapping the pre-instrumented agent
Assert.DoesNotContain(responsesActivities, a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name")));
}
[Fact]
public async Task CreateAsync_DefaultAgent_SpanDisplayNameContainsAgentNameAsync()
{
// Arrange
var activities = new List<Activity>();
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(activities)
.Build();
var agent = new TelemetryTestAgent();
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var (request, context) = BuildRequest();
// Act
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — display name follows "invoke_agent {Name}({Id})" convention; filter by agent name to isolate
var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
Assert.Contains("invoke_agent", mySpan.DisplayName, StringComparison.Ordinal);
Assert.Contains(TelemetryTestAgent.AgentName, mySpan.DisplayName, StringComparison.Ordinal);
}
private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null)
{
var request = agentKey is null
? AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test")
: AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference(agentKey));
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
content = new[] { new { type = "input_text", text = "Hello" } } }
});
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
return (request, mockContext.Object);
}
private sealed class TelemetryTestAgent : AIAgent
{
public const string AgentName = "TelemetryTestAgent";
public override string? Name => AgentName;
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
SingleUpdateAsync(new AgentResponseUpdate
{
MessageId = "resp_msg_1",
Contents = [new MeaiTextContent("telemetry test response")]
}, cancellationToken);
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
new(new TelemetryAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
new(new TelemetryAgentSession());
private static async IAsyncEnumerable<AgentResponseUpdate> SingleUpdateAsync(
AgentResponseUpdate update,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield return update;
}
}
private sealed class TelemetryAgentSession : AgentSession;
}
@@ -662,6 +662,44 @@ public class AgentFrameworkResponseHandlerTests
});
}
[Fact]
public async Task CreateAsync_DefaultAgent_IsAutoWrappedWithOpenTelemetryAsync()
{
// Arrange — register a plain (non-instrumented) agent
var agent = CreateTestAgent("otel test response");
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton<AIAgent>(agent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
content = new[] { new { type = "input_text", text = "Hello" } } }
});
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
// Act — OTel wrapping must not break the stream
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
{
events.Add(evt);
}
// Assert — stream events are still produced correctly through the wrapper
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.IsType<ResponseInProgressEvent>(events[1]);
}
private static TestAgent CreateTestAgent(string responseText)
{
return new TestAgent(responseText);
@@ -70,4 +70,27 @@ public class ServiceCollectionExtensionsTests
Assert.Throws<ArgumentNullException>(
() => services.AddFoundryResponses(null!));
}
[Fact]
public void ApplyOpenTelemetry_NonInstrumentedAgent_WrapsWithOpenTelemetryAgent()
{
var mockAgent = new Mock<AIAgent>();
var result = FoundryHostingExtensions.ApplyOpenTelemetry(mockAgent.Object);
Assert.NotNull(result.GetService<OpenTelemetryAgent>());
}
[Fact]
public void ApplyOpenTelemetry_AlreadyInstrumentedAgent_ReturnsSameReference()
{
var mockAgent = new Mock<AIAgent>();
var instrumented = mockAgent.Object.AsBuilder()
.UseOpenTelemetry()
.Build();
var result = FoundryHostingExtensions.ApplyOpenTelemetry(instrumented);
Assert.Same(instrumented, result);
}
}
@@ -13,6 +13,8 @@
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
</ItemGroup>
<!-- Hosting tests only compile on .NET Core TFMs -->