diff --git a/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs b/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs index d5c64ae416..0d9ac511ee 100644 --- a/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs @@ -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(); diff --git a/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs index a47ab1ce4a..d1850738a1 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs @@ -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. /// /// The agent to wrap. - /// The to use for emitting events. /// An optional source name that will be used on the telemetry data. /// When indicates whether potentially sensitive information should be included in telemetry. Default is /// An that wraps the original agent with telemetry. - 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; + } /// /// Creates a that will invoke the provided Agent. diff --git a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs index eda230675c..6960327286 100644 --- a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs @@ -64,7 +64,6 @@ internal sealed class FunctionInvocationDelegatingAgent : DelegatingAIAgent Arguments = arguments, Function = this.InnerFunction, CallContent = new(string.Empty, this.InnerFunction.Name, new Dictionary(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); diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index 765874a4b5..28b62cf1d4 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -1,4 +1,4 @@ - + $(ProjectsTargetFrameworks) @@ -11,6 +11,7 @@ true true true + true diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAIAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAIAgentBuilderExtensions.cs index 59b652ffe6..8cf0dbfcd6 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAIAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAIAgentBuilderExtensions.cs @@ -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. /// /// The . - /// An optional to use to create a logger for logging events. /// An optional source name that will be used on the telemetry data. /// An optional callback that can be used to configure the instance. /// The . public static AIAgentBuilder UseOpenTelemetry( this AIAgentBuilder builder, - ILoggerFactory? loggerFactory = null, string? sourceName = null, Action? configure = null) => Throw.IfNull(builder).Use((innerAgent, services) => { - loggerFactory ??= services.GetService(); - - var agent = new OpenTelemetryAgent(innerAgent, loggerFactory?.CreateLogger(typeof(OpenTelemetryAgent)), sourceName); + var agent = new OpenTelemetryAgent(innerAgent, sourceName); configure?.Invoke(agent); return agent; diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index 1fc4659aa8..ba8e35a46c 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -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; -/// -/// Represents a delegating agent that implements OpenTelemetry instrumentation for agent operations. -/// +/// Represents a delegating that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// -/// 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. +/// This class provides an implementation of the Semantic Conventions for Generative AI systems, 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 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 _operationDurationHistogram; - private readonly Histogram _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. - /// - /// Initializes a new instance of the class. - /// - /// The underlying agent to wrap with telemetry. - /// The to use for emitting events. + /// The providing the bulk of the telemetry. + private readonly OpenTelemetryChatClient _otelClient; + /// The provider name extracted from . + private readonly string? _providerName; + + /// Initializes a new instance of the class. + /// The underlying . /// An optional source name that will be used on the telemetry data. - public OpenTelemetryAgent(AIAgent innerAgent, ILogger? logger = null, string? sourceName = null) - : base(innerAgent) + /// is . + 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()?.ProviderName ?? OpenTelemetryConsts.GenAI.SystemNameValues.MicrosoftAgentsAI; + this._providerName = innerAgent.GetService()?.ProviderName; - // 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; + this._otelClient = new OpenTelemetryChatClient( + new ForwardingChatClient(this), + sourceName: string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!); } - /// 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(); - } + /// + public void Dispose() => this._otelClient.Dispose(); /// /// 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 /// /// if potentially sensitive information should be included in telemetry; /// if telemetry shouldn't include raw inputs and outputs. - /// The default value is . + /// 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. /// - public bool EnableSensitiveData { get; set; } - - /// - 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; } /// public override async Task RunAsync( - IEnumerable messages, - AgentThread? thread = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) + IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? 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!; } /// public override async IAsyncEnumerable RunStreamingAsync( - IEnumerable messages, - AgentThread? thread = null, - AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? 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 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 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!; } } - /// - /// Creates an activity for an agent request, or returns null if not enabled. - /// - private Activity? CreateAndConfigureActivity(string operationName, AgentThread? thread) + /// 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) { - // 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); + } + } + + /// State passed from this instance into the inner agent, circumventing the intermediate . + 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(); - 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; } } - /// - /// 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) + /// The stub used to delegate from the into the inner . + /// + private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient { - if (!string.IsNullOrWhiteSpace(value)) + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken) { - tags.Add(key, value); - } - } + ForwardedOptions? fo = options as ForwardedOptions; - /// - /// 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() + // 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 GetStreamingResponseAsync( + IEnumerable 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 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; } diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryConsts.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryConsts.cs index 152ab793f0..ac84dc00da 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryConsts.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryConsts.cs @@ -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"; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAIAgentBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAIAgentBuilderExtensionsTests.cs index 9181d81f63..5d895f9553 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAIAgentBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAIAgentBuilderExtensionsTests.cs @@ -39,24 +39,6 @@ public class OpenTelemetryAIAgentBuilderExtensionsTests Assert.IsType(result); } - /// - /// Verify that UseOpenTelemetry with logger factory works correctly. - /// - [Fact] - public void UseOpenTelemetry_WithLoggerFactory_UsesProvidedLoggerFactory() - { - // Arrange - var mockAgent = new Mock(); - using var loggerFactory = LoggerFactory.Create(builder => { }); - var builder = new AIAgentBuilder(mockAgent.Object); - - // Act - var result = builder.UseOpenTelemetry(loggerFactory).Build(); - - // Assert - Assert.IsType(result); - } - /// /// Verify that UseOpenTelemetry with source name works correctly. /// @@ -131,7 +113,6 @@ public class OpenTelemetryAIAgentBuilderExtensionsTests // Act var result = builder.UseOpenTelemetry( - loggerFactory: loggerFactory, sourceName: SourceName, configure: agent => { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs index 04165186c2..d8d5c56b0a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -5,2722 +5,595 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; -using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Moq; -using OpenTelemetry.Metrics; using OpenTelemetry.Trace; +#pragma warning disable CA1861 // Avoid constant arrays as arguments +#pragma warning disable RCS1186 // Use Regex instance instead of static method + namespace Microsoft.Agents.AI.UnitTests; public class OpenTelemetryAgentTests { - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task RunAsync_ExpectedTelemetryData_CollectedAsync(bool withError) + [Fact] + public void Ctor_InvalidArgs_Throws() { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); + Assert.Throws(() => new OpenTelemetryAgent(null!)); + } - var mockLogger = new Mock(); - var mockAgent = CreateMockAgent(withError); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); + [Fact] + public void Ctor_NullSourceName_Valid() + { + using var agent = new OpenTelemetryAgent(new TestAIAgent(), null); + Assert.NotNull(agent); + } - var messages = new List + [Fact] + public void Properties_DelegateToInnerAgent() + { + TestAIAgent innerAgent = new() { - new(ChatRole.User, "What's the weather like?") + NameFunc = () => "TestAgent", + DescriptionFunc = () => "This is a test agent.", }; - var thread = new Mock().Object; + using var agent = new OpenTelemetryAgent(innerAgent, "MySource"); - // Act & Assert - if (withError) + Assert.Equal("TestAgent", agent.Name); + Assert.Equal("This is a test agent.", agent.Description); + Assert.Equal(innerAgent.Id, agent.Id); + Assert.Equal(innerAgent.DisplayName, agent.DisplayName); + } + + [Fact] + public void EnableSensitiveData_Roundtrips() + { + using var agent = new OpenTelemetryAgent(new TestAIAgent(), "MySource"); + for (int i = 0; i < 2; i++) { - var exception = await Assert.ThrowsAsync( - () => telemetryAgent.RunAsync(messages, thread)); - Assert.Equal("Test error", exception.Message); + Assert.False(agent.EnableSensitiveData); + agent.EnableSensitiveData = true; + Assert.True(agent.EnableSensitiveData); + agent.EnableSensitiveData = false; } - else - { - var response = await telemetryAgent.RunAsync(messages, thread); - Assert.NotNull(response); - Assert.Equal("Test response", response.Messages.First().Text); - } - - // Verify activity was created - var activity = Assert.Single(activities); - Assert.NotNull(activity.Id); - Assert.NotEmpty(activity.Id); - Assert.Equal($"{OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent} TestAgent", activity.DisplayName); - Assert.Equal(ActivityKind.Client, activity.Kind); - - // Verify activity tags - Assert.Equal(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, activity.GetTagItem(OpenTelemetryConsts.GenAI.Operation.Name)); - Assert.Equal(OpenTelemetryConsts.GenAI.SystemNameValues.MicrosoftAgentsAI, activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - Assert.Equal("test-agent-id", activity.GetTagItem(OpenTelemetryConsts.GenAI.Agent.Id)); - Assert.Equal("TestAgent", activity.GetTagItem(OpenTelemetryConsts.GenAI.Agent.Name)); - Assert.Equal("Test Description", activity.GetTagItem(OpenTelemetryConsts.GenAI.Agent.Description)); - - if (withError) - { - Assert.Equal("System.InvalidOperationException", activity.GetTagItem(OpenTelemetryConsts.Error.Type)); - Assert.Equal(ActivityStatusCode.Error, activity.Status); - Assert.Equal("Test error", activity.StatusDescription); - } - else - { - Assert.Equal("test-response-id", activity.GetTagItem(OpenTelemetryConsts.GenAI.Response.Id)); - Assert.Equal(10, activity.GetTagItem(OpenTelemetryConsts.GenAI.Usage.InputTokens)); - Assert.Equal(20, activity.GetTagItem(OpenTelemetryConsts.GenAI.Usage.OutputTokens)); - } - - Assert.True(activity.Duration.TotalMilliseconds > 0); } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task RunStreamingAsync_ExpectedTelemetryData_CollectedAsync(bool withError) + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task WithoutChatOptions_ExpectedInformationLogged_Async(bool enableSensitiveData, bool streaming) { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); + var sourceName = Guid.NewGuid().ToString(); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) .AddInMemoryExporter(activities) .Build(); - var mockAgent = CreateMockStreamingAgent(withError); - var mockLogger = new Mock(); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); - - var messages = new List + var innerAgent = new TestAIAgent { - new(ChatRole.User, "Tell me a story") - }; + NameFunc = () => "TestAgent", + DescriptionFunc = () => "This is a test agent.", - var thread = new Mock().Object; - - // Act & Assert - if (withError) - { - var exception = await Assert.ThrowsAsync(async () => + RunAsyncFunc = async (messages, thread, options, cancellationToken) => { - await foreach (var update in telemetryAgent.RunStreamingAsync(messages, thread)) + await Task.Yield(); + return new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "The blue whale, I think.")) { - // Should not reach here - } - }); - Assert.Equal("Streaming error", exception.Message); - } - else + ResponseId = "id123", + Usage = new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 42, + }, + AdditionalProperties = new() + { + ["system_fingerprint"] = "abcdefgh", + ["AndSomethingElse"] = "value2", + }, + }; + }, + + RunStreamingAsyncFunc = CallbackAsync, + + GetServiceFunc = (serviceType, serviceKey) => + serviceType == typeof(AIAgentMetadata) ? new AIAgentMetadata("TestAgentProviderFromAIAgentMetadata") : + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("TestAgentProviderFromChatClientMetadata", new Uri("http://localhost:12345/something"), "amazingmodel") : + null, + }; + + async static IAsyncEnumerable CallbackAsync( + IEnumerable messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) { - var updates = new List(); - await foreach (var update in telemetryAgent.RunStreamingAsync(messages, thread)) + await Task.Yield(); + + foreach (string text in new[] { "The ", "blue ", "whale,", " ", "", "I", " think." }) { - updates.Add(update); + await Task.Yield(); + yield return new AgentRunResponseUpdate(ChatRole.Assistant, text) + { + ResponseId = "id123", + }; } - Assert.NotEmpty(updates); - } - // Verify activity was created - var activity = Assert.Single(activities); - Assert.NotNull(activity.Id); - Assert.NotEmpty(activity.Id); - Assert.Equal($"{OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent} TestAgent", activity.DisplayName); - Assert.Equal(ActivityKind.Client, activity.Kind); - - // Verify activity tags - Assert.Equal(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, activity.GetTagItem(OpenTelemetryConsts.GenAI.Operation.Name)); - Assert.Equal(OpenTelemetryConsts.GenAI.SystemNameValues.MicrosoftAgentsAI, activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - Assert.Equal("test-agent-id", activity.GetTagItem(OpenTelemetryConsts.GenAI.Agent.Id)); - Assert.Equal("TestAgent", activity.GetTagItem(OpenTelemetryConsts.GenAI.Agent.Name)); - Assert.Equal("Test Description", activity.GetTagItem(OpenTelemetryConsts.GenAI.Agent.Description)); - - if (withError) - { - Assert.Equal("System.InvalidOperationException", activity.GetTagItem(OpenTelemetryConsts.Error.Type)); - Assert.Equal(ActivityStatusCode.Error, activity.Status); - Assert.Equal("Streaming error", activity.StatusDescription); - } - else - { - Assert.Equal("stream-response-id", activity.GetTagItem(OpenTelemetryConsts.GenAI.Response.Id)); - Assert.Equal(15, activity.GetTagItem(OpenTelemetryConsts.GenAI.Usage.InputTokens)); - Assert.Equal(25, activity.GetTagItem(OpenTelemetryConsts.GenAI.Usage.OutputTokens)); - } - - Assert.True(activity.Duration.TotalMilliseconds > 0); - } - - [Fact] - public async Task RunAsync_WithChatClientAgent_IncludesInstructionsAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockChatClient = new Mock(); - mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"))); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Id = "chat-agent-id", - Name = "ChatAgent", - Instructions = "You are a helpful assistant." - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - Assert.Equal("You are a helpful assistant.", activity.GetTagItem(OpenTelemetryConsts.GenAI.Request.Instructions)); - // Should use default system when ChatClientMetadata is not available - Assert.Equal(OpenTelemetryConsts.GenAI.SystemNameValues.MicrosoftAgentsAI, activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - } - - [Fact] - public async Task RunAsync_WithChatClientAgent_WithMetadata_UsesProviderNameAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockChatClient = new Mock(); - mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"))); - - // Setup ChatClientMetadata to return a specific provider name - var metadata = new ChatClientMetadata("openai"); - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns(metadata); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Id = "chat-agent-id", - Name = "ChatAgent", - Instructions = "You are a helpful assistant." - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - Assert.Equal("You are a helpful assistant.", activity.GetTagItem(OpenTelemetryConsts.GenAI.Request.Instructions)); - // Should use the provider name from ChatClientMetadata - Assert.Equal("openai", activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - } - - [Fact] - public async Task RunAsync_WithNonChatClientAgent_UsesDefaultSystemAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockAgent = CreateMockAgent(false); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - // Should use default system when agent is not a ChatClientAgent - Assert.Equal(OpenTelemetryConsts.GenAI.SystemNameValues.MicrosoftAgentsAI, activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - } - - [Theory] - [InlineData("azure")] - [InlineData("openai")] - [InlineData("custom-provider")] - public async Task RunAsync_WithChatClientAgent_WithDifferentProviders_UsesCorrectSystemAsync(string providerName) - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockChatClient = new Mock(); - mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"))); - - // Setup ChatClientMetadata to return the specified provider name - var metadata = new ChatClientMetadata(providerName); - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns(metadata); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Id = "chat-agent-id", - Name = "ChatAgent" - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - // Should use the provider name from ChatClientMetadata - Assert.Equal(providerName, activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - } - - [Fact] - public async Task RunStreamingAsync_WithChatClientAgent_WithMetadata_UsesProviderNameAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockChatClient = new Mock(); - ChatResponseUpdate[] returnUpdates = - [ - new ChatResponseUpdate(role: ChatRole.Assistant, content: "Stream response") - ]; - mockChatClient.Setup(c => c.GetStreamingResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .Returns(returnUpdates.ToAsyncEnumerable()); - - // Setup ChatClientMetadata to return a specific provider name - var metadata = new ChatClientMetadata("azure"); - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns(metadata); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Id = "chat-agent-id", - Name = "ChatAgent", - Instructions = "You are a helpful assistant." - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await foreach (var update in telemetryAgent.RunStreamingAsync(messages)) - { - // Consume the stream - } - - // Assert - var activity = Assert.Single(activities); - Assert.Equal("You are a helpful assistant.", activity.GetTagItem(OpenTelemetryConsts.GenAI.Request.Instructions)); - // Should use the provider name from ChatClientMetadata - Assert.Equal("azure", activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - } - - [Fact] - public async Task RunAsync_WithThreadId_IncludesThreadIdAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockAgent = CreateMockAgent(false); - var mockThread = new Mock(); - mockThread.Setup(t => t.GetService(typeof(AgentThreadMetadata), null)) - .Returns(new AgentThreadMetadata("thread-123")); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages, mockThread.Object); - - // Assert - var activity = Assert.Single(activities); - Assert.Equal("thread-123", activity.GetTagItem(OpenTelemetryConsts.GenAI.Conversation.Id)); - } - - [Fact] - public void WithOpenTelemetry_ExtensionMethod_CreatesOpenTelemetryAgent() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - // Act - using var telemetryAgent = mockAgent.Object.WithOpenTelemetry(); - - // Assert - Assert.IsType(telemetryAgent); - Assert.Equal("test-id", telemetryAgent.Id); - Assert.Equal("TestAgent", telemetryAgent.Name); - Assert.False(telemetryAgent.EnableSensitiveData); // Default should be false - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - [InlineData(null)] - public void WithOpenTelemetry_ExtensionMethodWithEnableSensitiveData_SetsPropertyCorrectly(bool? enableSensitiveData) - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - // Act - using var telemetryAgent = mockAgent.Object.WithOpenTelemetry(enableSensitiveData: enableSensitiveData); - - // Assert - Assert.IsType(telemetryAgent); - Assert.Equal("test-id", telemetryAgent.Id); - Assert.Equal("TestAgent", telemetryAgent.Name); - Assert.Equal(enableSensitiveData ?? false, telemetryAgent.EnableSensitiveData); - } - - [Fact] - public void WithOpenTelemetry_ExtensionMethodWithAllParameters_CreatesOpenTelemetryAgentWithCorrectSettings() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - var mockLoggerFactory = new Mock(); - var mockLogger = new Mock(); - mockLoggerFactory.Setup(f => f.CreateLogger(It.IsAny())) - .Returns(mockLogger.Object); - const string SourceName = "custom-source"; - const bool EnableSensitiveData = true; - - // Act - using var telemetryAgent = mockAgent.Object.WithOpenTelemetry( - loggerFactory: mockLoggerFactory.Object, - sourceName: SourceName, - enableSensitiveData: EnableSensitiveData); - - // Assert - Assert.IsType(telemetryAgent); - Assert.Equal("test-id", telemetryAgent.Id); - Assert.Equal("TestAgent", telemetryAgent.Name); - Assert.True(telemetryAgent.EnableSensitiveData); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task WithOpenTelemetry_EnableSensitiveDataParameter_SetsPropertyCorrectlyAsync(bool enableSensitiveData) - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLoggerFactory = new Mock(); - var mockLogger = new Mock(); - mockLoggerFactory.Setup(f => f.CreateLogger(It.IsAny())) - .Returns(mockLogger.Object); - - var mockAgent = CreateMockAgent(false); - - // Use the extension method with enableSensitiveData parameter - using var telemetryAgent = mockAgent.Object.WithOpenTelemetry( - loggerFactory: mockLoggerFactory.Object, - sourceName: sourceName, - enableSensitiveData: enableSensitiveData); - - var messages = new List - { - new(ChatRole.User, "What's the weather like?"), - new(ChatRole.Assistant, [ - new TextContent("I'll check the weather for you."), - new FunctionCallContent("get_weather", "call_123", new Dictionary { ["location"] = "Seattle" }) - ]), - new(ChatRole.Tool, [ - new FunctionResultContent("call_123", "Sunny, 75°F") - ]) - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - Assert.NotNull(activity); - - // Verify that EnableSensitiveData property is set correctly - Assert.Equal(enableSensitiveData, telemetryAgent.EnableSensitiveData); - - // Verify that the telemetry agent was created successfully - Assert.IsType(telemetryAgent); - } - - [Fact] - public void WithOpenTelemetry_EnableSensitiveDataParameterOverridesDefault_WhenSpecified() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - // Act & Assert - Test that explicit true overrides default false - using var telemetryAgentTrue = mockAgent.Object.WithOpenTelemetry(enableSensitiveData: true); - Assert.True(telemetryAgentTrue.EnableSensitiveData); - - // Act & Assert - Test that explicit false is respected - using var telemetryAgentFalse = mockAgent.Object.WithOpenTelemetry(enableSensitiveData: false); - Assert.False(telemetryAgentFalse.EnableSensitiveData); - - // Act & Assert - Test that null uses default (false) - using var telemetryAgentDefault = mockAgent.Object.WithOpenTelemetry(enableSensitiveData: null); - Assert.False(telemetryAgentDefault.EnableSensitiveData); - - // Act & Assert - Test that omitting parameter uses default (false) - using var telemetryAgentOmitted = mockAgent.Object.WithOpenTelemetry(); - Assert.False(telemetryAgentOmitted.EnableSensitiveData); - } - - #region ILogger Tests - - /// - /// Verify that OpenTelemetryAgent constructor accepts ILogger parameter and uses it. - /// - [Fact] - public void Constructor_WithILogger_AcceptsLoggerParameter() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - var mockLogger = new Mock(); - const string SourceName = "test-source"; - - // Act - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, SourceName); - - // Assert - Assert.Equal("test-id", telemetryAgent.Id); - Assert.Equal("TestAgent", telemetryAgent.Name); - } - - /// - /// Verify that OpenTelemetryAgent constructor works with null ILogger parameter. - /// - [Fact] - public void Constructor_WithNullILogger_UsesNullLogger() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - const string SourceName = "test-source"; - - // Act - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, logger: null, SourceName); - - // Assert - Assert.Equal("test-id", telemetryAgent.Id); - Assert.Equal("TestAgent", telemetryAgent.Name); - } - - /// - /// Verify that OpenTelemetryAgent uses the provided ILogger for logging events during RunAsync. - /// - [Fact] - public async Task RunAsync_WithILogger_LogsEventsCorrectlyAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var mockLogger = new Mock(); - - // Setup the logger to return true for IsEnabled to ensure logging occurs - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - var mockAgent = CreateMockAgent(false); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); - - var messages = new List - { - new(ChatRole.User, "Test message") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - // Verify that the logger was called for logging events - mockLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>()), - Times.AtLeastOnce); - } - - /// - /// Verify that OpenTelemetryAgent extension method accepts ILogger parameter. - /// - [Fact] - public void WithOpenTelemetry_ExtensionMethodWithILogger_CreatesOpenTelemetryAgentWithLogger() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - var mockLoggerFactory = new Mock(); - var mockLogger = new Mock(); - mockLoggerFactory.Setup(f => f.CreateLogger(It.IsAny())) - .Returns(mockLogger.Object); - const string SourceName = "test-source"; - - // Act - using var telemetryAgent = mockAgent.Object.WithOpenTelemetry(mockLoggerFactory.Object, SourceName); - - // Assert - Assert.IsType(telemetryAgent); - Assert.Equal("test-id", telemetryAgent.Id); - Assert.Equal("TestAgent", telemetryAgent.Name); - } - - #endregion - - #region OpenTelemetry Logging Deduplication Tests - - /// - /// Verify that when OpenTelemetryAgent wraps a ChatClientAgent with OpenTelemetry-enabled ChatClient, - /// logs are not duplicated. - /// - [Fact] - public async Task RunAsync_WithOpenTelemetryChatClientAgent_DoesNotDuplicateLogsAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - - // Setup the logger to return true for IsEnabled to ensure logging occurs - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - var mockChatClient = new Mock(); - - // Setup ChatClient to return a response - mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"))); - - // Setup ChatClientMetadata - var metadata = new ChatClientMetadata("openai"); - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns(metadata); - - // Create a real OpenTelemetryChatClient to simulate OpenTelemetry-enabled ChatClient - var openTelemetryChatClient = new OpenTelemetryChatClient(mockChatClient.Object, sourceName: sourceName); - mockChatClient.Setup(c => c.GetService(typeof(OpenTelemetryChatClient), null)).Returns(openTelemetryChatClient); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Id = "chat-agent-id", - Name = "ChatAgent", - Instructions = "You are a helpful assistant." - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, mockLogger.Object, sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - // Verify that the logger was NOT called because OpenTelemetryChatClient is present (deduplication) - mockLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>()), - Times.Never); - - // Verify that activities were created (indicating telemetry is working) - Assert.NotEmpty(activities); - - // Cleanup - openTelemetryChatClient.Dispose(); - } - - /// - /// Verify that OpenTelemetryAgent works correctly when wrapping a ChatClientAgent - /// without OpenTelemetry-enabled ChatClient. - /// - [Fact] - public async Task RunAsync_WithRegularChatClientAgent_LogsCorrectlyAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - - // Setup the logger to return true for IsEnabled to ensure logging occurs - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - var mockChatClient = new Mock(); - - // Setup ChatClient to return a response - mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"))); - - // Setup ChatClientMetadata - var metadata = new ChatClientMetadata("openai"); - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns(metadata); - - // No OpenTelemetryChatClient setup - simulating regular ChatClient - mockChatClient.Setup(c => c.GetService(typeof(OpenTelemetryChatClient), null)).Returns((OpenTelemetryChatClient?)null); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Id = "chat-agent-id", - Name = "ChatAgent", - Instructions = "You are a helpful assistant." - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, mockLogger.Object, sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - // Verify that the logger was called - mockLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>()), - Times.AtLeastOnce); - - // Verify that activities were created - Assert.NotEmpty(activities); - } - - /// - /// Verify that EnableSensitiveData setting is inherited from OpenTelemetryChatClient when available. - /// - [Fact] - public void Constructor_WithOpenTelemetryChatClient_InheritsEnableSensitiveDataSetting() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var mockChatClient = new Mock(); - - // Setup ChatClientMetadata - var metadata = new ChatClientMetadata("openai"); - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns(metadata); - - // Create a real OpenTelemetryChatClient with EnableSensitiveData = true - var openTelemetryChatClient = new OpenTelemetryChatClient(mockChatClient.Object, sourceName: sourceName) - { - EnableSensitiveData = true - }; - mockChatClient.Setup(c => c.GetService(typeof(OpenTelemetryChatClient), null)).Returns(openTelemetryChatClient); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Id = "chat-agent-id", - Name = "ChatAgent" - }); - - // Act - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName); - - // Assert - Assert.True(telemetryAgent.EnableSensitiveData); - - // Cleanup - openTelemetryChatClient.Dispose(); - } - - #endregion - - #region GetService Method Tests - - /// - /// Verify that GetService returns ActivitySource when requested. - /// - [Fact] - public void GetService_RequestingActivitySource_ReturnsActivitySource() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - const string SourceName = "test-source"; - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: SourceName); - - // Act - var result = telemetryAgent.GetService(typeof(ActivitySource)); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - var activitySource = (ActivitySource)result; - Assert.Equal(SourceName, activitySource.Name); - } - - /// - /// Verify that GetService delegates to inner agent for unknown service types. - /// - [Fact] - public void GetService_RequestingUnknownServiceType_DelegatesToInnerAgent() - { - // Arrange - var mockAgent = new Mock(); - var customService = new object(); - mockAgent.Setup(a => a.GetService(typeof(string), null)) - .Returns(customService); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); - - // Act - var result = telemetryAgent.GetService(typeof(string)); - - // Assert - Assert.Same(customService, result); - mockAgent.Verify(a => a.GetService(typeof(string), null), Times.Once); - } - - /// - /// Verify that GetService returns null for unknown service types when inner agent returns null. - /// - [Fact] - public void GetService_RequestingUnknownServiceTypeWithNullFromInnerAgent_ReturnsNull() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.GetService(typeof(string), null)) - .Returns((object?)null); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); - - // Act - var result = telemetryAgent.GetService(typeof(string)); - - // Assert - Assert.Null(result); - mockAgent.Verify(a => a.GetService(typeof(string), null), Times.Once); - } - - /// - /// Verify that GetService with serviceKey parameter delegates correctly to inner agent. - /// - [Fact] - public void GetService_WithServiceKey_DelegatesToInnerAgent() - { - // Arrange - var mockAgent = new Mock(); - var customService = new object(); - const string ServiceKey = "test-key"; - mockAgent.Setup(a => a.GetService(typeof(string), ServiceKey)) - .Returns(customService); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); - - // Act - var result = telemetryAgent.GetService(typeof(string), ServiceKey); - - // Assert - Assert.Same(customService, result); - mockAgent.Verify(a => a.GetService(typeof(string), ServiceKey), Times.Once); - } - - /// - /// Verify that GetService returns ActivitySource even when inner agent has the same service type. - /// - [Fact] - public void GetService_RequestingActivitySourceWithInnerAgentHavingSameType_ReturnsOpenTelemetryActivitySource() - { - // Arrange - var mockAgent = new Mock(); - var innerActivitySource = new ActivitySource("inner-source"); - mockAgent.Setup(a => a.GetService(typeof(ActivitySource), null)) - .Returns(innerActivitySource); - - const string SourceName = "telemetry-source"; - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: SourceName); - - // Act - var result = telemetryAgent.GetService(typeof(ActivitySource)); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - var activitySource = (ActivitySource)result; - Assert.Equal(SourceName, activitySource.Name); - Assert.NotSame(innerActivitySource, result); // Should return OpenTelemetryAgent's ActivitySource, not inner agent's - - // Cleanup - innerActivitySource.Dispose(); - } - - /// - /// Verify that GetService can retrieve AIAgentMetadata from inner agent. - /// - [Fact] - public void GetService_RequestingAIAgentMetadata_DelegatesToInnerAgent() - { - // Arrange - var mockAgent = new Mock(); - var agentMetadata = new AIAgentMetadata("test-provider"); - mockAgent.Setup(a => a.GetService(typeof(AIAgentMetadata), null)) - .Returns(agentMetadata); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); - - // Act - var result = telemetryAgent.GetService(typeof(AIAgentMetadata)); - - // Assert - Assert.Same(agentMetadata, result); - mockAgent.Verify(a => a.GetService(typeof(AIAgentMetadata), null), Times.AtLeastOnce); - } - - /// - /// Verify that GetService calls base.GetService() first and returns the agent itself when requesting OpenTelemetryAgent type. - /// - [Fact] - public void GetService_RequestingOpenTelemetryAgentType_ReturnsBaseImplementation() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); - - // Act - var result = telemetryAgent.GetService(typeof(OpenTelemetryAgent)); - - // Assert - Assert.NotNull(result); - Assert.Same(telemetryAgent, result); - // Verify that the inner agent's GetService was not called for this type since base.GetService() handled it - mockAgent.Verify(a => a.GetService(typeof(OpenTelemetryAgent), null), Times.Never); - } - - /// - /// Verify that GetService calls base.GetService() first and returns the agent itself when requesting AIAgent type. - /// - [Fact] - public void GetService_RequestingAIAgentType_ReturnsBaseImplementation() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); - - // Act - var result = telemetryAgent.GetService(typeof(AIAgent)); - - // Assert - Assert.NotNull(result); - Assert.Same(telemetryAgent, result); - // Verify that the inner agent's GetService was not called for this type since base.GetService() handled it - mockAgent.Verify(a => a.GetService(typeof(AIAgent), null), Times.Never); - } - - /// - /// Verify that GetService calls base.GetService() first but continues to derived logic when base returns null. - /// For ActivitySource, it returns the agent's own ActivitySource regardless of service key. - /// - [Fact] - public void GetService_RequestingActivitySourceWithServiceKey_ReturnsOwnActivitySource() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - const string SourceName = "test-source"; - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: SourceName); - - // Act - Request ActivitySource with a service key (base.GetService will return null due to serviceKey) - var result = telemetryAgent.GetService(typeof(ActivitySource), "some-key"); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - var activitySource = (ActivitySource)result; - Assert.Equal(SourceName, activitySource.Name); - // Verify that the inner agent's GetService was NOT called because ActivitySource is handled by the telemetry agent itself - mockAgent.Verify(a => a.GetService(typeof(ActivitySource), "some-key"), Times.Never); - } - - /// - /// Verify that GetService calls base.GetService() first but continues to inner agent when base returns null and it's not ActivitySource. - /// - [Fact] - public void GetService_RequestingUnknownServiceWithServiceKey_CallsInnerAgent() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - mockAgent.Setup(a => a.GetService(typeof(string), "some-key")).Returns("test-result"); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); - - // Act - Request string with a service key (base.GetService will return null due to serviceKey) - var result = telemetryAgent.GetService(typeof(string), "some-key"); - - // Assert - Assert.NotNull(result); - Assert.Equal("test-result", result); - // Verify that the inner agent's GetService was called after base.GetService() returned null - mockAgent.Verify(a => a.GetService(typeof(string), "some-key"), Times.Once); - } - - /// - /// Verify that OpenTelemetryAgent delegates AIAgentMetadata requests to inner agent. - /// - [Fact] - public void GetService_RequestingAIAgentMetadata_DelegatesToInnerAgentWithChatClientAgent() - { - // Arrange - var mockChatClient = new Mock(); - var chatClientMetadata = new ChatClientMetadata("test-provider"); - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns(chatClientMetadata); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Instructions = "Test instructions" - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent); - - // Act - var result = telemetryAgent.GetService(typeof(AIAgentMetadata)); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - var agentMetadata = (AIAgentMetadata)result; - Assert.Equal("test-provider", agentMetadata.ProviderName); - } - - /// - /// Verify that when OpenTelemetryAgent wraps a ChatClientAgent, the AIAgentMetadata.ProviderName - /// from ChatClientMetadata is correctly reflected in OpenTelemetry activities. - /// - [Theory] - [InlineData("openai")] - [InlineData("azure")] - [InlineData("anthropic")] - [InlineData("custom-provider")] - public async Task RunAsync_WithChatClientAgent_ProviderNameReflectedInTelemetryAsync(string providerName) - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockChatClient = new Mock(); - var chatClientMetadata = new ChatClientMetadata(providerName); - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns(chatClientMetadata); - mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"))); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Instructions = "Test instructions" - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Test message") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - - // Verify that the provider name from ChatClientMetadata appears in telemetry - Assert.Equal(providerName, activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - - // Verify that GetService returns the same provider name - var agentMetadata = telemetryAgent.GetService(typeof(AIAgentMetadata)) as AIAgentMetadata; - Assert.NotNull(agentMetadata); - Assert.Equal(providerName, agentMetadata.ProviderName); - } - - /// - /// Verify that when OpenTelemetryAgent wraps a ChatClientAgent with null ChatClientMetadata, - /// the system defaults to "Microsoft.Extensions.AI" in telemetry. - /// - [Fact] - public async Task RunAsync_WithChatClientAgent_NullMetadata_DefaultsToMEAIAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockChatClient = new Mock(); - // Setup ChatClient to return null metadata - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns((ChatClientMetadata?)null); - mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"))); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Instructions = "Test instructions" - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Test message") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - - // Verify that the system defaults to "microsoft.agents.ai" when no metadata is available - Assert.Equal("microsoft.agents.ai", activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - - // Verify that GetService returns null provider name - var agentMetadata = telemetryAgent.GetService(typeof(AIAgentMetadata)) as AIAgentMetadata; - Assert.NotNull(agentMetadata); - Assert.Null(agentMetadata.ProviderName); - } - - /// - /// Verify that when OpenTelemetryAgent wraps a non-ChatClientAgent with custom metadata, - /// the custom provider name is correctly reflected in telemetry. - /// - [Fact] - public async Task RunAsync_WithCustomAgent_CustomMetadata_ReflectedInTelemetryAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - const string CustomProviderName = "custom-ai-provider"; - var mockAgent = new Mock(); - var customMetadata = new AIAgentMetadata(CustomProviderName); - - // Setup mock agent to return custom metadata - mockAgent.Setup(a => a.GetService(typeof(AIAgentMetadata), null)) - .Returns(customMetadata); - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Custom response"))); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Test message") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - - // Verify that the custom provider name appears in telemetry - Assert.Equal(CustomProviderName, activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - - // Verify that GetService returns the same custom provider name - var agentMetadata = telemetryAgent.GetService(typeof(AIAgentMetadata)) as AIAgentMetadata; - Assert.NotNull(agentMetadata); - Assert.Equal(CustomProviderName, agentMetadata.ProviderName); - } - - /// - /// Verify that when OpenTelemetryAgent wraps a non-ChatClientAgent with no metadata, - /// the system defaults to "Microsoft.Extensions.AI" in telemetry. - /// - [Fact] - public async Task RunAsync_WithCustomAgent_NoMetadata_DefaultsToMEAIAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockAgent = new Mock(); - - // Setup mock agent to return null metadata - mockAgent.Setup(a => a.GetService(typeof(AIAgentMetadata), null)) - .Returns((AIAgentMetadata?)null); - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"))); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Test message") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - - // Verify that the system defaults to "microsoft.agents.ai" when no metadata is available - Assert.Equal("microsoft.agents.ai", activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - - // Verify that GetService returns null for metadata - var agentMetadata = telemetryAgent.GetService(typeof(AIAgentMetadata)) as AIAgentMetadata; - Assert.Null(agentMetadata); - } - - /// - /// Verify that streaming operations also correctly reflect provider name in telemetry. - /// - [Theory] - [InlineData("openai")] - [InlineData("azure")] - [InlineData("anthropic")] - public async Task RunStreamingAsync_WithChatClientAgent_ProviderNameReflectedInTelemetryAsync(string providerName) - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockChatClient = new Mock(); - var chatClientMetadata = new ChatClientMetadata(providerName); - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns(chatClientMetadata); - - ChatResponseUpdate[] returnUpdates = - [ - new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hello"), - new ChatResponseUpdate(role: ChatRole.Assistant, content: " World"), - ]; - - mockChatClient.Setup(c => c.GetStreamingResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .Returns(returnUpdates.ToAsyncEnumerable()); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Instructions = "Test instructions" - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Test message") - }; - - // Act - await foreach (var update in telemetryAgent.RunStreamingAsync(messages)) - { - // Process updates - } - - // Assert - var activity = Assert.Single(activities); - - // Verify that the provider name from ChatClientMetadata appears in telemetry - Assert.Equal(providerName, activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - - // Verify that GetService returns the same provider name - var agentMetadata = telemetryAgent.GetService(typeof(AIAgentMetadata)) as AIAgentMetadata; - Assert.NotNull(agentMetadata); - Assert.Equal(providerName, agentMetadata.ProviderName); - } - - /// - /// Verify that provider name consistency is maintained across multiple RunAsync calls. - /// - [Fact] - public async Task RunAsync_MultipleCallsWithSameAgent_ConsistentProviderNameAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - const string ProviderName = "consistent-provider"; - var mockChatClient = new Mock(); - var chatClientMetadata = new ChatClientMetadata(ProviderName); - mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null)) - .Returns(chatClientMetadata); - mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response"))); - - var chatClientAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions - { - Instructions = "Test instructions" - }); - - using var telemetryAgent = new OpenTelemetryAgent(chatClientAgent, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Test message") - }; - - // Act - Make multiple calls - await telemetryAgent.RunAsync(messages); - await telemetryAgent.RunAsync(messages); - await telemetryAgent.RunAsync(messages); - - // Assert - Assert.Equal(3, activities.Count); - - // Verify that all activities have the same provider name - foreach (var activity in activities) - { - Assert.Equal(ProviderName, activity.GetTagItem(OpenTelemetryConsts.GenAI.SystemName)); - } - - // Verify that GetService consistently returns the same provider name - var agentMetadata1 = telemetryAgent.GetService(typeof(AIAgentMetadata)) as AIAgentMetadata; - var agentMetadata2 = telemetryAgent.GetService(typeof(AIAgentMetadata)) as AIAgentMetadata; - Assert.NotNull(agentMetadata1); - Assert.NotNull(agentMetadata2); - Assert.Equal(ProviderName, agentMetadata1.ProviderName); - Assert.Equal(ProviderName, agentMetadata2.ProviderName); - Assert.Same(agentMetadata1, agentMetadata2); // Should be cached - } - - #endregion - - [Fact] - public async Task RunAsync_NoListeners_NoActivitiesCreatedAsync() - { - // Arrange - No tracer provider, so no listeners - var mockAgent = CreateMockAgent(false); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: "test-source"); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - Should complete without creating activities - mockAgent.Verify(a => a.RunAsync(messages, null, null, It.IsAny()), Times.Once); - } - - private static Mock CreateMockAgent(bool throwError) - { - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - mockAgent.Setup(a => a.Description).Returns("Test Description"); - - if (throwError) - { - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new InvalidOperationException("Test error")); - } - else - { - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")) + yield return new AgentRunResponseUpdate { - ResponseId = "test-response-id", - Usage = new UsageDetails + Contents = [new UsageContent(new() { InputTokenCount = 10, - OutputTokenCount = 20 - } + OutputTokenCount = 20, + TotalTokenCount = 42, + })], + AdditionalProperties = new() + { + ["system_fingerprint"] = "abcdefgh", + ["AndSomethingElse"] = "value2", + }, }; - - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); } - return mockAgent; - } + using var agent = new OpenTelemetryAgent(innerAgent, sourceName) { EnableSensitiveData = enableSensitiveData }; - private static Mock CreateMockStreamingAgent(bool throwError) - { - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - mockAgent.Setup(a => a.Description).Returns("Test Description"); + List messages = + [ + new(ChatRole.System, "You are a close friend."), + new(ChatRole.User, "Hey!"), + new(ChatRole.Assistant, [new FunctionCallContent("12345", "GetPersonName")]), + new(ChatRole.Tool, [new FunctionResultContent("12345", "John")]), + new(ChatRole.Assistant, "Hey John, what's up?"), + new(ChatRole.User, "What's the biggest animal?") + ]; - if (throwError) + if (streaming) { - mockAgent.Setup(a => a.RunStreamingAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(ThrowingAsyncEnumerableAsync()); + await foreach (var update in agent.RunStreamingAsync(messages)) + { + await Task.Yield(); + } } else { - mockAgent.Setup(a => a.RunStreamingAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(CreateStreamingResponseAsync()); + await agent.RunAsync(messages); } - return mockAgent; + var activity = Assert.Single(activities); - static async IAsyncEnumerable ThrowingAsyncEnumerableAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + + Assert.Equal("localhost", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); + + Assert.Equal("invoke_agent TestAgent", activity.DisplayName); + Assert.Equal("TestAgentProviderFromAIAgentMetadata", activity.GetTagItem("gen_ai.provider.name")); + Assert.Equal(innerAgent.Name, activity.GetTagItem("gen_ai.agent.name")); + Assert.Equal(innerAgent.Id, activity.GetTagItem("gen_ai.agent.id")); + Assert.Equal(innerAgent.Description, activity.GetTagItem("gen_ai.agent.description")); + + Assert.Equal("amazingmodel", activity.GetTagItem("gen_ai.request.model")); + + Assert.Equal("id123", activity.GetTagItem("gen_ai.response.id")); + Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens")); + Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens")); + Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("system_fingerprint")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("AndSomethingElse")); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + + var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + if (enableSensitiveData) { - await Task.Yield(); - if (Environment.ProcessorCount > 0) // always true - { - throw new InvalidOperationException("Streaming error"); - } + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "system", + "parts": [ + { + "type": "text", + "content": "You are a close friend." + } + ] + }, + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "Hey!" + } + ] + }, + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": "12345", + "name": "GetPersonName" + } + ] + }, + { + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": "12345", + "response": "John" + } + ] + }, + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "Hey John, what's up?" + } + ] + }, + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "What's the biggest animal?" + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.input.messages"])); - yield break; + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "The blue whale, I think." + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.output.messages"])); + } + else + { + Assert.False(tags.ContainsKey("gen_ai.input.messages")); + Assert.False(tags.ContainsKey("gen_ai.output.messages")); } - static async IAsyncEnumerable CreateStreamingResponseAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + Assert.False(tags.ContainsKey("gen_ai.system_instructions")); + Assert.False(tags.ContainsKey("gen_ai.tool.definitions")); + } + + public static IEnumerable WithChatOptions_ExpectedInformationLogged_Async_MemberData() => + from enableSensitiveData in new[] { false, true } + from streaming in new[] { false, true } + from name in new[] { null, "TestAgent" } + from description in new[] { null, "This is a test agent." } + select new object[] { enableSensitiveData, streaming, name, description, true }; + + [Theory] + [MemberData(nameof(WithChatOptions_ExpectedInformationLogged_Async_MemberData))] + [InlineData(true, false, "TestAgent", "This is a test agent.", false)] + [InlineData(true, true, "TestAgent", "This is a test agent.", false)] + public async Task WithChatOptions_ExpectedInformationLogged_Async( + bool enableSensitiveData, bool streaming, string name, string description, bool hasListener) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + var builder = OpenTelemetry.Sdk.CreateTracerProviderBuilder(); + if (hasListener) { - await Task.Yield(); + builder.AddSource(sourceName); + } + using var tracerProvider = builder + .AddInMemoryExporter(activities) + .Build(); - yield return new AgentRunResponseUpdate(ChatRole.Assistant, "Hello") - { - ResponseId = "stream-response-id" - }; + var innerAgent = new TestAIAgent + { + NameFunc = () => name, + DescriptionFunc = () => description, - yield return new AgentRunResponseUpdate(ChatRole.Assistant, " there!") + RunAsyncFunc = async (messages, thread, options, cancellationToken) => { - ResponseId = "stream-response-id" - }; - - yield return new AgentRunResponseUpdate - { - ResponseId = "stream-response-id", - Contents = [new UsageContent(new UsageDetails + await Task.Yield(); + return new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "The blue whale, I think.")) { - InputTokenCount = 15, - OutputTokenCount = 25 - })] - }; - } - } + ResponseId = "id123", + Usage = new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 42, + }, + AdditionalProperties = new() + { + ["system_fingerprint"] = "abcdefgh", + ["AndSomethingElse"] = "value2", + }, + }; + }, - [Fact] - public void Constructor_NullAgent_ThrowsArgumentNullException() => - // Act & Assert - Assert.Throws(() => new OpenTelemetryAgent(null!)); + RunStreamingAsyncFunc = CallbackAsync, - [Fact] - public void Constructor_WithParameters_SetsProperties() - { - // Arrange - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - mockAgent.Setup(a => a.Description).Returns("Test Description"); - var mockLogger = new Mock(); - - var logger = new Mock().Object; - const string SourceName = "custom-source"; - - // Act - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, SourceName); - - // Assert - Assert.Equal("test-id", telemetryAgent.Id); - Assert.Equal("TestAgent", telemetryAgent.Name); - Assert.Equal("Test Description", telemetryAgent.Description); - } - - [Fact] - public void GetNewThread_DelegatesToInnerAgent() - { - // Arrange - var mockThread = new Mock().Object; - var mockAgent = new Mock(); - mockAgent.Setup(a => a.GetNewThread()).Returns(mockThread); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); - - // Act - var result = telemetryAgent.GetNewThread(); - - // Assert - Assert.Same(mockThread, result); - mockAgent.Verify(a => a.GetNewThread(), Times.Once); - } - - [Fact] - public void Dispose_DisposesResources() - { - // Arrange - var mockAgent = new Mock(); - var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); - - // Act & Assert - Should not throw - telemetryAgent.Dispose(); - telemetryAgent.Dispose(); // Should be safe to call multiple times - } - - [Fact] - public async Task RunAsync_WithNullResponseId_HandlesGracefullyAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")) - { - ResponseId = null, // Null response ID - Usage = null // Null usage + GetServiceFunc = (serviceType, serviceKey) => + serviceType == typeof(AIAgentMetadata) ? new AIAgentMetadata("TestAgentProviderFromAIAgentMetadata") : + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("TestAgentProviderFromChatClientMetadata", new Uri("http://localhost:12345/something"), "amazingmodel") : + null, }; - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - Assert.Null(activity.GetTagItem(OpenTelemetryConsts.GenAI.Response.Id)); - Assert.Null(activity.GetTagItem(OpenTelemetryConsts.GenAI.Usage.InputTokens)); - Assert.Null(activity.GetTagItem(OpenTelemetryConsts.GenAI.Usage.OutputTokens)); - } - - [Fact] - public async Task RunAsync_WithEmptyAgentName_UsesOperationNameOnlyAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent-id"); - mockAgent.Setup(a => a.Name).Returns((string?)null); // Null name - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")); - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - Assert.Equal(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, activity.DisplayName); - } - - [Fact] - public async Task RunStreamingAsync_WithPartialUpdates_CombinesCorrectlyAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - mockAgent.Setup(a => a.RunStreamingAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(CreatePartialStreamingResponseAsync()); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Tell me a story") - }; - - // Act - var updates = new List(); - await foreach (var update in telemetryAgent.RunStreamingAsync(messages)) - { - updates.Add(update); - } - - // Assert - Assert.Equal(4, updates.Count); // 3 content updates + 1 final update - - var activity = Assert.Single(activities); - Assert.Equal("partial-response-id", activity.GetTagItem(OpenTelemetryConsts.GenAI.Response.Id)); - - static async IAsyncEnumerable CreatePartialStreamingResponseAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + async static IAsyncEnumerable CallbackAsync( + IEnumerable messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.Yield(); - yield return new AgentRunResponseUpdate(ChatRole.Assistant, "Once") + foreach (string text in new[] { "The ", "blue ", "whale,", " ", "", "I", " think." }) { - ResponseId = "partial-response-id" - }; - - yield return new AgentRunResponseUpdate(ChatRole.Assistant, " upon") - { - ResponseId = "partial-response-id" - }; - - yield return new AgentRunResponseUpdate(ChatRole.Assistant, " a time...") - { - ResponseId = "partial-response-id" - }; + await Task.Yield(); + yield return new AgentRunResponseUpdate(ChatRole.Assistant, text) + { + ResponseId = "id123", + }; + } yield return new AgentRunResponseUpdate { - ResponseId = "partial-response-id" + Contents = [new UsageContent(new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 42, + })], + AdditionalProperties = new() + { + ["system_fingerprint"] = "abcdefgh", + ["AndSomethingElse"] = "value2", + }, }; } - } - [Fact] - public async Task RunAsync_DefaultSourceName_UsesCorrectSourceAsync() - { - // Arrange - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(OpenTelemetryConsts.DefaultSourceName) - .AddInMemoryExporter(activities) - .Build(); + using var agent = new OpenTelemetryAgent(innerAgent, sourceName) { EnableSensitiveData = enableSensitiveData }; - var mockAgent = CreateMockAgent(false); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object); // No custom source name + List messages = + [ + new(ChatRole.System, "You are a close friend."), + new(ChatRole.User, "Hey!"), + new(ChatRole.Assistant, [new FunctionCallContent("12345", "GetPersonName")]), + new(ChatRole.Tool, [new FunctionResultContent("12345", "John")]), + new(ChatRole.Assistant, "Hey John, what's up?"), + new(ChatRole.User, "What's the biggest animal?") + ]; - var messages = new List + var options = new ChatClientAgentRunOptions() { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var activity = Assert.Single(activities); - Assert.NotNull(activity); - Assert.Equal(OpenTelemetryConsts.DefaultSourceName, activity.Source.Name); - } - - [Fact] - public async Task RunAsync_WithMetricsEnabled_RecordsMetricsAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - var exportedMetrics = new List(); - - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder() - .AddMeter(sourceName) - .AddInMemoryExporter(exportedMetrics) - .Build(); - - var mockAgent = CreateMockAgent(false); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Force metric collection - meterProvider.ForceFlush(5000); - - // Assert - Verify metrics were recorded - Assert.NotEmpty(exportedMetrics); - - // Check for operation duration metric - var durationMetric = exportedMetrics.FirstOrDefault(m => m.Name == OpenTelemetryConsts.GenAI.Client.OperationDuration.Name); - Assert.NotNull(durationMetric); - - // Check for token usage metric - var tokenUsageMetric = exportedMetrics.FirstOrDefault(m => m.Name == OpenTelemetryConsts.GenAI.Client.TokenUsage.Name); - Assert.NotNull(tokenUsageMetric); - } - - [Fact] - public async Task RunAsync_WithMetricsEnabledAndError_RecordsErrorMetricsAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - var exportedMetrics = new List(); - - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder() - .AddMeter(sourceName) - .AddInMemoryExporter(exportedMetrics) - .Build(); - - var mockAgent = CreateMockAgent(true); // With error - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act & Assert - await Assert.ThrowsAsync(() => telemetryAgent.RunAsync(messages)); - - // Force metric collection - meterProvider.ForceFlush(5000); - - // Assert - Verify error metrics were recorded - Assert.NotEmpty(exportedMetrics); - - // Check for operation duration metric with error tag - var durationMetric = exportedMetrics.FirstOrDefault(m => m.Name == OpenTelemetryConsts.GenAI.Client.OperationDuration.Name); - Assert.NotNull(durationMetric); - } - - [Fact] - public async Task RunStreamingAsync_WithMetricsEnabled_RecordsMetricsAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - var exportedMetrics = new List(); - - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder() - .AddMeter(sourceName) - .AddInMemoryExporter(exportedMetrics) - .Build(); - - var mockAgent = CreateMockStreamingAgent(false); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Tell me a story") - }; - - // Act - var updates = new List(); - await foreach (var update in telemetryAgent.RunStreamingAsync(messages)) - { - updates.Add(update); - } - - // Force metric collection - meterProvider.ForceFlush(5000); - - // Assert - Verify metrics were recorded - Assert.NotEmpty(exportedMetrics); - Assert.NotEmpty(updates); - - // Check for operation duration metric - var durationMetric = exportedMetrics.FirstOrDefault(m => m.Name == OpenTelemetryConsts.GenAI.Client.OperationDuration.Name); - Assert.NotNull(durationMetric); - - // Check for token usage metric - var tokenUsageMetric = exportedMetrics.FirstOrDefault(m => m.Name == OpenTelemetryConsts.GenAI.Client.TokenUsage.Name); - Assert.NotNull(tokenUsageMetric); - } - - [Fact] - public async Task RunAsync_WithNullUsage_SkipsTokenMetricsAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var exportedMetrics = new List(); - - using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder() - .AddMeter(sourceName) - .AddInMemoryExporter(exportedMetrics) - .Build(); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - // Response with null usage - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")) - { - ResponseId = "test-response-id", - Usage = null // Null usage - }; - - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Force metric collection - meterProvider.ForceFlush(5000); - - // Assert - Should have duration and request count metrics, but no token usage metrics - var durationMetric = exportedMetrics.FirstOrDefault(m => m.Name == OpenTelemetryConsts.GenAI.Client.OperationDuration.Name); - Assert.NotNull(durationMetric); - - // Token usage metric should not be recorded when usage is null - var tokenUsageMetric = exportedMetrics.FirstOrDefault(m => m.Name == OpenTelemetryConsts.GenAI.Client.TokenUsage.Name); - Assert.Null(tokenUsageMetric); - } - - [Fact] - public async Task RunAsync_WithMetricsDisabled_SkipsMetricRecordingAsync() - { - // Arrange - No meter provider, so metrics are disabled - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockAgent = CreateMockAgent(false); - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - Should complete without recording metrics (since no meter provider) - var activity = Assert.Single(activities); - Assert.NotNull(activity); - - // Verify the agent was called - mockAgent.Verify(a => a.RunAsync(messages, null, null, It.IsAny()), Times.Once); - } - - [Fact] - public async Task RunAsync_WithPartialTokenUsage_RecordsAvailableTokensAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var exportedMetrics = new List(); - - using var meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder() - .AddMeter(sourceName) - .AddInMemoryExporter(exportedMetrics) - .Build(); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - // Response with only input tokens (no output tokens) - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")) - { - ResponseId = "test-response-id", - Usage = new UsageDetails + ChatOptions = new ChatOptions { - InputTokenCount = 10, - OutputTokenCount = null // No output tokens + FrequencyPenalty = 3.0f, + MaxOutputTokens = 123, + ModelId = "replacementmodel", + TopP = 4.0f, + TopK = 7, + PresencePenalty = 5.0f, + ResponseFormat = ChatResponseFormat.Json, + Temperature = 6.0f, + Seed = 42, + StopSequences = ["hello", "world"], + AdditionalProperties = new() + { + ["service_tier"] = "value1", + ["SomethingElse"] = "value2", + }, + Instructions = "You are helpful.", + Tools = + [ + AIFunctionFactory.Create((string personName) => personName, "GetPersonAge", "Gets the age of a person by name."), + new HostedWebSearchTool(), + AIFunctionFactory.Create((string location) => "", "GetCurrentWeather", "Gets the current weather for a location.").AsDeclarationOnly(), + ], } }; - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List + if (streaming) { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Force metric collection - meterProvider.ForceFlush(5000); - - // Assert - Should record input tokens but not output tokens - var tokenUsageMetric = exportedMetrics.FirstOrDefault(m => m.Name == OpenTelemetryConsts.GenAI.Client.TokenUsage.Name); - Assert.NotNull(tokenUsageMetric); - } - - [Fact] - public async Task RunAsync_WithNullDescription_SkipsDescriptionAttributeAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent-id"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - mockAgent.Setup(a => a.Description).Returns((string?)null); // Null description - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")); - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName); - - var messages = new List + await foreach (var update in agent.RunStreamingAsync(messages, options: options)) + { + await Task.Yield(); + } + } + else { - new(ChatRole.User, "Hello") - }; + await agent.RunAsync(messages, options: options); + } - // Act - await telemetryAgent.RunAsync(messages); + if (!hasListener) + { + Assert.Empty(activities); + return; + } - // Assert var activity = Assert.Single(activities); - Assert.Equal("test-agent-id", activity.GetTagItem(OpenTelemetryConsts.GenAI.Agent.Id)); - Assert.Equal("TestAgent", activity.GetTagItem(OpenTelemetryConsts.GenAI.Agent.Name)); + var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - // Description should not be present when null - Assert.Null(activity.GetTagItem(OpenTelemetryConsts.GenAI.Agent.Description)); - } + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); - #region Coverage Tests for Red Spots + Assert.Equal("localhost", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); - [Fact] - public async Task RunAsync_WithAssistantMessage_LogsAssistantEventAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - var loggedEvents = new List<(LogLevel level, EventId eventId, string message)>(); - - // Setup IsEnabled to return true for Information level - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - mockLogger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, ex, formatter) => loggedEvents.Add((level, eventId, formatter.DynamicInvoke(state, ex)?.ToString() ?? ""))); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test assistant response")); - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); - - var messages = new List + Assert.Equal($"invoke_agent {innerAgent.DisplayName}", activity.DisplayName); + Assert.Equal("TestAgentProviderFromAIAgentMetadata", activity.GetTagItem("gen_ai.provider.name")); + Assert.Equal(innerAgent.Name, activity.GetTagItem("gen_ai.agent.name")); + Assert.Equal(innerAgent.Id, activity.GetTagItem("gen_ai.agent.id")); + if (description is null) { - new(ChatRole.User, "Hello"), - new(ChatRole.Assistant, "Hi there!") // This should trigger assistant message logging - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var assistantLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == "gen_ai.assistant.message"); - Assert.NotEqual(default, assistantLogEvent); - Assert.Equal(LogLevel.Information, assistantLogEvent.level); - } - - [Fact] - public async Task RunAsync_WithToolMessage_LogsToolEventAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - var loggedEvents = new List<(LogLevel level, EventId eventId, string message)>(); - - // Setup IsEnabled to return true for Information level - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - mockLogger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, ex, formatter) => loggedEvents.Add((level, eventId, formatter.DynamicInvoke(state, ex)?.ToString() ?? ""))); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")); - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); - - var messages = new List + Assert.False(tags.ContainsKey("gen_ai.agent.description")); + } + else { - new(ChatRole.User, "Hello"), - new(ChatRole.Tool, [new FunctionResultContent("call-123", "Sunny, 75°F")]) // This should trigger tool message logging - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var toolLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == OpenTelemetryConsts.GenAI.Tool.Message); - Assert.NotEqual(default, toolLogEvent); - Assert.Equal(LogLevel.Information, toolLogEvent.level); - } - - [Fact] - public async Task RunAsync_WithToolMessageAndSensitiveData_LogsToolEventWithContentAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - var loggedEvents = new List<(LogLevel level, EventId eventId, string message)>(); - - // Setup IsEnabled to return true for Information level - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - mockLogger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, ex, formatter) => loggedEvents.Add((level, eventId, formatter.DynamicInvoke(state, ex)?.ToString() ?? ""))); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")); - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName) - { - EnableSensitiveData = true // Enable sensitive data logging - }; - - var toolResult = new { temperature = 75, condition = "sunny" }; - var messages = new List - { - new(ChatRole.User, "Hello"), - new(ChatRole.Tool, [new FunctionResultContent("call-123", toolResult)]) - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var toolLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == OpenTelemetryConsts.GenAI.Tool.Message); - Assert.NotEqual(default, toolLogEvent); - Assert.Equal(LogLevel.Information, toolLogEvent.level); - Assert.Contains("call-123", toolLogEvent.message); - - // Verify that sensitive content (tool result data) IS included when EnableSensitiveData is true - Assert.Contains("temperature", toolLogEvent.message); - Assert.Contains("75", toolLogEvent.message); - Assert.Contains("sunny", toolLogEvent.message); - - // Verify that the content field is present - Assert.Contains("\"content\":", toolLogEvent.message); - } - - [Fact] - public async Task RunAsync_WithFunctionCallAndSensitiveDataEnabled_LogsWithSensitiveContentAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - var loggedEvents = new List<(LogLevel level, EventId eventId, string message)>(); - - // Setup IsEnabled to return true for Information level - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - mockLogger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, ex, formatter) => loggedEvents.Add((level, eventId, formatter.DynamicInvoke(state, ex)?.ToString() ?? ""))); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, [ - new TextContent("I'll get the weather for you in Seattle."), - new FunctionCallContent("get_weather", "call-456", new Dictionary - { - ["location"] = "Seattle", - ["api_key"] = "secret-key-789", - ["units"] = "fahrenheit" - }) - ])); - - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName) - { - EnableSensitiveData = true // Enable sensitive data logging - }; - - var messages = new List - { - new(ChatRole.User, "What's the weather in Seattle? Use my API key: user-secret-123") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - Check that user message logging includes sensitive content - var userLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == OpenTelemetryConsts.GenAI.User.Message); - if (userLogEvent != default) - { - // Check for the content (may be JSON escaped) - Assert.True(userLogEvent.message.Contains("weather in Seattle") || userLogEvent.message.Contains("weather in Se"), - $"Expected user message to contain weather content, but got: {userLogEvent.message}"); - Assert.Contains("user-secret-123", userLogEvent.message); - Assert.Contains("\"content\":", userLogEvent.message); + Assert.Equal(innerAgent.Description, activity.GetTagItem("gen_ai.agent.description")); } - // Assert - Check that assistant message logging includes sensitive content - var assistantLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == OpenTelemetryConsts.GenAI.Assistant.Message); - if (assistantLogEvent != default) + Assert.Equal("replacementmodel", activity.GetTagItem("gen_ai.request.model")); + Assert.Equal(3.0f, activity.GetTagItem("gen_ai.request.frequency_penalty")); + Assert.Equal(4.0f, activity.GetTagItem("gen_ai.request.top_p")); + Assert.Equal(5.0f, activity.GetTagItem("gen_ai.request.presence_penalty")); + Assert.Equal(6.0f, activity.GetTagItem("gen_ai.request.temperature")); + Assert.Equal(7, activity.GetTagItem("gen_ai.request.top_k")); + Assert.Equal(123, activity.GetTagItem("gen_ai.request.max_tokens")); + Assert.Equal("""["hello", "world"]""", activity.GetTagItem("gen_ai.request.stop_sequences")); + Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse")); + Assert.Equal(42L, activity.GetTagItem("gen_ai.request.seed")); + + Assert.Equal("id123", activity.GetTagItem("gen_ai.response.id")); + Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens")); + Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens")); + Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("system_fingerprint")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("AndSomethingElse")); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + + if (enableSensitiveData) { - // Call ID should be logged - Assert.Contains("call-456", assistantLogEvent.message); + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "system", + "parts": [ + { + "type": "text", + "content": "You are a close friend." + } + ] + }, + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "Hey!" + } + ] + }, + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": "12345", + "name": "GetPersonName" + } + ] + }, + { + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": "12345", + "response": "John" + } + ] + }, + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "Hey John, what's up?" + } + ] + }, + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "What's the biggest animal?" + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.input.messages"])); - // Function arguments should be logged when EnableSensitiveData is true - Assert.Contains("Seattle", assistantLogEvent.message); - Assert.Contains("secret-key-789", assistantLogEvent.message); - Assert.Contains("fahrenheit", assistantLogEvent.message); + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "The blue whale, I think." + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.output.messages"])); - // Message content should be logged when EnableSensitiveData is true (may be JSON escaped) - Assert.True(assistantLogEvent.message.Contains("get the weather") || assistantLogEvent.message.Contains("weather for you"), - $"Expected assistant message to contain weather content, but got: {assistantLogEvent.message}"); + Assert.Equal(ReplaceWhitespace(""" + [ + { + "type": "text", + "content": "You are helpful." + } + ] + """), ReplaceWhitespace(tags["gen_ai.system_instructions"])); - // Verify that arguments field is present - Assert.Contains("\"arguments\":", assistantLogEvent.message); - Assert.Contains("\"content\":", assistantLogEvent.message); + Assert.Equal(ReplaceWhitespace(""" + [ + { + "type": "function", + "name": "GetPersonAge", + "description": "Gets the age of a person by name.", + "parameters": { + "type": "object", + "properties": { + "personName": { + "type": "string" + } + }, + "required": [ + "personName" + ] + } + }, + { + "type": "function", + "name": "GetCurrentWeather", + "description": "Gets the current weather for a location.", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ] + } + } + ] + """), ReplaceWhitespace(tags["gen_ai.tool.definitions"])); + } + else + { + Assert.False(tags.ContainsKey("gen_ai.input.messages")); + Assert.False(tags.ContainsKey("gen_ai.output.messages")); + Assert.False(tags.ContainsKey("gen_ai.system_instructions")); + Assert.False(tags.ContainsKey("gen_ai.tool.definitions")); } } - [Fact] - public async Task RunAsync_WithToolMessageAndSensitiveDataDisabled_LogsToolEventWithoutContentAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - var loggedEvents = new List<(LogLevel level, EventId eventId, string message)>(); - - // Setup IsEnabled to return true for Information level - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - mockLogger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, ex, formatter) => loggedEvents.Add((level, eventId, formatter.DynamicInvoke(state, ex)?.ToString() ?? ""))); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")); - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName) - { - EnableSensitiveData = false // Explicitly disable sensitive data logging - }; - - var toolResult = new { temperature = 75, condition = "sunny", secret = "api-key-12345" }; - var messages = new List - { - new(ChatRole.User, "What's the weather in Seattle?"), - new(ChatRole.Tool, [new FunctionResultContent("call-123", toolResult)]) - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - var toolLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == OpenTelemetryConsts.GenAI.Tool.Message); - Assert.NotEqual(default, toolLogEvent); - Assert.Equal(LogLevel.Information, toolLogEvent.level); - - // Verify that call ID is still logged (it's metadata, not sensitive content) - Assert.Contains("call-123", toolLogEvent.message); - - // Verify that sensitive content (function result data) is NOT included when EnableSensitiveData is false - Assert.DoesNotContain("api-key-12345", toolLogEvent.message); - Assert.DoesNotContain("temperature", toolLogEvent.message); - Assert.DoesNotContain("sunny", toolLogEvent.message); - - // Verify that the content field is omitted when EnableSensitiveData is false - Assert.DoesNotContain("\"content\":", toolLogEvent.message); - } - - [Fact] - public async Task RunAsync_WithFunctionCallAndSensitiveDataDisabled_LogsWithoutSensitiveContentAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - var loggedEvents = new List<(LogLevel level, EventId eventId, string message)>(); - - // Setup IsEnabled to return true for Information level - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - mockLogger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, ex, formatter) => loggedEvents.Add((level, eventId, formatter.DynamicInvoke(state, ex)?.ToString() ?? ""))); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, [ - new TextContent("I'll get the weather for you."), - new FunctionCallContent("get_weather", "call-456", new Dictionary - { - ["location"] = "Seattle", - ["api_key"] = "secret-key-789" - }) - ])); - - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName) - { - EnableSensitiveData = false // Explicitly disable sensitive data logging - }; - - var messages = new List - { - new(ChatRole.User, "What's the weather in Seattle? Use API key: secret-123") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - Check that user message logging excludes sensitive content - var userLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == OpenTelemetryConsts.GenAI.User.Message); - if (userLogEvent != default) - { - Assert.DoesNotContain("secret-123", userLogEvent.message); - Assert.DoesNotContain("What's the weather in Seattle?", userLogEvent.message); - } - - // Assert - Check that assistant message logging excludes sensitive content - var assistantLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == OpenTelemetryConsts.GenAI.Assistant.Message); - if (assistantLogEvent != default) - { - // Call ID is always logged (metadata, not sensitive) - Assert.Contains("call-456", assistantLogEvent.message); - - // Function arguments should NOT be logged when EnableSensitiveData is false - Assert.DoesNotContain("secret-key-789", assistantLogEvent.message); - Assert.DoesNotContain("Seattle", assistantLogEvent.message); - - // Message content should NOT be logged when EnableSensitiveData is false - Assert.DoesNotContain("I'll get the weather for you.", assistantLogEvent.message); - - // Verify that arguments field is omitted when EnableSensitiveData is false - Assert.DoesNotContain("\"arguments\":", assistantLogEvent.message); - } - } - - [Fact] - public async Task RunAsync_LoggerNotEnabled_DoesNotLogChatResponseAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - var loggedEvents = new List<(LogLevel level, EventId eventId, string message)>(); - - // Setup logger to return false for IsEnabled to trigger the early return in LogChatResponse - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(false); - - mockLogger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, ex, formatter) => loggedEvents.Add((level, eventId, formatter.DynamicInvoke(state, ex)?.ToString() ?? ""))); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")); - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - Should not have logged any choice events since logger is not enabled - var choiceLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == OpenTelemetryConsts.GenAI.Choice); - Assert.Equal(default, choiceLogEvent); - - // Verify IsEnabled was called - mockLogger.Verify(x => x.IsEnabled(LogLevel.Information), Times.AtLeastOnce); - } - - [Fact] - public async Task RunAsync_WithResponseMessages_LogsChoiceEventsAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - var loggedEvents = new List<(LogLevel level, EventId eventId, string message)>(); - - // Setup logger to be enabled - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - mockLogger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, ex, formatter) => loggedEvents.Add((level, eventId, formatter.DynamicInvoke(state, ex)?.ToString() ?? ""))); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - // Create a response with multiple messages to trigger choice event logging - var responseMessages = new List - { - new(ChatRole.Assistant, "First response"), - new(ChatRole.Assistant, "Second response") - }; - var response = new AgentRunResponse(responseMessages); - - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - Should have logged choice events for the response messages - var choiceLogEvents = loggedEvents.Where(e => e.eventId.Name == OpenTelemetryConsts.GenAI.Choice).ToList(); - Assert.NotEmpty(choiceLogEvents); - Assert.Equal(LogLevel.Information, choiceLogEvents.First().level); - - // Verify the choice events contain the expected structure - foreach (var choiceEvent in choiceLogEvents) - { - Assert.Contains("finish_reason", choiceEvent.message); - Assert.Contains("index", choiceEvent.message); - Assert.Contains("message", choiceEvent.message); - } - } - - [Fact] - public async Task RunAsync_WithSingleResponseMessage_LogsChoiceEventAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - var loggedEvents = new List<(LogLevel level, EventId eventId, string message)>(); - - // Setup logger to be enabled - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - mockLogger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, ex, formatter) => loggedEvents.Add((level, eventId, formatter.DynamicInvoke(state, ex)?.ToString() ?? ""))); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - // Create a response with a single message - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Single response")); - - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); - - var messages = new List - { - new(ChatRole.User, "Hello") - }; - - // Act - await telemetryAgent.RunAsync(messages); - - // Assert - Should have logged a choice event for the single response message - var choiceLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == OpenTelemetryConsts.GenAI.Choice); - Assert.NotEqual(default, choiceLogEvent); - Assert.Equal(LogLevel.Information, choiceLogEvent.level); - - // Verify the choice event contains the expected structure - Assert.Contains("finish_reason", choiceLogEvent.message); - Assert.Contains("index", choiceLogEvent.message); - Assert.Contains("message", choiceLogEvent.message); - } - - [Fact] - public void JsonSerializerOptions_GetterReturnsSetValue() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var mockLogger = new Mock(); - var mockAgent = new Mock(); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); - - // Act & Assert - Default value should be AIJsonUtilities.DefaultOptions - Assert.NotNull(telemetryAgent.JsonSerializerOptions); - Assert.Same(AIJsonUtilities.DefaultOptions, telemetryAgent.JsonSerializerOptions); - } - - [Fact] - public void JsonSerializerOptions_SetterUpdatesValue() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var mockLogger = new Mock(); - var mockAgent = new Mock(); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); - - var customOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - }; - - // Act - telemetryAgent.JsonSerializerOptions = customOptions; - - // Assert - Assert.Same(customOptions, telemetryAgent.JsonSerializerOptions); - } - - [Fact] - public void JsonSerializerOptions_SetterThrowsOnNull() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var mockLogger = new Mock(); - var mockAgent = new Mock(); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName); - - // Act & Assert - Assert.Throws(() => telemetryAgent.JsonSerializerOptions = null!); - } - - [Fact] - public async Task RunAsync_WithCustomJsonSerializerOptions_UsesCustomOptionsForSerializationAsync() - { - // Arrange - var sourceName = Guid.NewGuid().ToString("N"); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); - - var mockLogger = new Mock(); - var loggedEvents = new List<(LogLevel level, EventId eventId, string message)>(); - - // Setup IsEnabled to return true for Information level - mockLogger.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); - - mockLogger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, ex, formatter) => loggedEvents.Add((level, eventId, formatter.DynamicInvoke(state, ex)?.ToString() ?? ""))); - - var mockAgent = new Mock(); - mockAgent.Setup(a => a.Id).Returns("test-agent"); - mockAgent.Setup(a => a.Name).Returns("TestAgent"); - - var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response")); - mockAgent.Setup(a => a.RunAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - - using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, mockLogger.Object, sourceName) - { - EnableSensitiveData = true // Enable sensitive data to trigger JsonSerializerOptions usage - }; - - // Set custom JsonSerializerOptions - var customOptions = new JsonSerializerOptions(AIJsonUtilities.DefaultOptions); - telemetryAgent.JsonSerializerOptions = customOptions; - - // Use a Dictionary to test serialization - var toolResult = new Dictionary - { - ["temperature"] = 75, - ["condition"] = "sunny" - }; - var messages = new List - { - new(ChatRole.User, "Hello"), - new(ChatRole.Tool, [new FunctionResultContent("call-123", toolResult)]) - }; - - // Act - This should not throw an exception and should use the custom JsonSerializerOptions - await telemetryAgent.RunAsync(messages); - - // Assert - var toolLogEvent = loggedEvents.FirstOrDefault(e => e.eventId.Name == OpenTelemetryConsts.GenAI.Tool.Message); - Assert.NotEqual(default, toolLogEvent); - - // Verify that the custom JsonSerializerOptions were used successfully (no exception thrown) - // and that the content was serialized - Assert.Contains("temperature", toolLogEvent.message); - Assert.Contains("condition", toolLogEvent.message); - Assert.Contains("call-123", toolLogEvent.message); - - // Verify that the custom options object is being used - Assert.Same(customOptions, telemetryAgent.JsonSerializerOptions); - } - - #endregion + private static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", "").Trim(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs new file mode 100644 index 0000000000..fb00973c78 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs @@ -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? NameFunc; + public Func? DescriptionFunc; + + public Func DeserializeThreadFunc = delegate { throw new NotSupportedException(); }; + public Func GetNewThreadFunc = delegate { throw new NotSupportedException(); }; + public Func, AgentThread?, AgentRunOptions?, CancellationToken, Task> RunAsyncFunc = delegate { throw new NotSupportedException(); }; + public Func, AgentThread?, AgentRunOptions?, CancellationToken, IAsyncEnumerable> RunStreamingAsyncFunc = delegate { throw new NotSupportedException(); }; + public Func? 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 RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + this.RunAsyncFunc(messages, thread, options, cancellationToken); + + public override IAsyncEnumerable RunStreamingAsync(IEnumerable 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); +}