diff --git a/src/Observability/Extensions/AgentFramework/AgentFrameworkSpanProcessor.cs b/src/Observability/Extensions/AgentFramework/AgentFrameworkSpanProcessor.cs index a71aa475..1c7dfe3d 100644 --- a/src/Observability/Extensions/AgentFramework/AgentFrameworkSpanProcessor.cs +++ b/src/Observability/Extensions/AgentFramework/AgentFrameworkSpanProcessor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.Observability.Extensions.AgentFramework.Utils; using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes; using OpenTelemetry; using System.Diagnostics; @@ -8,9 +9,17 @@ namespace Microsoft.Agents.A365.Observability.Extensions.AgentFramework { internal class AgentFrameworkSpanProcessor : BaseProcessor { + private const string InvokeAgentOperation = "invoke_agent"; + private const string ChatOperation = "chat"; private const string ExecuteToolOperation = "execute_tool"; private const string ToolCallResultTag = "gen_ai.tool.call.result"; private const string EventContentTag = "gen_ai.event.content"; + private readonly string[] _additionalSources; + + public AgentFrameworkSpanProcessor(params string[] additionalSources) + { + _additionalSources = additionalSources ?? []; + } public override void OnStart(Activity activity) { @@ -21,15 +30,43 @@ public override void OnEnd(Activity activity) if (activity == null) return; - if (activity.Source.Name.StartsWith(BuilderExtensions.AgentFrameworkSource)) + if (IsTrackedSource(activity.Source.Name)) { var operationName = activity.GetTagItem(OpenTelemetryConstants.GenAiOperationNameKey); - if (operationName is string opName && opName == ExecuteToolOperation) + if (operationName is string opName) { - var toolCallResult = activity.GetTagItem(ToolCallResultTag); - activity.SetTag(EventContentTag, toolCallResult); + switch (opName) + { + case InvokeAgentOperation: + case ChatOperation: + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + break; + + case ExecuteToolOperation: + var toolCallResult = activity.GetTagItem(ToolCallResultTag); + activity.SetTag(EventContentTag, toolCallResult); + break; + } } } } + + private bool IsTrackedSource(string sourceName) + { + if (sourceName.StartsWith(BuilderExtensions.AgentFrameworkSource)) + { + return true; + } + + foreach (var source in _additionalSources) + { + if (!string.IsNullOrWhiteSpace(source) && sourceName.StartsWith(source)) + { + return true; + } + } + + return false; + } } } diff --git a/src/Observability/Extensions/AgentFramework/BuilderExtensions.cs b/src/Observability/Extensions/AgentFramework/BuilderExtensions.cs index c9de56a5..de75521b 100644 --- a/src/Observability/Extensions/AgentFramework/BuilderExtensions.cs +++ b/src/Observability/Extensions/AgentFramework/BuilderExtensions.cs @@ -1,12 +1,14 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------------ + namespace Microsoft.Agents.A365.Observability.Extensions.AgentFramework; using Microsoft.Extensions.DependencyInjection; using Microsoft.Agents.A365.Observability.Runtime; using OpenTelemetry.Trace; -using OpenTelemetry; - +using OpenTelemetry; + /// /// Extension methods for configuring Builder with Agent Framework integration. /// @@ -32,24 +34,37 @@ public static class BuilderExtensions /// /// The builder to configure. /// If true, enables Agent Framework activity source tracing for OpenTelemetry. + /// Optional additional activity source names to include in tracing. /// The configured builder for method chaining. - public static Builder WithAgentFramework(this Builder builder, bool enableRelatedSources = true) + public static Builder WithAgentFramework(this Builder builder, bool enableRelatedSources = true, params string[] additionalSources) { if (enableRelatedSources) { var telmConfig = builder.Services.AddOpenTelemetry() - .WithTracing(tracing => tracing - .AddSource(AgentFrameworkSource) - .AddSource(AgentFrameworkAgentSource) - .AddSource(AgentFrameworkChatClientSource) - .AddProcessor(new AgentFrameworkSpanProcessor())); - - if (builder.Configuration != null - && !string.IsNullOrEmpty(builder.Configuration["EnableOtlpExporter"]) + .WithTracing(tracing => + { + tracing + .AddSource(AgentFrameworkSource) + .AddSource(AgentFrameworkAgentSource) + .AddSource(AgentFrameworkChatClientSource) + .AddProcessor(new AgentFrameworkSpanProcessor(additionalSources)); + + // Add any custom sources provided by the caller + foreach (var source in additionalSources) + { + if (!string.IsNullOrWhiteSpace(source)) + { + tracing.AddSource(source); + } + } + }); + + if (builder.Configuration != null + && !string.IsNullOrEmpty(builder.Configuration["EnableOtlpExporter"]) && bool.TryParse(builder.Configuration["EnableOtlpExporter"], out bool enabled) && enabled) - { - telmConfig.UseOtlpExporter(); - } + { + telmConfig.UseOtlpExporter(); + } } return builder; diff --git a/src/Observability/Extensions/AgentFramework/Microsoft.Agents.A365.Observability.Extensions.AgentFramework.csproj b/src/Observability/Extensions/AgentFramework/Microsoft.Agents.A365.Observability.Extensions.AgentFramework.csproj index b9859b39..e40f7262 100644 --- a/src/Observability/Extensions/AgentFramework/Microsoft.Agents.A365.Observability.Extensions.AgentFramework.csproj +++ b/src/Observability/Extensions/AgentFramework/Microsoft.Agents.A365.Observability.Extensions.AgentFramework.csproj @@ -11,6 +11,10 @@ Microsoft Agent 365 Agent Framework Observability SDK Microsoft;Agents;A365;Observability;AgentFramework;OpenTelemetry;AI;Agents;Tracing;Monitoring + + + + diff --git a/src/Observability/Extensions/AgentFramework/Models/AgentFrameworkMessageContent.cs b/src/Observability/Extensions/AgentFramework/Models/AgentFrameworkMessageContent.cs new file mode 100644 index 00000000..6d1f6fd6 --- /dev/null +++ b/src/Observability/Extensions/AgentFramework/Models/AgentFrameworkMessageContent.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.A365.Observability.Extensions.AgentFramework.Models; + +/// +/// Represents the structure of a message as found in Agent Framework OpenTelemetry activity tags. +/// Used in the gen_ai.input.messages and gen_ai.output.messages tags. +/// +internal class AgentFrameworkMessageContent +{ + /// + /// The role of the message, such as "user" or "assistant". + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// The parts of the message, each containing type and content. + /// + [JsonPropertyName("parts")] + public List? Parts { get; set; } + + /// + /// The finish reason for the message (e.g., "stop"). + /// + [JsonPropertyName("finish_reason")] + public string? FinishReason { get; set; } +} + +/// +/// Represents a part of an Agent Framework message. +/// +internal class AgentFrameworkMessagePart +{ + /// + /// The type of the part (e.g., "text"). + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The content of the part. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } +} diff --git a/src/Observability/Extensions/AgentFramework/Utils/AgentFrameworkSpanProcessorHelper.cs b/src/Observability/Extensions/AgentFramework/Utils/AgentFrameworkSpanProcessorHelper.cs new file mode 100644 index 00000000..f44c0295 --- /dev/null +++ b/src/Observability/Extensions/AgentFramework/Utils/AgentFrameworkSpanProcessorHelper.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Observability.Extensions.AgentFramework.Utils; + +using Microsoft.Agents.A365.Observability.Extensions.AgentFramework.Models; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes; +using System.Diagnostics; +using System.Text.Json; + +/// +/// Provides helper methods for processing and filtering Agent Framework span tags. +/// +internal static class AgentFrameworkSpanProcessorHelper +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + /// + /// Processes and filters the gen_ai.input.messages and gen_ai.output.messages tags to keep only user and assistant messages. + /// + /// The activity containing the tags to process. + public static void ProcessInputOutputMessages(Activity activity) + { + TryFilterMessages(activity, OpenTelemetryConstants.GenAiInputMessagesKey); + TryFilterMessages(activity, OpenTelemetryConstants.GenAiOutputMessagesKey); + } + + /// + /// Gets the value of a tag from the activity by key. + /// + /// The activity containing the tag. + /// The key of the tag to retrieve. + /// The tag value as a string, or null if not found. + private static string? GetTagValue(Activity activity, string key) + { + return activity.TagObjects + .OfType>() + .FirstOrDefault(k => k.Key == key).Value as string; + } + + /// + /// Attempts to filter the messages in the specified tag. + /// + /// The activity containing the tag to filter. + /// The name of the tag to filter. + private static void TryFilterMessages(Activity activity, string tagName) + { + var jsonString = GetTagValue(activity, tagName); + if (jsonString != null) + { + TryFilterMessages(activity, jsonString, tagName); + } + } + + /// + /// Attempts to parse and filter the messages JSON string, keeping only user and assistant messages + /// and extracting text content from the parts array. + /// + /// The activity to update with the filtered tag. + /// The JSON string to parse and filter. + /// The name of the tag to update. + private static void TryFilterMessages(Activity activity, string jsonString, string tagName) + { + try + { + var messages = JsonSerializer.Deserialize>(jsonString, JsonOptions); + if (messages == null || messages.Count == 0) + { + return; + } + + var filtered = messages + .Where(m => IsUserOrAssistantRole(m.Role)) + .Select(m => ExtractTextContent(m)) + .Where(content => !string.IsNullOrEmpty(content)) + .ToList(); + + var filteredString = JsonSerializer.Serialize(filtered, JsonOptions); + activity.SetTag(tagName, filteredString); + } + catch (JsonException) + { + // Swallow exception and leave the original tag value + } + } + + /// + /// Checks if the role is "user" or "assistant". + /// + /// The role to check. + /// True if the role is "user" or "assistant"; otherwise, false. + private static bool IsUserOrAssistantRole(string? role) + { + return string.Equals(role, "user", StringComparison.OrdinalIgnoreCase) || + string.Equals(role, "assistant", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Extracts the text content from a message's parts array. + /// + /// The message to extract content from. + /// The concatenated text content from all text parts, or null if no text parts exist. + private static string? ExtractTextContent(AgentFrameworkMessageContent message) + { + if (message.Parts == null || message.Parts.Count == 0) + { + return null; + } + + var textParts = message.Parts + .Where(p => string.Equals(p.Type, "text", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(p.Content)) + .Select(p => p.Content) + .ToList(); + + return textParts.Count > 0 ? string.Join(" ", textParts) : null; + } +} diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Extension.Tests/AgentFrameworkSpanProcessorHelperTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Extension.Tests/AgentFrameworkSpanProcessorHelperTests.cs new file mode 100644 index 00000000..147ce72c --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Observability.Extension.Tests/AgentFrameworkSpanProcessorHelperTests.cs @@ -0,0 +1,352 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Agents.A365.Observability.Extensions.AgentFramework.Utils; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes; + +namespace Microsoft.Agents.A365.Observability.Extension.Tests +{ + [TestClass] + public class AgentFrameworkSpanProcessorHelperTests + { + [TestMethod] + public void ProcessInputOutputMessages_FiltersOutSystemMessages() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""system"", ""parts"": [{""type"": ""text"", ""content"": ""You are a helpful assistant.""}]}, + {""role"": ""user"", ""parts"": [{""type"": ""text"", ""content"": ""Hello""}]}, + {""role"": ""assistant"", ""parts"": [{""type"": ""text"", ""content"": ""Hi there!""}]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + Assert.IsFalse(filtered.Contains("You are a helpful assistant"), "System message should be filtered out"); + Assert.IsTrue(filtered.Contains("Hello"), "User message should be preserved"); + Assert.IsTrue(filtered.Contains("Hi there!"), "Assistant message should be preserved"); + } + + [TestMethod] + public void ProcessInputOutputMessages_ExtractsTextContentFromParts() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""user"", ""parts"": [{""type"": ""text"", ""content"": ""What is the weather?""}]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + Assert.IsTrue(filtered.Contains("What is the weather?"), "Text content should be extracted from parts"); + } + + [TestMethod] + public void ProcessInputOutputMessages_HandlesMultipleTextParts() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""user"", ""parts"": [ + {""type"": ""text"", ""content"": ""First part""}, + {""type"": ""text"", ""content"": ""Second part""} + ]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + Assert.IsTrue(filtered.Contains("First part"), "First text part should be included"); + Assert.IsTrue(filtered.Contains("Second part"), "Second text part should be included"); + } + + [TestMethod] + public void ProcessInputOutputMessages_FiltersNonTextParts() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""user"", ""parts"": [ + {""type"": ""image"", ""content"": ""base64imagedata""}, + {""type"": ""text"", ""content"": ""Describe this image""} + ]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + Assert.IsFalse(filtered.Contains("base64imagedata"), "Non-text parts should be filtered out"); + Assert.IsTrue(filtered.Contains("Describe this image"), "Text parts should be preserved"); + } + + [TestMethod] + public void ProcessInputOutputMessages_HandlesOutputMessagesWithFinishReason() + { + var activity = new Activity("test"); + var outputMessages = @"[ + { + ""role"": ""assistant"", + ""parts"": [{""type"": ""text"", ""content"": ""Here is your answer.""}], + ""finish_reason"": ""stop"" + } + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiOutputMessagesKey, outputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiOutputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + Assert.IsTrue(filtered.Contains("Here is your answer"), "Output message content should be preserved"); + } + + [TestMethod] + public void ProcessInputOutputMessages_HandlesEmptyPartsArray() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""user"", ""parts"": []}, + {""role"": ""assistant"", ""parts"": [{""type"": ""text"", ""content"": ""Valid message""}]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + Assert.IsTrue(filtered.Contains("Valid message"), "Valid message should be preserved"); + } + + [TestMethod] + public void ProcessInputOutputMessages_HandlesNullPartsArray() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""user""}, + {""role"": ""assistant"", ""parts"": [{""type"": ""text"", ""content"": ""Valid message""}]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + Assert.IsTrue(filtered.Contains("Valid message"), "Valid message should be preserved"); + } + + [TestMethod] + public void ProcessInputOutputMessages_PreservesOriginalOnInvalidJson() + { + var activity = new Activity("test"); + var invalidJson = "not a valid json"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, invalidJson); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var result = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.AreEqual(invalidJson, result, "Original value should be preserved on invalid JSON"); + } + + [TestMethod] + public void ProcessInputOutputMessages_HandlesMissingTags() + { + var activity = new Activity("test"); + // No tags set + + // Should not throw + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var inputResult = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value; + var outputResult = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiOutputMessagesKey).Value; + + Assert.IsNull(inputResult); + Assert.IsNull(outputResult); + } + + [TestMethod] + public void ProcessInputOutputMessages_HandlesEmptyMessagesArray() + { + var activity = new Activity("test"); + var emptyArray = "[]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, emptyArray); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var result = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.AreEqual(emptyArray, result, "Empty array should remain unchanged"); + } + + [TestMethod] + public void ProcessInputOutputMessages_IsCaseInsensitiveForRoles() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""USER"", ""parts"": [{""type"": ""text"", ""content"": ""User message""}]}, + {""role"": ""Assistant"", ""parts"": [{""type"": ""text"", ""content"": ""Assistant message""}]}, + {""role"": ""SYSTEM"", ""parts"": [{""type"": ""text"", ""content"": ""System message""}]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + Assert.IsTrue(filtered.Contains("User message"), "User message should be preserved (case-insensitive)"); + Assert.IsTrue(filtered.Contains("Assistant message"), "Assistant message should be preserved (case-insensitive)"); + Assert.IsFalse(filtered.Contains("System message"), "System message should be filtered out (case-insensitive)"); + } + + [TestMethod] + public void ProcessInputOutputMessages_IsCaseInsensitiveForPartType() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""user"", ""parts"": [{""type"": ""TEXT"", ""content"": ""Uppercase type""}]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + Assert.IsTrue(filtered.Contains("Uppercase type"), "Text content should be extracted regardless of case"); + } + + [TestMethod] + public void ProcessInputOutputMessages_FiltersToolRole() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""user"", ""parts"": [{""type"": ""text"", ""content"": ""User message""}]}, + {""role"": ""tool"", ""parts"": [{""type"": ""text"", ""content"": ""Tool response""}]}, + {""role"": ""assistant"", ""parts"": [{""type"": ""text"", ""content"": ""Assistant message""}]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + Assert.IsTrue(filtered.Contains("User message"), "User message should be preserved"); + Assert.IsTrue(filtered.Contains("Assistant message"), "Assistant message should be preserved"); + Assert.IsFalse(filtered.Contains("Tool response"), "Tool messages should be filtered out"); + } + + [TestMethod] + public void ProcessInputOutputMessages_ProcessesBothInputAndOutputTags() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""user"", ""parts"": [{""type"": ""text"", ""content"": ""User input""}]} + ]"; + var outputMessages = @"[ + {""role"": ""assistant"", ""parts"": [{""type"": ""text"", ""content"": ""Assistant output""}]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + activity.SetTag(OpenTelemetryConstants.GenAiOutputMessagesKey, outputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filteredInput = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + var filteredOutput = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiOutputMessagesKey).Value as string; + + Assert.IsNotNull(filteredInput); + Assert.IsNotNull(filteredOutput); + Assert.IsTrue(filteredInput.Contains("User input"), "Input messages should be processed"); + Assert.IsTrue(filteredOutput.Contains("Assistant output"), "Output messages should be processed"); + } + + [TestMethod] + public void ProcessInputOutputMessages_HandlesRealWorldExample() + { + var activity = new Activity("test"); + // Real-world example from the user's sample data + var inputMessages = @"[ + { + ""role"": ""user"", + ""parts"": [ + { + ""type"": ""text"", + ""content"": ""hi"" + } + ] + }, + { + ""role"": ""assistant"", + ""parts"": [ + { + ""type"": ""text"", + ""content"": ""Hello! How can I assist you today?"" + } + ] + }, + { + ""role"": ""user"", + ""parts"": [ + { + ""type"": ""text"", + ""content"": ""what can you do"" + } + ] + } + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + + // Verify the output is a JSON array of strings + var result = JsonSerializer.Deserialize>(filtered); + Assert.IsNotNull(result); + Assert.AreEqual(3, result.Count); + Assert.AreEqual("hi", result[0]); + Assert.AreEqual("Hello! How can I assist you today?", result[1]); + Assert.AreEqual("what can you do", result[2]); + } + + [TestMethod] + public void ProcessInputOutputMessages_SkipsEmptyContentParts() + { + var activity = new Activity("test"); + var inputMessages = @"[ + {""role"": ""user"", ""parts"": [{""type"": ""text"", ""content"": """"}]}, + {""role"": ""assistant"", ""parts"": [{""type"": ""text"", ""content"": ""Valid content""}]} + ]"; + + activity.SetTag(OpenTelemetryConstants.GenAiInputMessagesKey, inputMessages); + + AgentFrameworkSpanProcessorHelper.ProcessInputOutputMessages(activity); + + var filtered = activity.Tags.FirstOrDefault(t => t.Key == OpenTelemetryConstants.GenAiInputMessagesKey).Value as string; + Assert.IsNotNull(filtered); + + var result = JsonSerializer.Deserialize>(filtered); + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count, "Empty content messages should be skipped"); + Assert.AreEqual("Valid content", result[0]); + } + } +} diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Extension.Tests/Microsoft.Agents.A365.Observability.Extension.Tests.csproj b/src/Tests/Microsoft.Agents.A365.Observability.Extension.Tests/Microsoft.Agents.A365.Observability.Extension.Tests.csproj index 5d914e04..377864cf 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Extension.Tests/Microsoft.Agents.A365.Observability.Extension.Tests.csproj +++ b/src/Tests/Microsoft.Agents.A365.Observability.Extension.Tests/Microsoft.Agents.A365.Observability.Extension.Tests.csproj @@ -22,5 +22,6 @@ +