// Copyright (c) Microsoft. All rights reserved. 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; /// /// Represents a delegating agent that implements OpenTelemetry instrumentation for agent operations. /// /// /// This class provides telemetry instrumentation for agent operations including activities, metrics, and logging. /// The telemetry output follows OpenTelemetry semantic conventions in and is subject to change as the conventions evolve. /// public sealed partial 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 _operationDurationHistogram; private readonly Histogram _tokenUsageHistogram; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The underlying agent to wrap with telemetry. /// The to use for emitting events. /// An optional source name that will be used on the telemetry data. public OpenTelemetryAgent(AIAgent innerAgent, ILogger? logger = null, 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()?.ProviderName ?? OpenTelemetryConsts.GenAI.SystemNameValues.MicrosoftExtensionsAIAgents; // Attempt to get the open telemetry chat client if the inner agent is a ChatClientAgent. this._openTelemetryChatClient = (this.InnerAgent as ChatClientAgent)?.ChatClient.GetService(); // Inherit by default the EnableSensitiveData setting from the TelemetryChatClient if available. this.EnableSensitiveData = this._openTelemetryChatClient?.EnableSensitiveData ?? false; this._operationDurationHistogram = this._meter.CreateHistogram( 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( 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; } /// Gets or sets JSON serialization options to use when formatting chat data into telemetry strings. public JsonSerializerOptions JsonSerializerOptions { get => this._jsonSerializerOptions; set => this._jsonSerializerOptions = Throw.IfNull(value); } /// /// Disposes the telemetry resources. /// public void Dispose() { this._activitySource.Dispose(); this._meter.Dispose(); } /// /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. /// /// /// if potentially sensitive information should be included in telemetry; /// if telemetry shouldn't include raw inputs and outputs. /// The default value is . /// /// /// 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. /// public bool EnableSensitiveData { get; set; } /// public override object? GetService(Type serviceType, object? serviceKey = null) { // 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); } /// public override async Task RunAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? messages.ToList(); using Activity? activity = this.CreateAndConfigureActivity(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, thread); Stopwatch? stopwatch = this._operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; 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); } } /// public override async IAsyncEnumerable RunStreamingAsync( IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? messages.ToList(); using Activity? activity = this.CreateAndConfigureActivity(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, thread); Stopwatch? stopwatch = this._operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; IAsyncEnumerable updates; try { updates = base.RunStreamingAsync(inputMessages, thread, options, cancellationToken); } catch (Exception ex) { this.TraceResponse(activity, response: null, ex, stopwatch); throw; } var responseEnumerator = updates.GetAsyncEnumerator(cancellationToken); List 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); } } /// /// Creates an activity for an agent request, or returns null if not enabled. /// private Activity? CreateAndConfigureActivity(string operationName, AgentThread? thread) { // Get the GenAI system name for telemetry var chatClientAgent = this.InnerAgent as ChatClientAgent; Activity? activity = null; if (this._activitySource.HasListeners()) { string activityName = string.IsNullOrWhiteSpace(this.Name) ? operationName : $"{operationName} {this.Name}"; activity = this._activitySource.StartActivity(activityName, ActivityKind.Client); if (activity is not null) { _ = 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(); 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); } } } return activity; } /// /// Adds a tag to the tag list if the value is not null or whitespace. /// private static void AddIfNotWhiteSpace(ref TagList tags, string key, string? value) { if (!string.IsNullOrWhiteSpace(value)) { tags.Add(key, value); } } /// /// Adds agent response information to the activity and records metrics. /// 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() { { OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent } }; 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) { if (usage.InputTokenCount is long inputTokens) { TagList tags = new() { { OpenTelemetryConsts.GenAI.Token.Type, "input" } }; 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); } 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); } } private void LogChatMessages(IEnumerable 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()) { 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[] 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 contents) { var toolCalls = contents.OfType().Select(fc => new ToolCall { Id = fc.CallId, Function = new() { Name = fc.Name, Arguments = this.EnableSensitiveData ? JsonSerializer.SerializeToNode(fc.Arguments, this._jsonSerializerOptions.GetTypeInfo(typeof(IDictionary))) : null, }, }).ToArray(); return new() { Content = this.GetMessageContent(contents), ToolCalls = toolCalls.Length > 0 ? toolCalls : null, }; } private string? GetMessageContent(IEnumerable contents) { if (this.EnableSensitiveData) { string content = string.Concat(contents.OfType()); 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; }