diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 21a14394c54..3cada739baf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,6 +13,8 @@ # FFE /utils/docker_fixtures/_mock_ffe_agentless_backend.py @DataDog/feature-flagging-and-experimentation-sdk @DataDog/system-tests-core +/utils/build/docker/dotnet/parametric/Endpoints/FfeTestApi.cs @DataDog/feature-flagging-and-experimentation-sdk @DataDog/system-tests-core +/utils/build/docker/dotnet/weblog/Controllers/FeatureFlagEvaluatorController.cs @DataDog/feature-flagging-and-experimentation-sdk @DataDog/system-tests-core /tests/ffe/ @DataDog/feature-flagging-and-experimentation-sdk /tests/test_the_test/test_mock_ffe_agentless_backend.py @DataDog/feature-flagging-and-experimentation-sdk /tests/parametric/test_ffe/ @DataDog/feature-flagging-and-experimentation-sdk diff --git a/manifests/dotnet.yml b/manifests/dotnet.yml index 8e0043a7794..102373a27a2 100644 --- a/manifests/dotnet.yml +++ b/manifests/dotnet.yml @@ -960,7 +960,17 @@ manifest: tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Ignore: v3.10.0 tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Restart: incomplete_test_app (The parametric test app does not emit restart span links or preserve baggage) tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Restart_With_Extract_First: incomplete_test_app (The parametric test app does not emit restart span links or preserve baggage) - tests/parametric/test_ffe/test_configuration_sources.py: missing_feature (FFL-2703 tracks .NET agentless configuration-source implementation; FFL-2731 tracks system-tests configuration-source contract) + tests/parametric/test_ffe/test_configuration_sources.py: '>=3.54.0' # FFL-2703 + ? tests/parametric/test_ffe/test_configuration_sources.py::Test_Feature_Flag_Configuration_Source_Cold_Failure_And_Recovery::test_bad_to_unchanged_cold_preserves_not_ready + : bug (FFL-3325) + ? tests/parametric/test_ffe/test_configuration_sources.py::Test_Feature_Flag_Configuration_Source_Cold_Failure_And_Recovery::test_malformed_cold + : bug (FFL-3325) + ? tests/parametric/test_ffe/test_configuration_sources.py::Test_Feature_Flag_Configuration_Source_Cold_Failure_And_Recovery::test_request_timeout_cold + : bug (FFL-3325) + ? tests/parametric/test_ffe/test_configuration_sources.py::Test_Feature_Flag_Configuration_Source_Cold_Failure_And_Recovery::test_unauthorized_cold + : bug (FFL-3325) + ? tests/parametric/test_ffe/test_configuration_sources.py::Test_Feature_Flag_Configuration_Source_Selection::test_remote_config_without_rc_does_not_fallback_to_agentless + : bug (FFL-3325) tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: '>=3.36.0' # Modified by easy win activation script tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_flag_evaluation: missing_feature # Created by easy win activation script tests/parametric/test_ffe/test_span_enrichment.py: missing_feature diff --git a/utils/build/docker/dotnet/parametric/ApmTestApi.csproj b/utils/build/docker/dotnet/parametric/ApmTestApi.csproj index 9b1f4807601..b2fcbc99bf0 100644 --- a/utils/build/docker/dotnet/parametric/ApmTestApi.csproj +++ b/utils/build/docker/dotnet/parametric/ApmTestApi.csproj @@ -13,6 +13,7 @@ + diff --git a/utils/build/docker/dotnet/parametric/Endpoints/FfeTestApi.cs b/utils/build/docker/dotnet/parametric/Endpoints/FfeTestApi.cs new file mode 100644 index 00000000000..5c6a642da30 --- /dev/null +++ b/utils/build/docker/dotnet/parametric/Endpoints/FfeTestApi.cs @@ -0,0 +1,110 @@ +using System.ComponentModel; +using System.Reflection; +using System.Text.Json; +using OpenFeature; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace ApmTestApi.Endpoints; + +public abstract class FfeTestApi +{ + private static FeatureClient? _client; + private static ILogger? _logger; + + public static void MapFfeEndpoints(WebApplication app, ILogger logger) + { + _logger = logger; + + app.MapPost("/ffe/start", StartFfe); + app.MapPost("/ffe/evaluate", EvaluateFfe); + } + + private static async Task StartFfe() + { + try + { + _logger?.LogInformation("Initializing FFE provider"); + + var provider = new Datadog.FeatureFlags.OpenFeature.DatadogProvider(); + + // Only the async call runs InitializeAsync, which waits for the first configuration. + await Api.Instance.SetProviderAsync(provider); + _client = Api.Instance.GetClient(); + + return Results.Ok(); + } + catch (Exception e) + { + _logger?.LogError(e, "Error starting FFE provider"); + return Results.Json(new { error = e.Message }, statusCode: 500); + } + } + + private static async Task EvaluateFfe(EvaluateRequest request) + { + if (_client is null) + { + return Results.Json(new { error = "FFE provider not initialized" }, statusCode: 500); + } + + var contextBuilder = EvaluationContext.Builder().SetTargetingKey(request.TargetingKey); + + foreach (var (key, attribute) in request.Attributes ?? new()) + { + switch (attribute.ValueKind) + { + case JsonValueKind.String: + contextBuilder.Set(key, attribute.GetString()!); + break; + case JsonValueKind.Number: + contextBuilder.Set(key, attribute.GetDouble()); + break; + case JsonValueKind.True: + case JsonValueKind.False: + contextBuilder.Set(key, attribute.GetBoolean()); + break; + default: + contextBuilder.Set(key, attribute.GetRawText()); + break; + } + } + + var context = contextBuilder.Build(); + var defaultValue = request.DefaultValue; + + try + { + return request.VariationType switch + { + "BOOLEAN" => ToResult(await _client.GetBooleanDetailsAsync(request.Flag, defaultValue.GetBoolean(), context)), + "STRING" => ToResult(await _client.GetStringDetailsAsync(request.Flag, defaultValue.GetString()!, context)), + "INTEGER" => ToResult(await _client.GetIntegerDetailsAsync(request.Flag, defaultValue.GetInt32(), context)), + "NUMERIC" => ToResult(await _client.GetDoubleDetailsAsync(request.Flag, defaultValue.GetDouble(), context)), + "JSON" => ToResult(await _client.GetObjectDetailsAsync(request.Flag, new Value(defaultValue.GetRawText()), context)), + _ => Results.Ok(new { value = defaultValue, reason = "DEFAULT", errorCode = (string?)null }), + }; + } + catch (Exception e) + { + _logger?.LogError(e, "Error evaluating flag"); + return Results.Ok(new { value = defaultValue, reason = "ERROR", errorCode = (string?)null }); + } + } + + private static IResult ToResult(FlagEvaluationDetails details) + => Results.Ok(new { value = details.Value, reason = details.Reason ?? "DEFAULT", errorCode = ToErrorCode(details.ErrorType) }); + + // OpenFeature carries the spec error code on each ErrorType member as its Description. + private static string? ToErrorCode(ErrorType errorType) + => errorType == ErrorType.None + ? null + : typeof(ErrorType).GetField(errorType.ToString())?.GetCustomAttribute()?.Description; + + public sealed record EvaluateRequest( + string Flag, + string VariationType, + JsonElement DefaultValue, + string TargetingKey, + Dictionary? Attributes); +} diff --git a/utils/build/docker/dotnet/parametric/Program.cs b/utils/build/docker/dotnet/parametric/Program.cs index ffc43036ed8..54e10f92bd4 100644 --- a/utils/build/docker/dotnet/parametric/Program.cs +++ b/utils/build/docker/dotnet/parametric/Program.cs @@ -11,6 +11,7 @@ // Map endpoints ApmTestApi.Endpoints.ApmTestApi.MapApmTraceEndpoints(app, logger); ApmTestApi.Endpoints.ApmTestApiOtel.MapApmOtelEndpoints(app, otelLogger); +ApmTestApi.Endpoints.FfeTestApi.MapFfeEndpoints(app, logger); if (!int.TryParse(Environment.GetEnvironmentVariable("APM_TEST_CLIENT_SERVER_PORT"), out var port)) {