.NET: Update OpenTelemetryAgent to latest spec (#967)

* Update OpenTelemetryAgent to latest spec

It was stale. Rather than try to keep it up-to-date manually, I've changed it to piggy back on OpenTelemetryChatClient, so that it inherits everything OpenTelemetryChatClient does and then augments it just with agent-specific tags.

* Address feedback / merge
This commit is contained in:
Stephen Toub
2025-09-30 09:50:01 -04:00
committed by GitHub
Unverified
parent 4fd61924fc
commit 0502f3ef91
10 changed files with 702 additions and 3243 deletions
@@ -103,7 +103,7 @@ using var instrumentedChatClient = new AzureOpenAIClient(new Uri(endpoint), new
.AsIChatClient() // Converts a native OpenAI SDK ChatClient into a Microsoft.Extensions.AI.IChatClient
.AsBuilder()
.UseFunctionInvocation()
.UseOpenTelemetry(loggerFactory: loggerFactory, sourceName: SourceName, (cfg) => cfg.EnableSensitiveData = true)
.UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true)
.Build();
appLogger.LogInformation("Creating Agent with OpenTelemetry instrumentation");
@@ -112,7 +112,7 @@ using var agent = new ChatClientAgent(instrumentedChatClient,
name: "OpenTelemetryDemoAgent",
instructions: "You are a helpful assistant that provides concise and informative responses.",
tools: [AIFunctionFactory.Create(GetWeatherAsync)])
.WithOpenTelemetry(loggerFactory, SourceName); // Enable telemetry on the agent
.WithOpenTelemetry(SourceName); // Enable telemetry on the agent
var thread = agent.GetNewThread();
@@ -4,7 +4,6 @@ using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -18,15 +17,20 @@ public static class AgentExtensions
/// Wraps the agent with OpenTelemetry instrumentation.
/// </summary>
/// <param name="agent">The agent to wrap.</param>
/// <param name="loggerFactory">The <see cref="ILogger"/> to use for emitting events.</param>
/// <param name="sourceName">An optional source name that will be used on the telemetry data.</param>
/// <param name="enableSensitiveData">When <see langword="true"/> indicates whether potentially sensitive information should be included in telemetry. Default is <see langword="false"/></param>
/// <returns>An <see cref="OpenTelemetryAgent"/> that wraps the original agent with telemetry.</returns>
public static OpenTelemetryAgent WithOpenTelemetry(this AIAgent agent, ILoggerFactory? loggerFactory = null, string? sourceName = null, bool? enableSensitiveData = null) =>
new(agent, loggerFactory?.CreateLogger(typeof(OpenTelemetryAgent)), sourceName)
public static OpenTelemetryAgent WithOpenTelemetry(this AIAgent agent, string? sourceName = null, bool? enableSensitiveData = null)
{
OpenTelemetryAgent otel = new(agent, sourceName);
if (enableSensitiveData is not null)
{
EnableSensitiveData = enableSensitiveData ?? false
};
otel.EnableSensitiveData = enableSensitiveData.Value;
}
return otel;
}
/// <summary>
/// Creates a <see cref="AIFunction"/> that will invoke the provided Agent.
@@ -64,7 +64,6 @@ internal sealed class FunctionInvocationDelegatingAgent : DelegatingAIAgent
Arguments = arguments,
Function = this.InnerFunction,
CallContent = new(string.Empty, this.InnerFunction.Name, new Dictionary<string, object?>(arguments)),
Iteration = 0, // Indicate this function was not invoked by a FICC and has no iteration flow.
};
return await next(innerAgent, context, CoreLogicAsync, cancellationToken).ConfigureAwait(false);
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
@@ -11,6 +11,7 @@
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -1,9 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -19,20 +16,16 @@ public static class OpenTelemetryAIAgentBuilderExtensions
/// The specification is still experimental and subject to change; as such, the telemetry output by this agent is also subject to change.
/// </remarks>
/// <param name="builder">The <see cref="AIAgentBuilder"/>.</param>
/// <param name="loggerFactory">An optional <see cref="ILoggerFactory"/> to use to create a logger for logging events.</param>
/// <param name="sourceName">An optional source name that will be used on the telemetry data.</param>
/// <param name="configure">An optional callback that can be used to configure the <see cref="OpenTelemetryAgent"/> instance.</param>
/// <returns>The <paramref name="builder"/>.</returns>
public static AIAgentBuilder UseOpenTelemetry(
this AIAgentBuilder builder,
ILoggerFactory? loggerFactory = null,
string? sourceName = null,
Action<OpenTelemetryAgent>? configure = null) =>
Throw.IfNull(builder).Use((innerAgent, services) =>
{
loggerFactory ??= services.GetService<ILoggerFactory>();
var agent = new OpenTelemetryAgent(innerAgent, loggerFactory?.CreateLogger(typeof(OpenTelemetryAgent)), sourceName);
var agent = new OpenTelemetryAgent(innerAgent, sourceName);
configure?.Invoke(agent);
return agent;
@@ -3,97 +3,49 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a delegating agent that implements OpenTelemetry instrumentation for agent operations.
/// </summary>
/// <summary>Represents a delegating <see cref="AIAgent"/> that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
/// <remarks>
/// This class provides telemetry instrumentation for agent operations including activities, metrics, and logging.
/// The telemetry output follows OpenTelemetry semantic conventions in <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/"/> and is subject to change as the conventions evolve.
/// This class provides an implementation of the Semantic Conventions for Generative AI systems, defined at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change.
/// </remarks>
public sealed partial class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
{
private const LogLevel EventLogLevel = LogLevel.Information;
private JsonSerializerOptions _jsonSerializerOptions;
private readonly OpenTelemetryChatClient? _openTelemetryChatClient;
private readonly string? _system;
private readonly ActivitySource _activitySource;
private readonly Meter _meter;
private readonly Histogram<double> _operationDurationHistogram;
private readonly Histogram<int> _tokenUsageHistogram;
private readonly ILogger _logger;
// IMPLEMENTATION NOTE: The OpenTelemetryChatClient from Microsoft.Extensions.AI provides a full and up-to-date
// implementationof the OpenTelemetry Semantic Conventions for Generative AI systems, specifically for the client
// metrics and the chat span. But the chat span is almost identical to the invoke_agent span, just with invoke_agent
// have a different value for the operation name and a few additional tags. To avoid needing to reimplement the
// convention, then, and keep it up-to-date as the convention evolves, for now this implementation just delegates
// to OpenTelemetryChatClient for the actual telemetry work. For RunAsync and RunStreamingAsync, it delegates to the
// inner agent not directly but rather via OpenTelemetryChatClient, which wraps a ForwardingChatClient that in turn
// calls back into the inner agent.
/// <summary>
/// Initializes a new instance of the <see cref="OpenTelemetryAgent"/> class.
/// </summary>
/// <param name="innerAgent">The underlying agent to wrap with telemetry.</param>
/// <param name="logger">The <see cref="ILogger"/> to use for emitting events.</param>
/// <summary>The <see cref="OpenTelemetryChatClient"/> providing the bulk of the telemetry.</summary>
private readonly OpenTelemetryChatClient _otelClient;
/// <summary>The provider name extracted from <see cref="AIAgentMetadata"/>.</summary>
private readonly string? _providerName;
/// <summary>Initializes a new instance of the <see cref="OpenTelemetryAgent"/> class.</summary>
/// <param name="innerAgent">The underlying <see cref="AIAgent"/>.</param>
/// <param name="sourceName">An optional source name that will be used on the telemetry data.</param>
public OpenTelemetryAgent(AIAgent innerAgent, ILogger? logger = null, string? sourceName = null)
: base(innerAgent)
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) : base(innerAgent)
{
string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!;
this._activitySource = new(name);
this._meter = new(name);
this._logger = logger ?? NullLogger.Instance;
this._system = this.GetService<AIAgentMetadata>()?.ProviderName ?? OpenTelemetryConsts.GenAI.SystemNameValues.MicrosoftAgentsAI;
this._providerName = innerAgent.GetService<AIAgentMetadata>()?.ProviderName;
// Attempt to get the open telemetry chat client if the inner agent is a ChatClientAgent.
this._openTelemetryChatClient = (this.InnerAgent as ChatClientAgent)?.ChatClient.GetService<OpenTelemetryChatClient>();
// Inherit by default the EnableSensitiveData setting from the TelemetryChatClient if available.
this.EnableSensitiveData = this._openTelemetryChatClient?.EnableSensitiveData ?? false;
this._operationDurationHistogram = this._meter.CreateHistogram<double>(
OpenTelemetryConsts.GenAI.Client.OperationDuration.Name,
OpenTelemetryConsts.SecondsUnit,
OpenTelemetryConsts.GenAI.Client.OperationDuration.Description
#if NET9_0_OR_GREATER
, advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries }
#endif
);
this._tokenUsageHistogram = this._meter.CreateHistogram<int>(
OpenTelemetryConsts.GenAI.Client.TokenUsage.Name,
OpenTelemetryConsts.TokensUnit,
OpenTelemetryConsts.GenAI.Client.TokenUsage.Description
#if NET9_0_OR_GREATER
, advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries }
#endif
);
this._jsonSerializerOptions = AIJsonUtilities.DefaultOptions;
this._otelClient = new OpenTelemetryChatClient(
new ForwardingChatClient(this),
sourceName: string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!);
}
/// <summary>Gets or sets JSON serialization options to use when formatting chat data into telemetry strings.</summary>
public JsonSerializerOptions JsonSerializerOptions
{
get => this._jsonSerializerOptions;
set => this._jsonSerializerOptions = Throw.IfNull(value);
}
/// <summary>
/// Disposes the telemetry resources.
/// </summary>
public void Dispose()
{
this._activitySource.Dispose();
this._meter.Dispose();
}
/// <inheritdoc/>
public void Dispose() => this._otelClient.Dispose();
/// <summary>
/// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry.
@@ -101,434 +53,176 @@ public sealed partial class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
/// <value>
/// <see langword="true"/> if potentially sensitive information should be included in telemetry;
/// <see langword="false"/> if telemetry shouldn't include raw inputs and outputs.
/// The default value is <see langword="false"/>.
/// The default value is <see langword="false"/>, unless the <c>OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT</c>
/// environment variable is set to "true" (case-insensitive).
/// </value>
/// <remarks>
/// By default, telemetry includes metadata, such as token counts, but not raw inputs
/// and outputs, such as message content, function call arguments, and function call results.
/// The default value can be overridden by setting the <c>OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT</c>
/// environment variable to "true". Explicitly setting this property will override the environment variable.
/// </remarks>
public bool EnableSensitiveData { get; set; }
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
public bool EnableSensitiveData
{
// Handle ActivitySource requests directly - always return our own ActivitySource
if (serviceType == typeof(ActivitySource))
{
return this._activitySource;
}
// For other service types, use the base delegation logic
return base.GetService(serviceType, serviceKey);
get => this._otelClient.EnableSensitiveData;
set => this._otelClient.EnableSensitiveData = value;
}
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
ChatOptions co = new ForwardedOptions(options, thread, Activity.Current);
using Activity? activity = this.CreateAndConfigureActivity(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, thread);
Stopwatch? stopwatch = this._operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null;
var response = await this._otelClient.GetResponseAsync(messages, co, cancellationToken).ConfigureAwait(false);
this.LogChatMessages(inputMessages);
AgentRunResponse? response = null;
Exception? error = null;
try
{
response = await base.RunAsync(inputMessages, thread, options, cancellationToken).ConfigureAwait(false);
return response;
}
catch (Exception ex)
{
error = ex;
throw;
}
finally
{
this.TraceResponse(activity, response, error, stopwatch);
}
return (AgentRunResponse)response.RawRepresentation!;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
ChatOptions co = new ForwardedOptions(options, thread, Activity.Current);
using Activity? activity = this.CreateAndConfigureActivity(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, thread);
Stopwatch? stopwatch = this._operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null;
IAsyncEnumerable<AgentRunResponseUpdate> updates;
try
await foreach (var update in this._otelClient.GetStreamingResponseAsync(messages, co, cancellationToken).ConfigureAwait(false))
{
updates = base.RunStreamingAsync(inputMessages, thread, options, cancellationToken);
}
catch (Exception ex)
{
this.TraceResponse(activity, response: null, ex, stopwatch);
throw;
}
var responseEnumerator = updates.GetAsyncEnumerator(cancellationToken);
List<AgentRunResponseUpdate> trackedUpdates = [];
Exception? error = null;
try
{
while (true)
{
AgentRunResponseUpdate update;
try
{
if (!await responseEnumerator.MoveNextAsync().ConfigureAwait(false))
{
break;
}
update = responseEnumerator.Current;
}
catch (Exception ex)
{
error = ex;
throw;
}
trackedUpdates.Add(update);
yield return update;
Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802
}
}
finally
{
this.TraceResponse(activity, trackedUpdates.ToAgentRunResponse(), error, stopwatch);
await responseEnumerator.DisposeAsync().ConfigureAwait(false);
yield return (AgentRunResponseUpdate)update.RawRepresentation!;
}
}
/// <summary>
/// Creates an activity for an agent request, or returns null if not enabled.
/// </summary>
private Activity? CreateAndConfigureActivity(string operationName, AgentThread? thread)
/// <summary>Augments the current activity created by the <see cref="OpenTelemetryChatClient"/> with agent-specific information.</summary>
/// <param name="previousActivity">The <see cref="Activity"/> that was current prior to the <see cref="OpenTelemetryChatClient"/>'s invocation.</param>
private void UpdateCurrentActivity(Activity? previousActivity)
{
// Get the GenAI system name for telemetry
var chatClientAgent = this.InnerAgent as ChatClientAgent;
Activity? activity = null;
if (this._activitySource.HasListeners())
// If there isn't a current activity to augment, or it's the same one that was current when the agent was invoked (meaning
// the OpenTelemetryChatClient didn't create one), then there's nothing to do.
if (Activity.Current is not { } activity ||
ReferenceEquals(activity, previousActivity))
{
string activityName = string.IsNullOrWhiteSpace(this.Name) ? operationName : $"{operationName} {this.Name}";
activity = this._activitySource.StartActivity(activityName, ActivityKind.Client);
return;
}
if (activity is not null)
// Override information set by OpenTelemetryChatClient to make it specific to invoke_agent.
activity.DisplayName = $"invoke_agent {this.DisplayName}";
if (!string.IsNullOrWhiteSpace(this._providerName))
{
_ = activity.SetTag(OpenTelemetryConsts.GenAI.Provider.Name, this._providerName);
}
// Further augment the activity with agent-specific tags.
_ = activity.SetTag(OpenTelemetryConsts.GenAI.Agent.Id, this.Id);
if (this.Name is { } name && !string.IsNullOrWhiteSpace(name))
{
_ = activity.SetTag(OpenTelemetryConsts.GenAI.Agent.Name, this.Name);
}
if (this.Description is { } description && !string.IsNullOrWhiteSpace(description))
{
_ = activity.SetTag(OpenTelemetryConsts.GenAI.Agent.Description, description);
}
}
/// <summary>State passed from this instance into the inner agent, circumventing the intermediate <see cref="OpenTelemetryChatClient"/>.</summary>
private sealed class ForwardedOptions : ChatOptions
{
public ForwardedOptions(AgentRunOptions? options, AgentThread? thread, Activity? currentActivity)
{
this.Options = options;
this.Thread = thread;
this.CurrentActivity = currentActivity;
if (options is ChatClientAgentRunOptions { ChatOptions: { } chatClientOptions })
{
_ = activity
// Required attributes per OpenTelemetry semantic conventions
.AddTag(OpenTelemetryConsts.GenAI.Operation.Name, operationName)
.AddTag(OpenTelemetryConsts.GenAI.SystemName, this._system)
// Agent-specific attributes
.AddTag(OpenTelemetryConsts.GenAI.Agent.Id, this.Id);
// Add agent name if available (following gen_ai.agent.name convention - conditionally required when available)
if (!string.IsNullOrWhiteSpace(this.Name))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Agent.Name, this.Name);
}
// Add description if available (following gen_ai.agent.description convention)
if (!string.IsNullOrWhiteSpace(this.Description))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Agent.Description, this.Description);
}
// Add conversation ID if thread is available (following gen_ai.conversation.id convention)
var metadata = thread?.GetService<AgentThreadMetadata>();
if (!string.IsNullOrWhiteSpace(metadata?.ConversationId))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Conversation.Id, metadata.ConversationId);
}
// Add instructions if available (for ChatClientAgent)
if (!string.IsNullOrWhiteSpace(chatClientAgent?.Instructions))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.Instructions, chatClientAgent.Instructions);
}
// Keep this faux copy ctor in sync with public properties on ChatOptions.
this.AdditionalProperties = chatClientOptions.AdditionalProperties;
this.AllowMultipleToolCalls = chatClientOptions.AllowMultipleToolCalls;
this.ConversationId = chatClientOptions.ConversationId;
this.FrequencyPenalty = chatClientOptions.FrequencyPenalty;
this.Instructions = chatClientOptions.Instructions;
this.MaxOutputTokens = chatClientOptions.MaxOutputTokens;
this.ModelId = chatClientOptions.ModelId;
this.PresencePenalty = chatClientOptions.PresencePenalty;
this.RawRepresentationFactory = chatClientOptions.RawRepresentationFactory;
this.ResponseFormat = chatClientOptions.ResponseFormat;
this.Seed = chatClientOptions.Seed;
this.StopSequences = chatClientOptions.StopSequences;
this.Temperature = chatClientOptions.Temperature;
this.Tools = chatClientOptions.Tools;
this.ToolMode = chatClientOptions.ToolMode;
this.TopK = chatClientOptions.TopK;
this.TopP = chatClientOptions.TopP;
}
}
return activity;
public AgentRunOptions? Options { get; }
public AgentThread? Thread { get; }
public Activity? CurrentActivity { get; }
}
/// <summary>
/// Adds a tag to the tag list if the value is not null or whitespace.
/// </summary>
private static void AddIfNotWhiteSpace(ref TagList tags, string key, string? value)
/// <summary>The stub <see cref="IChatClient"/> used to delegate from the <see cref="OpenTelemetryChatClient"/> into the inner <see cref="AIAgent"/>.</summary>
/// <param name="parentAgent"></param>
private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient
{
if (!string.IsNullOrWhiteSpace(value))
public async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken cancellationToken)
{
tags.Add(key, value);
}
}
ForwardedOptions? fo = options as ForwardedOptions;
/// <summary>
/// Adds agent response information to the activity and records metrics.
/// </summary>
private void TraceResponse(
Activity? activity,
AgentRunResponse? response,
Exception? error,
Stopwatch? stopwatch)
{
// Record operation duration metric
if (this._operationDurationHistogram.Enabled && stopwatch is not null)
{
TagList tags = new()
// Update the current activity to reflect the agent invocation.
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
// Invoke the inner agent.
var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Thread, fo?.Options, cancellationToken).ConfigureAwait(false);
// Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient.
return new ChatResponse
{
{ OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent }
AdditionalProperties = response.AdditionalProperties,
CreatedAt = response.CreatedAt,
Messages = response.Messages,
RawRepresentation = response,
ResponseId = response.ResponseId,
Usage = response.Usage,
};
AddIfNotWhiteSpace(ref tags, OpenTelemetryConsts.GenAI.Agent.Name, this.DisplayName);
if (error is not null)
{
tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName);
}
this._operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags);
}
// Record token usage metrics
if (this._tokenUsageHistogram.Enabled && response?.Usage is { } usage)
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
if (usage.InputTokenCount is long inputTokens)
ForwardedOptions? fo = options as ForwardedOptions;
// Update the current activity to reflect the agent invocation.
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
// Invoke the inner agent.
await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Thread, fo?.Options, cancellationToken).ConfigureAwait(false))
{
TagList tags = new()
// Wrap the response updates in ChatResponseUpdates so we can pass them back through OpenTelemetryChatClient.
yield return new ChatResponseUpdate
{
{ OpenTelemetryConsts.GenAI.Token.Type, "input" }
AdditionalProperties = update.AdditionalProperties,
AuthorName = update.AuthorName,
Contents = update.Contents,
CreatedAt = update.CreatedAt,
MessageId = update.MessageId,
RawRepresentation = update,
ResponseId = update.ResponseId,
Role = update.Role,
};
AddIfNotWhiteSpace(ref tags, OpenTelemetryConsts.GenAI.Agent.Name, this.Name);
this._tokenUsageHistogram.Record((int)inputTokens, tags);
}
if (usage.OutputTokenCount is long outputTokens)
{
TagList tags = new()
{
{ OpenTelemetryConsts.GenAI.Token.Type, "output" }
};
AddIfNotWhiteSpace(ref tags, OpenTelemetryConsts.GenAI.Agent.Name, this.Name);
this._tokenUsageHistogram.Record((int)outputTokens, tags);
}
}
// Add activity tags
if (activity is not null)
{
if (error is not null)
{
_ = activity
.AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName)
.SetStatus(ActivityStatusCode.Error, error.Message);
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
// Delegate any inquiries made by the OpenTelemetryChatClient back to the parent agent.
parentAgent.GetService(serviceType, serviceKey);
if (response is not null)
{
if (!string.IsNullOrWhiteSpace(response.ResponseId))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Id, response.ResponseId);
}
if (response.Usage?.InputTokenCount is long inputTokens)
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens);
}
if (response.Usage?.OutputTokenCount is long outputTokens)
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens);
}
}
}
// Log the agent response for choice events
if (response is not null)
{
this.LogAgentResponse(response);
}
public void Dispose() { }
}
private void LogChatMessages(IEnumerable<ChatMessage> messages)
{
if (this._openTelemetryChatClient is not null)
{
// To avoid duplication of telemetry data the logging will be skipped if the agent is a ChatClientAgent and
// its innerChatClient already has telemetry enabled,
return;
}
if (!this._logger.IsEnabled(EventLogLevel))
{
return;
}
foreach (ChatMessage message in messages)
{
if (message.Role == ChatRole.Assistant)
{
this.Log(new EventId(1, OpenTelemetryConsts.GenAI.Assistant.Message),
JsonSerializer.Serialize(this.CreateAssistantEvent(message.Contents), OtelContext.Default.AssistantEvent));
}
else if (message.Role == ChatRole.Tool)
{
foreach (FunctionResultContent frc in message.Contents.OfType<FunctionResultContent>())
{
this.Log(new EventId(1, OpenTelemetryConsts.GenAI.Tool.Message),
JsonSerializer.Serialize(new ToolEvent()
{
Id = frc.CallId,
Content = this.EnableSensitiveData && frc.Result is object result ?
JsonSerializer.SerializeToNode(result, this._jsonSerializerOptions.GetTypeInfo(result.GetType())) :
null,
}, OtelContext.Default.ToolEvent));
}
}
else
{
this.Log(new EventId(1, message.Role == ChatRole.System ? OpenTelemetryConsts.GenAI.System.Message : OpenTelemetryConsts.GenAI.User.Message),
JsonSerializer.Serialize(new SystemOrUserEvent()
{
Role = message.Role != ChatRole.System && message.Role != ChatRole.User && !string.IsNullOrWhiteSpace(message.Role.Value) ? message.Role.Value : null,
Content = this.GetMessageContent(message.Contents),
}, OtelContext.Default.SystemOrUserEvent));
}
}
}
private void LogAgentResponse(AgentRunResponse response)
{
if (this._openTelemetryChatClient is not null)
{
// To avoid duplication of telemetry data the logging will be skipped if the agent is a ChatClientAgent and
// its innerChatClient already has telemetry enabled
return;
}
if (!this._logger.IsEnabled(EventLogLevel))
{
return;
}
EventId id = new(1, OpenTelemetryConsts.GenAI.Choice);
this.Log(id, JsonSerializer.Serialize(new ChoiceEvent()
{
FinishReason = (response.RawRepresentation as ChatResponse)?.FinishReason?.Value ?? string.Empty,
Index = 0,
Message = this.CreateAssistantEvent(response.Messages is { Count: 1 } ? response.Messages[0].Contents : response.Messages.SelectMany(m => m.Contents)),
}, OtelContext.Default.ChoiceEvent));
}
private void Log(EventId id, string eventBodyJson)
{
// This is not the idiomatic way to log, but it's necessary for now in order to structure
// the data in a way that the OpenTelemetry collector can work with it. The event body
// can be very large and should not be logged as an attribute.
KeyValuePair<string, object?>[] tags =
[
new(OpenTelemetryConsts.Event.Name, id.Name),
new(OpenTelemetryConsts.GenAI.SystemName, this._system),
];
this._logger.Log(EventLogLevel, id, tags, null, (_, __) => eventBodyJson);
}
private AssistantEvent CreateAssistantEvent(IEnumerable<AIContent> contents)
{
var toolCalls = contents.OfType<FunctionCallContent>().Select(fc => new ToolCall
{
Id = fc.CallId,
Function = new()
{
Name = fc.Name,
Arguments = this.EnableSensitiveData ?
JsonSerializer.SerializeToNode(fc.Arguments, this._jsonSerializerOptions.GetTypeInfo(typeof(IDictionary<string, object?>))) :
null,
},
}).ToArray();
return new()
{
Content = this.GetMessageContent(contents),
ToolCalls = toolCalls.Length > 0 ? toolCalls : null,
};
}
private string? GetMessageContent(IEnumerable<AIContent> contents)
{
if (this.EnableSensitiveData)
{
string content = string.Concat(contents.OfType<TextContent>());
if (content.Length > 0)
{
return content;
}
}
return null;
}
private sealed partial class SystemOrUserEvent
{
public string? Role { get; set; }
public string? Content { get; set; }
}
private sealed class AssistantEvent
{
public string? Content { get; set; }
public ToolCall[]? ToolCalls { get; set; }
}
private sealed partial class ToolEvent
{
public string? Id { get; set; }
public JsonNode? Content { get; set; }
}
private sealed partial class ChoiceEvent
{
public string? FinishReason { get; set; }
public int Index { get; set; }
public AssistantEvent? Message { get; set; }
}
private sealed partial class ToolCall
{
public string? Id { get; set; }
public string? Type { get; set; } = "function";
public ToolCallFunction? Function { get; set; }
}
private sealed partial class ToolCallFunction
{
public string? Name { get; set; }
public JsonNode? Arguments { get; set; }
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(SystemOrUserEvent))]
[JsonSerializable(typeof(AssistantEvent))]
[JsonSerializable(typeof(ToolEvent))]
[JsonSerializable(typeof(ChoiceEvent))]
[JsonSerializable(typeof(object))]
private sealed partial class OtelContext : JsonSerializerContext;
}
@@ -7,33 +7,9 @@ internal static class OpenTelemetryConsts
{
public const string DefaultSourceName = "Experimental.Microsoft.Agents.AI";
public const string SecondsUnit = "s";
public const string TokensUnit = "token";
public static class Event
{
public const string Name = "event.name";
}
public static class Error
{
public const string Type = "error.type";
}
public static class GenAI
{
public const string Choice = "gen_ai.choice";
public const string SystemName = "gen_ai.system";
public static class SystemNameValues
{
public const string MicrosoftAgentsAI = "microsoft.agents.ai";
}
public const string Chat = "chat";
public const string Embeddings = "embeddings";
public const string ExecuteTool = "execute_tool";
public const string InvokeAgent = "invoke_agent";
public static class Agent
{
@@ -42,113 +18,9 @@ internal static class OpenTelemetryConsts
public const string Description = "gen_ai.agent.description";
}
public static class Assistant
public static class Provider
{
public const string Message = "gen_ai.assistant.message";
public const string Name = "gen_ai.provider.name";
}
public static class Client
{
public static class OperationDuration
{
public const string Description = "Measures the duration of a GenAI operation";
public const string Name = "gen_ai.client.operation.duration";
public static readonly double[] ExplicitBucketBoundaries = [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92];
}
public static class TokenUsage
{
public const string Description = "Measures number of input and output tokens used";
public const string Name = "gen_ai.client.token.usage";
public static readonly int[] ExplicitBucketBoundaries = [1, 4, 16, 64, 256, 1_024, 4_096, 16_384, 65_536, 262_144, 1_048_576, 4_194_304, 16_777_216, 67_108_864];
}
}
public static class Conversation
{
public const string Id = "gen_ai.conversation.id";
}
public static class Operation
{
public const string Name = "gen_ai.operation.name";
public static class NameValues
{
public const string InvokeAgent = "invoke_agent";
}
}
public static class Output
{
public const string Type = "gen_ai.output.type";
}
public static class Request
{
public const string EmbeddingDimensions = "gen_ai.request.embedding.dimensions";
public const string FrequencyPenalty = "gen_ai.request.frequency_penalty";
public const string Model = "gen_ai.request.model";
public const string MaxTokens = "gen_ai.request.max_tokens";
public const string PresencePenalty = "gen_ai.request.presence_penalty";
public const string Seed = "gen_ai.request.seed";
public const string StopSequences = "gen_ai.request.stop_sequences";
public const string Temperature = "gen_ai.request.temperature";
public const string TopK = "gen_ai.request.top_k";
public const string TopP = "gen_ai.request.top_p";
// Not available in OTEL : Potential proposals
public const string Instructions = "gen_ai.request.instructions";
public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.request.{parameterName}";
}
public static class Response
{
public const string FinishReasons = "gen_ai.response.finish_reasons";
public const string Id = "gen_ai.response.id";
public const string Model = "gen_ai.response.model";
public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.response.{parameterName}";
}
public static class System
{
public const string Message = "gen_ai.system.message";
}
public static class Token
{
public const string Type = "gen_ai.token.type";
}
public static class Tool
{
public const string Name = "gen_ai.tool.name";
public const string Description = "gen_ai.tool.description";
public const string Message = "gen_ai.tool.message";
public static class Call
{
public const string Id = "gen_ai.tool.call.id";
}
}
public static class Usage
{
public const string InputTokens = "gen_ai.usage.input_tokens";
public const string OutputTokens = "gen_ai.usage.output_tokens";
}
public static class User
{
public const string Message = "gen_ai.user.message";
}
}
public static class Server
{
public const string Address = "server.address";
public const string Port = "server.port";
}
}
@@ -39,24 +39,6 @@ public class OpenTelemetryAIAgentBuilderExtensionsTests
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry with logger factory works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithLoggerFactory_UsesProvidedLoggerFactory()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
using var loggerFactory = LoggerFactory.Create(builder => { });
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseOpenTelemetry(loggerFactory).Build();
// Assert
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry with source name works correctly.
/// </summary>
@@ -131,7 +113,6 @@ public class OpenTelemetryAIAgentBuilderExtensionsTests
// Act
var result = builder.UseOpenTelemetry(
loggerFactory: loggerFactory,
sourceName: SourceName,
configure: agent =>
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
internal sealed class TestAIAgent : AIAgent
{
public Func<string>? NameFunc;
public Func<string>? DescriptionFunc;
public Func<JsonElement, JsonSerializerOptions?, AgentThread> DeserializeThreadFunc = delegate { throw new NotSupportedException(); };
public Func<AgentThread> GetNewThreadFunc = delegate { throw new NotSupportedException(); };
public Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, Task<AgentRunResponse>> RunAsyncFunc = delegate { throw new NotSupportedException(); };
public Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, IAsyncEnumerable<AgentRunResponseUpdate>> RunStreamingAsyncFunc = delegate { throw new NotSupportedException(); };
public Func<Type, object?, object?>? GetServiceFunc;
public override string? Name => this.NameFunc?.Invoke() ?? base.Name;
public override string? Description => this.DescriptionFunc?.Invoke() ?? base.Description;
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
this.DeserializeThreadFunc(serializedThread, jsonSerializerOptions);
public override AgentThread GetNewThread() =>
this.GetNewThreadFunc();
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
this.RunAsyncFunc(messages, thread, options, cancellationToken);
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
this.RunStreamingAsyncFunc(messages, thread, options, cancellationToken);
public override object? GetService(Type serviceType, object? serviceKey = null) =>
this.GetServiceFunc is { } func ? func(serviceType, serviceKey) :
base.GetService(serviceType, serviceKey);
}