diff --git a/sdk/ai/Azure.AI.Projects/README.md b/sdk/ai/Azure.AI.Projects/README.md index 8db7d5b98b83..c84864440a24 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 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 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..55071d14d1e7 --- /dev/null +++ b/sdk/ai/Azure.AI.Projects/tests/Utilities/MemoryTraceExporter.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +using System.Collections.Generic; +using OpenTelemetry; +using System.Diagnostics; + +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; + } + + 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(); + } +}