Skip to content
6 changes: 6 additions & 0 deletions docs/understand/weblogs/end-to-end_weblog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 :
Expand Down
34 changes: 23 additions & 11 deletions manifests/php.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +3 to +4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid introducing the PHP manifest's first anchor

php.yml had no refs section before this change, so this adds its first reusable anchor. .cursor/rules/test-activation.mdc permits new references only in manifests that already contain references and with explicit approval; expand this weblog list at the affected conditions rather than introducing refs: here.

AGENTS.md reference: AGENTS.md:L24-L24

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -744,26 +746,36 @@ 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)
tests/ffe/test_flag_eval_evp.py::Test_FFE_EVP_Flagevaluation_Degradation: bug (FFL-2676)
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
Expand Down
39 changes: 39 additions & 0 deletions tests/ffe/test_dynamic_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import uuid
from http import HTTPStatus

from tests.ffe.utils.fixtures import make_ufc_fixture
from utils import (
weblog,
interfaces,
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep Remote Config acknowledgement out of setup

apply() calls send_state() with acknowledgement waiting enabled and can raise when RC delivery or acknowledgement times out. Because an exception from setup_openfeature_evaluation aborts the entire scenario rather than producing an isolated test failure, make this setup operation non-throwing and perform its validation in the matching test, as required by .cursor/rules/pr-review.mdc.

AGENTS.md reference: AGENTS.md:L33-L35

Useful? React with 👍 / 👎.


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":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require configured evaluation on supported PHP weblogs

On PHP 8 weblogs, where this commit installs the OpenFeature SDK and activates the test, this branch lets the test pass when OpenFeature is completely broken or unwired. The PHP handler catches any Throwable—including missing classes, provider construction failures, or incompatible SDK calls—and maps it to PROVIDER_NOT_READY, so every supported weblog could return the default without this test detecting the regression. Reserve this fallback behavior for PHP 7 through a separate manifest-scoped contract test, and require the configured true value here.

Useful? React with 👍 / 👎.

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:
Expand Down
1 change: 1 addition & 0 deletions utils/build/docker/php/apache-mod/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 100 additions & 3 deletions utils/build/docker/php/common/ffe.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<?php

if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
}

function dd_ffe_json_response($statusCode, array $payload)
{
header('Content-Type: application/json');
Expand Down Expand Up @@ -109,6 +113,61 @@ function dd_ffe_evaluate_with_client($flagKey, $variationType, $defaultValue, $t
}
}

function dd_ffe_throw_error_exception($severity, $message, $file, $line)
{
if (!(error_reporting() & $severity)) {
return false;
}

throw new ErrorException($message, 0, $severity, $file, $line);
}

function dd_ffe_evaluate_with_openfeature($flagKey, $variationType, $defaultValue, $targetingKey, array $attributes)
{
if (!class_exists('\\OpenFeature\\OpenFeatureAPI')) {
throw new RuntimeException('The OpenFeature PHP SDK is not available.');
}

if (!class_exists('\\DDTrace\\OpenFeature\\DataDogProvider')) {
throw new RuntimeException('The Datadog OpenFeature provider is not available.');
}

$api = \OpenFeature\OpenFeatureAPI::getInstance();
$api->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) {
Expand All @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion utils/build/docker/php/common/install_ddtrace.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment on lines +29 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip OpenFeature installation for the parametric image

This shared installer is also called by utils/build/docker/php/parametric/Dockerfile, where the locked application dependencies are installed under /binaries and no FFE endpoint uses OpenFeature. On that PHP 8.2 build, this unconditional command instead runs from /var/www/html, creates or updates a separate Composer project, and adds an unnecessary unpinned network resolution that can fail an otherwise reproducible parametric build. Guard the requirement to weblog images that actually expose /ffe, or install it in the weblog-specific build path.

Useful? React with 👍 / 👎.

fi

cd /binaries

ARCH=$(uname -m)
Expand Down
8 changes: 8 additions & 0 deletions utils/build/docker/php/weblogs/laravel11x/routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading