From 8d4f3e5a7133b2fc3e8856fa4554d8f8df5e9e1b Mon Sep 17 00:00:00 2001 From: M-Hietala <78813398+M-Hietala@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:56:45 -0500 Subject: [PATCH 1/2] adding function tracer --- sdk/ai/Azure.AI.Projects/README.md | 46 ++ .../src/Azure.AI.Projects.csproj | 4 + .../src/Telemetry/FunctionTracer.cs | 495 ++++++++++++++ .../tests/Azure.AI.Projects.Tests.csproj | 4 +- .../tests/FunctionTracerTests.cs | 640 ++++++++++++++++++ .../Sample_Telemetry_FunctionTracer.cs | 77 +++ .../tests/Utilities/GenAITraceVerifier.cs | 240 +++++++ .../tests/Utilities/MemoryTraceExporter.cs | 28 + 8 files changed, 1533 insertions(+), 1 deletion(-) create mode 100644 sdk/ai/Azure.AI.Projects/src/Telemetry/FunctionTracer.cs create mode 100644 sdk/ai/Azure.AI.Projects/tests/FunctionTracerTests.cs create mode 100644 sdk/ai/Azure.AI.Projects/tests/Samples/Telemetry/Sample_Telemetry_FunctionTracer.cs create mode 100644 sdk/ai/Azure.AI.Projects/tests/Utilities/GenAITraceVerifier.cs create mode 100644 sdk/ai/Azure.AI.Projects/tests/Utilities/MemoryTraceExporter.cs diff --git a/sdk/ai/Azure.AI.Projects/README.md b/sdk/ai/Azure.AI.Projects/README.md index 8db7d5b98b83..d2728ec2cdd8 100644 --- a/sdk/ai/Azure.AI.Projects/README.md +++ b/sdk/ai/Azure.AI.Projects/README.md @@ -33,6 +33,8 @@ See [full set of Agents samples](https://github.com/Azure/azure-sdk-for-net/tree - [Connections operations](#connections-operations) - [Dataset operations](#dataset-operations) - [Indexes operations](#indexes-operations) +- [Telemetry](#telemetry) + - [Trace your own functions](#trace-your-own-functions) - [Troubleshooting](#troubleshooting) - [Next steps](#next-steps) - [Contributing](#contributing) @@ -360,6 +362,50 @@ Console.WriteLine("Delete the Index version created above:"); indexesClient.Delete(name: indexName, version: indexVersion); ``` +## Telemetry + +### Trace your own functions + +A helper class is provided to trace your own functions. The trace functions in the class will log function parameters and the return value for supported types. +Note that this helper class will log the parameters and return value always when tracing is enabled, so be mindful with sensitive data. + +Here are is a sample async function that we want to trace: +```C# Snippet:AI_Projects_TelemetryAsyncFunctionExample +// Simple async function to trace +public static async Task ProcessOrderAsync(string orderId, int quantity, decimal price) +{ + await Task.Delay(100); // Simulate async work + var total = quantity * price; + return $"Order {orderId}: {quantity} items, Total: ${total:F2}"; +} +``` + +You can trace async functions like this: +```C# Snippet:AI_Projects_TelemetryTraceFunctionExampleAsync +using (tracerProvider) +{ + var asyncResult = await FunctionTracer.TraceAsync(() => ProcessOrderAsync("ORD-456", 3, 15.50m)); +} +``` + +Here are is a sample sync function that we want to trace: +```C# Snippet:AI_Projects_TelemetrySyncFunctionExample +// Simple sync function to trace +public static string ProcessOrder(string orderId, int quantity, decimal price) +{ + var total = quantity * price; + return $"Order {orderId}: {quantity} items, Total: ${total:F2}"; +} +``` + +Sync functions can be traced like this: +```C# Snippet:AI_Projects_TelemetryTraceFunctionExampleSync +using (tracerProvider) +{ + var syncResult = FunctionTracer.Trace(() => ProcessOrder("ORD-123", 5, 29.99m)); +} +``` + ## Troubleshooting Any operation that fails will throw a [RequestFailedException][RequestFailedException]. The exception's `code` will hold the HTTP response status code. The exception's `message` contains a detailed message that may be helpful in diagnosing the issue: diff --git a/sdk/ai/Azure.AI.Projects/src/Azure.AI.Projects.csproj b/sdk/ai/Azure.AI.Projects/src/Azure.AI.Projects.csproj index 8d5811164bdb..84a25e4702ee 100644 --- a/sdk/ai/Azure.AI.Projects/src/Azure.AI.Projects.csproj +++ b/sdk/ai/Azure.AI.Projects/src/Azure.AI.Projects.csproj @@ -32,4 +32,8 @@ + + + + diff --git a/sdk/ai/Azure.AI.Projects/src/Telemetry/FunctionTracer.cs b/sdk/ai/Azure.AI.Projects/src/Telemetry/FunctionTracer.cs new file mode 100644 index 000000000000..39e75fe8eea2 --- /dev/null +++ b/sdk/ai/Azure.AI.Projects/src/Telemetry/FunctionTracer.cs @@ -0,0 +1,495 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; + +namespace Azure.AI.Projects +{ + public static class FunctionTracer + { + private static readonly ActivitySource ActivitySource = new("Azure.AI.Projects.FunctionTracer"); + + #region Synchronous Methods + + /// + /// Traces a synchronous function execution with automatic parameter and return value capture. + /// Only traces supported data types (primitives, common framework types, and collections). + /// Object types are omitted from tracing. + /// + public static T Trace(Expression> expression, string? functionName = null) + { + var func = expression.Compile(); + var extractedName = functionName ?? ExtractFunctionName(expression); + + var activity = ActivitySource.StartActivity(extractedName); + + try + { + // Extract and trace parameters + TraceParameters(activity, expression); + + var stopwatch = Stopwatch.StartNew(); + var result = func(); + stopwatch.Stop(); + + activity?.SetTag("duration_ms", stopwatch.ElapsedMilliseconds); + + // Only trace if result is a supported type + if (result != null && IsSupportedType(result)) + { + activity?.SetTag("code.function.return.value", ConvertToTraceValue(result)); + } + + return result; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.SetTag("error.type", ex.GetType().Name); + activity?.SetTag("error.message", ex.Message); + throw; + } + finally + { + activity?.Dispose(); + } + } + + /// + /// Traces a synchronous action (void return) with automatic parameter capture. + /// + public static void Trace(Expression expression, string? functionName = null) + { + var action = expression.Compile(); + var extractedName = functionName ?? ExtractFunctionName(expression); + + var activity = ActivitySource.StartActivity(extractedName); + + try + { + // Extract and trace parameters + TraceParameters(activity, expression); + + var stopwatch = Stopwatch.StartNew(); + action(); + stopwatch.Stop(); + + activity?.SetTag("duration_ms", stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.SetTag("error.type", ex.GetType().Name); + activity?.SetTag("error.message", ex.Message); + throw; + } + finally + { + activity?.Dispose(); + } + } + + #endregion + + #region Asynchronous Methods + + /// + /// Traces an asynchronous function execution with automatic parameter and return value capture. + /// Only traces supported data types (primitives, common framework types, and collections). + /// Object types are omitted from tracing. + /// + public static async Task TraceAsync(Expression>> expression, string? functionName = null) + { + var func = expression.Compile(); + var extractedName = functionName ?? ExtractFunctionName(expression); + + var activity = ActivitySource.StartActivity(extractedName); + + try + { + // Extract and trace parameters + TraceParameters(activity, expression); + + var stopwatch = Stopwatch.StartNew(); + var result = await func().ConfigureAwait(false); + stopwatch.Stop(); + + activity?.SetTag("duration_ms", stopwatch.ElapsedMilliseconds); + + // Only trace if result is a supported type + if (result != null && IsSupportedType(result)) + { + activity?.SetTag("code.function.return.value", ConvertToTraceValue(result)); + } + + return result; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.SetTag("error.type", ex.GetType().Name); + activity?.SetTag("error.message", ex.Message); + throw; + } + finally + { + activity?.Dispose(); + } + } + + /// + /// Traces an asynchronous action (Task return) with automatic parameter capture. + /// + public static async Task TraceAsync(Expression> expression, string? functionName = null) + { + var func = expression.Compile(); + var extractedName = functionName ?? ExtractFunctionName(expression); + + var activity = ActivitySource.StartActivity(extractedName); + + try + { + // Extract and trace parameters + TraceParameters(activity, expression); + + var stopwatch = Stopwatch.StartNew(); + await func().ConfigureAwait(false); + stopwatch.Stop(); + + activity?.SetTag("duration_ms", stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.SetTag("error.type", ex.GetType().Name); + activity?.SetTag("error.message", ex.Message); + throw; + } + finally + { + activity?.Dispose(); + } + } + + #endregion + + #region Expression Analysis and Parameter Tracing + + /// + /// Extracts function name from expression tree. + /// + private static string ExtractFunctionName(LambdaExpression expression) + { + return expression.Body switch + { + MethodCallExpression methodCall => methodCall.Method.Name, + MemberExpression member => member.Member.Name, + _ => "UnknownFunction" + }; + } + + /// + /// Traces function parameters by analyzing the expression tree. + /// Only evaluates and traces parameters of supported types. + /// + private static void TraceParameters(Activity? activity, LambdaExpression expression) + { + if (expression.Body is not MethodCallExpression methodCall) + return; + + var method = methodCall.Method; + var parameters = method.GetParameters(); + var arguments = methodCall.Arguments; + + for (int i = 0; i < parameters.Length && i < arguments.Count; i++) + { + var param = parameters[i]; + var argExpression = arguments[i]; + + // First, check if the parameter TYPE is potentially supported + if (!CouldBeSupportedType(param.ParameterType)) + { + // Skip entirely - don't even try to evaluate + continue; + } + + try + { + // Only evaluate if the type might be supported + var value = EvaluateExpression(argExpression); + + // Double-check with actual value (for polymorphic cases) + if (value != null && IsSupportedType(value)) + { + activity?.SetTag($"code.function.parameter.{param.Name}", ConvertToTraceValue(value)); + } + // If not supported, omit (like Python @trace_function) + } + catch + { + // If evaluation fails, skip this parameter silently + // This handles complex expressions, side effects, etc. + } + } + } + + /// + /// Quick type check to see if a parameter type could potentially be supported. + /// This avoids expensive expression evaluation for obviously unsupported types. + /// + private static bool CouldBeSupportedType(Type type) + { + // Handle nullable types + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + return underlyingType == typeof(string) || + underlyingType == typeof(int) || + underlyingType == typeof(long) || + underlyingType == typeof(float) || + underlyingType == typeof(double) || + underlyingType == typeof(decimal) || + underlyingType == typeof(bool) || + underlyingType == typeof(char) || + underlyingType == typeof(byte) || + underlyingType == typeof(sbyte) || + underlyingType == typeof(short) || + underlyingType == typeof(ushort) || + underlyingType == typeof(uint) || + underlyingType == typeof(ulong) || + underlyingType == typeof(DateTime) || + underlyingType == typeof(DateTimeOffset) || + underlyingType == typeof(Guid) || + underlyingType == typeof(TimeSpan) || + typeof(IEnumerable).IsAssignableFrom(underlyingType); // Collections + } + + /// + /// Safely evaluates an expression to get its runtime value. + /// Returns null if evaluation fails or is unsafe. + /// + private static object? EvaluateExpression(Expression expression) + { + try + { + // Additional safety: avoid evaluating expressions that might have side effects + if (HasPotentialSideEffects(expression)) + { + return null; + } + + var lambda = Expression.Lambda(expression); + var compiled = lambda.Compile(); + return compiled.DynamicInvoke(); + } + catch + { + // Silently ignore evaluation failures + return null; + } + } + + /// + /// Checks if an expression might have side effects and should not be evaluated twice. + /// + private static bool HasPotentialSideEffects(Expression expression) + { + return expression switch + { + // Method calls might have side effects + MethodCallExpression => true, + + // New object creation is usually safe but can be expensive + NewExpression => false, + + // Member access is usually safe + MemberExpression => false, + + // Constants are always safe + ConstantExpression => false, + + // Parameter access is safe + ParameterExpression => false, + + // Unary expressions (like conversions) are usually safe + UnaryExpression => false, + + // Binary expressions (like arithmetic) are usually safe + BinaryExpression => false, + + // For other types, be conservative + _ => true + }; + } + + #endregion + + #region Type Checking and Conversion + + /// + /// Determines if a value is of a supported type for tracing. + /// Supports: all C# primitive types, common framework types, and collections. + /// Object types are not supported and will be omitted from tracing. + /// + private static bool IsSupportedType(object value) + { + return value switch + { + // Integer types + byte or sbyte => true, + short or ushort => true, + int or uint => true, + long or ulong => true, + + // Floating-point types + float or double or decimal => true, + + // Other basic types + bool => true, + char => true, + string => true, + + // Common framework types + DateTime => true, + DateTimeOffset => true, + Guid => true, + TimeSpan => true, + + // Collections (C# equivalents of Python list, dict, tuple, set) + IEnumerable when value is not string => true, + + // Object types are omitted + _ => false + }; + } + + /// + /// Converts a supported value to its string representation for tracing. + /// Handles special formatting for different types and nested collections. + /// + private static string ConvertToTraceValue(object value) + { + return value switch + { + // String - return as-is + string str => str, + + // Integer types + byte b => b.ToString(), + sbyte sb => sb.ToString(), + short s => s.ToString(), + ushort us => us.ToString(), + int i => i.ToString(), + uint ui => ui.ToString(), + long l => l.ToString(), + ulong ul => ul.ToString(), + + // Floating-point types + float f => f.ToString(), + double d => d.ToString(), + decimal dec => dec.ToString(), + + // Other basic types + bool b => b.ToString().ToLowerInvariant(), // "true"/"false" to match Python + char c => c.ToString(), + + // Common framework types + DateTime dt => dt.ToString("O"), // ISO 8601 format + DateTimeOffset dto => dto.ToString("O"), // ISO 8601 with offset + Guid guid => guid.ToString(), // Standard GUID format + TimeSpan ts => ts.ToString(), // Standard TimeSpan format + + // Collections + IEnumerable enumerable when value is not string => ConvertCollectionToString(enumerable), + + // Fallback (shouldn't reach here if IsSupportedType is correct) + _ => value.ToString() ?? "null" + }; + } + + /// + /// Converts collections to string representation, handling nested collections. + /// Follows Python @trace_function behavior for collection handling. + /// + private static string ConvertCollectionToString(IEnumerable collection) + { + var items = new List(); + + foreach (var item in collection) + { + if (item == null) + { + items.Add("null"); + } + else if (IsSupportedType(item)) + { + // If item is a collection itself, convert entire thing to string + if (item is IEnumerable && !(item is string)) + { + items.Add($"[{ConvertCollectionToString((IEnumerable)item)}]"); + } + else + { + items.Add(ConvertToTraceValue(item)); + } + } + // Unsupported types in collections are omitted + } + + return string.Join(", ", items); + } + + #endregion + } + + #region Extension Methods for Fluent Syntax + + /// + /// Extension methods to provide fluent syntax for tracing. + /// + public static class FunctionTracerExtensions + { + /// + /// Traces the execution of a function with fluent syntax and automatic parameter capture. + /// + public static T WithTracing(this Expression> expression, string? functionName = null) + { + return FunctionTracer.Trace(expression, functionName); + } + + /// + /// Traces the execution of an action with fluent syntax and automatic parameter capture. + /// + public static void WithTracing(this Expression expression, string? functionName = null) + { + FunctionTracer.Trace(expression, functionName); + } + + /// + /// Traces the execution of an async function with fluent syntax and automatic parameter capture. + /// + public static Task WithTracingAsync(this Expression>> expression, string? functionName = null) + { + return FunctionTracer.TraceAsync(expression, functionName); + } + + /// + /// Traces the execution of an async action with fluent syntax and automatic parameter capture. + /// + public static Task WithTracingAsync(this Expression> expression, string? functionName = null) + { + return FunctionTracer.TraceAsync(expression, functionName); + } + } + + #endregion +} diff --git a/sdk/ai/Azure.AI.Projects/tests/Azure.AI.Projects.Tests.csproj b/sdk/ai/Azure.AI.Projects/tests/Azure.AI.Projects.Tests.csproj index 55af3f348443..29b9ce0faef2 100644 --- a/sdk/ai/Azure.AI.Projects/tests/Azure.AI.Projects.Tests.csproj +++ b/sdk/ai/Azure.AI.Projects/tests/Azure.AI.Projects.Tests.csproj @@ -13,15 +13,17 @@ - + + + diff --git a/sdk/ai/Azure.AI.Projects/tests/FunctionTracerTests.cs b/sdk/ai/Azure.AI.Projects/tests/FunctionTracerTests.cs new file mode 100644 index 000000000000..8bded9805c63 --- /dev/null +++ b/sdk/ai/Azure.AI.Projects/tests/FunctionTracerTests.cs @@ -0,0 +1,640 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure.AI.Projects.Tests.Utilities; +using Azure.Core.TestFramework; +using NUnit.Framework; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Azure.AI.Projects.Tests; + +public class FunctionTracerTelemetryTests : RecordedTestBase +{ + public const string EnableOpenTelemetryEnvironmentVariable = "AZURE_EXPERIMENTAL_ENABLE_ACTIVITY_SOURCE"; + private MemoryTraceExporter _exporter; + private TracerProvider _tracerProvider; + private GenAiTraceVerifier _traceVerifier; + private bool _tracesEnabledInitialValue = false; + + // Test data classes + public class CustomObject + { + public string Name { get; set; } = "Test"; + public int Value { get; set; } = 42; + } + + public FunctionTracerTelemetryTests(bool isAsync) : base(isAsync) + { + TestDiagnostics = false; + } + + [SetUp] + public void Setup() + { + _exporter = new MemoryTraceExporter(); + _traceVerifier = new GenAiTraceVerifier(); + + _tracesEnabledInitialValue = string.Equals( + Environment.GetEnvironmentVariable(EnableOpenTelemetryEnvironmentVariable), + "true", + StringComparison.OrdinalIgnoreCase); + + Environment.SetEnvironmentVariable(EnableOpenTelemetryEnvironmentVariable, "true", EnvironmentVariableTarget.Process); + + _tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource("Azure.AI.Projects.FunctionTracer") + .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("FunctionTracerTest")) + .AddProcessor(new SimpleActivityExportProcessor(_exporter)) + .Build(); + } + + [TearDown] + public void Cleanup() + { + _tracerProvider.Dispose(); + _exporter.Clear(); + Environment.SetEnvironmentVariable( + EnableOpenTelemetryEnvironmentVariable, + _tracesEnabledInitialValue.ToString(), + EnvironmentVariableTarget.Process); + } + + #region Test Functions + + // Functions with supported types + public static string ProcessOrderWithSupportedTypes(string orderId, int quantity, decimal price, bool isUrgent) + { + return $"Order {orderId}: {quantity} items at ${price:F2}, Urgent: {isUrgent}"; + } + + public static async Task ProcessOrderWithSupportedTypesAsync(string orderId, int quantity, decimal price, bool isUrgent) + { + await Task.Delay(10); + return $"Order {orderId}: {quantity} items at ${price:F2}, Urgent: {isUrgent}"; + } + + // Functions with mixed types (some supported, some not) + public static string ProcessOrderWithMixedTypes(string orderId, CustomObject config, int quantity, DateTime orderDate) + { + return $"Order {orderId}: {quantity} items on {orderDate:yyyy-MM-dd}"; + } + + public static async Task ProcessOrderWithMixedTypesAsync(string orderId, CustomObject config, int quantity, DateTime orderDate) + { + await Task.Delay(10); + return $"Order {orderId}: {quantity} items on {orderDate:yyyy-MM-dd}"; + } + + // Functions with collections + public static double CalculateAverage(List numbers, string description) + { + return numbers.Count > 0 ? numbers.Average() : 0; + } + + public static async Task CalculateAverageAsync(List numbers, string description) + { + await Task.Delay(10); + return numbers.Count > 0 ? numbers.Average() : 0; + } + + // Functions with only unsupported types + public static CustomObject ProcessCustomObject(CustomObject input, CustomObject config) + { + return new CustomObject { Name = input.Name + "_processed", Value = input.Value * 2 }; + } + + public static async Task ProcessCustomObjectAsync(CustomObject input, CustomObject config) + { + await Task.Delay(10); + return new CustomObject { Name = input.Name + "_processed", Value = input.Value * 2 }; + } + + // Functions with framework types + public static TimeSpan CalculateWorkingHours(DateTime start, DateTime end, Guid sessionId) + { + return end - start; + } + + public static async Task CalculateWorkingHoursAsync(DateTime start, DateTime end, Guid sessionId) + { + await Task.Delay(10); + return end - start; + } + + // Void functions + public static void LogEvent(string eventName, int severity) + { + // Simulate logging + } + + public static async Task LogEventAsync(string eventName, int severity) + { + await Task.Delay(10); + // Simulate logging + } + + #endregion + + [Test] + public void TestSyncFunctionWithSupportedTypesTracing() + { + // Execute traced function + var result = FunctionTracer.Trace(() => ProcessOrderWithSupportedTypes("ORD-123", 5, 29.99m, true)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "ProcessOrderWithSupportedTypes"); + Assert.IsNotNull(span, "ProcessOrderWithSupportedTypes span should exist"); + + // Verify basic attributes + var expectedAttributes = new Dictionary + { + { "code.function.parameter.orderId", "ORD-123" }, + { "code.function.parameter.quantity", "5" }, + { "code.function.parameter.price", "29.99" }, + { "code.function.parameter.isUrgent", "true" }, + { "code.function.return.value", "Order ORD-123: 5 items at $29.99, Urgent: True" }, + { "duration_ms", "+" } // Should be >= 0 + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + Assert.AreEqual("Order ORD-123: 5 items at $29.99, Urgent: True", result); + } + + [Test] + public async Task TestAsyncFunctionWithSupportedTypesTracing() + { + // Execute traced async function + var result = await FunctionTracer.TraceAsync(() => ProcessOrderWithSupportedTypesAsync("ORD-456", 3, 15.50m, false)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "ProcessOrderWithSupportedTypesAsync"); + Assert.IsNotNull(span, "ProcessOrderWithSupportedTypesAsync span should exist"); + + // Verify basic attributes + var expectedAttributes = new Dictionary + { + { "code.function.parameter.orderId", "ORD-456" }, + { "code.function.parameter.quantity", "3" }, + { "code.function.parameter.price", "15.50" }, + { "code.function.parameter.isUrgent", "false" }, + { "code.function.return.value", "Order ORD-456: 3 items at $15.50, Urgent: False" }, + { "duration_ms", "+" } // Should be >= 0 and likely > 10 due to Task.Delay + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + Assert.AreEqual("Order ORD-456: 3 items at $15.50, Urgent: False", result); + } + + [Test] + public void TestSyncFunctionWithMixedTypesTracing() + { + var config = new CustomObject { Name = "TestConfig", Value = 100 }; + var orderDate = new DateTime(2025, 6, 30, 14, 30, 0); + + // Execute traced function + var result = FunctionTracer.Trace(() => ProcessOrderWithMixedTypes("ORD-789", config, 7, orderDate)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "ProcessOrderWithMixedTypes"); + Assert.IsNotNull(span, "ProcessOrderWithMixedTypes span should exist"); + + // Verify attributes - only supported types should be traced + var expectedAttributes = new Dictionary + { + { "code.function.parameter.orderId", "ORD-789" }, + { "code.function.parameter.quantity", "7" }, + { "code.function.parameter.orderDate", "2025-06-30T14:30:00.0000000" }, // ISO 8601 format + { "code.function.return.value", "Order ORD-789: 7 items on 2025-06-30" }, + { "duration_ms", "+" } + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + + // Verify that config parameter (CustomObject) is NOT traced + var actualAttributes = new Dictionary(); + foreach (var tag in span.EnumerateTagObjects()) + { + actualAttributes[tag.Key] = tag.Value; + } + Assert.IsFalse(actualAttributes.ContainsKey("code.function.parameter.config"), "CustomObject parameter should not be traced"); + } + + [Test] + public async Task TestAsyncFunctionWithMixedTypesTracing() + { + var config = new CustomObject { Name = "TestConfig", Value = 100 }; + var orderDate = new DateTime(2025, 6, 30, 14, 30, 0); + + // Execute traced async function + var result = await FunctionTracer.TraceAsync(() => ProcessOrderWithMixedTypesAsync("ORD-999", config, 2, orderDate)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "ProcessOrderWithMixedTypesAsync"); + Assert.IsNotNull(span, "ProcessOrderWithMixedTypesAsync span should exist"); + + // Verify attributes - only supported types should be traced + var expectedAttributes = new Dictionary + { + { "code.function.parameter.orderId", "ORD-999" }, + { "code.function.parameter.quantity", "2" }, + { "code.function.parameter.orderDate", "2025-06-30T14:30:00.0000000" }, + { "code.function.return.value", "Order ORD-999: 2 items on 2025-06-30" }, + { "duration_ms", "+" } + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + + // Verify that config parameter (CustomObject) is NOT traced + var actualAttributes = new Dictionary(); + foreach (var tag in span.EnumerateTagObjects()) + { + actualAttributes[tag.Key] = tag.Value; + } + Assert.IsFalse(actualAttributes.ContainsKey("code.function.parameter.config"), "CustomObject parameter should not be traced"); + } + + [Test] + public void TestSyncFunctionWithCollectionsTracing() + { + var numbers = new List { 10, 20, 30, 40, 50 }; + + // Execute traced function + var result = FunctionTracer.Trace(() => CalculateAverage(numbers, "Test calculation")); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "CalculateAverage"); + Assert.IsNotNull(span, "CalculateAverage span should exist"); + + // Verify attributes + var expectedAttributes = new Dictionary + { + { "code.function.parameter.numbers", "10, 20, 30, 40, 50" }, + { "code.function.parameter.description", "Test calculation" }, + { "code.function.return.value", "30" }, + { "duration_ms", "+" } + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + Assert.AreEqual(30.0, result); + } + + [Test] + public async Task TestAsyncFunctionWithCollectionsTracing() + { + var numbers = new List { 5, 15, 25 }; + + // Execute traced async function + var result = await FunctionTracer.TraceAsync(() => CalculateAverageAsync(numbers, "Async calculation")); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "CalculateAverageAsync"); + Assert.IsNotNull(span, "CalculateAverageAsync span should exist"); + + // Verify attributes + var expectedAttributes = new Dictionary + { + { "code.function.parameter.numbers", "5, 15, 25" }, + { "code.function.parameter.description", "Async calculation" }, + { "code.function.return.value", "15" }, + { "duration_ms", "+" } + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + Assert.AreEqual(15.0, result); + } + + [Test] + public void TestSyncFunctionWithUnsupportedTypesOnly() + { + var input = new CustomObject { Name = "Input", Value = 10 }; + var config = new CustomObject { Name = "Config", Value = 20 }; + + // Execute traced function + var result = FunctionTracer.Trace(() => ProcessCustomObject(input, config)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "ProcessCustomObject"); + Assert.IsNotNull(span, "ProcessCustomObject span should exist"); + + // Verify that NO parameters are traced (all are unsupported types) + var expectedAttributes = new Dictionary + { + { "duration_ms", "+" } + // No param.* or result attributes should exist + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + + // Verify that no parameters or result are traced + var actualAttributes = new Dictionary(); + foreach (var tag in span.EnumerateTagObjects()) + { + actualAttributes[tag.Key] = tag.Value; + } + Assert.IsFalse(actualAttributes.ContainsKey("code.function.parameter.input"), "CustomObject input parameter should not be traced"); + Assert.IsFalse(actualAttributes.ContainsKey("code.function.parameter.config"), "CustomObject config parameter should not be traced"); + Assert.IsFalse(actualAttributes.ContainsKey("code.function.return.value"), "CustomObject result should not be traced"); + + Assert.AreEqual("Input_processed", result.Name); + Assert.AreEqual(20, result.Value); + } + + [Test] + public async Task TestAsyncFunctionWithUnsupportedTypesOnly() + { + var input = new CustomObject { Name = "AsyncInput", Value = 15 }; + var config = new CustomObject { Name = "AsyncConfig", Value = 25 }; + + // Execute traced async function + var result = await FunctionTracer.TraceAsync(() => ProcessCustomObjectAsync(input, config)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "ProcessCustomObjectAsync"); + Assert.IsNotNull(span, "ProcessCustomObjectAsync span should exist"); + + // Verify that NO parameters are traced (all are unsupported types) + var expectedAttributes = new Dictionary + { + { "duration_ms", "+" } + // No param.* or result attributes should exist + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + + // Verify that no parameters or result are traced + var actualAttributes = new Dictionary(); + foreach (var tag in span.EnumerateTagObjects()) + { + actualAttributes[tag.Key] = tag.Value; + } + Assert.IsFalse(actualAttributes.ContainsKey("code.function.parameter.input"), "CustomObject input parameter should not be traced"); + Assert.IsFalse(actualAttributes.ContainsKey("code.function.parameter.config"), "CustomObject config parameter should not be traced"); + Assert.IsFalse(actualAttributes.ContainsKey("code.function.return.value"), "CustomObject result should not be traced"); + + Assert.AreEqual("AsyncInput_processed", result.Name); + Assert.AreEqual(30, result.Value); + } + + [Test] + public void TestSyncFunctionWithFrameworkTypesTracing() + { + var start = new DateTime(2025, 6, 30, 9, 0, 0); + var end = new DateTime(2025, 6, 30, 17, 30, 0); + var sessionId = Guid.NewGuid(); + + // Execute traced function + var result = FunctionTracer.Trace(() => CalculateWorkingHours(start, end, sessionId)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "CalculateWorkingHours"); + Assert.IsNotNull(span, "CalculateWorkingHours span should exist"); + + // Verify attributes + var expectedAttributes = new Dictionary + { + { "code.function.parameter.start", "2025-06-30T09:00:00.0000000" }, + { "code.function.parameter.end", "2025-06-30T17:30:00.0000000" }, + { "code.function.parameter.sessionId", sessionId.ToString() }, + { "code.function.return.value", "08:30:00" }, // TimeSpan format + { "duration_ms", "+" } + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + Assert.AreEqual(TimeSpan.FromHours(8.5), result); + } + + [Test] + public async Task TestAsyncFunctionWithFrameworkTypesTracing() + { + var start = new DateTime(2025, 6, 30, 10, 0, 0); + var end = new DateTime(2025, 6, 30, 16, 0, 0); + var sessionId = Guid.NewGuid(); + + // Execute traced async function + var result = await FunctionTracer.TraceAsync(() => CalculateWorkingHoursAsync(start, end, sessionId)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "CalculateWorkingHoursAsync"); + Assert.IsNotNull(span, "CalculateWorkingHoursAsync span should exist"); + + // Verify attributes + var expectedAttributes = new Dictionary + { + { "code.function.parameter.start", "2025-06-30T10:00:00.0000000" }, + { "code.function.parameter.end", "2025-06-30T16:00:00.0000000" }, + { "code.function.parameter.sessionId", sessionId.ToString() }, + { "code.function.return.value", "06:00:00" }, // TimeSpan format + { "duration_ms", "+" } + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + Assert.AreEqual(TimeSpan.FromHours(6), result); + } + + [Test] + public void TestSyncVoidFunctionTracing() + { + // Execute traced void function + FunctionTracer.Trace(() => LogEvent("UserLogin", 2)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "LogEvent"); + Assert.IsNotNull(span, "LogEvent span should exist"); + + // Verify attributes + var expectedAttributes = new Dictionary + { + { "code.function.parameter.eventName", "UserLogin" }, + { "code.function.parameter.severity", "2" }, + { "duration_ms", "+" } + // No result attribute for void functions + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + + // Verify that no result is traced + var actualAttributes = new Dictionary(); + foreach (var tag in span.EnumerateTagObjects()) + { + actualAttributes[tag.Key] = tag.Value; + } + Assert.IsFalse(actualAttributes.ContainsKey("code.function.return.value"), "Void function should not have result attribute"); + } + + [Test] + public async Task TestAsyncVoidFunctionTracing() + { + // Execute traced async void function + await FunctionTracer.TraceAsync(() => LogEventAsync("UserLogout", 1)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "LogEventAsync"); + Assert.IsNotNull(span, "LogEventAsync span should exist"); + + // Verify attributes + var expectedAttributes = new Dictionary + { + { "code.function.parameter.eventName", "UserLogout" }, + { "code.function.parameter.severity", "1" }, + { "duration_ms", "+" } + // No result attribute for void functions + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + + // Verify that no result is traced + var actualAttributes = new Dictionary(); + foreach (var tag in span.EnumerateTagObjects()) + { + actualAttributes[tag.Key] = tag.Value; + } + Assert.IsFalse(actualAttributes.ContainsKey("code.function.return.value"), "Async void function should not have result attribute"); + } + + [Test] + public void TestFunctionTracingWithActivitySourceDisabled() + { + // Disable OpenTelemetry + Environment.SetEnvironmentVariable(EnableOpenTelemetryEnvironmentVariable, "false", EnvironmentVariableTarget.Process); + + // Recreate tracer provider without activity source + _tracerProvider.Dispose(); + _tracerProvider = Sdk.CreateTracerProviderBuilder() + .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("FunctionTracerTest")) + .AddProcessor(new SimpleActivityExportProcessor(_exporter)) + .Build(); + + // Execute traced function + var result = FunctionTracer.Trace(() => ProcessOrderWithSupportedTypes("ORD-000", 1, 10.00m, false)); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify no spans are created + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "ProcessOrderWithSupportedTypes"); + Assert.IsNull(span, "No span should be created when activity source is disabled"); + + // But function should still execute normally + Assert.AreEqual("Order ORD-000: 1 items at $10.00, Urgent: False", result); + + // Restore environment + Environment.SetEnvironmentVariable(EnableOpenTelemetryEnvironmentVariable, "true", EnvironmentVariableTarget.Process); + } + + [Test] + public void TestFunctionWithCustomFunctionName() + { + // Execute traced function with custom name + var result = FunctionTracer.Trace(() => ProcessOrderWithSupportedTypes("ORD-CUSTOM", 2, 25.00m, true), "CustomOrderProcessor"); + + // Force flush spans + _exporter.ForceFlush(); + + // Verify span exists with custom name + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "CustomOrderProcessor"); + Assert.IsNotNull(span, "CustomOrderProcessor span should exist"); + + // Verify attributes + var expectedAttributes = new Dictionary + { + { "code.function.parameter.orderId", "ORD-CUSTOM" }, + { "code.function.parameter.quantity", "2" }, + { "code.function.parameter.price", "25.00" }, + { "code.function.parameter.isUrgent", "true" }, + { "code.function.return.value", "Order ORD-CUSTOM: 2 items at $25.00, Urgent: True" }, + { "duration_ms", "+" } + }; + + Assert.IsTrue(_traceVerifier.CheckSpanAttributes(span, expectedAttributes)); + } + + [Test] + public void TestFunctionWithNestedCollections() + { + var nestedList = new List> + { + new() { 1, 2, 3 }, + new() { 4, 5 }, + new() { 6 } + }; + + // Create a function that actually takes the nested collection as a parameter + var result = FunctionTracer.Trace(() => ProcessNestedCollection(nestedList), "ProcessNestedCollection"); + + // Force flush spans + _exporter.ForceFlush(); + + var span = _exporter.GetExportedActivities().FirstOrDefault(s => s.DisplayName == "ProcessNestedCollection"); + Assert.IsNotNull(span, "ProcessNestedCollection span should exist"); + + // The nested collection should be converted to string format + var actualAttributes = new Dictionary(); + foreach (var tag in span.EnumerateTagObjects()) + { + actualAttributes[tag.Key] = tag.Value; + } + + // Should contain nested collection parameter + Assert.IsTrue(actualAttributes.ContainsKey("code.function.parameter.nestedList"), + "Should have parameter for nested collection"); + + // Should contain nested collection as string representation with brackets + var paramValue = actualAttributes["code.function.parameter.nestedList"].ToString(); + Assert.IsTrue(paramValue!.Contains("[") && paramValue.Contains("]"), + $"Nested collections should be represented with brackets, got: {paramValue}"); + + // Should look something like: "[1, 2, 3], [4, 5], [6]" + Assert.IsTrue(paramValue.Contains("1, 2, 3") && paramValue.Contains("4, 5") && paramValue.Contains("6"), + $"Should contain all nested values, got: {paramValue}"); + } + + // Add this helper method to the test class + public static int ProcessNestedCollection(List> nestedList) + { + return nestedList.SelectMany(x => x).Sum(); + } +} diff --git a/sdk/ai/Azure.AI.Projects/tests/Samples/Telemetry/Sample_Telemetry_FunctionTracer.cs b/sdk/ai/Azure.AI.Projects/tests/Samples/Telemetry/Sample_Telemetry_FunctionTracer.cs new file mode 100644 index 000000000000..6846f810e1ff --- /dev/null +++ b/sdk/ai/Azure.AI.Projects/tests/Samples/Telemetry/Sample_Telemetry_FunctionTracer.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core.TestFramework; +using NUnit.Framework; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using OpenTelemetry; +using Azure.Monitor.OpenTelemetry.Exporter; + +namespace Azure.AI.Projects.Tests +{ + public partial class Sample_Telemetry_FunctionTracer : SamplesBase + { + #region Snippet:AI_Projects_TelemetrySyncFunctionExample + // Simple sync function to trace + public static string ProcessOrder(string orderId, int quantity, decimal price) + { + var total = quantity * price; + return $"Order {orderId}: {quantity} items, Total: ${total:F2}"; + } + #endregion + + #region Snippet:AI_Projects_TelemetryAsyncFunctionExample + // Simple async function to trace + public static async Task ProcessOrderAsync(string orderId, int quantity, decimal price) + { + await Task.Delay(100); // Simulate async work + var total = quantity * price; + return $"Order {orderId}: {quantity} items, Total: ${total:F2}"; + } + #endregion + + [Test] + [AsyncOnly] + public async Task TracingToConsoleExample() + { + var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource("Azure.AI.Projects.*") // Add the required sources name + .SetResourceBuilder(OpenTelemetry.Resources.ResourceBuilder.CreateDefault().AddService("FunctionTracerSample")) + .AddConsoleExporter() // Export traces to the console + .Build(); + + #region Snippet:AI_Projects_TelemetryTraceFunctionExampleAsync + using (tracerProvider) + { + var asyncResult = await FunctionTracer.TraceAsync(() => ProcessOrderAsync("ORD-456", 3, 15.50m)); + } + #endregion + } + + [Test] + [SyncOnly] + public void TracingToConsoleExampleSync() + { + var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource("Azure.AI.Projects.*") // Add the required sources name + .SetResourceBuilder(OpenTelemetry.Resources.ResourceBuilder.CreateDefault().AddService("AgentTracingSample")) + .AddConsoleExporter() // Export traces to the console + .Build(); + + #region Snippet:AI_Projects_TelemetryTraceFunctionExampleSync + using (tracerProvider) + { + var syncResult = FunctionTracer.Trace(() => ProcessOrder("ORD-123", 5, 29.99m)); + } + #endregion + } + } +} diff --git a/sdk/ai/Azure.AI.Projects/tests/Utilities/GenAITraceVerifier.cs b/sdk/ai/Azure.AI.Projects/tests/Utilities/GenAITraceVerifier.cs new file mode 100644 index 000000000000..cbf320c98e86 --- /dev/null +++ b/sdk/ai/Azure.AI.Projects/tests/Utilities/GenAITraceVerifier.cs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; + +namespace Azure.AI.Projects.Tests.Utilities +{ + public class GenAiTraceVerifier + { + public bool CheckSpanAttributes(Activity span, Dictionary expectedAttributes) + { + var actualAttributes = new Dictionary(); + foreach (var tag in span.EnumerateTagObjects()) + { + actualAttributes[tag.Key] = tag.Value; + } + + foreach (var expected in expectedAttributes) + { + if (!actualAttributes.ContainsKey(expected.Key)) + { + Console.WriteLine($"Attribute '{expected.Key}' not found in span."); + return false; + } + + var actualValue = actualAttributes[expected.Key]; + + if (!CheckAttributeValue(expected.Value, actualValue)) + { + Console.WriteLine($"Attribute '{expected.Key}' value mismatch. Expected: {expected.Value}, Actual: {actualValue}"); + return false; + } + } + return true; + } + + public bool CheckSpanEvents(Activity span, List<(string Name, Dictionary Attributes)> expectedEvents) + { + var spanEvents = span.Events.ToList(); + + foreach (var expectedEvent in expectedEvents) + { + var matchingEvent = spanEvents.FirstOrDefault(e => e.Name == expectedEvent.Name); + if (matchingEvent.Name == null) + { + Console.WriteLine($"Event '{expectedEvent.Name}' not found."); + return false; + } + + var actualEventAttributes = new Dictionary(); + foreach (var tag in matchingEvent.EnumerateTagObjects()) + { + actualEventAttributes[tag.Key] = tag.Value; + } + + if (!CheckEventAttributes(expectedEvent.Attributes, actualEventAttributes)) + { + Console.WriteLine($"Event '{expectedEvent.Name}' attributes mismatch."); + return false; + } + + spanEvents.Remove(matchingEvent); + } + + if (spanEvents.Any()) + { + Console.WriteLine("Unexpected additional events found in span."); + return false; + } + + return true; + } + + public bool CheckEventAttributes(Dictionary expected, Dictionary actual) + { + var expectedKeys = new HashSet(expected.Keys); + var actualKeys = new HashSet(actual.Keys); + + if (!expectedKeys.SetEquals(actualKeys)) + { + Console.WriteLine("Event attribute keys mismatch."); + return false; + } + + foreach (var key in expectedKeys) + { + if (!CheckAttributeValue(expected[key], actual[key])) + { + Console.WriteLine($"Event attribute '{key}' value mismatch. Expected: {expected[key]}, Actual: {actual[key]}"); + return false; + } + } + + return true; + } + + private bool CheckAttributeValue(object expected, object actual) + { + if (expected is string expectedStr) + { + if (expectedStr == "*") + { + return !string.IsNullOrEmpty(actual?.ToString()); + } + if (expectedStr == "+") + { + if (double.TryParse(actual?.ToString(), out double numericValue)) + { + return numericValue >= 0; + } + return false; + } + + // First try simple string comparison + if (expectedStr == actual?.ToString()) + { + return true; + } + + // Only try JSON comparison if both are complex JSON (objects/arrays) + if (IsComplexJson(expectedStr) && IsComplexJson(actual?.ToString())) + { + return CheckJsonString(expectedStr, actual.ToString()); + } + + return false; + } + else if (expected is Dictionary expectedDict) + { + if (actual is string actualStr && IsValidJson(actualStr)) + { + var actualDict = JsonSerializer.Deserialize>(actualStr); + return CheckEventAttributes(expectedDict, actualDict); + } + return false; + } + else if (expected is IEnumerable expectedList) + { + if (actual is string actualStr && IsValidJson(actualStr)) + { + var actualList = JsonSerializer.Deserialize>(actualStr); + return expectedList.SequenceEqual(actualList); + } + return false; + } + else + { + return expected.Equals(actual); + } + } + + private bool IsValidJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return false; + try + { + JsonDocument.Parse(json); + return true; + } + catch + { + return false; + } + } + + // New helper method to check if JSON is complex (object/array) vs simple value + private bool IsComplexJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return false; + + try + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.ValueKind == JsonValueKind.Object || + doc.RootElement.ValueKind == JsonValueKind.Array; + } + catch + { + return false; + } + } + + private bool CheckJsonString(string expectedJson, string actualJson) + { + try + { + var expectedDoc = JsonDocument.Parse(expectedJson); + var actualDoc = JsonDocument.Parse(actualJson); + return JsonElementDeepEquals(expectedDoc.RootElement, actualDoc.RootElement); + } + catch + { + return false; + } + } + + private bool JsonElementDeepEquals(JsonElement expected, JsonElement actual) + { + if (expected.ValueKind != actual.ValueKind) + return false; + + switch (expected.ValueKind) + { + case JsonValueKind.Object: + var expectedProps = expected.EnumerateObject().OrderBy(p => p.Name).ToList(); + var actualProps = actual.EnumerateObject().OrderBy(p => p.Name).ToList(); + if (expectedProps.Count != actualProps.Count) + return false; + for (int i = 0; i < expectedProps.Count; i++) + { + if (expectedProps[i].Name != actualProps[i].Name || + !JsonElementDeepEquals(expectedProps[i].Value, actualProps[i].Value)) + return false; + } + return true; + + case JsonValueKind.Array: + var expectedItems = expected.EnumerateArray().ToList(); + var actualItems = actual.EnumerateArray().ToList(); + if (expectedItems.Count != actualItems.Count) + return false; + for (int i = 0; i < expectedItems.Count; i++) + { + if (!JsonElementDeepEquals(expectedItems[i], actualItems[i])) + return false; + } + return true; + + default: + return CheckAttributeValue(expected.GetString(), actual.GetString()); + } + } + } +} diff --git a/sdk/ai/Azure.AI.Projects/tests/Utilities/MemoryTraceExporter.cs b/sdk/ai/Azure.AI.Projects/tests/Utilities/MemoryTraceExporter.cs new file mode 100644 index 000000000000..271b9be1ced0 --- /dev/null +++ b/sdk/ai/Azure.AI.Projects/tests/Utilities/MemoryTraceExporter.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +using System.Collections.Generic; +using OpenTelemetry; +using OpenTelemetry.Trace; +using System.Diagnostics; +using Moq; + +namespace Azure.AI.Projects.Tests.Utilities +{ + public class MemoryTraceExporter : BaseExporter + { + private readonly List _activities = new(); + + public override ExportResult Export(in Batch batch) + { + foreach (var activity in batch) + { + _activities.Add(activity); + } + return ExportResult.Success; + } + + public IReadOnlyList GetExportedActivities() => _activities; + + public void Clear() => _activities.Clear(); + } +} From 1fb628f9f60e2edf053ed5cc0f7cd627cc183dfd Mon Sep 17 00:00:00 2001 From: M-Hietala <78813398+M-Hietala@users.noreply.github.com> Date: Tue, 1 Jul 2025 12:33:30 -0500 Subject: [PATCH 2/2] review changes --- sdk/ai/Azure.AI.Projects/README.md | 4 ++-- .../tests/Utilities/MemoryTraceExporter.cs | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/sdk/ai/Azure.AI.Projects/README.md b/sdk/ai/Azure.AI.Projects/README.md index d2728ec2cdd8..c84864440a24 100644 --- a/sdk/ai/Azure.AI.Projects/README.md +++ b/sdk/ai/Azure.AI.Projects/README.md @@ -369,7 +369,7 @@ indexesClient.Delete(name: indexName, version: indexVersion); A helper class is provided to trace your own functions. The trace functions in the class will log function parameters and the return value for supported types. Note that this helper class will log the parameters and return value always when tracing is enabled, so be mindful with sensitive data. -Here are is a sample async function that we want to trace: +Here is a sample async function that we want to trace: ```C# Snippet:AI_Projects_TelemetryAsyncFunctionExample // Simple async function to trace public static async Task ProcessOrderAsync(string orderId, int quantity, decimal price) @@ -388,7 +388,7 @@ using (tracerProvider) } ``` -Here are is a sample sync function that we want to trace: +Here is a sample sync function that we want to trace: ```C# Snippet:AI_Projects_TelemetrySyncFunctionExample // Simple sync function to trace public static string ProcessOrder(string orderId, int quantity, decimal price) diff --git a/sdk/ai/Azure.AI.Projects/tests/Utilities/MemoryTraceExporter.cs b/sdk/ai/Azure.AI.Projects/tests/Utilities/MemoryTraceExporter.cs index 271b9be1ced0..55071d14d1e7 100644 --- a/sdk/ai/Azure.AI.Projects/tests/Utilities/MemoryTraceExporter.cs +++ b/sdk/ai/Azure.AI.Projects/tests/Utilities/MemoryTraceExporter.cs @@ -2,9 +2,7 @@ // Licensed under the MIT License. using System.Collections.Generic; using OpenTelemetry; -using OpenTelemetry.Trace; using System.Diagnostics; -using Moq; namespace Azure.AI.Projects.Tests.Utilities { @@ -21,6 +19,13 @@ public override ExportResult Export(in Batch batch) return ExportResult.Success; } + protected override bool OnForceFlush(int timeoutMilliseconds) + { + // Since this is an in-memory exporter, there's nothing to flush + // Return true to indicate the flush was successful + return true; + } + public IReadOnlyList GetExportedActivities() => _activities; public void Clear() => _activities.Clear();