diff --git a/docs/understand/weblogs/end-to-end_weblog.md b/docs/understand/weblogs/end-to-end_weblog.md index 08dabf6b078..ff88e4c623e 100644 --- a/docs/understand/weblogs/end-to-end_weblog.md +++ b/docs/understand/weblogs/end-to-end_weblog.md @@ -1026,6 +1026,20 @@ The endpoint must accept a query string parameter `code`, which should be an int This endpoint is used for client-stats tests to provide a separate "resource" via the endpoint path `stats-unique` to disambiguate those tests from other stats generating tests. +### POST /ffe + +This endpoint is used by the Feature Flags & Experimentation scenario. It must +accept a JSON body with these fields: + +- `flag`: the feature flag key to evaluate. +- `variationType`: the expected variation type. +- `defaultValue`: the value to return when evaluation cannot resolve the flag. +- `targetingKey`: the evaluation subject key. +- `attributes`: flat scalar targeting attributes. + +The response must be JSON and include at least `value` and `reason`. Error +responses should also include `errorCode` and `errorMessage`. + ### GET /healthcheck Returns a JSON dict, with those values : diff --git a/manifests/dotnet.yml b/manifests/dotnet.yml index 04ff6b5a89e..d85d2ccce2d 100644 --- a/manifests/dotnet.yml +++ b/manifests/dotnet.yml @@ -810,7 +810,6 @@ manifest: tests/parametric/test_dynamic_configuration.py::TestDynamicConfigV2: v2.44.0 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_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_of7_empty_targeting_key: missing_feature # Created by easy win activation script tests/parametric/test_ffe/test_span_enrichment.py: missing_feature tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_extract_valid: missing_feature (Need to remove b3=b3multi alias) tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_inject_valid: missing_feature (Need to remove b3=b3multi alias) diff --git a/manifests/java.yml b/manifests/java.yml index 873328f27df..cab7b078d5c 100644 --- a/manifests/java.yml +++ b/manifests/java.yml @@ -3644,7 +3644,6 @@ manifest: tests/parametric/test_dynamic_configuration.py::TestDynamicConfigV1_ServiceTargets::test_not_match_service_target: irrelevant (APMAPI-1003) tests/parametric/test_dynamic_configuration.py::TestDynamicConfigV2: v1.31.0 tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: v1.56.0 - tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_of7_empty_targeting_key: bug (FFL-1729) tests/parametric/test_ffe/test_span_enrichment.py: missing_feature tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_extract_invalid: # Modified by easy win activation script - declaration: missing_feature (Need to remove b3=b3multi alias) diff --git a/manifests/nodejs.yml b/manifests/nodejs.yml index 3ea27c8774d..c27be14c3ff 100644 --- a/manifests/nodejs.yml +++ b/manifests/nodejs.yml @@ -2014,7 +2014,6 @@ manifest: tests/parametric/test_dynamic_configuration.py::TestDynamicConfigV1_ServiceTargets::test_not_match_service_target: bug (APMAPI-865) tests/parametric/test_dynamic_configuration.py::TestDynamicConfigV2: *ref_4_23_0 tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: *ref_5_75_0 - tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_of7_empty_targeting_key: bug (FFL-1730) tests/parametric/test_ffe/test_span_enrichment.py: "missing_feature (dd-trace-js#8343)" tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_extract_invalid: missing_feature (Need to remove b3=b3multi alias) tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_extract_valid: missing_feature (Need to remove b3=b3multi alias) diff --git a/manifests/php.yml b/manifests/php.yml index f8e7e3b664f..7ed35b2aa90 100644 --- a/manifests/php.yml +++ b/manifests/php.yml @@ -605,8 +605,8 @@ manifest: component_version: <1.12.0 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_dynamic_evaluation.py: missing_feature - tests/ffe/test_exposures.py: missing_feature + tests/ffe/test_dynamic_evaluation.py: v1.21.0-dev + tests/ffe/test_exposures.py: v1.21.0-dev tests/ffe/test_flag_eval_metrics.py: missing_feature 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 @@ -731,7 +731,7 @@ manifest: tests/parametric/test_dynamic_configuration.py::TestDynamicConfigV1_ServiceTargets::test_not_match_service_target: missing_feature tests/parametric/test_dynamic_configuration.py::TestDynamicConfigV2: '>=1.16.0' tests/parametric/test_dynamic_configuration.py::TestDynamicConfigV2::test_tracing_client_tracing_tags: missing_feature - tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: missing_feature + tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: v1.21.0-dev tests/parametric/test_ffe/test_span_enrichment.py: missing_feature tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_extract_invalid: - declaration: missing_feature (Need to remove b3=b3multi alias) @@ -870,7 +870,7 @@ manifest: tests/parametric/test_parametric_endpoints.py::Test_Parametric_DDSpan_Start: v1.13.0+4663b2fa7c20c6920f347d059b57dc2a419cb7f7 tests/parametric/test_parametric_endpoints.py::Test_Parametric_DDTrace_Baggage: missing_feature (baggage is not supported) tests/parametric/test_parametric_endpoints.py::Test_Parametric_DDTrace_Current_Span: bug (APMAPI-778) # current span endpoint should return span and trace id of zero if no span is "active" - tests/parametric/test_parametric_endpoints.py::Test_Parametric_FFE_Start: missing_feature + tests/parametric/test_parametric_endpoints.py::Test_Parametric_FFE_Start: v1.21.0-dev tests/parametric/test_parametric_endpoints.py::Test_Parametric_Otel_Baggage: missing_feature (otel baggage is not supported) tests/parametric/test_parametric_endpoints.py::Test_Parametric_Otel_Current_Span: bug (APMAPI-778) # otel current span endpoint should return a span and trace id of zero if no span is "active" tests/parametric/test_parametric_endpoints.py::Test_Parametric_Write_Log: missing_feature diff --git a/tests/parametric/test_ffe/test_dynamic_evaluation.py b/tests/parametric/test_ffe/test_dynamic_evaluation.py index 224c96ea1dc..bc79c1daec3 100644 --- a/tests/parametric/test_ffe/test_dynamic_evaluation.py +++ b/tests/parametric/test_ffe/test_dynamic_evaluation.py @@ -2,11 +2,11 @@ import json import pytest +import time from pathlib import Path from typing import Any from utils import ( - context, features, scenarios, ) @@ -16,6 +16,8 @@ RC_PRODUCT = "FFE_FLAGS" RC_PATH = f"datadog/2/{RC_PRODUCT}" +FFE_READY_RETRY_ATTEMPTS = 10 +FFE_READY_RETRY_INTERVAL_SECONDS = 0.2 parametrize = pytest.mark.parametrize @@ -81,6 +83,44 @@ def _set_and_wait_ffe_rc( return test_agent.wait_for_rc_apply_state(RC_PRODUCT, state=RemoteConfigApplyState.ACKNOWLEDGED, clear=True) +def _is_ffe_waiting_for_rc(result: dict[str, Any]) -> bool: + provider_state = result.get("providerState") + return result.get("errorCode") == "PROVIDER_NOT_READY" or ( + isinstance(provider_state, dict) and provider_state.get("hasConfig") is False + ) + + +def _ffe_evaluate_with_rc_retry( + test_library: APMLibrary, + *, + flag: str, + variation_type: str, + default_value: bool | str | float | dict[str, Any], + targeting_key: str, + attributes: dict[str, Any] | None = None, +) -> dict[str, Any]: + result = test_library.ffe_evaluate( + flag=flag, + variation_type=variation_type, + default_value=default_value, + targeting_key=targeting_key, + attributes=attributes, + ) + for _ in range(FFE_READY_RETRY_ATTEMPTS - 1): + if not _is_ffe_waiting_for_rc(result): + return result + time.sleep(FFE_READY_RETRY_INTERVAL_SECONDS) + result = test_library.ffe_evaluate( + flag=flag, + variation_type=variation_type, + default_value=default_value, + targeting_key=targeting_key, + attributes=attributes, + ) + + return result + + @scenarios.parametric @features.feature_flags_dynamic_evaluation class Test_Feature_Flag_Dynamic_Evaluation: @@ -111,13 +151,6 @@ def test_ffe_flag_evaluation(self, test_case_file: str, test_agent: TestAgentAPI 4. Handles user targeting, attribute matching, and rollout percentages """ - # Skip OF.7 (empty targeting key) test for libraries with known bugs - # Java: FFL-1729 - OpenFeature Java SDK rejects empty targeting keys - # Node.js: FFL-1730 - OpenFeature JS SDK rejects empty targeting keys - if test_case_file == "test-case-of-7-empty-targeting-key.json": - if context.library.name in ("java", "nodejs"): - pytest.skip("OF.7 empty targeting key bug: FFL-1729 (java), FFL-1730 (nodejs)") - # Load the test case file test_case_path = Path(__file__).parent / test_case_file @@ -131,7 +164,7 @@ def test_ffe_flag_evaluation(self, test_case_file: str, test_agent: TestAgentAPI _set_and_wait_ffe_rc(test_agent, UFC_FIXTURE_DATA) # Initialize FFE provider - success = test_library.ffe_start() + success = test_library.ffe_start(UFC_FIXTURE_DATA) assert success, "Failed to start FFE provider" # Run each test case @@ -143,13 +176,18 @@ def test_ffe_flag_evaluation(self, test_case_file: str, test_agent: TestAgentAPI attributes = test_case.get("attributes", {}) expected_result = test_case["result"]["value"] - result = test_library.ffe_evaluate( + result = _ffe_evaluate_with_rc_retry( + test_library, flag=flag, variation_type=variation_type, default_value=default_value, targeting_key=targeting_key, attributes=attributes, ) + assert not _is_ffe_waiting_for_rc(result), ( + f"Test case {i} in {test_case_file} failed: FFE provider did not load RC data after " + f"{FFE_READY_RETRY_ATTEMPTS} attempts; result={result}" + ) actual_value = result.get("value") # Assert the evaluation result matches expected value @@ -158,33 +196,3 @@ def test_ffe_flag_evaluation(self, test_case_file: str, test_agent: TestAgentAPI f"flag='{flag}', targetingKey='{targeting_key}', " f"expected={expected_result}, actual={actual_value}" ) - - @parametrize("library_env", [{**DEFAULT_ENVVARS}]) - def test_ffe_of7_empty_targeting_key(self, test_agent: TestAgentAPI, test_library: APMLibrary) -> None: - """OF.7: Empty string is a valid targeting key. - - This test validates that flag evaluation succeeds when the targeting key - is an empty string. The flag should still match allocations and return - the expected value, not fail with TARGETING_KEY_MISSING. - - Temporary dedicated test until FFL-1729 (Java) and FFL-1730 (Node.js) are resolved. - """ - # Set up UFC Remote Config and wait for it to be applied - _set_and_wait_ffe_rc(test_agent, UFC_FIXTURE_DATA) - - # Initialize FFE provider - success = test_library.ffe_start() - assert success, "Failed to start FFE provider" - - # Evaluate flag with empty targeting key - result = test_library.ffe_evaluate( - flag="empty-targeting-key-flag", - variation_type="STRING", - default_value="default", - targeting_key="", - attributes={}, - ) - - assert result.get("value") == "on-value", ( - f"OF.7 failed: empty targeting key should return 'on-value', got '{result.get('value')}'" - ) diff --git a/utils/build/docker/php/common/ffe.php b/utils/build/docker/php/common/ffe.php new file mode 100644 index 00000000000..e4230d21691 --- /dev/null +++ b/utils/build/docker/php/common/ffe.php @@ -0,0 +1,209 @@ + null, + 'reason' => 'ERROR', + 'variant' => null, + 'errorCode' => $errorCode, + 'errorMessage' => $errorMessage, + 'providerState' => array('ready' => false), + )); +} + +function dd_ffe_read_payload() +{ + $rawBody = file_get_contents('php://input'); + if ($rawBody === false || $rawBody === '') { + return array(); + } + + $payload = json_decode($rawBody, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($payload)) { + dd_ffe_error_response(400, 'INVALID_REQUEST', 'Expected a JSON object request body.'); + exit; + } + + return $payload; +} + +function dd_ffe_normalized_variation_type($variationType) +{ + return strtoupper(str_replace('-', '_', (string) $variationType)); +} + +function dd_ffe_normalize_default_value($defaultValue, $variationType) +{ + switch (dd_ffe_normalized_variation_type($variationType)) { + case 'BOOLEAN': + return is_bool($defaultValue) ? $defaultValue : (bool) $defaultValue; + case 'STRING': + return is_string($defaultValue) ? $defaultValue : (string) $defaultValue; + case 'INTEGER': + return is_int($defaultValue) ? $defaultValue : (int) $defaultValue; + case 'NUMERIC': + case 'FLOAT': + case 'DOUBLE': + return is_int($defaultValue) || is_float($defaultValue) ? $defaultValue : (float) $defaultValue; + case 'JSON': + case 'OBJECT': + return is_array($defaultValue) ? $defaultValue : array(); + default: + return $defaultValue; + } +} + +function dd_ffe_scalar_attributes(array $attributes) +{ + $normalized = array(); + foreach ($attributes as $key => $value) { + if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { + $normalized[(string) $key] = $value; + } + } + + return $normalized; +} + +function dd_ffe_evaluate_with_client($flagKey, $variationType, $defaultValue, $targetingKey, array $attributes) +{ + if (!class_exists('\\DDTrace\\FeatureFlags\\Client')) { + return null; + } + + if (method_exists('\\DDTrace\\FeatureFlags\\Client', 'create')) { + $client = \DDTrace\FeatureFlags\Client::create(); + } else { + $client = new \DDTrace\FeatureFlags\Client(); + } + + $context = array( + 'targetingKey' => $targetingKey, + 'attributes' => $attributes, + ); + + 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); + } +} + +function dd_ffe_warning_handler($severity, $message) +{ + if ($severity === E_USER_WARNING && strpos($message, 'Datadog-backed PHP feature flag evaluation') !== false) { + return true; + } + + return false; +} + +function dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $targetingKey, array $attributes) +{ + set_error_handler('dd_ffe_warning_handler'); + try { + return dd_ffe_evaluate_with_client($flagKey, $variationType, $defaultValue, $targetingKey, $attributes); + } finally { + restore_error_handler(); + } +} + +function dd_ffe_details_payload($details) +{ + $payload = array( + 'value' => $details->getValue(), + 'reason' => $details->getReason(), + 'variant' => $details->getVariant(), + 'errorCode' => $details->getErrorCode(), + 'errorMessage' => $details->getErrorMessage(), + 'flagMetadata' => $details->getFlagMetadata(), + 'exposureData' => $details->getExposureData(), + 'providerState' => $details->getProviderState(), + ); + + if (method_exists($details, 'getValueType')) { + $payload['valueType'] = $details->getValueType(); + } + + return $payload; +} + +$payload = dd_ffe_read_payload(); + +if (!isset($payload['flag']) || !is_string($payload['flag']) || $payload['flag'] === '') { + dd_ffe_error_response(400, 'INVALID_REQUEST', 'Expected non-empty string field: flag.'); + exit; +} + +if (!array_key_exists('variationType', $payload) || !is_string($payload['variationType'])) { + dd_ffe_error_response(400, 'INVALID_REQUEST', 'Expected string field: variationType.'); + exit; +} + +if (!array_key_exists('defaultValue', $payload)) { + dd_ffe_error_response(400, 'INVALID_REQUEST', 'Expected field: defaultValue.'); + exit; +} + +$flagKey = $payload['flag']; +$variationType = $payload['variationType']; +$defaultValue = dd_ffe_normalize_default_value($payload['defaultValue'], $variationType); +$targetingKey = isset($payload['targetingKey']) && $payload['targetingKey'] !== null + ? (string) $payload['targetingKey'] + : null; +$attributes = isset($payload['attributes']) && is_array($payload['attributes']) + ? dd_ffe_scalar_attributes($payload['attributes']) + : array(); + +try { + $details = dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $targetingKey, $attributes); + if ($details !== null) { + dd_ffe_json_response(200, dd_ffe_details_payload($details)); + return; + } +} catch (Throwable $exception) { + dd_ffe_json_response(200, array( + 'value' => $defaultValue, + 'reason' => 'ERROR', + 'variant' => null, + 'errorCode' => 'PROVIDER_NOT_READY', + 'errorMessage' => $exception->getMessage(), + 'providerState' => array( + 'ready' => false, + 'productionRuntime' => false, + ), + )); + return; +} + +dd_ffe_json_response(200, array( + 'value' => $defaultValue, + 'reason' => 'ERROR', + 'variant' => null, + 'errorCode' => 'PROVIDER_NOT_READY', + 'errorMessage' => 'Datadog-backed PHP feature flag evaluation is not wired in this weblog yet.', + 'providerState' => array( + 'ready' => false, + 'productionRuntime' => false, + ), +)); diff --git a/utils/build/docker/php/common/rewrite-rules.conf b/utils/build/docker/php/common/rewrite-rules.conf index 1191ca1fc4c..e55c8981dde 100644 --- a/utils/build/docker/php/common/rewrite-rules.conf +++ b/utils/build/docker/php/common/rewrite-rules.conf @@ -34,6 +34,7 @@ RewriteRule "^/trace/mongo$" "/trace_mongo/" RewriteRule "^/e2e_otel_span$" "/e2e_otel_span/" RewriteRule "^/e2e_single_span$" "/e2e_single_span/" RewriteRule "^/crashme$" "/crashme/" +RewriteRule "^/ffe$" "/ffe/" RewriteRule "^/exceptionreplay/(.*)$" "/debugger/exceptionreplay/$1" [QSA] RewriteRule "^/llm$" "/llm/" RewriteRule "^/stats-unique$" "/stats-unique/" diff --git a/utils/build/docker/php/parametric/server.php b/utils/build/docker/php/parametric/server.php index 60240250a42..1548de54de5 100644 --- a/utils/build/docker/php/parametric/server.php +++ b/utils/build/docker/php/parametric/server.php @@ -61,6 +61,91 @@ function arg($req, $arg) { return ($buffer[$req] ??= json_decode($req->getBody()->buffer(), true))[$arg] ?? null; } +function ffeNormalizeDefaultValue($defaultValue, $variationType) { + switch (strtoupper((string)$variationType)) { + case 'BOOLEAN': + return is_bool($defaultValue) ? $defaultValue : (bool)$defaultValue; + case 'STRING': + return is_string($defaultValue) ? $defaultValue : (string)$defaultValue; + case 'INTEGER': + return is_int($defaultValue) ? $defaultValue : (int)$defaultValue; + case 'NUMERIC': + case 'FLOAT': + case 'DOUBLE': + return is_int($defaultValue) || is_float($defaultValue) ? $defaultValue : (float)$defaultValue; + case 'JSON': + case 'OBJECT': + return is_array($defaultValue) ? $defaultValue : []; + default: + return $defaultValue; + } +} + +function ffeEvaluate($client, $flagKey, $variationType, $defaultValue, $targetingKey, array $attributes) { + $context = [ + 'targetingKey' => $targetingKey, + 'attributes' => $attributes, + ]; + + switch (strtoupper((string)$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, $defaultValue, $context); + default: + throw new InvalidArgumentException('Unsupported variationType: ' . (string)$variationType); + } +} + +function ffeDetailsPayload($details) { + $value = $details->getValue(); + if (method_exists($details, 'getValueType') && $details->getValueType() === 'object') { + $value = ffeJsonResponseValue($value); + } + + $payload = [ + 'value' => $value, + 'reason' => $details->getReason(), + 'variant' => $details->getVariant(), + 'errorCode' => $details->getErrorCode(), + 'errorMessage' => $details->getErrorMessage(), + 'flagMetadata' => $details->getFlagMetadata(), + 'exposureData' => $details->getExposureData(), + 'providerState' => $details->getProviderState(), + ]; + + if (method_exists($details, 'getValueType')) { + $payload['valueType'] = $details->getValueType(); + } + + return $payload; +} + +function ffeJsonResponseValue($value) { + if (!is_array($value)) { + return $value; + } + + if ($value === []) { + return new stdClass(); + } + + foreach ($value as $key => $nestedValue) { + $value[$key] = ffeJsonResponseValue($nestedValue); + } + + return $value; +} + // Source: https://magp.ie/2015/09/30/convert-large-integer-to-hexadecimal-without-php-math-extension/ function convertBase16ToBase10($numString) { @@ -123,6 +208,8 @@ function remappedSpanKind($spanKind) { $spansDistributedTracingHeaders = []; /** @var Logger[] $loggerDict */ $loggerDict = []; +/** @var ?\DDTrace\FeatureFlags\Client $ffeClient */ +$ffeClient = null; // Construct the OTel LoggerProvider directly when DD_LOGS_OTEL_ENABLED=true. // Mirrors how a user would wire up OTLP logs export themselves. dd-trace-php's @@ -151,6 +238,73 @@ function remappedSpanKind($spanKind) { } $router = new Router($server, $logger, $errorHandler); +$router->addRoute('POST', '/ffe/start', new ClosureRequestHandler(function (Request $req) use (&$ffeClient) { + if (!class_exists('\\DDTrace\\FeatureFlags\\Client')) { + return new Response(status: 500, headers: ['content-type' => 'application/json'], body: json_encode([ + 'success' => false, + 'message' => 'DDTrace\\FeatureFlags\\Client is not available', + ])); + } + + $configuration = arg($req, 'configuration'); + if ($configuration !== null) { + if (!function_exists('\\DDTrace\\Testing\\ffe_load_config')) { + return new Response(status: 500, headers: ['content-type' => 'application/json'], body: json_encode([ + 'success' => false, + 'message' => 'DDTrace\\Testing\\ffe_load_config is not available', + ])); + } + + $configurationJson = json_encode($configuration); + if (!is_string($configurationJson) || !\DDTrace\Testing\ffe_load_config($configurationJson)) { + return new Response(status: 500, headers: ['content-type' => 'application/json'], body: json_encode([ + 'success' => false, + 'message' => 'Failed to load FFE test configuration', + ])); + } + } + + $ffeClient = new \DDTrace\FeatureFlags\Client(); + + return jsonResponse(['success' => true]); +})); +$router->addRoute('POST', '/ffe/evaluate', new ClosureRequestHandler(function (Request $req) use (&$ffeClient) { + if ($ffeClient === null) { + $ffeClient = new \DDTrace\FeatureFlags\Client(); + } + + $variationType = arg($req, 'variationType'); + $defaultValue = ffeNormalizeDefaultValue(arg($req, 'defaultValue'), $variationType); + $targetingKey = arg($req, 'targetingKey'); + if ($targetingKey !== null) { + $targetingKey = (string)$targetingKey; + } + $attributes = arg($req, 'attributes') ?? []; + if (!is_array($attributes)) { + $attributes = []; + } + + try { + $details = ffeEvaluate( + $ffeClient, + arg($req, 'flag'), + $variationType, + $defaultValue, + $targetingKey, + $attributes + ); + + return jsonResponse(ffeDetailsPayload($details)); + } catch (Throwable $e) { + return jsonResponse([ + 'value' => $defaultValue, + 'reason' => 'ERROR', + 'variant' => null, + 'errorCode' => 'GENERAL', + 'errorMessage' => $e->getMessage(), + ]); + } +})); $router->addRoute('POST', '/trace/span/start', new ClosureRequestHandler(function (Request $req) use (&$spans, &$activeSpan, &$spansDistributedTracingHeaders) { if ($parent = arg($req, 'parent_id')) { if (isset($spans[$parent])) { diff --git a/utils/docker_fixtures/_test_clients/_test_client_parametric.py b/utils/docker_fixtures/_test_clients/_test_client_parametric.py index 0e28f1c32c6..8b28d05bf26 100644 --- a/utils/docker_fixtures/_test_clients/_test_client_parametric.py +++ b/utils/docker_fixtures/_test_clients/_test_client_parametric.py @@ -737,14 +737,18 @@ def otel_current_span(self) -> _TestOtelSpan | None: return _TestOtelSpan(self, span_response["span_id"], span_response["trace_id"]) - def ffe_start(self) -> bool: + def ffe_start(self, configuration: dict | None = None) -> bool: """Initialize the FFE (Feature Flagging & Experimentation) provider. Returns: bool: True if the provider was initialized successfully, False otherwise """ - resp = self._session.post(self._url("/ffe/start"), json={}) + payload = {} + if configuration is not None: + payload["configuration"] = configuration + + resp = self._session.post(self._url("/ffe/start"), json=payload) return HTTPStatus(resp.status_code).is_success def ffe_evaluate( @@ -1166,9 +1170,9 @@ def write_log( ) -> bool: return self._client.write_log(logger_name, level, message, span_id=span_id) - def ffe_start(self) -> bool: + def ffe_start(self, configuration: dict | None = None) -> bool: """Initialize the FFE (Feature Flagging & Experimentation) provider.""" - return self._client.ffe_start() + return self._client.ffe_start(configuration) def ffe_evaluate( self,