// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; /// /// Provides a delegating implementation that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// /// /// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.37, defined at . /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. /// public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable { // 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. /// The providing the bulk of the telemetry. private readonly OpenTelemetryChatClient _otelClient; /// The provider name extracted from . private readonly string? _providerName; /// The resolved source name for telemetry. Always non-empty; defaults to . private readonly string _sourceName; /// /// Indicates whether the underlying of a inner agent /// should be automatically wrapped with on each invocation. /// private readonly bool _autoWireChatClient; /// Initializes a new instance of the class. /// The underlying to be augmented with telemetry capabilities. /// /// An optional source name that will be used to identify telemetry data from this agent. /// If not provided, a default source name will be used for telemetry identification. /// /// is . /// /// The constructor automatically extracts provider metadata from the inner agent and configures /// telemetry collection according to OpenTelemetry semantic conventions for AI systems. /// public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) #pragma warning disable MAAI001 // Auto-wiring is the new default; the experimental opt-out lives on the 3-arg overload. : this(innerAgent, sourceName, autoWireChatClient: true) #pragma warning restore MAAI001 { } /// Initializes a new instance of the class. /// The underlying to be augmented with telemetry capabilities. /// /// An optional source name that will be used to identify telemetry data from this agent. /// If not provided, a default source name will be used for telemetry identification. /// /// /// When and the inner agent is a , the underlying /// is automatically wrapped with for each invocation /// so that chat-level telemetry flows alongside agent-level telemetry. If the underlying chat client is already /// instrumented, no additional wrapping is applied. Set to to opt-out of this behavior. /// /// is . /// /// The constructor automatically extracts provider metadata from the inner agent and configures /// telemetry collection according to OpenTelemetry semantic conventions for AI systems. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireChatClient) : base(innerAgent) { this._providerName = innerAgent.GetService()?.ProviderName; // Resolve once so the outer OpenTelemetryChatClient and the auto-wired inner // OpenTelemetryChatClient always emit spans under the same ActivitySource, even when // the caller passes "" or whitespace (which neither client should treat as a real source). this._sourceName = string.IsNullOrWhiteSpace(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; this._autoWireChatClient = autoWireChatClient; this._otelClient = new OpenTelemetryChatClient( new ForwardingChatClient(this), sourceName: this._sourceName); } /// public void Dispose() => this._otelClient.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 , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT /// environment variable is set to "true" (case-insensitive). /// /// /// 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 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT /// environment variable to "true". Explicitly setting this property will override the environment variable. /// /// Security consideration: When sensitive data capture is enabled, the full text of chat messages — /// including user inputs, LLM responses, function call arguments, and function results — is emitted as telemetry. /// This data may contain PII or other sensitive information. Ensure that your telemetry pipeline is configured /// with appropriate access controls and data retention policies. /// /// public bool EnableSensitiveData { get => this._otelClient.EnableSensitiveData; set => this._otelClient.EnableSensitiveData = value; } /// protected override async Task RunCoreAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { ChatOptions co = new ForwardedOptions(options, session, Activity.Current); var response = await this._otelClient.GetResponseAsync(messages, co, cancellationToken).ConfigureAwait(false); return response.RawRepresentation as AgentResponse ?? new AgentResponse(response); } /// protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ChatOptions co = new ForwardedOptions(options, session, Activity.Current); await foreach (var update in this._otelClient.GetStreamingResponseAsync(messages, co, cancellationToken).ConfigureAwait(false)) { yield return update.RawRepresentation as AgentResponseUpdate ?? new AgentResponseUpdate(update); } } /// Augments the current activity created by the with agent-specific information. /// The that was current prior to the 's invocation. private void UpdateCurrentActivity(Activity? previousActivity) { // 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)) { return; } // Override information set by OpenTelemetryChatClient to make it specific to invoke_agent. activity.DisplayName = string.IsNullOrWhiteSpace(this.Name) ? $"{OpenTelemetryConsts.GenAI.InvokeAgent} {this.Id}" : $"{OpenTelemetryConsts.GenAI.InvokeAgent} {this.Name}({this.Id})"; activity.SetTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.InvokeAgent); 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); } } /// State passed from this instance into the inner agent, circumventing the intermediate . private sealed class ForwardedOptions : ChatOptions { public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activity? currentActivity) : base((options as ChatClientAgentRunOptions)?.ChatOptions) { this.Options = options; this.Session = session; this.CurrentActivity = currentActivity; } public AgentRunOptions? Options { get; } public AgentSession? Session { get; } public Activity? CurrentActivity { get; } } /// /// If auto-wiring is enabled and the inner agent is a whose underlying /// is not already instrumented with , returns a /// new with a /// that wraps the chat client with . When is a /// plain (the base type, not ), the base /// properties are copied onto the new so high-level callers that pass /// the abstract still benefit from auto-wiring and propagate their settings to /// the inner agent. Otherwise, returns unchanged. /// private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options) { if (!this._autoWireChatClient) { return options; } // The auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op. // Use GetService rather than a type check so wrapping agents that expose a nested ChatClientAgent are supported. var chatClientAgent = this.InnerAgent.GetService(); if (chatClientAgent is null) { return options; } // Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out. if (chatClientAgent.GetService()?.UseProvidedChatClientAsIs is true) { return options; } // Capture the underlying IChatClient and check whether it is already instrumented. var chatClient = chatClientAgent.GetService(); if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null) { return options; } string sourceName = this._sourceName; static IChatClient WrapIfNeeded(IChatClient cc, string sourceName) => cc.GetService(typeof(OpenTelemetryChatClient)) is not null ? cc : cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(); if (options is ChatClientAgentRunOptions ccOptions) { // Don't mutate the caller's options; clone and chain any caller-provided factory. // If the user factory already returns an OpenTelemetry-instrumented client, don't double-wrap. var clone = (ChatClientAgentRunOptions)ccOptions.Clone(); var userFactory = clone.ChatClientFactory; clone.ChatClientFactory = cc => WrapIfNeeded(userFactory is null ? cc : userFactory(cc), sourceName); return clone; } // For a plain AgentRunOptions (or null), create a ChatClientAgentRunOptions and preserve // any base AgentRunOptions properties from the caller so they reach the inner agent. var newOptions = new ChatClientAgentRunOptions { ChatClientFactory = cc => WrapIfNeeded(cc, sourceName), }; if (options is not null) { CopyBaseAgentRunOptions(options, newOptions); } return newOptions; } #pragma warning disable MEAI001 // ContinuationToken is experimental; copy it through to preserve caller-provided value. private static void CopyBaseAgentRunOptions(AgentRunOptions source, AgentRunOptions target) { target.ContinuationToken = source.ContinuationToken; target.AllowBackgroundResponses = source.AllowBackgroundResponses; target.AdditionalProperties = source.AdditionalProperties?.Clone(); target.ResponseFormat = source.ResponseFormat; } #pragma warning restore MEAI001 /// The stub used to delegate from the into the inner . /// private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient { public async Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { ForwardedOptions? fo = options as ForwardedOptions; // Update the current activity to reflect the agent invocation. parentAgent.UpdateCurrentActivity(fo?.CurrentActivity); // If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory. var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options); // Invoke the inner agent. var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false); // Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient. return response.AsChatResponse(); } public async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ForwardedOptions? fo = options as ForwardedOptions; // Update the current activity to reflect the agent invocation. parentAgent.UpdateCurrentActivity(fo?.CurrentActivity); // If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory. var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options); // Invoke the inner agent. await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false)) { // Wrap the response updates in ChatResponseUpdates so we can pass them back through OpenTelemetryChatClient. yield return update.AsChatResponseUpdate(); } } public object? GetService(Type serviceType, object? serviceKey = null) => // Delegate any inquiries made by the OpenTelemetryChatClient back to the parent agent. parentAgent.GetService(serviceType, serviceKey); public void Dispose() { } } }