-
Notifications
You must be signed in to change notification settings - Fork 8
[Observability] Agent framework spans - clean up input and output, listen to custom sources #183
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Activity> | ||
| { | ||
| 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 ?? []; | ||
| } | ||
|
Comment on lines
+19
to
+22
|
||
|
|
||
| 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; | ||
| } | ||
| } | ||
|
Comment on lines
+61
to
+67
|
||
|
|
||
| return false; | ||
| } | ||
|
Comment on lines
+54
to
+70
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,14 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
| // ------------------------------------------------------------------------------ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // ------------------------------------------------------------------------------ | ||
|
Comment on lines
+1
to
+3
|
||
|
|
||
| namespace Microsoft.Agents.A365.Observability.Extensions.AgentFramework; | ||
|
|
||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Microsoft.Agents.A365.Observability.Runtime; | ||
| using OpenTelemetry.Trace; | ||
| using OpenTelemetry; | ||
| using OpenTelemetry; | ||
|
|
||
| /// <summary> | ||
| /// Extension methods for configuring Builder with Agent Framework integration. | ||
| /// </summary> | ||
|
|
@@ -32,24 +34,37 @@ public static class BuilderExtensions | |
| /// </summary> | ||
| /// <param name="builder">The builder to configure.</param> | ||
| /// <param name="enableRelatedSources">If true, enables Agent Framework activity source tracing for OpenTelemetry.</param> | ||
| /// <param name="additionalSources">Optional additional activity source names to include in tracing.</param> | ||
| /// <returns>The configured builder for method chaining.</returns> | ||
| 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); | ||
| } | ||
| } | ||
| }); | ||
threddy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (builder.Configuration != null | ||
| && !string.IsNullOrEmpty(builder.Configuration["EnableOtlpExporter"]) | ||
| && bool.TryParse(builder.Configuration["EnableOtlpExporter"], out bool enabled) && enabled) | ||
| { | ||
| telmConfig.UseOtlpExporter(); | ||
| } | ||
| { | ||
| telmConfig.UseOtlpExporter(); | ||
| } | ||
| } | ||
|
|
||
| return builder; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /// <summary> | ||
| /// Represents the structure of a message as found in Agent Framework OpenTelemetry activity tags. | ||
| /// Used in the <c>gen_ai.input.messages</c> and <c>gen_ai.output.messages</c> tags. | ||
| /// </summary> | ||
| internal class AgentFrameworkMessageContent | ||
| { | ||
| /// <summary> | ||
| /// The role of the message, such as "user" or "assistant". | ||
| /// </summary> | ||
| [JsonPropertyName("role")] | ||
| public string? Role { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// The parts of the message, each containing type and content. | ||
| /// </summary> | ||
| [JsonPropertyName("parts")] | ||
| public List<AgentFrameworkMessagePart>? Parts { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// The finish reason for the message (e.g., "stop"). | ||
| /// </summary> | ||
| [JsonPropertyName("finish_reason")] | ||
| public string? FinishReason { get; set; } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Represents a part of an Agent Framework message. | ||
| /// </summary> | ||
| internal class AgentFrameworkMessagePart | ||
| { | ||
| /// <summary> | ||
| /// The type of the part (e.g., "text"). | ||
| /// </summary> | ||
| [JsonPropertyName("type")] | ||
| public string? Type { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// The content of the part. | ||
| /// </summary> | ||
| [JsonPropertyName("content")] | ||
| public string? Content { get; set; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /// <summary> | ||
| /// Provides helper methods for processing and filtering Agent Framework span tags. | ||
| /// </summary> | ||
| internal static class AgentFrameworkSpanProcessorHelper | ||
| { | ||
| private static readonly JsonSerializerOptions JsonOptions = new() | ||
| { | ||
| PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||
| WriteIndented = false | ||
| }; | ||
|
|
||
| /// <summary> | ||
| /// Processes and filters the gen_ai.input.messages and gen_ai.output.messages tags to keep only user and assistant messages. | ||
| /// </summary> | ||
| /// <param name="activity">The activity containing the tags to process.</param> | ||
| public static void ProcessInputOutputMessages(Activity activity) | ||
| { | ||
| TryFilterMessages(activity, OpenTelemetryConstants.GenAiInputMessagesKey); | ||
| TryFilterMessages(activity, OpenTelemetryConstants.GenAiOutputMessagesKey); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the value of a tag from the activity by key. | ||
| /// </summary> | ||
| /// <param name="activity">The activity containing the tag.</param> | ||
| /// <param name="key">The key of the tag to retrieve.</param> | ||
| /// <returns>The tag value as a string, or null if not found.</returns> | ||
| private static string? GetTagValue(Activity activity, string key) | ||
| { | ||
| return activity.TagObjects | ||
| .OfType<KeyValuePair<string, object>>() | ||
| .FirstOrDefault(k => k.Key == key).Value as string; | ||
threddy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /// <summary> | ||
| /// Attempts to filter the messages in the specified tag. | ||
| /// </summary> | ||
| /// <param name="activity">The activity containing the tag to filter.</param> | ||
| /// <param name="tagName">The name of the tag to filter.</param> | ||
| private static void TryFilterMessages(Activity activity, string tagName) | ||
| { | ||
| var jsonString = GetTagValue(activity, tagName); | ||
| if (jsonString != null) | ||
| { | ||
| TryFilterMessages(activity, jsonString, tagName); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Attempts to parse and filter the messages JSON string, keeping only user and assistant messages | ||
| /// and extracting text content from the parts array. | ||
threddy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /// </summary> | ||
| /// <param name="activity">The activity to update with the filtered tag.</param> | ||
| /// <param name="jsonString">The JSON string to parse and filter.</param> | ||
| /// <param name="tagName">The name of the tag to update.</param> | ||
| private static void TryFilterMessages(Activity activity, string jsonString, string tagName) | ||
| { | ||
| try | ||
| { | ||
| var messages = JsonSerializer.Deserialize<List<AgentFrameworkMessageContent>>(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 | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Checks if the role is "user" or "assistant". | ||
| /// </summary> | ||
| /// <param name="role">The role to check.</param> | ||
| /// <returns>True if the role is "user" or "assistant"; otherwise, false.</returns> | ||
| private static bool IsUserOrAssistantRole(string? role) | ||
| { | ||
| return string.Equals(role, "user", StringComparison.OrdinalIgnoreCase) || | ||
| string.Equals(role, "assistant", StringComparison.OrdinalIgnoreCase); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Extracts the text content from a message's parts array. | ||
| /// </summary> | ||
| /// <param name="message">The message to extract content from.</param> | ||
| /// <returns>The concatenated text content from all text parts, or null if no text parts exist.</returns> | ||
| 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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The null-coalescing operator with empty array initializer requires C# 12 or later. While this syntax is valid, consider verifying that the project's target framework and language version support this feature. If compatibility with earlier C# versions is required, use the traditional null-coalescing assignment:
_additionalSources = additionalSources ?? Array.Empty<string>();