diff --git a/docs/understand/weblogs/end-to-end_weblog.md b/docs/understand/weblogs/end-to-end_weblog.md index e659a6f4e4a..15b58713f7e 100644 --- a/docs/understand/weblogs/end-to-end_weblog.md +++ b/docs/understand/weblogs/end-to-end_weblog.md @@ -1093,10 +1093,16 @@ accept a JSON body with these fields: - `defaultValue`: the value to return when evaluation cannot resolve the flag. - `targetingKey`: the evaluation subject key. - `attributes`: flat scalar targeting attributes. +- `evaluationApi`: optional. The default is `openfeature`. Use `native` only to + test the language-native Datadog client. The response must be JSON and include at least `value` and `reason`. Error responses should also include `errorCode` and `errorMessage`. +The endpoint contract does not depend on OpenFeature SDK availability. If the +selected evaluation API is not available, return the supplied `defaultValue`. +Set `reason` to `ERROR` and set `errorCode` to `PROVIDER_NOT_READY`. + ### GET /healthcheck Returns a JSON dict, with those values : diff --git a/manifests/php.yml b/manifests/php.yml index 00007aa0943..547ad356efb 100644 --- a/manifests/php.yml +++ b/manifests/php.yml @@ -1,5 +1,7 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/DataDog/system-tests/refs/heads/main/utils/manifest/schema.json --- +refs: + - &php7 "apache-mod-7.0, apache-mod-7.0-zts, apache-mod-7.1, apache-mod-7.1-zts, apache-mod-7.2, apache-mod-7.2-zts, apache-mod-7.3, apache-mod-7.3-zts, apache-mod-7.4, apache-mod-7.4-zts, php-fpm-7.0, php-fpm-7.1, php-fpm-7.2, php-fpm-7.3, php-fpm-7.4" manifest: tests/ai_guard/test_ai_guard_sdk.py::Test_AIGuardEvent_Tag: missing_feature tests/ai_guard/test_ai_guard_sdk.py::Test_AIGuardStandalone: missing_feature @@ -744,16 +746,27 @@ manifest: tests/docker_ssi/test_docker_ssi_appsec.py::TestDockerSSIAppsecFeatures::test_telemetry_source_ssi: v1.8.3 tests/docker_ssi/test_docker_ssi_crash.py::TestDockerSSICrash::test_crash: missing_feature (No implemented the endpoint /crashme) tests/ffe/test_agentless_configuration.py: missing_feature (FFL-2705 tracks PHP agentless configuration-source implementation; FFL-2731 tracks the system-tests contract) - tests/ffe/test_dynamic_evaluation.py: - - weblog_declaration: - "*": v1.21.0-dev - laravel11x: incomplete_test_app - symfony7x: incomplete_test_app + tests/ffe/test_dynamic_evaluation.py: v1.24.0-dev + tests/ffe/test_dynamic_evaluation.py::Test_FFE_Flag_Parse_Error_Isolation: + - declaration: missing_feature (OpenFeature PHP SDK requires PHP 8 or later) + weblog: *php7 + tests/ffe/test_dynamic_evaluation.py::Test_FFE_OpenFeature_Evaluation: v1.24.0-dev + tests/ffe/test_dynamic_evaluation.py::Test_FFE_RC_Down_From_Start: + - declaration: missing_feature (OpenFeature PHP SDK requires PHP 8 or later) + weblog: *php7 + tests/ffe/test_dynamic_evaluation.py::Test_FFE_RC_Unavailable: + - declaration: missing_feature (OpenFeature PHP SDK requires PHP 8 or later) + weblog: *php7 + tests/ffe/test_dynamic_evaluation.py::Test_FFE_Unknown_Fields_Tolerance: + - declaration: missing_feature (OpenFeature PHP SDK requires PHP 8 or later) + weblog: *php7 + tests/ffe/test_dynamic_evaluation.py::Test_FFE_Unknown_Operator_Tolerance: + - declaration: missing_feature (OpenFeature PHP SDK requires PHP 8 or later) + weblog: *php7 tests/ffe/test_exposures.py: - weblog_declaration: - "*": v1.21.0-dev - laravel11x: incomplete_test_app - symfony7x: incomplete_test_app + "*": v1.24.0-dev + *php7: missing_feature (OpenFeature PHP SDK requires PHP 8 or later) tests/ffe/test_exposures.py::Test_FFE_Exposure_Events::test_ffe_multiple_remote_config_files: flaky (FFL-2676) tests/ffe/test_flag_eval_evp.py: missing_feature (FFL-2446) tests/ffe/test_flag_eval_evp.py::Test_FFE_EVP_Flagevaluation_Burst_Aggregation: bug (FFL-2676) @@ -761,9 +774,8 @@ manifest: tests/ffe/test_flag_eval_evp.py::Test_FFE_EVP_Flagevaluation_High_Cardinality_Aggregation: bug (FFL-2676) tests/ffe/test_flag_eval_metrics.py: - weblog_declaration: - "*": v1.21.0-dev - laravel11x: incomplete_test_app - symfony7x: incomplete_test_app + "*": v1.24.0-dev + *php7: missing_feature (OpenFeature PHP SDK requires PHP 8 or later) tests/integration_frameworks/llm/anthropic/test_anthropic_llmobs.py::TestAnthropicLlmObsMessages::test_create_error: bug (MLOB-1234) tests/integrations/crossed_integrations/test_kafka.py::Test_Kafka: missing_feature tests/integrations/crossed_integrations/test_kinesis.py::Test_Kinesis_PROPAGATION_VIA_MESSAGE_ATTRIBUTES: missing_feature diff --git a/tests/ffe/test_dynamic_evaluation.py b/tests/ffe/test_dynamic_evaluation.py index a3d9e671df8..101a333d2c5 100644 --- a/tests/ffe/test_dynamic_evaluation.py +++ b/tests/ffe/test_dynamic_evaluation.py @@ -5,6 +5,7 @@ import uuid from http import HTTPStatus +from tests.ffe.utils.fixtures import make_ufc_fixture from utils import ( weblog, interfaces, @@ -43,6 +44,44 @@ } +@scenarios.feature_flagging_and_experimentation +@features.feature_flags_dynamic_evaluation +class Test_FFE_OpenFeature_Evaluation: + """The weblog must use OpenFeature for evaluation by default.""" + + def setup_openfeature_evaluation(self) -> None: + self.flag_key = "openfeature-evaluation" + config = make_ufc_fixture(self.flag_key, variation_type="BOOLEAN") + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/openfeature-evaluation/config", config).apply() + + self.response = weblog.post( + "/ffe", + json={ + "flag": self.flag_key, + "variationType": "BOOLEAN", + "defaultValue": False, + "targetingKey": "customer-request", + "attributes": {}, + }, + ) + + def test_openfeature_evaluation(self) -> None: + assert self.response.status_code == 200, f"Flag evaluation failed: {self.response.text}" + result = json.loads(self.response.text) + + if result.get("errorCode") == "PROVIDER_NOT_READY": + assert result["value"] is False, f"Unavailable providers must return the supplied default: {result}" + assert result.get("reason") == "ERROR", f"Unavailable providers must return an error result: {result}" + assert result.get("errorMessage"), ( + f"Unavailable providers must explain why evaluation is unavailable: {result}" + ) + return + + assert result["value"] is True, f"OpenFeature evaluation did not return the configured value: {result}" + assert result.get("reason") != "ERROR", f"OpenFeature evaluation returned an unexpected error: {result}" + assert result.get("errorCode") is None, f"OpenFeature evaluation returned an unexpected error code: {result}" + + @scenarios.feature_flagging_and_experimentation @features.feature_flags_dynamic_evaluation class Test_FFE_Unknown_Operator_Tolerance: diff --git a/utils/build/docker/php/apache-mod/build.sh b/utils/build/docker/php/apache-mod/build.sh index 429c7bd215e..d1951824593 100755 --- a/utils/build/docker/php/apache-mod/build.sh +++ b/utils/build/docker/php/apache-mod/build.sh @@ -14,6 +14,7 @@ export APPSEC_VERSION=latest mkdir -p /etc/apache2/mods-available/ /var/www/html/rasp /etc/php/ cp -rf /tmp/php/apache-mod/php.load /etc/apache2/mods-available/ cp -rf /tmp/php/weblogs/$WEBLOG/* /var/www/html/ +cp -rf /tmp/php/common/ffe.php /var/www/html/ cp -rf /tmp/php/common/php.ini /etc/php/ # Install required packages and PHP extensions diff --git a/utils/build/docker/php/common/ffe.php b/utils/build/docker/php/common/ffe.php index 1fae050cbec..c0af0cde9b0 100644 --- a/utils/build/docker/php/common/ffe.php +++ b/utils/build/docker/php/common/ffe.php @@ -1,5 +1,9 @@ setProvider(new \DDTrace\OpenFeature\DataDogProvider()); + $client = $api->getClient('system-tests'); + $context = new \OpenFeature\implementation\flags\EvaluationContext( + $targetingKey, + new \OpenFeature\implementation\flags\Attributes($attributes) + ); + + set_error_handler('dd_ffe_throw_error_exception'); + try { + switch (dd_ffe_normalized_variation_type($variationType)) { + case 'BOOLEAN': + return $client->getBooleanDetails($flagKey, $defaultValue, $context); + case 'STRING': + return $client->getStringDetails($flagKey, $defaultValue, $context); + case 'INTEGER': + return $client->getIntegerDetails($flagKey, $defaultValue, $context); + case 'NUMERIC': + case 'FLOAT': + case 'DOUBLE': + return $client->getFloatDetails($flagKey, $defaultValue, $context); + case 'JSON': + case 'OBJECT': + return $client->getObjectDetails( + $flagKey, + is_array($defaultValue) ? $defaultValue : array(), + $context + ); + default: + throw new InvalidArgumentException('Unsupported variationType: ' . (string) $variationType); + } + } finally { + restore_error_handler(); + } +} + function dd_ffe_warning_handler($severity, $message) { if ($severity === E_USER_WARNING && strpos($message, 'Datadog-backed PHP feature flag evaluation') !== false) { @@ -118,8 +177,18 @@ function dd_ffe_warning_handler($severity, $message) return false; } -function dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $targetingKey, array $attributes) +function dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $targetingKey, array $attributes, $evaluationApi) { + if ($evaluationApi === 'openfeature') { + return dd_ffe_evaluate_with_openfeature( + $flagKey, + $variationType, + $defaultValue, + $targetingKey, + $attributes + ); + } + set_error_handler('dd_ffe_warning_handler'); try { return dd_ffe_evaluate_with_client($flagKey, $variationType, $defaultValue, $targetingKey, $attributes); @@ -128,6 +197,24 @@ function dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $targetingKey, } } +function dd_ffe_openfeature_details_payload($details) +{ + $error = $details->getError(); + + return array( + 'value' => $details->getValue(), + 'reason' => $details->getReason(), + 'variant' => $details->getVariant(), + 'errorCode' => $error === null + ? null + : $error->getResolutionErrorCode()->getValue(), + 'errorMessage' => $error === null + ? null + : $error->getResolutionErrorMessage(), + 'providerState' => array(), + ); +} + function dd_ffe_details_payload($details) { $payload = array( @@ -168,6 +255,7 @@ function dd_ffe_details_payload($details) $flagKey = $payload['flag']; $variationType = $payload['variationType']; $defaultValue = dd_ffe_normalize_default_value($payload['defaultValue'], $variationType); +$evaluationApi = isset($payload['evaluationApi']) ? strtolower((string) $payload['evaluationApi']) : 'openfeature'; $targetingKey = isset($payload['targetingKey']) && $payload['targetingKey'] !== null ? (string) $payload['targetingKey'] : null; @@ -181,10 +269,19 @@ function dd_ffe_details_payload($details) try { $details = null; foreach ($targetingKeys as $key) { - $details = dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $key, $attributes); + $details = dd_ffe_evaluate( + $flagKey, + $variationType, + $defaultValue, + $key, + $attributes, + $evaluationApi + ); } if ($details !== null) { - $response = dd_ffe_details_payload($details); + $response = $evaluationApi === 'openfeature' + ? dd_ffe_openfeature_details_payload($details) + : dd_ffe_details_payload($details); $response['count'] = count($targetingKeys); dd_ffe_json_response(200, $response); return; diff --git a/utils/build/docker/php/common/install_ddtrace.sh b/utils/build/docker/php/common/install_ddtrace.sh index a05b7411263..eddbd12fc4f 100755 --- a/utils/build/docker/php/common/install_ddtrace.sh +++ b/utils/build/docker/php/common/install_ddtrace.sh @@ -10,7 +10,7 @@ fi cd /var/www/html export COMPOSER=composer.json -if [ "$(printf '%s\n' "$PHP_VERSION" "8.2" | sort -V | head -n1)" = "8.2" ]; then +if [ "$(printf '%s\n' "$PHP_VERSION" "8.2" | sort -V | head -n1)" = "8.2" ] && [ -f composer.gte8.2.json ]; then export COMPOSER=composer.gte8.2.json fi if [ -f "$COMPOSER" ] && grep -Fq 'stripe/stripe-php' "$COMPOSER"; then @@ -26,6 +26,11 @@ if [ -f "$COMPOSER" ] && grep -Fq 'stripe/stripe-php' "$COMPOSER"; then fi fi +if [[ "$(printf '%s\n' "$PHP_VERSION" "8.0" | sort -V | head -n1)" = "8.0" ]]; then + COMPOSER_DISCARD_CHANGES=true composer require "open-feature/sdk:^2.2" \ + --prefer-dist --no-interaction --ignore-platform-req=ext-mbstring +fi + cd /binaries ARCH=$(uname -m) diff --git a/utils/build/docker/php/weblogs/laravel11x/routes/web.php b/utils/build/docker/php/weblogs/laravel11x/routes/web.php index e81366cb4fe..1a322f31cc9 100644 --- a/utils/build/docker/php/weblogs/laravel11x/routes/web.php +++ b/utils/build/docker/php/weblogs/laravel11x/routes/web.php @@ -37,6 +37,14 @@ ]); }); +Route::post('/ffe', function () { + ob_start(); + require base_path('ffe.php'); + $body = ob_get_clean(); + + return response($body, http_response_code(), ['Content-Type' => 'application/json']); +}); + Route::get('/status', function (Request $request) { $code = intval($request->query('code', 200)); diff --git a/utils/build/docker/php/weblogs/symfony7x/src/Controller/AppController.php b/utils/build/docker/php/weblogs/symfony7x/src/Controller/AppController.php index 10379663fbc..a7cc5421c6b 100644 --- a/utils/build/docker/php/weblogs/symfony7x/src/Controller/AppController.php +++ b/utils/build/docker/php/weblogs/symfony7x/src/Controller/AppController.php @@ -67,6 +67,16 @@ public function healthcheck(): JsonResponse ]); } + #[Route('/ffe', name: 'ffe', methods: ['POST'])] + public function ffe(): Response + { + ob_start(); + require dirname(__DIR__, 2).'/ffe.php'; + $body = ob_get_clean(); + + return new Response($body, http_response_code(), ['Content-Type' => 'application/json']); + } + #[Route('/status', name: 'status', methods: ['GET'])] public function status(Request $request): Response {