Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Expand All @@ -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 ?? [];
Copy link

Copilot AI Jan 16, 2026

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>();

Copilot uses AI. Check for mistakes.
}
Comment on lines +19 to +22
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding XML documentation comments for the constructor parameter to improve code maintainability and developer experience. This would help consumers of the API understand the purpose of the additionalSources parameter.

Copilot uses AI. Check for mistakes.

public override void OnStart(Activity activity)
{
Expand All @@ -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
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.

return false;
}
Comment on lines +54 to +70
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IsTrackedSource method doesn't handle the case where sourceName parameter could potentially be null. While Activity.Source.Name is typically not null in practice, defensive programming would suggest adding a null check at the beginning of the method to prevent potential NullReferenceException when calling StartsWith.

Consider adding:

if (string.IsNullOrEmpty(sourceName))
{
    return false;
}

at the beginning of the method.

Copilot uses AI. Check for mistakes.
}
}
47 changes: 31 additions & 16 deletions src/Observability/Extensions/AgentFramework/BuilderExtensions.cs
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
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The copyright header format is inconsistent with the rest of the codebase. The standard format used throughout the project is:

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

However, this file uses:

// ------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------------

Please update the copyright header to match the standard format used in other files in this codebase.

Copilot generated this review using guidance from repository custom instructions.

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>
Expand All @@ -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));
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The foreach loop adds sources that pass the !string.IsNullOrWhiteSpace check, but the AgentFrameworkSpanProcessor constructor receives the original unfiltered additionalSources array. This creates an inconsistency where:

  1. Only non-whitespace sources are added to OpenTelemetry tracing via AddSource
  2. But the processor receives ALL sources (including potentially null or whitespace entries) and checks them in IsTrackedSource

While the IsTrackedSource method does check for !string.IsNullOrWhiteSpace, this still creates unnecessary overhead. The processor should only receive the sources that were actually registered with OpenTelemetry.

Copilot uses AI. Check for mistakes.

// 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
<Product>Microsoft Agent 365 Agent Framework Observability SDK</Product>
<PackageTags>Microsoft;Agents;A365;Observability;AgentFramework;OpenTelemetry;AI;Agents;Tracing;Monitoring</PackageTags>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.A365.Observability.Extension.Tests" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
Expand Down
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;
}

/// <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.
/// </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;
}
}
Loading