Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,73 @@ namespace Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters
public static class Agent365ExporterCore
{
private const string CorrelationIdHeaderKey = "x-ms-correlation-id";
private const int MaxActivitySizeBytes = 250 * 1024;

/// <summary>
/// Truncates the largest-to-smallest of the specified activity attributes until the activity's serialized size is under 250 KB.
/// Logs the size of each key/value and each truncation.
/// </summary>
/// <param name="activity">The activity to check and potentially truncate.</param>
/// <param name="resource">The resource for serialization context.</param>
/// <param name="logInformation">Logger for informational messages.</param>
/// <returns>True if any truncation occurred, otherwise false.</returns>
public static bool TruncateActivityToMaxSize(
Activity activity,
Resource resource,
Action<string>? logInformation = null)
{
if (activity == null) return false;

// Check initial size
string json = ExportFormatter.FormatSingle(activity, resource);
Comment thread
threddy marked this conversation as resolved.
Outdated
if (Encoding.UTF8.GetByteCount(json) <= Agent365ExporterCore.MaxActivitySizeBytes)
return false;

string[] keys = new[]
Comment thread
threddy marked this conversation as resolved.
Outdated
{
"gen_ai.tool.arguments",
"gen_ai.event.content",
"gen_ai.input.messages",
"gen_ai.agent.invocation_input",
"gen_ai.output.messages",
"gen_ai.agent.invocation_output"
};

// Get all key/value sizes and log them
var keySizes = new List<(string Key, int Size, string? Value)>();
foreach (var key in keys)
{
var value = activity.GetTagItem(key) as string;
int size = !string.IsNullOrEmpty(value) ? Encoding.UTF8.GetByteCount(value) : 0;
keySizes.Add((key, size, value));
logInformation?.Invoke($"Activity '{activity.DisplayName}': Key '{key}' size = {size / 1024} KB.");
}

// Sort keys by size descending
var sorted = keySizes
.Where(k => !string.IsNullOrEmpty(k.Value) && k.Size > 0)
.OrderByDescending(k => k.Size)
.ToList();

bool truncated = false;

foreach (var (key, size, _) in sorted)
{
activity.SetTag(key, "TRUNCATED");
Comment thread
threddy marked this conversation as resolved.
Outdated
logInformation?.Invoke($"Truncated '{key}' in activity '{activity.DisplayName}' to reduce size. Previous size: {size / 1024} KB.");

// Re-check size after each truncation
json = ExportFormatter.FormatSingle(activity, resource);
if (Encoding.UTF8.GetByteCount(json) <= Agent365ExporterCore.MaxActivitySizeBytes)
{
truncated = true;
break;
}
truncated = true;
Comment thread
threddy marked this conversation as resolved.
Outdated
}

return truncated;
}

/// <summary>
/// Partitions a batch of activities by tenant and agent identity.
Expand Down Expand Up @@ -110,6 +177,13 @@ public static async Task<ExportResult> ExportBatchCoreAsync(
foreach (var g in groups)
{
var (tenantId, agentId, activities) = g;

// Truncate activities if needed before serialization
foreach (var activity in activities)
{
Agent365ExporterCore.TruncateActivityToMaxSize(activity, resource, logInformation);
}

var json = ExportFormatter.FormatMany(activities, resource);
using var content = new StringContent(json, Encoding.UTF8, "application/json");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,102 @@ public async Task AddTracing_NestedScopes_AllExporterRequestsReceived()
allOperationNames.Should().Contain(new[] { "invoke_agent", "execute_tool", InferenceOperationType.Chat.ToString() }, "All three nested scopes should be exported, even if batched in fewer requests.");
}

[TestMethod]
public async Task Exporter_Truncates_Scope()
{
// Arrange
this.SetupExporterTest();
this._receivedRequest = false;
this._receivedContent = null;

// Create a sample text file >250KB and base64 encode it
var tempFile = Path.GetTempFileName();
var fileBytes = new byte[300 * 1024]; // 300KB
new Random(42).NextBytes(fileBytes);
await File.WriteAllBytesAsync(tempFile, fileBytes);
var base64 = Convert.ToBase64String(await File.ReadAllBytesAsync(tempFile));
File.Delete(tempFile);
Comment thread
threddy marked this conversation as resolved.

var agentDetails = new AgentDetails(
agentId: Guid.NewGuid().ToString(),
agentName: "Test Agent",
agentDescription: "Agent for truncation test.",
agentAUID: Guid.NewGuid().ToString(),
agentUPN: "testagent@contoso.com",
agentBlueprintId: Guid.NewGuid().ToString(),
tenantId: Guid.NewGuid().ToString());
var tenantDetails = new TenantDetails(Guid.NewGuid());
var endpoint = new Uri("https://test-endpoint");
var invokeAgentDetails = new InvokeAgentDetails(endpoint, agentDetails);
var request = new Request(
content: "Test request content",
executionType: ExecutionType.HumanToAgent,
sourceMetadata: new SourceMetadata("test", "test-id"));

var toolCallDetails = new ToolCallDetails(
toolName: "LargeFileTool",
arguments: base64,
toolCallId: "call-123",
description: "Test tool with large file content",
toolType: "file-upload",
endpoint: endpoint);

// Act: Start nested scopes
using (var agentScope = InvokeAgentScope.Start(invokeAgentDetails, tenantDetails, request))
{
agentScope.RecordInputMessages(new[] { "Agent input" });
agentScope.RecordOutputMessages(new[] { "Agent output" });
using (var toolScope = ExecuteToolScope.Start(toolCallDetails, agentDetails, tenantDetails))
{
toolScope.RecordResponse("Tool response");
}
}

// Wait for export
var timeout = TimeSpan.FromSeconds(10);
var start = DateTime.UtcNow;
while (!this._receivedRequest && DateTime.UtcNow - start < timeout)
{
await Task.Delay(500).ConfigureAwait(false);
}

this._receivedRequest.Should().BeTrue("Exporter should make the expected HTTP request.");
this._receivedContent.Should().NotBeNull("Exporter should send a request body.");
Comment thread
threddy marked this conversation as resolved.

// Assert: Find both activities in the exported payload
using var doc = JsonDocument.Parse(this._receivedContent!);
Comment thread
threddy marked this conversation as resolved.
var root = doc.RootElement;
var spans = root
.GetProperty("resourceSpans")[0]
.GetProperty("scopeSpans")[0]
.GetProperty("spans")
.EnumerateArray();

bool foundInvokeAgent = false;
bool foundExecuteTool = false;
foreach (var span in spans)
{
var attrs = span.GetProperty("attributes");
var opName = this.GetAttribute(attrs, "gen_ai.operation.name");
if (opName == "invoke_agent")
{
foundInvokeAgent = true;
// Should NOT be truncated
var input = this.GetAttribute(attrs, "gen_ai.input.messages");
input.Should().Be("Agent input");
}
if (opName == "execute_tool")
{
foundExecuteTool = true;
// Should be truncated
var args = this.GetAttribute(attrs, "gen_ai.tool.arguments");
args.Should().Be("TRUNCATED");
}
}
Comment thread
threddy marked this conversation as resolved.
Comment thread
threddy marked this conversation as resolved.
Comment thread
threddy marked this conversation as resolved.
foundInvokeAgent.Should().BeTrue();
foundExecuteTool.Should().BeTrue();
}

private class TestHttpMessageHandler : HttpMessageHandler
{
private Func<HttpRequestMessage, HttpResponseMessage> _handler;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters;
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using OpenTelemetry;
using OpenTelemetry.Resources;
using System.Diagnostics;
Expand Down Expand Up @@ -1044,4 +1043,94 @@ public void Export_StandardEndpoint_WithDifferentClusterCategories_ProcessesCorr
}

#endregion

#region TruncateActivityToMaxSize Tests

[TestMethod]
public void TruncateActivityToMaxSize_DoesNothing_WhenUnderLimit()
{
// Arrange
using var activity = CreateActivity("tenant-1", "agent-1");
activity.SetTag("gen_ai.tool.arguments", new string('a', 1024)); // 1KB
var resource = ResourceBuilder.CreateEmpty().Build();
var logs = new List<string>();

// Act
var result = Agent365ExporterCore.TruncateActivityToMaxSize(activity, resource, logs.Add);

// Assert
result.Should().BeFalse();
activity.GetTagItem("gen_ai.tool.arguments").Should().BeOfType<string>().Which.Should().NotBe("TRUNCATED");
}

[TestMethod]
public void TruncateActivityToMaxSize_TruncatesSingleLargeKey()
{
// Arrange
using var activity = CreateActivity("tenant-1", "agent-1");
// 300KB value
activity.SetTag("gen_ai.tool.arguments", new string('b', 300 * 1024));
var resource = ResourceBuilder.CreateEmpty().Build();
var logs = new List<string>();

// Act
var result = Agent365ExporterCore.TruncateActivityToMaxSize(activity, resource, logs.Add);

// Assert
result.Should().BeTrue();
activity.GetTagItem("gen_ai.tool.arguments").Should().Be("TRUNCATED");
logs.Should().Contain(l => l.Contains("Key 'gen_ai.tool.arguments' size = "));
logs.Should().Contain(l => l.Contains("Truncated 'gen_ai.tool.arguments'"));
}

[TestMethod]
public void TruncateActivityToMaxSize_TruncatesMultipleKeys_LargestFirst()
{
// Arrange
using var activity = CreateActivity("tenant-1", "agent-1");
// 200KB and 100KB values, total > 250KB
activity.SetTag("gen_ai.tool.arguments", new string('c', 200 * 1024));
activity.SetTag("gen_ai.event.content", new string('d', 100 * 1024));
var resource = ResourceBuilder.CreateEmpty().Build();
var logs = new List<string>();

// Act
var result = Agent365ExporterCore.TruncateActivityToMaxSize(activity, resource, logs.Add);

// Assert
result.Should().BeTrue();
// The largest should be truncated first
activity.GetTagItem("gen_ai.tool.arguments").Should().Be("TRUNCATED");
// If still over, the next largest is truncated
// (depends on serialization overhead, so check at least one is truncated)
logs.Should().Contain(l => l.Contains("Truncated 'gen_ai.tool.arguments'"));
logs.Should().Contain(l => l.Contains("Key 'gen_ai.tool.arguments' size = "));
logs.Should().Contain(l => l.Contains("Key 'gen_ai.event.content' size = "));
}

[TestMethod]
public void TruncateActivityToMaxSize_LogsAllKeySizes()
{
// Arrange
using var activity = CreateActivity("tenant-1", "agent-1");
activity.SetTag("gen_ai.tool.arguments", new string('x', 100 * 1024));
activity.SetTag("gen_ai.event.content", new string('y', 125 * 1024));
activity.SetTag("gen_ai.input.messages", new string('z', 75 * 1024));
var resource = ResourceBuilder.CreateEmpty().Build();
var logs = new List<string>();

// Act
Agent365ExporterCore.TruncateActivityToMaxSize(activity, resource, logs.Add);

// Assert
logs.Should().Contain(l => l.Contains("Key 'gen_ai.tool.arguments' size = 100"));
logs.Should().Contain(l => l.Contains("Key 'gen_ai.event.content' size = 125"));
logs.Should().Contain(l => l.Contains("Key 'gen_ai.input.messages' size = 75"));
logs.Should().Contain(l => l.Contains("Key 'gen_ai.agent.invocation_input' size = 0"));
logs.Should().Contain(l => l.Contains("Key 'gen_ai.output.messages' size = 0"));
logs.Should().Contain(l => l.Contains("Key 'gen_ai.agent.invocation_output' size = 0"));
}

#endregion

}
Loading