.NET: Add LoggingAgent wrapper for ILogger-based observability (#2701)

* Initial plan

* Add LoggingAgent class and UseLogging extension method

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Add unit tests for LoggingAgent and fix JSON serialization error handling

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Add comments explaining unreachable code in test async iterators

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Fix file encoding - add UTF-8 BOM to all C# files

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Address Format issues

* Addres format

* Break up extensions in dedicated files

* Adjust class names

* Add xmldoc info

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
This commit is contained in:
Copilot
2025-12-10 12:17:01 +00:00
committed by GitHub
co-authored by rogerbarreto copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent 1949193a2e
commit 90964acd2d
10 changed files with 915 additions and 45 deletions
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for configuring and customizing <see cref="AIAgentBuilder"/> instances.
/// </summary>
public static class FunctionInvocationDelegatingAgentBuilderExtensions
{
/// <summary>
/// Adds function invocation callbacks to the <see cref="AIAgent"/> pipeline that intercepts and processes <see cref="AIFunction"/> calls.
/// </summary>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which the function invocation callback is added.</param>
/// <param name="callback">
/// A delegate that processes function invocations. The delegate receives the <see cref="AIAgent"/> instance,
/// the function invocation context, and a continuation delegate representing the next callback in the pipeline.
/// It returns a task representing the result of the function invocation.
/// </param>
/// <returns>The <see cref="AIAgentBuilder"/> instance with the function invocation callback added, enabling method chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="callback"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// The callback must call the provided continuation delegate to proceed with the function invocation,
/// unless it intends to completely replace the function's behavior.
/// </para>
/// <para>
/// The inner agent or the pipeline wrapping it must include a <see cref="FunctionInvokingChatClient"/>. If one does not exist,
/// the <see cref="AIAgent"/> added to the pipline by this method will throw an exception when it is invoked.
/// </para>
/// </remarks>
public static AIAgentBuilder Use(this AIAgentBuilder builder, Func<AIAgent, FunctionInvocationContext, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>>, CancellationToken, ValueTask<object?>> callback)
{
_ = Throw.IfNull(builder);
_ = Throw.IfNull(callback);
return builder.Use((innerAgent, _) =>
{
// Function calling requires a ChatClientAgent inner agent.
if (innerAgent.GetService<FunctionInvokingChatClient>() is null)
{
throw new InvalidOperationException($"The function invocation middleware can only be used with decorations of a {nameof(AIAgent)} that support usage of FunctionInvokingChatClient decorated chat clients.");
}
return new FunctionInvocationDelegatingAgent(innerAgent, callback);
});
}
}
@@ -0,0 +1,209 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating AI agent that logs agent operations to an <see cref="ILogger"/>.
/// </summary>
/// <remarks>
/// <para>
/// The provided implementation of <see cref="AIAgent"/> is thread-safe for concurrent use so long as the
/// <see cref="ILogger"/> employed is also thread-safe for concurrent use.
/// </para>
/// <para>
/// When the employed <see cref="ILogger"/> enables <see cref="LogLevel.Trace"/>, the contents of
/// messages, options, and responses are logged. These may contain sensitive application data.
/// <see cref="LogLevel.Trace"/> is disabled by default and should never be enabled in a production environment.
/// Messages and options are not logged at other logging levels.
/// </para>
/// </remarks>
public sealed partial class LoggingAgent : DelegatingAIAgent
{
/// <summary>An <see cref="ILogger"/> instance used for all logging.</summary>
private readonly ILogger _logger;
/// <summary>The <see cref="JsonSerializerOptions"/> to use for serialization of state written to the logger.</summary>
private JsonSerializerOptions _jsonSerializerOptions;
/// <summary>Initializes a new instance of the <see cref="LoggingAgent"/> class.</summary>
/// <param name="innerAgent">The underlying <see cref="AIAgent"/>.</param>
/// <param name="logger">An <see cref="ILogger"/> instance that will be used for all logging.</param>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> or <paramref name="logger"/> is <see langword="null"/>.</exception>
public LoggingAgent(AIAgent innerAgent, ILogger logger)
: base(innerAgent)
{
this._logger = Throw.IfNull(logger);
this._jsonSerializerOptions = AgentJsonUtilities.DefaultOptions;
}
/// <summary>Gets or sets JSON serialization options to use when serializing logging data.</summary>
public JsonSerializerOptions JsonSerializerOptions
{
get => this._jsonSerializerOptions;
set => this._jsonSerializerOptions = Throw.IfNull(value);
}
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
if (this._logger.IsEnabled(LogLevel.Debug))
{
if (this._logger.IsEnabled(LogLevel.Trace))
{
this.LogInvokedSensitive(nameof(RunAsync), this.AsJson(messages), this.AsJson(options), this.AsJson(this.GetService<AIAgentMetadata>()));
}
else
{
this.LogInvoked(nameof(RunAsync));
}
}
try
{
AgentRunResponse response = await base.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false);
if (this._logger.IsEnabled(LogLevel.Debug))
{
if (this._logger.IsEnabled(LogLevel.Trace))
{
this.LogCompletedSensitive(nameof(RunAsync), this.AsJson(response));
}
else
{
this.LogCompleted(nameof(RunAsync));
}
}
return response;
}
catch (OperationCanceledException)
{
this.LogInvocationCanceled(nameof(RunAsync));
throw;
}
catch (Exception ex)
{
this.LogInvocationFailed(nameof(RunAsync), ex);
throw;
}
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (this._logger.IsEnabled(LogLevel.Debug))
{
if (this._logger.IsEnabled(LogLevel.Trace))
{
this.LogInvokedSensitive(nameof(RunStreamingAsync), this.AsJson(messages), this.AsJson(options), this.AsJson(this.GetService<AIAgentMetadata>()));
}
else
{
this.LogInvoked(nameof(RunStreamingAsync));
}
}
IAsyncEnumerator<AgentRunResponseUpdate> e;
try
{
e = base.RunStreamingAsync(messages, thread, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
}
catch (OperationCanceledException)
{
this.LogInvocationCanceled(nameof(RunStreamingAsync));
throw;
}
catch (Exception ex)
{
this.LogInvocationFailed(nameof(RunStreamingAsync), ex);
throw;
}
try
{
AgentRunResponseUpdate? update = null;
while (true)
{
try
{
if (!await e.MoveNextAsync().ConfigureAwait(false))
{
break;
}
update = e.Current;
}
catch (OperationCanceledException)
{
this.LogInvocationCanceled(nameof(RunStreamingAsync));
throw;
}
catch (Exception ex)
{
this.LogInvocationFailed(nameof(RunStreamingAsync), ex);
throw;
}
if (this._logger.IsEnabled(LogLevel.Trace))
{
this.LogStreamingUpdateSensitive(this.AsJson(update));
}
yield return update;
}
this.LogCompleted(nameof(RunStreamingAsync));
}
finally
{
await e.DisposeAsync().ConfigureAwait(false);
}
}
private string AsJson<T>(T value)
{
try
{
return JsonSerializer.Serialize(value, this._jsonSerializerOptions.GetTypeInfo(typeof(T)));
}
catch
{
// If serialization fails, return a simple string representation
return value?.ToString() ?? "null";
}
}
[LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")]
private partial void LogInvoked(string methodName);
[LoggerMessage(LogLevel.Trace, "{MethodName} invoked: {Messages}. Options: {Options}. Metadata: {Metadata}.")]
private partial void LogInvokedSensitive(string methodName, string messages, string options, string metadata);
[LoggerMessage(LogLevel.Debug, "{MethodName} completed.")]
private partial void LogCompleted(string methodName);
[LoggerMessage(LogLevel.Trace, "{MethodName} completed: {Response}.")]
private partial void LogCompletedSensitive(string methodName, string response);
[LoggerMessage(LogLevel.Trace, "RunStreamingAsync received update: {Update}")]
private partial void LogStreamingUpdateSensitive(string update);
[LoggerMessage(LogLevel.Debug, "{MethodName} canceled.")]
private partial void LogInvocationCanceled(string methodName);
[LoggerMessage(LogLevel.Error, "{MethodName} failed.")]
private partial void LogInvocationFailed(string methodName, Exception error);
}
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for adding logging support to <see cref="AIAgentBuilder"/> instances.
/// </summary>
public static class LoggingAgentBuilderExtensions
{
/// <summary>
/// Adds logging to the agent pipeline, enabling detailed observability of agent operations.
/// </summary>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which logging support will be added.</param>
/// <param name="loggerFactory">
/// An optional <see cref="ILoggerFactory"/> used to create a logger with which logging should be performed.
/// If not supplied, a required instance will be resolved from the service provider.
/// </param>
/// <param name="configure">
/// An optional callback that provides additional configuration of the <see cref="LoggingAgent"/> instance.
/// This allows for fine-tuning logging behavior such as customizing JSON serialization options.
/// </param>
/// <returns>The <see cref="AIAgentBuilder"/> with logging support added, enabling method chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// When the employed <see cref="ILogger"/> enables <see cref="LogLevel.Trace"/>, the contents of
/// messages, options, and responses are logged. These may contain sensitive application data.
/// <see cref="LogLevel.Trace"/> is disabled by default and should never be enabled in a production environment.
/// Messages and options are not logged at other logging levels.
/// </para>
/// <para>
/// If the resolved or provided <see cref="ILoggerFactory"/> is <see cref="NullLoggerFactory"/>, this will be a no-op where
/// logging will be effectively disabled. In this case, the <see cref="LoggingAgent"/> will not be added.
/// </para>
/// </remarks>
public static AIAgentBuilder UseLogging(
this AIAgentBuilder builder,
ILoggerFactory? loggerFactory = null,
Action<LoggingAgent>? configure = null)
{
_ = Throw.IfNull(builder);
return builder.Use((innerAgent, services) =>
{
loggerFactory ??= services.GetRequiredService<ILoggerFactory>();
// If the factory we resolve is for the null logger, the LoggingAgent will end up
// being an expensive nop, so skip adding it and just return the inner agent.
if (loggerFactory == NullLoggerFactory.Instance)
{
return innerAgent;
}
LoggingAgent agent = new(innerAgent, loggerFactory.CreateLogger(nameof(LoggingAgent)));
configure?.Invoke(agent);
return agent;
});
}
}
@@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
@@ -1,55 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for configuring and customizing <see cref="AIAgentBuilder"/> instances.
/// Provides extension methods for adding OpenTelemetry instrumentation to <see cref="AIAgentBuilder"/> instances.
/// </summary>
public static class AIAgentBuilderExtensions
public static class OpenTelemetryAgentBuilderExtensions
{
/// <summary>
/// Adds function invocation callbacks to the <see cref="AIAgent"/> pipeline that intercepts and processes <see cref="AIFunction"/> calls.
/// </summary>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which the function invocation callback is added.</param>
/// <param name="callback">
/// A delegate that processes function invocations. The delegate receives the <see cref="AIAgent"/> instance,
/// the function invocation context, and a continuation delegate representing the next callback in the pipeline.
/// It returns a task representing the result of the function invocation.
/// </param>
/// <returns>The <see cref="AIAgentBuilder"/> instance with the function invocation callback added, enabling method chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> or <paramref name="callback"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// The callback must call the provided continuation delegate to proceed with the function invocation,
/// unless it intends to completely replace the function's behavior.
/// </para>
/// <para>
/// The inner agent or the pipeline wrapping it must include a <see cref="FunctionInvokingChatClient"/>. If one does not exist,
/// the <see cref="AIAgent"/> added to the pipline by this method will throw an exception when it is invoked.
/// </para>
/// </remarks>
public static AIAgentBuilder Use(this AIAgentBuilder builder, Func<AIAgent, FunctionInvocationContext, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>>, CancellationToken, ValueTask<object?>> callback)
{
_ = Throw.IfNull(builder);
_ = Throw.IfNull(callback);
return builder.Use((innerAgent, _) =>
{
// Function calling requires a ChatClientAgent inner agent.
if (innerAgent.GetService<FunctionInvokingChatClient>() is null)
{
throw new InvalidOperationException($"The function invocation middleware can only be used with decorations of a {nameof(AIAgent)} that support usage of FunctionInvokingChatClient decorated chat clients.");
}
return new FunctionInvocationDelegatingAgent(innerAgent, callback);
});
}
/// <summary>
/// Adds OpenTelemetry instrumentation to the agent pipeline, enabling comprehensive observability for agent operations.
/// </summary>