From 31e57e0baf922e876d0c8675988cc9d2336b51e0 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 07:52:02 -0400 Subject: [PATCH 01/20] Move FFE exposure waits into setup --- tests/ffe/test_exposures.py | 41 +++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index a90a1a6ed89..826fb87169a 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -132,11 +132,12 @@ def setup_ffe_exposure_event_generation(self): "attributes": attributes, }, ) + assert self.r.status_code == 200, f"Flag evaluation failed: {self.r.text}" + wait_for_exposure_event({self.flag}, self.targeting_key) def test_ffe_exposure_event_generation(self): """Test that FFE generates exposure events when flags are evaluated via weblog.""" assert self.r.status_code == 200, f"Flag evaluation failed: {self.r.text}" - wait_for_exposure_event({self.flag}, self.targeting_key) # Search for our specific flag in all exposure events matching_event = None @@ -277,12 +278,14 @@ def setup_ffe_multiple_remote_config_files(self): "attributes": {}, }, ) + assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" + assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" + wait_for_exposure_event({self.flag_1, self.flag_2}, self.targeting_key) def test_ffe_multiple_remote_config_files(self): """Test that FFE correctly handles multiple remote config files with different flags.""" assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" - wait_for_exposure_event({self.flag_1, self.flag_2}, self.targeting_key) # Collect all exposure events for our specific flags flags_found = set() @@ -443,12 +446,14 @@ def setup_ffe_malformed_remote_config_rejection(self): "attributes": {}, }, ) + assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" + assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" + wait_for_exposure_event({self.flag}, self.targeting_key) def test_ffe_malformed_remote_config_rejection(self): """Test that FFE rejects malformed remote config and preserves the old valid configuration.""" assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" - wait_for_exposure_event({self.flag}, self.targeting_key) # Verify that exposure events are still generated for both requests # and the flag configuration remained valid despite the malformed update @@ -532,6 +537,9 @@ def setup_ffe_exposure_caching_same_subject(self): }, ) self.responses.append(r) + for i, r in enumerate(self.responses): + assert r.status_code == 200, f"Request {i + 1} failed: {r.text}" + wait_for_min_exposure_count(self.flag_key, 1, self.targeting_key) def test_ffe_exposure_caching_same_subject(self): """Test that multiple evaluations for the same subject generate at most one exposure event.""" @@ -542,7 +550,7 @@ def test_ffe_exposure_caching_same_subject(self): assert result["value"] == "value-a", f"Request {i + 1}: expected 'value-a', got '{result['value']}'" # Count exposure events for this specific subject - exposure_count = wait_for_min_exposure_count(self.flag_key, 1, self.targeting_key) + exposure_count = count_exposure_events(self.flag_key, self.targeting_key) # The exposure cache should deduplicate events - we expect exactly 1 exposure # for the same (subject, allocation, variant) tuple @@ -583,6 +591,12 @@ def setup_ffe_exposure_caching_different_subjects(self): }, ) self.responses.append(r) + for i, r in enumerate(self.responses): + assert r.status_code == 200, f"Request {i + 1} failed: {r.text}" + + # Wait for each subject while the weblog and agent are still running. + for subject in self.subjects: + wait_for_min_exposure_count(self.flag_key, 1, subject) def test_ffe_exposure_caching_different_subjects(self): """Test that each unique subject generates exactly one exposure event.""" @@ -592,10 +606,6 @@ def test_ffe_exposure_caching_different_subjects(self): result = json.loads(r.text) assert result["value"] == "value-a", f"Request {i + 1}: expected 'value-a', got '{result['value']}'" - # Wait for each subject to be observed before asserting exact totals. - for subject in self.subjects: - wait_for_min_exposure_count(self.flag_key, 1, subject) - # Count total exposure events for this flag total_exposure_count = count_exposure_events(self.flag_key) @@ -678,6 +688,10 @@ def setup_ffe_exposure_caching_allocation_cycle(self): "attributes": {}, }, ) + assert self.response_1.status_code == 200, f"Request 1 failed: {self.response_1.text}" + assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" + assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" + wait_for_min_exposure_count(self.flag_key, 3, self.targeting_key) def test_ffe_exposure_caching_allocation_cycle(self): """Test that allocation-a → allocation-b → allocation-a generates 3 exposures.""" @@ -700,7 +714,7 @@ def test_ffe_exposure_caching_allocation_cycle(self): # - Exposure #1: default-allocation # - Exposure #2: different-allocation (allocation changed) # - Exposure #3: default-allocation (allocation changed back) - exposure_count = wait_for_min_exposure_count(self.flag_key, 3, self.targeting_key) + exposure_count = count_exposure_events(self.flag_key, self.targeting_key) assert exposure_count == 3, ( f"Expected exactly 3 exposure events for subject '{self.targeting_key}' " @@ -773,6 +787,10 @@ def setup_ffe_exposure_caching_variant_cycle(self): "attributes": {}, }, ) + assert self.response_1.status_code == 200, f"Request 1 failed: {self.response_1.text}" + assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" + assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" + wait_for_min_exposure_count(self.flag_key, 3, self.targeting_key) def test_ffe_exposure_caching_variant_cycle(self): """Test that variant-a → variant-b → variant-a generates 3 exposures.""" @@ -795,7 +813,7 @@ def test_ffe_exposure_caching_variant_cycle(self): # - Exposure #1: variant-a # - Exposure #2: variant-b (variant changed) # - Exposure #3: variant-a (variant changed back) - exposure_count = wait_for_min_exposure_count(self.flag_key, 3, self.targeting_key) + exposure_count = count_exposure_events(self.flag_key, self.targeting_key) assert exposure_count == 3, ( f"Expected exactly 3 exposure events for subject '{self.targeting_key}' " @@ -962,6 +980,8 @@ def setup_ffe_exp_5_missing_targeting_key(self): "attributes": {}, }, ) + assert self.response.status_code == 200, f"Flag evaluation failed: {self.response.text}" + wait_for_exposure_event({self.flag_key}, "") def test_ffe_exp_5_missing_targeting_key(self): """EXP.5: Test that empty targeting key generates exposure with subject.id = ''.""" @@ -969,7 +989,6 @@ def test_ffe_exp_5_missing_targeting_key(self): result = json.loads(self.response.text) assert result["value"] == "value-a", f"Expected 'value-a', got '{result['value']}'" - wait_for_exposure_event({self.flag_key}, "") # Search for exposure event with empty subject.id matching_event = None From 86c5ef64c3daf4b4470727bc1f919f12fc33ed55 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 08:12:46 -0400 Subject: [PATCH 02/20] Avoid setup wait for empty FFE subject --- tests/ffe/test_exposures.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index 826fb87169a..df91eb9019a 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -981,7 +981,6 @@ def setup_ffe_exp_5_missing_targeting_key(self): }, ) assert self.response.status_code == 200, f"Flag evaluation failed: {self.response.text}" - wait_for_exposure_event({self.flag_key}, "") def test_ffe_exp_5_missing_targeting_key(self): """EXP.5: Test that empty targeting key generates exposure with subject.id = ''.""" From ed8dc1c23c4c6725e3713ed344be8f0fef2e866e Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 09:05:23 -0400 Subject: [PATCH 03/20] Make FFE setup waits non-fatal --- tests/ffe/test_exposures.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index df91eb9019a..2216169a8ba 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -60,22 +60,22 @@ def find_exposure_events(flag_key: str, subject_id: str | None = None) -> list[d def wait_for_exposure_event(flag_keys: set[str], subject_id: str | None = None) -> None: - """Wait until the agent receives an exposure event for one of the given flags.""" - assert interfaces.agent.wait_for( + """Give the agent a chance to receive an exposure event before shutdown.""" + interfaces.agent.wait_for( lambda data: bool(exposure_events_from_data(data, flag_keys, subject_id)), timeout=EXPOSURE_WAIT_TIMEOUT_SECONDS, - ), f"Timed out waiting for exposure event for flags {sorted(flag_keys)} and subject {subject_id!r}" + ) def wait_for_min_exposure_count(flag_key: str, expected: int, subject_id: str | None = None) -> int: - """Wait until enough matching exposure events are available, then return the current count.""" + """Give the agent a chance to receive enough exposure events before shutdown.""" count = count_exposure_events(flag_key, subject_id) if count < expected: - assert interfaces.agent.wait_for( + interfaces.agent.wait_for( lambda _: count_exposure_events(flag_key, subject_id) >= expected, timeout=EXPOSURE_WAIT_TIMEOUT_SECONDS, - ), f"Timed out waiting for exposure count >= {expected} for flag {flag_key} and subject {subject_id!r}" + ) count = count_exposure_events(flag_key, subject_id) return count From 6afc3eb3afee801d3f90b0d3541b06a88fbfd173 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 11:20:15 -0400 Subject: [PATCH 04/20] Mark PHP multi-RC FFE exposure missing --- manifests/php.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/manifests/php.yml b/manifests/php.yml index c7a1b2cec0f..bcebc587ff4 100644 --- a/manifests/php.yml +++ b/manifests/php.yml @@ -626,6 +626,7 @@ manifest: tests/docker_ssi/test_docker_ssi_crash.py::TestDockerSSICrash::test_crash: missing_feature (No implemented the endpoint /crashme) tests/ffe/test_dynamic_evaluation.py: v1.21.0-dev tests/ffe/test_exposures.py: v1.21.0-dev + tests/ffe/test_exposures.py::Test_FFE_Exposure_Events::test_ffe_multiple_remote_config_files: missing_feature (Multiple remote config exposure events are not emitted by PHP) tests/ffe/test_flag_eval_evp.py: missing_feature (FFL-2446) tests/ffe/test_flag_eval_metrics.py: v1.21.0-dev tests/integration_frameworks/llm/anthropic/test_anthropic_llmobs.py::TestAnthropicLlmObsMessages::test_create_error: bug (MLOB-1234) From be0e8abaf60e398eaf78928de6cff997957cb025 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 11:40:44 -0400 Subject: [PATCH 05/20] Use scenario wait for FFE exposures --- manifests/php.yml | 1 - tests/ffe/test_exposures.py | 49 --------------------------- utils/_context/_scenarios/__init__.py | 1 + 3 files changed, 1 insertion(+), 50 deletions(-) diff --git a/manifests/php.yml b/manifests/php.yml index bcebc587ff4..c7a1b2cec0f 100644 --- a/manifests/php.yml +++ b/manifests/php.yml @@ -626,7 +626,6 @@ manifest: tests/docker_ssi/test_docker_ssi_crash.py::TestDockerSSICrash::test_crash: missing_feature (No implemented the endpoint /crashme) tests/ffe/test_dynamic_evaluation.py: v1.21.0-dev tests/ffe/test_exposures.py: v1.21.0-dev - tests/ffe/test_exposures.py::Test_FFE_Exposure_Events::test_ffe_multiple_remote_config_files: missing_feature (Multiple remote config exposure events are not emitted by PHP) tests/ffe/test_flag_eval_evp.py: missing_feature (FFL-2446) tests/ffe/test_flag_eval_metrics.py: v1.21.0-dev tests/integration_frameworks/llm/anthropic/test_anthropic_llmobs.py::TestAnthropicLlmObsMessages::test_create_error: bug (MLOB-1234) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index 2216169a8ba..cf0e424cde6 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -15,7 +15,6 @@ RC_PRODUCT = "FFE_FLAGS" RC_PATH = f"datadog/2/{RC_PRODUCT}" EXPOSURES_PATH = "/api/v2/exposures" -EXPOSURE_WAIT_TIMEOUT_SECONDS = 30 def exposure_events_from_data( @@ -59,28 +58,6 @@ def find_exposure_events(flag_key: str, subject_id: str | None = None) -> list[d return events -def wait_for_exposure_event(flag_keys: set[str], subject_id: str | None = None) -> None: - """Give the agent a chance to receive an exposure event before shutdown.""" - interfaces.agent.wait_for( - lambda data: bool(exposure_events_from_data(data, flag_keys, subject_id)), - timeout=EXPOSURE_WAIT_TIMEOUT_SECONDS, - ) - - -def wait_for_min_exposure_count(flag_key: str, expected: int, subject_id: str | None = None) -> int: - """Give the agent a chance to receive enough exposure events before shutdown.""" - count = count_exposure_events(flag_key, subject_id) - - if count < expected: - interfaces.agent.wait_for( - lambda _: count_exposure_events(flag_key, subject_id) >= expected, - timeout=EXPOSURE_WAIT_TIMEOUT_SECONDS, - ) - count = count_exposure_events(flag_key, subject_id) - - return count - - # Simple UFC fixture for testing with doLog: true UFC_FIXTURE_DATA = { "createdAt": "2024-04-17T19:40:53.716Z", @@ -132,8 +109,6 @@ def setup_ffe_exposure_event_generation(self): "attributes": attributes, }, ) - assert self.r.status_code == 200, f"Flag evaluation failed: {self.r.text}" - wait_for_exposure_event({self.flag}, self.targeting_key) def test_ffe_exposure_event_generation(self): """Test that FFE generates exposure events when flags are evaluated via weblog.""" @@ -278,9 +253,6 @@ def setup_ffe_multiple_remote_config_files(self): "attributes": {}, }, ) - assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" - assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" - wait_for_exposure_event({self.flag_1, self.flag_2}, self.targeting_key) def test_ffe_multiple_remote_config_files(self): """Test that FFE correctly handles multiple remote config files with different flags.""" @@ -446,9 +418,6 @@ def setup_ffe_malformed_remote_config_rejection(self): "attributes": {}, }, ) - assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" - assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" - wait_for_exposure_event({self.flag}, self.targeting_key) def test_ffe_malformed_remote_config_rejection(self): """Test that FFE rejects malformed remote config and preserves the old valid configuration.""" @@ -537,9 +506,6 @@ def setup_ffe_exposure_caching_same_subject(self): }, ) self.responses.append(r) - for i, r in enumerate(self.responses): - assert r.status_code == 200, f"Request {i + 1} failed: {r.text}" - wait_for_min_exposure_count(self.flag_key, 1, self.targeting_key) def test_ffe_exposure_caching_same_subject(self): """Test that multiple evaluations for the same subject generate at most one exposure event.""" @@ -591,12 +557,6 @@ def setup_ffe_exposure_caching_different_subjects(self): }, ) self.responses.append(r) - for i, r in enumerate(self.responses): - assert r.status_code == 200, f"Request {i + 1} failed: {r.text}" - - # Wait for each subject while the weblog and agent are still running. - for subject in self.subjects: - wait_for_min_exposure_count(self.flag_key, 1, subject) def test_ffe_exposure_caching_different_subjects(self): """Test that each unique subject generates exactly one exposure event.""" @@ -688,10 +648,6 @@ def setup_ffe_exposure_caching_allocation_cycle(self): "attributes": {}, }, ) - assert self.response_1.status_code == 200, f"Request 1 failed: {self.response_1.text}" - assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" - assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" - wait_for_min_exposure_count(self.flag_key, 3, self.targeting_key) def test_ffe_exposure_caching_allocation_cycle(self): """Test that allocation-a → allocation-b → allocation-a generates 3 exposures.""" @@ -787,10 +743,6 @@ def setup_ffe_exposure_caching_variant_cycle(self): "attributes": {}, }, ) - assert self.response_1.status_code == 200, f"Request 1 failed: {self.response_1.text}" - assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" - assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" - wait_for_min_exposure_count(self.flag_key, 3, self.targeting_key) def test_ffe_exposure_caching_variant_cycle(self): """Test that variant-a → variant-b → variant-a generates 3 exposures.""" @@ -980,7 +932,6 @@ def setup_ffe_exp_5_missing_targeting_key(self): "attributes": {}, }, ) - assert self.response.status_code == 200, f"Flag evaluation failed: {self.response.text}" def test_ffe_exp_5_missing_targeting_key(self): """EXP.5: Test that empty targeting key generates exposure with subject.id = ''.""" diff --git a/utils/_context/_scenarios/__init__.py b/utils/_context/_scenarios/__init__.py index f7273144624..7b30d89890f 100644 --- a/utils/_context/_scenarios/__init__.py +++ b/utils/_context/_scenarios/__init__.py @@ -706,6 +706,7 @@ class _Scenarios: "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "http://agent:4318/v1/metrics", "OTEL_METRIC_EXPORT_INTERVAL": "1000", }, + agent_interface_timeout=30, doc="", scenario_groups=[scenario_groups.ffe], ) From d13d049abe9298a9a728f7566860eb84571d8a74 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 14:10:54 -0400 Subject: [PATCH 06/20] Wait for PHP FFE exposure readiness --- docs/understand/weblogs/end-to-end_weblog.md | 14 +- tests/ffe/test_exposures.py | 341 ++++++++++++++++--- utils/_context/_scenarios/__init__.py | 1 - utils/build/docker/php/common/ffe.php | 200 ++++++++++- 4 files changed, 500 insertions(+), 56 deletions(-) diff --git a/docs/understand/weblogs/end-to-end_weblog.md b/docs/understand/weblogs/end-to-end_weblog.md index dd941941ab2..ad2a51b2020 100644 --- a/docs/understand/weblogs/end-to-end_weblog.md +++ b/docs/understand/weblogs/end-to-end_weblog.md @@ -1078,8 +1078,20 @@ accept a JSON body with these fields: - `targetingKey`: the evaluation subject key. - `attributes`: flat scalar targeting attributes. +PHP weblogs may also accept these test-only fields: + +- `expectedConfigIds`: remote configuration IDs expected by this evaluation. +- `expectedConfigVersion`: remote configuration version to wait for before + evaluating the flag. +- `configWaitTimeoutMs`: timeout in milliseconds. The endpoint caps the value at + 30000. +- `waitForFlag`: whether to retry `FLAG_NOT_FOUND` results until the requested + flag is visible in the PHP sidecar-backed evaluator. + The response must be JSON and include at least `value` and `reason`. Error -responses should also include `errorCode` and `errorMessage`. +responses should also include `errorCode` and `errorMessage`. PHP responses +include `configWait` when `expectedConfigVersion` is provided, and +`flagWaitAttempts` and `exposuresFlushed` when `waitForFlag` is true. ### GET /healthcheck diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index cf0e424cde6..b07a8c595fc 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -1,20 +1,28 @@ """Test feature flags exposure events logging in weblog end-to-end scenario.""" import json +import time from tests.ffe.utils.fixtures import make_exposure_ufc_fixture as make_ufc_fixture from utils import ( + context, weblog, interfaces, scenarios, features, remote_config as rc, ) +from utils._weblog import HttpResponse RC_PRODUCT = "FFE_FLAGS" RC_PATH = f"datadog/2/{RC_PRODUCT}" EXPOSURES_PATH = "/api/v2/exposures" +EXPOSURE_WAIT_TIMEOUT_SECONDS = 30 +PHP_FFE_CONFIG_WAIT_TIMEOUT_MS = 5000 +_UNSET = object() + +WaitResult = tuple[bool, str] def exposure_events_from_data( @@ -58,6 +66,172 @@ def find_exposure_events(flag_key: str, subject_id: str | None = None) -> list[d return events +def exposure_flag_keys_seen(flag_keys: set[str], subject_id: str | None = None) -> set[str]: + """Return matching flag keys already captured by the agent.""" + seen = set() + for data in interfaces.agent.get_data(path_filters=EXPOSURES_PATH): + for event in exposure_events_from_data(data, flag_keys, subject_id): + flag = event.get("flag") + flag_key = flag.get("key") if isinstance(flag, dict) else None + if isinstance(flag_key, str): + seen.add(flag_key) + return seen + + +def post_ffe( + payload: dict, + config_ids: set[str], + version: int, + *, + wait_for_flag: bool = True, + wait_for_config_version: bool = False, + expected_value: object = _UNSET, + expected_allocation_key: str | None = None, + expected_variant: str | None = None, + require_exposure_flush: bool = False, +): + """Evaluate FFE, asking PHP to wait for the applied config version in the same request.""" + timeout = 5 + if context.library == "php": + payload = { + **payload, + "configWaitTimeoutMs": PHP_FFE_CONFIG_WAIT_TIMEOUT_MS, + "waitForFlag": wait_for_flag, + } + if wait_for_config_version: + payload["expectedConfigIds"] = sorted(config_ids) + payload["expectedConfigVersion"] = version + timeout = (PHP_FFE_CONFIG_WAIT_TIMEOUT_MS * 2 // 1000) + 5 + + if context.library != "php": + return weblog.post("/ffe", json=payload, timeout=timeout) + + deadline = time.monotonic() + EXPOSURE_WAIT_TIMEOUT_SECONDS + response = None + while response is None or time.monotonic() < deadline: + response = weblog.post("/ffe", json=payload, timeout=timeout) + if php_ffe_response_ready( + response, + wait_for_flag=wait_for_flag, + expected_value=expected_value, + expected_allocation_key=expected_allocation_key, + expected_variant=expected_variant, + require_exposure_flush=require_exposure_flush, + ): + return response + time.sleep(0.1) + + return response + + +def php_ffe_response_ready( + response: HttpResponse, + *, + wait_for_flag: bool, + expected_value: object = _UNSET, + expected_allocation_key: str | None = None, + expected_variant: str | None = None, + require_exposure_flush: bool = False, +) -> bool: + """Return whether a PHP FFE response should stop setup-time request retries.""" + if response.status_code != 200 or response.text is None: + return False + + try: + payload = json.loads(response.text) + except json.JSONDecodeError: + return True + + error_code = payload.get("errorCode") + if error_code == "PROVIDER_NOT_READY": + return False + if wait_for_flag and error_code == "FLAG_NOT_FOUND": + return False + if require_exposure_flush and payload.get("exposuresFlushed") is not True: + return False + if expected_value is not _UNSET and payload.get("value") != expected_value: + return False + if expected_variant is not None and payload.get("variant") != expected_variant: + return False + if expected_allocation_key is not None: + exposure_data = payload.get("exposureData") + allocation_key = exposure_data.get("allocationKey") if isinstance(exposure_data, dict) else None + if allocation_key != expected_allocation_key: + return False + return True + + +def flush_ffe_payloads() -> WaitResult: + """Flush FFE payloads for weblogs that expose a flush endpoint.""" + if context.library not in ("nodejs", "ruby"): + return True, "" + + response = weblog.get("/flush", timeout=10) + if response.status_code == 200: + return True, "" + + return False, f"Failed to flush FFE payloads with status {response.status_code}: {response.text}" + + +def wait_for_exposure_event(flag_keys: set[str], subject_id: str | None = None) -> WaitResult: + """Wait until the agent receives exposure events for all of the given flags.""" + flush_result = flush_ffe_payloads() + if not flush_result[0]: + return flush_result + + sorted_flag_keys = sorted(flag_keys) + seen_flag_keys = exposure_flag_keys_seen(flag_keys, subject_id) + + def collect_matching_flags(data: dict) -> bool: + for event in exposure_events_from_data(data, flag_keys, subject_id): + flag = event.get("flag") + flag_key = flag.get("key") if isinstance(flag, dict) else None + if isinstance(flag_key, str): + seen_flag_keys.add(flag_key) + return flag_keys <= seen_flag_keys + + if flag_keys <= seen_flag_keys or interfaces.agent.wait_for( + collect_matching_flags, + timeout=EXPOSURE_WAIT_TIMEOUT_SECONDS, + ): + return True, "" + + missing_flag_keys = sorted(flag_keys - seen_flag_keys) + return ( + False, + f"Timed out waiting for exposure events for flags {sorted_flag_keys} " + f"and subject {subject_id!r}; missing {missing_flag_keys}", + ) + + +def wait_for_min_exposure_count(flag_key: str, expected: int, subject_id: str | None = None) -> WaitResult: + """Wait until enough matching exposure events are available.""" + flush_result = flush_ffe_payloads() + if not flush_result[0]: + return flush_result + + if count_exposure_events(flag_key, subject_id) >= expected: + return True, "" + + if interfaces.agent.wait_for( + lambda _: count_exposure_events(flag_key, subject_id) >= expected, + timeout=EXPOSURE_WAIT_TIMEOUT_SECONDS, + ): + return True, "" + + count = count_exposure_events(flag_key, subject_id) + return ( + False, + f"Timed out waiting for exposure count >= {expected} for flag {flag_key} " + f"and subject {subject_id!r}; observed {count}", + ) + + +def assert_wait_results(*wait_results: WaitResult) -> None: + for success, failure_message in wait_results: + assert success, failure_message + + # Simple UFC fixture for testing with doLog: true UFC_FIXTURE_DATA = { "createdAt": "2024-04-17T19:40:53.716Z", @@ -90,7 +264,7 @@ def setup_ffe_exposure_event_generation(self): # Set up Remote Config config_id = "ffe-test-config" rc_config = UFC_FIXTURE_DATA - rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", rc_config).apply() + config_state = rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", rc_config).apply() # Evaluate a feature flag self.flag = "test-flag" @@ -99,20 +273,24 @@ def setup_ffe_exposure_event_generation(self): self.targeting_key = "test-user" attributes: dict[str, str] = {} - self.r = weblog.post( - "/ffe", - json={ + self.r = post_ffe( + { "flag": self.flag, "variationType": variation_type, "defaultValue": default_value, "targetingKey": self.targeting_key, "attributes": attributes, }, + {config_id}, + config_state.version, + require_exposure_flush=True, ) + self.exposure_ready = wait_for_exposure_event({self.flag}, self.targeting_key) def test_ffe_exposure_event_generation(self): """Test that FFE generates exposure events when flags are evaluated via weblog.""" assert self.r.status_code == 200, f"Flag evaluation failed: {self.r.text}" + assert_wait_results(self.exposure_ready) # Search for our specific flag in all exposure events matching_event = None @@ -220,44 +398,60 @@ def setup_ffe_multiple_remote_config_files(self): }, } - # Apply both configurations - rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).set_config( - f"{RC_PATH}/{config_id_2}/config", rc_config_2 - ).apply() - # Evaluate both feature flags self.flag_1 = "test-flag-1" self.flag_2 = "test-flag-2" self.targeting_key = "test-user-multi" - # Evaluate first flag - self.r1 = weblog.post( - "/ffe", - json={ + # Apply and evaluate the first file before adding the second one. PHP's + # sidecar-backed evaluator can surface each FFE file asynchronously. + config_state_1 = rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).apply() + self.r1 = post_ffe( + { "flag": self.flag_1, "variationType": "STRING", "defaultValue": "default", "targetingKey": self.targeting_key, "attributes": {}, }, + {config_id_1}, + config_state_1.version, + require_exposure_flush=True, ) - - # Evaluate second flag - self.r2 = weblog.post( - "/ffe", - json={ + self.exposure_ready_1 = wait_for_exposure_event({self.flag_1}, self.targeting_key) + + # Add the second file and evaluate its flag while the weblog is still + # alive, then wait for that specific exposure before teardown. PHP's + # sidecar-backed evaluator exposes one FFE file per evaluator snapshot, + # so exercise the second file in its own applied RC state. + config_ids_2 = {config_id_1, config_id_2} + if context.library == "php": + rc.tracer_rc_state.reset() + config_ids_2 = {config_id_2} + config_state_2 = rc.tracer_rc_state.set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2).apply() + self.r2 = post_ffe( + { "flag": self.flag_2, "variationType": "BOOLEAN", "defaultValue": False, "targetingKey": self.targeting_key, "attributes": {}, }, + config_ids_2, + config_state_2.version, + require_exposure_flush=True, ) + self.exposure_ready_2 = wait_for_exposure_event({self.flag_2}, self.targeting_key) def test_ffe_multiple_remote_config_files(self): """Test that FFE correctly handles multiple remote config files with different flags.""" assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" + result_1 = json.loads(self.r1.text) + result_2 = json.loads(self.r2.text) + assert result_1["value"] == "on", f"First flag expected 'on', got: {self.r1.text}" + assert result_2["value"] is True, f"Second flag expected true, got: {self.r2.text}" + assert_wait_results(self.exposure_ready_1, self.exposure_ready_2) # Collect all exposure events for our specific flags flags_found = set() @@ -291,9 +485,8 @@ def test_ffe_multiple_remote_config_files(self): ) # Verify that both flags were evaluated and sent exposure events - assert self.flag_1 in flags_found or self.flag_2 in flags_found, ( - f"Expected to find flags '{self.flag_1}' or '{self.flag_2}' in exposure events, found: {flags_found}" - ) + expected_flags = {self.flag_1, self.flag_2} + assert expected_flags <= flags_found, f"Expected to find flags {expected_flags}, found: {flags_found}" @scenarios.feature_flagging_and_experimentation @@ -369,22 +562,25 @@ def setup_ffe_malformed_remote_config_rejection(self): }, } - rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", valid_rc_config).apply() + config_state = rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", valid_rc_config).apply() # Evaluate the flag with valid config self.flag = "test-flag-resilient" self.targeting_key = "test-user-resilient" - self.r1 = weblog.post( - "/ffe", - json={ + self.r1 = post_ffe( + { "flag": self.flag, "variationType": "STRING", "defaultValue": "default", "targetingKey": self.targeting_key, "attributes": {}, }, + {config_id}, + config_state.version, + require_exposure_flush=True, ) + self.exposure_ready = wait_for_exposure_event({self.flag}, self.targeting_key) # Now update with a malformed config (missing allocations and variationType) malformed_rc_config = { @@ -408,21 +604,23 @@ def setup_ffe_malformed_remote_config_rejection(self): rc.tracer_rc_state.set_config(f"{RC_PATH}/{config_id}/config", malformed_rc_config).apply() # Evaluate the flag again after malformed config update - self.r2 = weblog.post( - "/ffe", - json={ + self.r2 = post_ffe( + { "flag": self.flag, "variationType": "STRING", "defaultValue": "default", "targetingKey": self.targeting_key, "attributes": {}, }, + {config_id}, + config_state.version, ) def test_ffe_malformed_remote_config_rejection(self): """Test that FFE rejects malformed remote config and preserves the old valid configuration.""" assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" + assert_wait_results(self.exposure_ready) # Verify that exposure events are still generated for both requests # and the flag configuration remained valid despite the malformed update @@ -488,30 +686,40 @@ def setup_ffe_exposure_caching_same_subject(self): """Set up FFE exposure caching test with multiple evaluations for the same subject.""" config_id = "ffe-caching-test" self.flag_key = "same-subject-test-flag" # Unique flag key for this test - rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)).apply() + config_state = ( + rc.tracer_rc_state.reset() + .set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)) + .apply() + ) self.targeting_key = "same-subject-user" # Evaluate the same flag multiple times with the same subject self.responses = [] for _i in range(5): - r = weblog.post( - "/ffe", - json={ + r = post_ffe( + { "flag": self.flag_key, "variationType": "STRING", "defaultValue": "default", "targetingKey": self.targeting_key, "attributes": {}, }, + {config_id}, + config_state.version, + require_exposure_flush=_i == 0, ) self.responses.append(r) + self.exposure_ready = wait_for_min_exposure_count(self.flag_key, 1, self.targeting_key) def test_ffe_exposure_caching_same_subject(self): """Test that multiple evaluations for the same subject generate at most one exposure event.""" # Verify all requests succeeded for i, r in enumerate(self.responses): assert r.status_code == 200, f"Request {i + 1} failed: {r.text}" + assert_wait_results(self.exposure_ready) + + for i, r in enumerate(self.responses): result = json.loads(r.text) assert result["value"] == "value-a", f"Request {i + 1}: expected 'value-a', got '{result['value']}'" @@ -539,30 +747,42 @@ def setup_ffe_exposure_caching_different_subjects(self): """Set up FFE exposure caching test with multiple different subjects.""" config_id = "ffe-caching-test-subjects" self.flag_key = "diff-subjects-test-flag" # Unique flag key for this test - rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)).apply() + config_state = ( + rc.tracer_rc_state.reset() + .set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)) + .apply() + ) self.subjects = [f"unique-subject-{i}" for i in range(5)] # Evaluate the flag with different subjects self.responses = [] for subject in self.subjects: - r = weblog.post( - "/ffe", - json={ + r = post_ffe( + { "flag": self.flag_key, "variationType": "STRING", "defaultValue": "default", "targetingKey": subject, "attributes": {}, }, + {config_id}, + config_state.version, + require_exposure_flush=True, ) self.responses.append(r) + self.exposure_wait_results = [ + wait_for_min_exposure_count(self.flag_key, 1, subject) for subject in self.subjects + ] def test_ffe_exposure_caching_different_subjects(self): """Test that each unique subject generates exactly one exposure event.""" # Verify all requests succeeded for i, r in enumerate(self.responses): assert r.status_code == 200, f"Request {i + 1} failed: {r.text}" + assert_wait_results(*self.exposure_wait_results) + + for i, r in enumerate(self.responses): result = json.loads(r.text) assert result["value"] == "value-a", f"Request {i + 1}: expected 'value-a', got '{result['value']}'" @@ -653,16 +873,17 @@ def test_ffe_exposure_caching_allocation_cycle(self): """Test that allocation-a → allocation-b → allocation-a generates 3 exposures.""" # Verify step 1: variant-a from default-allocation assert self.response_1.status_code == 200, f"Request 1 failed: {self.response_1.text}" + assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" + assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" + result_1 = json.loads(self.response_1.text) assert result_1["value"] == "value-a", f"Request 1: expected 'value-a', got '{result_1['value']}'" # Verify step 2: variant-a from different-allocation - assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" result_2 = json.loads(self.response_2.text) assert result_2["value"] == "value-a", f"Request 2: expected 'value-a', got '{result_2['value']}'" # Verify step 3: variant-a from default-allocation again - assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" result_3 = json.loads(self.response_3.text) assert result_3["value"] == "value-a", f"Request 3: expected 'value-a', got '{result_3['value']}'" @@ -748,16 +969,17 @@ def test_ffe_exposure_caching_variant_cycle(self): """Test that variant-a → variant-b → variant-a generates 3 exposures.""" # Verify step 1: variant-a assert self.response_1.status_code == 200, f"Request 1 failed: {self.response_1.text}" + assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" + assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" + result_1 = json.loads(self.response_1.text) assert result_1["value"] == "value-a", f"Request 1: expected 'value-a', got '{result_1['value']}'" # Verify step 2: variant-b - assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" result_2 = json.loads(self.response_2.text) assert result_2["value"] == "value-b", f"Request 2: expected 'value-b', got '{result_2['value']}'" # Verify step 3: variant-a again - assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" result_3 = json.loads(self.response_3.text) assert result_3["value"] == "value-a", f"Request 3: expected 'value-a', got '{result_3['value']}'" @@ -786,7 +1008,7 @@ def setup_ffe_exposure_missing_flag(self): """Set up FFE exposure test for a missing flag.""" # Set up a config with a different flag (not the one we'll request) config_id = "ffe-missing-flag-test" - rc.tracer_rc_state.reset().set_config( + config_state = rc.tracer_rc_state.reset().set_config( f"{RC_PATH}/{config_id}/config", make_ufc_fixture("some-other-flag") ).apply() @@ -796,15 +1018,18 @@ def setup_ffe_exposure_missing_flag(self): # Evaluate a flag that doesn't exist self.responses = [] for _i in range(3): - r = weblog.post( - "/ffe", - json={ + r = post_ffe( + { "flag": self.flag_key, "variationType": "STRING", "defaultValue": "default-value", "targetingKey": self.targeting_key, "attributes": {}, }, + {config_id}, + config_state.version, + wait_for_flag=False, + wait_for_config_version=True, ) self.responses.append(r) @@ -818,7 +1043,6 @@ def test_ffe_exposure_missing_flag(self): assert result["value"] == "default-value", ( f"Request {i + 1}: expected 'default-value', got '{result['value']}'" ) - # Count exposure events - should be 0 because flag doesn't exist exposure_count = count_exposure_events(self.flag_key, self.targeting_key) @@ -869,20 +1093,23 @@ def setup_ffe_exposure_dolog_false(self): self.targeting_key = "dolog-false-user" # Set up config with doLog=false - rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", UFC_EXPOSURE_DOLOG_FALSE_FIXTURE).apply() + config_state = rc.tracer_rc_state.reset().set_config( + f"{RC_PATH}/{config_id}/config", UFC_EXPOSURE_DOLOG_FALSE_FIXTURE + ).apply() # Evaluate the flag multiple times self.responses = [] for _i in range(3): - r = weblog.post( - "/ffe", - json={ + r = post_ffe( + { "flag": self.flag_key, "variationType": "STRING", "defaultValue": "default", "targetingKey": self.targeting_key, "attributes": {}, }, + {config_id}, + config_state.version, ) self.responses.append(r) @@ -891,6 +1118,8 @@ def test_ffe_exposure_dolog_false(self): # Verify all requests succeeded and returned the expected value for i, r in enumerate(self.responses): assert r.status_code == 200, f"Request {i + 1} failed: {r.text}" + + for i, r in enumerate(self.responses): result = json.loads(r.text) assert result["value"] == "value-a", f"Request {i + 1}: expected 'value-a', got '{result['value']}'" @@ -919,23 +1148,29 @@ def setup_ffe_exp_5_missing_targeting_key(self): self.flag_key = "exp-5-missing-targeting-key-flag" # Use a simple fixture with doLog=true - rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)).apply() + config_state = rc.tracer_rc_state.reset().set_config( + f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key) + ).apply() # Evaluate the flag with an empty targeting key - self.response = weblog.post( - "/ffe", - json={ + self.response = post_ffe( + { "flag": self.flag_key, "variationType": "STRING", "defaultValue": "default", "targetingKey": "", # Empty targeting key "attributes": {}, }, + {config_id}, + config_state.version, + require_exposure_flush=True, ) + self.exposure_ready = wait_for_exposure_event({self.flag_key}, "") def test_ffe_exp_5_missing_targeting_key(self): """EXP.5: Test that empty targeting key generates exposure with subject.id = ''.""" assert self.response.status_code == 200, f"Flag evaluation failed: {self.response.text}" + assert_wait_results(self.exposure_ready) result = json.loads(self.response.text) assert result["value"] == "value-a", f"Expected 'value-a', got '{result['value']}'" diff --git a/utils/_context/_scenarios/__init__.py b/utils/_context/_scenarios/__init__.py index 7b30d89890f..f7273144624 100644 --- a/utils/_context/_scenarios/__init__.py +++ b/utils/_context/_scenarios/__init__.py @@ -706,7 +706,6 @@ class _Scenarios: "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "http://agent:4318/v1/metrics", "OTEL_METRIC_EXPORT_INTERVAL": "1000", }, - agent_interface_timeout=30, doc="", scenario_groups=[scenario_groups.ffe], ) diff --git a/utils/build/docker/php/common/ffe.php b/utils/build/docker/php/common/ffe.php index 1fae050cbec..f1ae9dce08a 100644 --- a/utils/build/docker/php/common/ffe.php +++ b/utils/build/docker/php/common/ffe.php @@ -19,6 +19,100 @@ function dd_ffe_error_response($statusCode, $errorCode, $errorMessage) )); } +function dd_ffe_loaded_remote_config_keys() +{ + if (!function_exists('dd_trace_internal_fn')) { + return array(); + } + + $loadedConfigs = dd_trace_internal_fn('get_loaded_remote_configs'); + if (!is_array($loadedConfigs)) { + return array(); + } + + return array_keys($loadedConfigs); +} + +function dd_ffe_config_key_matches($loadedKey, $expectedId) +{ + return $loadedKey === $expectedId || strpos($loadedKey, $expectedId) !== false; +} + +function dd_ffe_missing_config_ids(array $loadedKeys, array $expectedIds) +{ + $missing = array(); + foreach ($expectedIds as $expectedId) { + $found = false; + foreach ($loadedKeys as $loadedKey) { + if (dd_ffe_config_key_matches($loadedKey, $expectedId)) { + $found = true; + break; + } + } + if (!$found) { + $missing[] = $expectedId; + } + } + + return $missing; +} + +function dd_ffe_config_status() +{ + $nativeStateAvailable = function_exists('DDTrace\\ffe_has_config') && function_exists('DDTrace\\ffe_config_version'); + + return array( + 'nativeStateAvailable' => $nativeStateAvailable, + 'hasConfig' => $nativeStateAvailable ? \DDTrace\ffe_has_config() : false, + 'configVersion' => $nativeStateAvailable ? \DDTrace\ffe_config_version() : null, + ); +} + +function dd_ffe_wait_for_config_state(array $ids, $expectedVersion, $timeoutMs) +{ + $timeoutMs = max(0, min($timeoutMs, 30000)); + $deadline = microtime(true) * 1000 + $timeoutMs; + $loadedKeys = array(); + $missing = $ids; + $status = dd_ffe_config_status(); + $ready = false; + + do { + $status = dd_ffe_config_status(); + $loadedKeys = dd_ffe_loaded_remote_config_keys(); + $missing = dd_ffe_missing_config_ids($loadedKeys, $ids); + if ($status['nativeStateAvailable']) { + $ready = $status['hasConfig'] + && ($expectedVersion === null || $status['configVersion'] >= $expectedVersion); + } else { + $ready = count($missing) === 0; + } + + if ($ready) { + $missing = array(); + break; + } + usleep(10000); + } while (microtime(true) * 1000 < $deadline); + + return array( + 'ready' => $ready, + 'missing' => array_values($missing), + 'loadedConfigKeys' => array_values($loadedKeys), + 'expectedVersion' => $expectedVersion, + 'nativeState' => $status, + ); +} + +function dd_ffe_flush_exposures() +{ + if (function_exists('DDTrace\\Testing\\flush_ffe_exposures')) { + return \DDTrace\Testing\flush_ffe_exposures(); + } + + return null; +} + function dd_ffe_read_payload() { $rawBody = file_get_contents('php://input'); @@ -35,6 +129,19 @@ function dd_ffe_read_payload() return $payload; } +function dd_ffe_expected_config_ids(array $payload) +{ + if (isset($payload['expectedConfigIds']) && is_array($payload['expectedConfigIds'])) { + return array_values(array_filter(array_map('strval', $payload['expectedConfigIds']))); + } + + if (isset($payload['expectedConfigId']) && is_string($payload['expectedConfigId'])) { + return array($payload['expectedConfigId']); + } + + return array(); +} + function dd_ffe_normalized_variation_type($variationType) { return strtoupper(str_replace('-', '_', (string) $variationType)); @@ -128,6 +235,47 @@ function dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $targetingKey, } } +function dd_ffe_should_wait_for_flag(array $payload) +{ + return isset($payload['waitForFlag']) && $payload['waitForFlag'] === true; +} + +function dd_ffe_should_retry_evaluation($details) +{ + if (!method_exists($details, 'getErrorCode')) { + return false; + } + + $errorCode = $details->getErrorCode(); + return $errorCode === 'FLAG_NOT_FOUND' || $errorCode === 'PROVIDER_NOT_READY'; +} + +function dd_ffe_evaluate_until_flag_ready( + $flagKey, + $variationType, + $defaultValue, + $targetingKey, + array $attributes, + $timeoutMs, + &$attempts +) { + $timeoutMs = max(0, min((int) $timeoutMs, 30000)); + $deadline = microtime(true) * 1000 + $timeoutMs; + $attempts = 0; + $details = null; + + do { + $attempts++; + $details = dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $targetingKey, $attributes); + if (!dd_ffe_should_retry_evaluation($details)) { + break; + } + usleep(10000); + } while (microtime(true) * 1000 < $deadline); + + return $details; +} + function dd_ffe_details_payload($details) { $payload = array( @@ -168,6 +316,15 @@ function dd_ffe_details_payload($details) $flagKey = $payload['flag']; $variationType = $payload['variationType']; $defaultValue = dd_ffe_normalize_default_value($payload['defaultValue'], $variationType); +$configWait = null; +$configWaitTimeoutMs = isset($payload['configWaitTimeoutMs']) ? (int) $payload['configWaitTimeoutMs'] : 5000; +if (isset($payload['expectedConfigVersion'])) { + $configWait = dd_ffe_wait_for_config_state( + dd_ffe_expected_config_ids($payload), + (int) $payload['expectedConfigVersion'], + $configWaitTimeoutMs + ); +} $targetingKey = isset($payload['targetingKey']) && $payload['targetingKey'] !== null ? (string) $payload['targetingKey'] : null; @@ -178,14 +335,53 @@ function dd_ffe_details_payload($details) ? dd_ffe_scalar_attributes($payload['attributes']) : array(); +if ($configWait !== null && $configWait['ready'] !== true) { + dd_ffe_json_response(200, array( + 'value' => $defaultValue, + 'reason' => 'ERROR', + 'variant' => null, + 'errorCode' => 'PROVIDER_NOT_READY', + 'errorMessage' => 'Timed out waiting for PHP FFE config before evaluation.', + 'configWait' => $configWait, + 'providerState' => array( + 'ready' => false, + 'productionRuntime' => false, + ), + )); + return; +} + try { $details = null; + $flagWaitAttempts = 0; + $waitForFlag = dd_ffe_should_wait_for_flag($payload); foreach ($targetingKeys as $key) { - $details = dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $key, $attributes); + if ($waitForFlag) { + $attempts = 0; + $details = dd_ffe_evaluate_until_flag_ready( + $flagKey, + $variationType, + $defaultValue, + $key, + $attributes, + $configWaitTimeoutMs, + $attempts + ); + $flagWaitAttempts += $attempts; + } else { + $details = dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $key, $attributes); + } } if ($details !== null) { $response = dd_ffe_details_payload($details); + if ($configWait !== null) { + $response['configWait'] = $configWait; + } + if ($waitForFlag) { + $response['flagWaitAttempts'] = $flagWaitAttempts; + } $response['count'] = count($targetingKeys); + $response['exposuresFlushed'] = dd_ffe_flush_exposures(); dd_ffe_json_response(200, $response); return; } @@ -196,6 +392,7 @@ function dd_ffe_details_payload($details) 'variant' => null, 'errorCode' => 'PROVIDER_NOT_READY', 'errorMessage' => $exception->getMessage(), + 'configWait' => $configWait, 'providerState' => array( 'ready' => false, 'productionRuntime' => false, @@ -210,6 +407,7 @@ function dd_ffe_details_payload($details) 'variant' => null, 'errorCode' => 'PROVIDER_NOT_READY', 'errorMessage' => 'Datadog-backed PHP feature flag evaluation is not wired in this weblog yet.', + 'configWait' => $configWait, 'providerState' => array( 'ready' => false, 'productionRuntime' => false, From 6052c53aeaaeebe5efb6fa512df97364eda7372c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 14:37:05 -0400 Subject: [PATCH 07/20] Format FFE exposure tests --- tests/ffe/test_exposures.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index b07a8c595fc..c3bea7430f6 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -1008,9 +1008,11 @@ def setup_ffe_exposure_missing_flag(self): """Set up FFE exposure test for a missing flag.""" # Set up a config with a different flag (not the one we'll request) config_id = "ffe-missing-flag-test" - config_state = rc.tracer_rc_state.reset().set_config( - f"{RC_PATH}/{config_id}/config", make_ufc_fixture("some-other-flag") - ).apply() + config_state = ( + rc.tracer_rc_state.reset() + .set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture("some-other-flag")) + .apply() + ) self.flag_key = "non-existent-flag" # This flag doesn't exist in the config self.targeting_key = "missing-flag-user" @@ -1093,9 +1095,11 @@ def setup_ffe_exposure_dolog_false(self): self.targeting_key = "dolog-false-user" # Set up config with doLog=false - config_state = rc.tracer_rc_state.reset().set_config( - f"{RC_PATH}/{config_id}/config", UFC_EXPOSURE_DOLOG_FALSE_FIXTURE - ).apply() + config_state = ( + rc.tracer_rc_state.reset() + .set_config(f"{RC_PATH}/{config_id}/config", UFC_EXPOSURE_DOLOG_FALSE_FIXTURE) + .apply() + ) # Evaluate the flag multiple times self.responses = [] @@ -1148,9 +1152,11 @@ def setup_ffe_exp_5_missing_targeting_key(self): self.flag_key = "exp-5-missing-targeting-key-flag" # Use a simple fixture with doLog=true - config_state = rc.tracer_rc_state.reset().set_config( - f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key) - ).apply() + config_state = ( + rc.tracer_rc_state.reset() + .set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)) + .apply() + ) # Evaluate the flag with an empty targeting key self.response = post_ffe( From 36ec83678ab788b6ab4d55c1d354d7137ca45b05 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 16:56:58 -0400 Subject: [PATCH 08/20] Preserve non-PHP exposure expectations --- tests/ffe/test_exposures.py | 126 ++++++++++++++++++++++++++---------- 1 file changed, 91 insertions(+), 35 deletions(-) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index c3bea7430f6..350f31e3503 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -204,6 +204,26 @@ def collect_matching_flags(data: dict) -> bool: ) +def wait_for_any_exposure_event(flag_keys: set[str], subject_id: str | None = None) -> WaitResult: + """Wait until the agent receives an exposure event for one of the given flags.""" + flush_result = flush_ffe_payloads() + if not flush_result[0]: + return flush_result + + sorted_flag_keys = sorted(flag_keys) + seen_flag_keys = exposure_flag_keys_seen(flag_keys, subject_id) + if seen_flag_keys: + return True, "" + + if interfaces.agent.wait_for( + lambda data: bool(exposure_events_from_data(data, flag_keys, subject_id)), + timeout=EXPOSURE_WAIT_TIMEOUT_SECONDS, + ): + return True, "" + + return False, f"Timed out waiting for any exposure event for flags {sorted_flag_keys} and subject {subject_id!r}" + + def wait_for_min_exposure_count(flag_key: str, expected: int, subject_id: str | None = None) -> WaitResult: """Wait until enough matching exposure events are available.""" flush_result = flush_ffe_payloads() @@ -402,33 +422,58 @@ def setup_ffe_multiple_remote_config_files(self): self.flag_1 = "test-flag-1" self.flag_2 = "test-flag-2" self.targeting_key = "test-user-multi" + self.require_all_exposure_flags = context.library == "php" + + if self.require_all_exposure_flags: + # Apply and evaluate the first file before adding the second one. + # PHP's sidecar-backed evaluator can surface each FFE file + # asynchronously, so exercise each snapshot while the weblog is + # still alive. + config_state_1 = ( + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).apply() + ) + self.r1 = post_ffe( + { + "flag": self.flag_1, + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": self.targeting_key, + "attributes": {}, + }, + {config_id_1}, + config_state_1.version, + require_exposure_flush=True, + ) + self.exposure_ready_1 = wait_for_exposure_event({self.flag_1}, self.targeting_key) + + config_state_2 = ( + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2).apply() + ) + config_ids = {config_id_2} + else: + # Other SDKs keep the original multiple-file behavior: both RC + # files are applied together, then the test waits for any matching + # exposure event. + config_state_2 = ( + rc.tracer_rc_state.reset() + .set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1) + .set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2) + .apply() + ) + config_ids = {config_id_1, config_id_2} + self.r1 = post_ffe( + { + "flag": self.flag_1, + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": self.targeting_key, + "attributes": {}, + }, + config_ids, + config_state_2.version, + require_exposure_flush=True, + ) - # Apply and evaluate the first file before adding the second one. PHP's - # sidecar-backed evaluator can surface each FFE file asynchronously. - config_state_1 = rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).apply() - self.r1 = post_ffe( - { - "flag": self.flag_1, - "variationType": "STRING", - "defaultValue": "default", - "targetingKey": self.targeting_key, - "attributes": {}, - }, - {config_id_1}, - config_state_1.version, - require_exposure_flush=True, - ) - self.exposure_ready_1 = wait_for_exposure_event({self.flag_1}, self.targeting_key) - - # Add the second file and evaluate its flag while the weblog is still - # alive, then wait for that specific exposure before teardown. PHP's - # sidecar-backed evaluator exposes one FFE file per evaluator snapshot, - # so exercise the second file in its own applied RC state. - config_ids_2 = {config_id_1, config_id_2} - if context.library == "php": - rc.tracer_rc_state.reset() - config_ids_2 = {config_id_2} - config_state_2 = rc.tracer_rc_state.set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2).apply() self.r2 = post_ffe( { "flag": self.flag_2, @@ -437,21 +482,27 @@ def setup_ffe_multiple_remote_config_files(self): "targetingKey": self.targeting_key, "attributes": {}, }, - config_ids_2, + config_ids, config_state_2.version, require_exposure_flush=True, ) - self.exposure_ready_2 = wait_for_exposure_event({self.flag_2}, self.targeting_key) + if self.require_all_exposure_flags: + self.exposure_ready_2 = wait_for_exposure_event({self.flag_2}, self.targeting_key) + else: + self.exposure_ready = wait_for_any_exposure_event({self.flag_1, self.flag_2}, self.targeting_key) def test_ffe_multiple_remote_config_files(self): """Test that FFE correctly handles multiple remote config files with different flags.""" assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" - result_1 = json.loads(self.r1.text) - result_2 = json.loads(self.r2.text) - assert result_1["value"] == "on", f"First flag expected 'on', got: {self.r1.text}" - assert result_2["value"] is True, f"Second flag expected true, got: {self.r2.text}" - assert_wait_results(self.exposure_ready_1, self.exposure_ready_2) + if self.require_all_exposure_flags: + result_1 = json.loads(self.r1.text) + result_2 = json.loads(self.r2.text) + assert result_1["value"] == "on", f"First flag expected 'on', got: {self.r1.text}" + assert result_2["value"] is True, f"Second flag expected true, got: {self.r2.text}" + assert_wait_results(self.exposure_ready_1, self.exposure_ready_2) + else: + assert_wait_results(self.exposure_ready) # Collect all exposure events for our specific flags flags_found = set() @@ -485,8 +536,13 @@ def test_ffe_multiple_remote_config_files(self): ) # Verify that both flags were evaluated and sent exposure events - expected_flags = {self.flag_1, self.flag_2} - assert expected_flags <= flags_found, f"Expected to find flags {expected_flags}, found: {flags_found}" + if self.require_all_exposure_flags: + expected_flags = {self.flag_1, self.flag_2} + assert expected_flags <= flags_found, f"Expected to find flags {expected_flags}, found: {flags_found}" + else: + assert self.flag_1 in flags_found or self.flag_2 in flags_found, ( + f"Expected to find flags '{self.flag_1}' or '{self.flag_2}' in exposure events, found: {flags_found}" + ) @scenarios.feature_flagging_and_experimentation From 01e4b61b492740132a72149b9531ff505a1dbf11 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 18:58:57 -0400 Subject: [PATCH 09/20] Align FFE multi-config test with latest config --- tests/ffe/test_exposures.py | 119 +++++++++--------------------------- 1 file changed, 29 insertions(+), 90 deletions(-) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index 350f31e3503..047ae4ec76c 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -204,26 +204,6 @@ def collect_matching_flags(data: dict) -> bool: ) -def wait_for_any_exposure_event(flag_keys: set[str], subject_id: str | None = None) -> WaitResult: - """Wait until the agent receives an exposure event for one of the given flags.""" - flush_result = flush_ffe_payloads() - if not flush_result[0]: - return flush_result - - sorted_flag_keys = sorted(flag_keys) - seen_flag_keys = exposure_flag_keys_seen(flag_keys, subject_id) - if seen_flag_keys: - return True, "" - - if interfaces.agent.wait_for( - lambda data: bool(exposure_events_from_data(data, flag_keys, subject_id)), - timeout=EXPOSURE_WAIT_TIMEOUT_SECONDS, - ): - return True, "" - - return False, f"Timed out waiting for any exposure event for flags {sorted_flag_keys} and subject {subject_id!r}" - - def wait_for_min_exposure_count(flag_key: str, expected: int, subject_id: str | None = None) -> WaitResult: """Wait until enough matching exposure events are available.""" flush_result = flush_ffe_payloads() @@ -422,57 +402,25 @@ def setup_ffe_multiple_remote_config_files(self): self.flag_1 = "test-flag-1" self.flag_2 = "test-flag-2" self.targeting_key = "test-user-multi" - self.require_all_exposure_flags = context.library == "php" - - if self.require_all_exposure_flags: - # Apply and evaluate the first file before adding the second one. - # PHP's sidecar-backed evaluator can surface each FFE file - # asynchronously, so exercise each snapshot while the weblog is - # still alive. - config_state_1 = ( - rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).apply() - ) - self.r1 = post_ffe( - { - "flag": self.flag_1, - "variationType": "STRING", - "defaultValue": "default", - "targetingKey": self.targeting_key, - "attributes": {}, - }, - {config_id_1}, - config_state_1.version, - require_exposure_flush=True, - ) - self.exposure_ready_1 = wait_for_exposure_event({self.flag_1}, self.targeting_key) - config_state_2 = ( - rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2).apply() - ) - config_ids = {config_id_2} - else: - # Other SDKs keep the original multiple-file behavior: both RC - # files are applied together, then the test waits for any matching - # exposure event. - config_state_2 = ( - rc.tracer_rc_state.reset() - .set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1) - .set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2) - .apply() - ) - config_ids = {config_id_1, config_id_2} - self.r1 = post_ffe( - { - "flag": self.flag_1, - "variationType": "STRING", - "defaultValue": "default", - "targetingKey": self.targeting_key, - "attributes": {}, - }, - config_ids, - config_state_2.version, - require_exposure_flush=True, - ) + config_state_1 = rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).apply() + self.r1 = post_ffe( + { + "flag": self.flag_1, + "variationType": "STRING", + "defaultValue": "default", + "targetingKey": self.targeting_key, + "attributes": {}, + }, + {config_id_1}, + config_state_1.version, + wait_for_config_version=True, + expected_value="on", + require_exposure_flush=True, + ) + self.exposure_ready_1 = wait_for_exposure_event({self.flag_1}, self.targeting_key) + + config_state_2 = rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2).apply() self.r2 = post_ffe( { @@ -482,27 +430,23 @@ def setup_ffe_multiple_remote_config_files(self): "targetingKey": self.targeting_key, "attributes": {}, }, - config_ids, + {config_id_2}, config_state_2.version, + wait_for_config_version=True, + expected_value=True, require_exposure_flush=True, ) - if self.require_all_exposure_flags: - self.exposure_ready_2 = wait_for_exposure_event({self.flag_2}, self.targeting_key) - else: - self.exposure_ready = wait_for_any_exposure_event({self.flag_1, self.flag_2}, self.targeting_key) + self.exposure_ready_2 = wait_for_exposure_event({self.flag_2}, self.targeting_key) def test_ffe_multiple_remote_config_files(self): """Test that FFE correctly handles multiple remote config files with different flags.""" assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" - if self.require_all_exposure_flags: - result_1 = json.loads(self.r1.text) - result_2 = json.loads(self.r2.text) - assert result_1["value"] == "on", f"First flag expected 'on', got: {self.r1.text}" - assert result_2["value"] is True, f"Second flag expected true, got: {self.r2.text}" - assert_wait_results(self.exposure_ready_1, self.exposure_ready_2) - else: - assert_wait_results(self.exposure_ready) + result_1 = json.loads(self.r1.text) + result_2 = json.loads(self.r2.text) + assert result_1["value"] == "on", f"First flag expected 'on', got: {self.r1.text}" + assert result_2["value"] is True, f"Second flag expected true, got: {self.r2.text}" + assert_wait_results(self.exposure_ready_1, self.exposure_ready_2) # Collect all exposure events for our specific flags flags_found = set() @@ -536,13 +480,8 @@ def test_ffe_multiple_remote_config_files(self): ) # Verify that both flags were evaluated and sent exposure events - if self.require_all_exposure_flags: - expected_flags = {self.flag_1, self.flag_2} - assert expected_flags <= flags_found, f"Expected to find flags {expected_flags}, found: {flags_found}" - else: - assert self.flag_1 in flags_found or self.flag_2 in flags_found, ( - f"Expected to find flags '{self.flag_1}' or '{self.flag_2}' in exposure events, found: {flags_found}" - ) + expected_flags = {self.flag_1, self.flag_2} + assert expected_flags <= flags_found, f"Expected to find flags {expected_flags}, found: {flags_found}" @scenarios.feature_flagging_and_experimentation From 7bd493875227fa01ab507959a57d31afaebc1658 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 21:05:19 -0400 Subject: [PATCH 10/20] Use PHP flag readiness for FFE config wait --- tests/ffe/test_exposures.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index 047ae4ec76c..d77e74fd51f 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -90,7 +90,7 @@ def post_ffe( expected_variant: str | None = None, require_exposure_flush: bool = False, ): - """Evaluate FFE, asking PHP to wait for the applied config version in the same request.""" + """Evaluate FFE, asking PHP to retry readiness-sensitive requests in setup.""" timeout = 5 if context.library == "php": payload = { @@ -414,7 +414,6 @@ def setup_ffe_multiple_remote_config_files(self): }, {config_id_1}, config_state_1.version, - wait_for_config_version=True, expected_value="on", require_exposure_flush=True, ) @@ -432,7 +431,6 @@ def setup_ffe_multiple_remote_config_files(self): }, {config_id_2}, config_state_2.version, - wait_for_config_version=True, expected_value=True, require_exposure_flush=True, ) @@ -1026,7 +1024,6 @@ def setup_ffe_exposure_missing_flag(self): {config_id}, config_state.version, wait_for_flag=False, - wait_for_config_version=True, ) self.responses.append(r) From deb6f20b2082e2443238f38854c540cb3dde31ea Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 23:12:52 -0400 Subject: [PATCH 11/20] Simplify PHP FFE exposure flush wait --- docs/understand/weblogs/end-to-end_weblog.md | 11 +- tests/ffe/test_exposures.py | 155 +++---------------- utils/build/docker/php/common/ffe.php | 137 +--------------- 3 files changed, 29 insertions(+), 274 deletions(-) diff --git a/docs/understand/weblogs/end-to-end_weblog.md b/docs/understand/weblogs/end-to-end_weblog.md index ad2a51b2020..2bdfa379e32 100644 --- a/docs/understand/weblogs/end-to-end_weblog.md +++ b/docs/understand/weblogs/end-to-end_weblog.md @@ -1080,18 +1080,15 @@ accept a JSON body with these fields: PHP weblogs may also accept these test-only fields: -- `expectedConfigIds`: remote configuration IDs expected by this evaluation. -- `expectedConfigVersion`: remote configuration version to wait for before - evaluating the flag. -- `configWaitTimeoutMs`: timeout in milliseconds. The endpoint caps the value at - 30000. - `waitForFlag`: whether to retry `FLAG_NOT_FOUND` results until the requested flag is visible in the PHP sidecar-backed evaluator. +- `flagWaitTimeoutMs`: timeout in milliseconds. The endpoint caps the value at + 30000. The response must be JSON and include at least `value` and `reason`. Error responses should also include `errorCode` and `errorMessage`. PHP responses -include `configWait` when `expectedConfigVersion` is provided, and -`flagWaitAttempts` and `exposuresFlushed` when `waitForFlag` is true. +include `flagWaitAttempts` when `waitForFlag` is true and `exposuresFlushed` +after evaluating the flag. ### GET /healthcheck diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index d77e74fd51f..1e4ae37780b 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -1,7 +1,6 @@ """Test feature flags exposure events logging in weblog end-to-end scenario.""" import json -import time from tests.ffe.utils.fixtures import make_exposure_ufc_fixture as make_ufc_fixture from utils import ( @@ -12,15 +11,13 @@ features, remote_config as rc, ) -from utils._weblog import HttpResponse RC_PRODUCT = "FFE_FLAGS" RC_PATH = f"datadog/2/{RC_PRODUCT}" EXPOSURES_PATH = "/api/v2/exposures" EXPOSURE_WAIT_TIMEOUT_SECONDS = 30 -PHP_FFE_CONFIG_WAIT_TIMEOUT_MS = 5000 -_UNSET = object() +PHP_FFE_FLAG_WAIT_TIMEOUT_MS = 5000 WaitResult = tuple[bool, str] @@ -78,87 +75,18 @@ def exposure_flag_keys_seen(flag_keys: set[str], subject_id: str | None = None) return seen -def post_ffe( - payload: dict, - config_ids: set[str], - version: int, - *, - wait_for_flag: bool = True, - wait_for_config_version: bool = False, - expected_value: object = _UNSET, - expected_allocation_key: str | None = None, - expected_variant: str | None = None, - require_exposure_flush: bool = False, -): - """Evaluate FFE, asking PHP to retry readiness-sensitive requests in setup.""" +def post_ffe(payload: dict, *, wait_for_flag: bool = True): + """Evaluate FFE, asking PHP to wait for the requested flag before flushing exposure EVP.""" timeout = 5 if context.library == "php": payload = { **payload, - "configWaitTimeoutMs": PHP_FFE_CONFIG_WAIT_TIMEOUT_MS, "waitForFlag": wait_for_flag, + "flagWaitTimeoutMs": PHP_FFE_FLAG_WAIT_TIMEOUT_MS, } - if wait_for_config_version: - payload["expectedConfigIds"] = sorted(config_ids) - payload["expectedConfigVersion"] = version - timeout = (PHP_FFE_CONFIG_WAIT_TIMEOUT_MS * 2 // 1000) + 5 - - if context.library != "php": - return weblog.post("/ffe", json=payload, timeout=timeout) - - deadline = time.monotonic() + EXPOSURE_WAIT_TIMEOUT_SECONDS - response = None - while response is None or time.monotonic() < deadline: - response = weblog.post("/ffe", json=payload, timeout=timeout) - if php_ffe_response_ready( - response, - wait_for_flag=wait_for_flag, - expected_value=expected_value, - expected_allocation_key=expected_allocation_key, - expected_variant=expected_variant, - require_exposure_flush=require_exposure_flush, - ): - return response - time.sleep(0.1) - - return response - - -def php_ffe_response_ready( - response: HttpResponse, - *, - wait_for_flag: bool, - expected_value: object = _UNSET, - expected_allocation_key: str | None = None, - expected_variant: str | None = None, - require_exposure_flush: bool = False, -) -> bool: - """Return whether a PHP FFE response should stop setup-time request retries.""" - if response.status_code != 200 or response.text is None: - return False - - try: - payload = json.loads(response.text) - except json.JSONDecodeError: - return True - - error_code = payload.get("errorCode") - if error_code == "PROVIDER_NOT_READY": - return False - if wait_for_flag and error_code == "FLAG_NOT_FOUND": - return False - if require_exposure_flush and payload.get("exposuresFlushed") is not True: - return False - if expected_value is not _UNSET and payload.get("value") != expected_value: - return False - if expected_variant is not None and payload.get("variant") != expected_variant: - return False - if expected_allocation_key is not None: - exposure_data = payload.get("exposureData") - allocation_key = exposure_data.get("allocationKey") if isinstance(exposure_data, dict) else None - if allocation_key != expected_allocation_key: - return False - return True + timeout = (PHP_FFE_FLAG_WAIT_TIMEOUT_MS * 2 // 1000) + 5 + + return weblog.post("/ffe", json=payload, timeout=timeout) def flush_ffe_payloads() -> WaitResult: @@ -264,7 +192,7 @@ def setup_ffe_exposure_event_generation(self): # Set up Remote Config config_id = "ffe-test-config" rc_config = UFC_FIXTURE_DATA - config_state = rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", rc_config).apply() + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", rc_config).apply() # Evaluate a feature flag self.flag = "test-flag" @@ -281,9 +209,6 @@ def setup_ffe_exposure_event_generation(self): "targetingKey": self.targeting_key, "attributes": attributes, }, - {config_id}, - config_state.version, - require_exposure_flush=True, ) self.exposure_ready = wait_for_exposure_event({self.flag}, self.targeting_key) @@ -403,7 +328,7 @@ def setup_ffe_multiple_remote_config_files(self): self.flag_2 = "test-flag-2" self.targeting_key = "test-user-multi" - config_state_1 = rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).apply() + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).apply() self.r1 = post_ffe( { "flag": self.flag_1, @@ -412,14 +337,10 @@ def setup_ffe_multiple_remote_config_files(self): "targetingKey": self.targeting_key, "attributes": {}, }, - {config_id_1}, - config_state_1.version, - expected_value="on", - require_exposure_flush=True, ) self.exposure_ready_1 = wait_for_exposure_event({self.flag_1}, self.targeting_key) - config_state_2 = rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2).apply() + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2).apply() self.r2 = post_ffe( { @@ -429,10 +350,6 @@ def setup_ffe_multiple_remote_config_files(self): "targetingKey": self.targeting_key, "attributes": {}, }, - {config_id_2}, - config_state_2.version, - expected_value=True, - require_exposure_flush=True, ) self.exposure_ready_2 = wait_for_exposure_event({self.flag_2}, self.targeting_key) @@ -555,7 +472,7 @@ def setup_ffe_malformed_remote_config_rejection(self): }, } - config_state = rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", valid_rc_config).apply() + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", valid_rc_config).apply() # Evaluate the flag with valid config self.flag = "test-flag-resilient" @@ -569,9 +486,6 @@ def setup_ffe_malformed_remote_config_rejection(self): "targetingKey": self.targeting_key, "attributes": {}, }, - {config_id}, - config_state.version, - require_exposure_flush=True, ) self.exposure_ready = wait_for_exposure_event({self.flag}, self.targeting_key) @@ -605,8 +519,6 @@ def setup_ffe_malformed_remote_config_rejection(self): "targetingKey": self.targeting_key, "attributes": {}, }, - {config_id}, - config_state.version, ) def test_ffe_malformed_remote_config_rejection(self): @@ -679,11 +591,7 @@ def setup_ffe_exposure_caching_same_subject(self): """Set up FFE exposure caching test with multiple evaluations for the same subject.""" config_id = "ffe-caching-test" self.flag_key = "same-subject-test-flag" # Unique flag key for this test - config_state = ( - rc.tracer_rc_state.reset() - .set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)) - .apply() - ) + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)).apply() self.targeting_key = "same-subject-user" @@ -698,9 +606,6 @@ def setup_ffe_exposure_caching_same_subject(self): "targetingKey": self.targeting_key, "attributes": {}, }, - {config_id}, - config_state.version, - require_exposure_flush=_i == 0, ) self.responses.append(r) self.exposure_ready = wait_for_min_exposure_count(self.flag_key, 1, self.targeting_key) @@ -740,11 +645,7 @@ def setup_ffe_exposure_caching_different_subjects(self): """Set up FFE exposure caching test with multiple different subjects.""" config_id = "ffe-caching-test-subjects" self.flag_key = "diff-subjects-test-flag" # Unique flag key for this test - config_state = ( - rc.tracer_rc_state.reset() - .set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)) - .apply() - ) + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)).apply() self.subjects = [f"unique-subject-{i}" for i in range(5)] @@ -759,9 +660,6 @@ def setup_ffe_exposure_caching_different_subjects(self): "targetingKey": subject, "attributes": {}, }, - {config_id}, - config_state.version, - require_exposure_flush=True, ) self.responses.append(r) self.exposure_wait_results = [ @@ -1001,11 +899,9 @@ def setup_ffe_exposure_missing_flag(self): """Set up FFE exposure test for a missing flag.""" # Set up a config with a different flag (not the one we'll request) config_id = "ffe-missing-flag-test" - config_state = ( - rc.tracer_rc_state.reset() - .set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture("some-other-flag")) - .apply() - ) + rc.tracer_rc_state.reset().set_config( + f"{RC_PATH}/{config_id}/config", make_ufc_fixture("some-other-flag") + ).apply() self.flag_key = "non-existent-flag" # This flag doesn't exist in the config self.targeting_key = "missing-flag-user" @@ -1021,8 +917,6 @@ def setup_ffe_exposure_missing_flag(self): "targetingKey": self.targeting_key, "attributes": {}, }, - {config_id}, - config_state.version, wait_for_flag=False, ) self.responses.append(r) @@ -1087,11 +981,7 @@ def setup_ffe_exposure_dolog_false(self): self.targeting_key = "dolog-false-user" # Set up config with doLog=false - config_state = ( - rc.tracer_rc_state.reset() - .set_config(f"{RC_PATH}/{config_id}/config", UFC_EXPOSURE_DOLOG_FALSE_FIXTURE) - .apply() - ) + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", UFC_EXPOSURE_DOLOG_FALSE_FIXTURE).apply() # Evaluate the flag multiple times self.responses = [] @@ -1104,8 +994,6 @@ def setup_ffe_exposure_dolog_false(self): "targetingKey": self.targeting_key, "attributes": {}, }, - {config_id}, - config_state.version, ) self.responses.append(r) @@ -1144,11 +1032,7 @@ def setup_ffe_exp_5_missing_targeting_key(self): self.flag_key = "exp-5-missing-targeting-key-flag" # Use a simple fixture with doLog=true - config_state = ( - rc.tracer_rc_state.reset() - .set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)) - .apply() - ) + rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)).apply() # Evaluate the flag with an empty targeting key self.response = post_ffe( @@ -1159,9 +1043,6 @@ def setup_ffe_exp_5_missing_targeting_key(self): "targetingKey": "", # Empty targeting key "attributes": {}, }, - {config_id}, - config_state.version, - require_exposure_flush=True, ) self.exposure_ready = wait_for_exposure_event({self.flag_key}, "") diff --git a/utils/build/docker/php/common/ffe.php b/utils/build/docker/php/common/ffe.php index f1ae9dce08a..9efe55cc51d 100644 --- a/utils/build/docker/php/common/ffe.php +++ b/utils/build/docker/php/common/ffe.php @@ -19,91 +19,6 @@ function dd_ffe_error_response($statusCode, $errorCode, $errorMessage) )); } -function dd_ffe_loaded_remote_config_keys() -{ - if (!function_exists('dd_trace_internal_fn')) { - return array(); - } - - $loadedConfigs = dd_trace_internal_fn('get_loaded_remote_configs'); - if (!is_array($loadedConfigs)) { - return array(); - } - - return array_keys($loadedConfigs); -} - -function dd_ffe_config_key_matches($loadedKey, $expectedId) -{ - return $loadedKey === $expectedId || strpos($loadedKey, $expectedId) !== false; -} - -function dd_ffe_missing_config_ids(array $loadedKeys, array $expectedIds) -{ - $missing = array(); - foreach ($expectedIds as $expectedId) { - $found = false; - foreach ($loadedKeys as $loadedKey) { - if (dd_ffe_config_key_matches($loadedKey, $expectedId)) { - $found = true; - break; - } - } - if (!$found) { - $missing[] = $expectedId; - } - } - - return $missing; -} - -function dd_ffe_config_status() -{ - $nativeStateAvailable = function_exists('DDTrace\\ffe_has_config') && function_exists('DDTrace\\ffe_config_version'); - - return array( - 'nativeStateAvailable' => $nativeStateAvailable, - 'hasConfig' => $nativeStateAvailable ? \DDTrace\ffe_has_config() : false, - 'configVersion' => $nativeStateAvailable ? \DDTrace\ffe_config_version() : null, - ); -} - -function dd_ffe_wait_for_config_state(array $ids, $expectedVersion, $timeoutMs) -{ - $timeoutMs = max(0, min($timeoutMs, 30000)); - $deadline = microtime(true) * 1000 + $timeoutMs; - $loadedKeys = array(); - $missing = $ids; - $status = dd_ffe_config_status(); - $ready = false; - - do { - $status = dd_ffe_config_status(); - $loadedKeys = dd_ffe_loaded_remote_config_keys(); - $missing = dd_ffe_missing_config_ids($loadedKeys, $ids); - if ($status['nativeStateAvailable']) { - $ready = $status['hasConfig'] - && ($expectedVersion === null || $status['configVersion'] >= $expectedVersion); - } else { - $ready = count($missing) === 0; - } - - if ($ready) { - $missing = array(); - break; - } - usleep(10000); - } while (microtime(true) * 1000 < $deadline); - - return array( - 'ready' => $ready, - 'missing' => array_values($missing), - 'loadedConfigKeys' => array_values($loadedKeys), - 'expectedVersion' => $expectedVersion, - 'nativeState' => $status, - ); -} - function dd_ffe_flush_exposures() { if (function_exists('DDTrace\\Testing\\flush_ffe_exposures')) { @@ -129,19 +44,6 @@ function dd_ffe_read_payload() return $payload; } -function dd_ffe_expected_config_ids(array $payload) -{ - if (isset($payload['expectedConfigIds']) && is_array($payload['expectedConfigIds'])) { - return array_values(array_filter(array_map('strval', $payload['expectedConfigIds']))); - } - - if (isset($payload['expectedConfigId']) && is_string($payload['expectedConfigId'])) { - return array($payload['expectedConfigId']); - } - - return array(); -} - function dd_ffe_normalized_variation_type($variationType) { return strtoupper(str_replace('-', '_', (string) $variationType)); @@ -240,6 +142,12 @@ function dd_ffe_should_wait_for_flag(array $payload) return isset($payload['waitForFlag']) && $payload['waitForFlag'] === true; } +function dd_ffe_flag_wait_timeout_ms(array $payload) +{ + $timeoutMs = isset($payload['flagWaitTimeoutMs']) ? (int) $payload['flagWaitTimeoutMs'] : 5000; + return max(0, min($timeoutMs, 30000)); +} + function dd_ffe_should_retry_evaluation($details) { if (!method_exists($details, 'getErrorCode')) { @@ -259,7 +167,6 @@ function dd_ffe_evaluate_until_flag_ready( $timeoutMs, &$attempts ) { - $timeoutMs = max(0, min((int) $timeoutMs, 30000)); $deadline = microtime(true) * 1000 + $timeoutMs; $attempts = 0; $details = null; @@ -316,15 +223,6 @@ function dd_ffe_details_payload($details) $flagKey = $payload['flag']; $variationType = $payload['variationType']; $defaultValue = dd_ffe_normalize_default_value($payload['defaultValue'], $variationType); -$configWait = null; -$configWaitTimeoutMs = isset($payload['configWaitTimeoutMs']) ? (int) $payload['configWaitTimeoutMs'] : 5000; -if (isset($payload['expectedConfigVersion'])) { - $configWait = dd_ffe_wait_for_config_state( - dd_ffe_expected_config_ids($payload), - (int) $payload['expectedConfigVersion'], - $configWaitTimeoutMs - ); -} $targetingKey = isset($payload['targetingKey']) && $payload['targetingKey'] !== null ? (string) $payload['targetingKey'] : null; @@ -335,22 +233,6 @@ function dd_ffe_details_payload($details) ? dd_ffe_scalar_attributes($payload['attributes']) : array(); -if ($configWait !== null && $configWait['ready'] !== true) { - dd_ffe_json_response(200, array( - 'value' => $defaultValue, - 'reason' => 'ERROR', - 'variant' => null, - 'errorCode' => 'PROVIDER_NOT_READY', - 'errorMessage' => 'Timed out waiting for PHP FFE config before evaluation.', - 'configWait' => $configWait, - 'providerState' => array( - 'ready' => false, - 'productionRuntime' => false, - ), - )); - return; -} - try { $details = null; $flagWaitAttempts = 0; @@ -364,7 +246,7 @@ function dd_ffe_details_payload($details) $defaultValue, $key, $attributes, - $configWaitTimeoutMs, + dd_ffe_flag_wait_timeout_ms($payload), $attempts ); $flagWaitAttempts += $attempts; @@ -374,9 +256,6 @@ function dd_ffe_details_payload($details) } if ($details !== null) { $response = dd_ffe_details_payload($details); - if ($configWait !== null) { - $response['configWait'] = $configWait; - } if ($waitForFlag) { $response['flagWaitAttempts'] = $flagWaitAttempts; } @@ -392,7 +271,6 @@ function dd_ffe_details_payload($details) 'variant' => null, 'errorCode' => 'PROVIDER_NOT_READY', 'errorMessage' => $exception->getMessage(), - 'configWait' => $configWait, 'providerState' => array( 'ready' => false, 'productionRuntime' => false, @@ -407,7 +285,6 @@ function dd_ffe_details_payload($details) 'variant' => null, 'errorCode' => 'PROVIDER_NOT_READY', 'errorMessage' => 'Datadog-backed PHP feature flag evaluation is not wired in this weblog yet.', - 'configWait' => $configWait, 'providerState' => array( 'ready' => false, 'productionRuntime' => false, From fd38b6b6359e73dcbccfa57ec2422e1ad6a62742 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 23:27:33 -0400 Subject: [PATCH 12/20] Use universal FFE wait request fields --- docs/understand/weblogs/end-to-end_weblog.md | 10 +++++----- tests/ffe/test_exposures.py | 19 ++++++++----------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/docs/understand/weblogs/end-to-end_weblog.md b/docs/understand/weblogs/end-to-end_weblog.md index 2bdfa379e32..1c3c329c18d 100644 --- a/docs/understand/weblogs/end-to-end_weblog.md +++ b/docs/understand/weblogs/end-to-end_weblog.md @@ -1078,17 +1078,17 @@ accept a JSON body with these fields: - `targetingKey`: the evaluation subject key. - `attributes`: flat scalar targeting attributes. -PHP weblogs may also accept these test-only fields: +Weblogs may also accept these test-only fields: - `waitForFlag`: whether to retry `FLAG_NOT_FOUND` results until the requested - flag is visible in the PHP sidecar-backed evaluator. + flag is visible in the evaluator. - `flagWaitTimeoutMs`: timeout in milliseconds. The endpoint caps the value at 30000. The response must be JSON and include at least `value` and `reason`. Error -responses should also include `errorCode` and `errorMessage`. PHP responses -include `flagWaitAttempts` when `waitForFlag` is true and `exposuresFlushed` -after evaluating the flag. +responses should also include `errorCode` and `errorMessage`. Weblogs that +implement `waitForFlag` may include `flagWaitAttempts`, and weblogs that flush +exposure events may include `exposuresFlushed`. ### GET /healthcheck diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index 1e4ae37780b..c368d113130 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -17,7 +17,7 @@ RC_PATH = f"datadog/2/{RC_PRODUCT}" EXPOSURES_PATH = "/api/v2/exposures" EXPOSURE_WAIT_TIMEOUT_SECONDS = 30 -PHP_FFE_FLAG_WAIT_TIMEOUT_MS = 5000 +FFE_FLAG_WAIT_TIMEOUT_MS = 5000 WaitResult = tuple[bool, str] @@ -76,16 +76,13 @@ def exposure_flag_keys_seen(flag_keys: set[str], subject_id: str | None = None) def post_ffe(payload: dict, *, wait_for_flag: bool = True): - """Evaluate FFE, asking PHP to wait for the requested flag before flushing exposure EVP.""" - timeout = 5 - if context.library == "php": - payload = { - **payload, - "waitForFlag": wait_for_flag, - "flagWaitTimeoutMs": PHP_FFE_FLAG_WAIT_TIMEOUT_MS, - } - timeout = (PHP_FFE_FLAG_WAIT_TIMEOUT_MS * 2 // 1000) + 5 - + """Evaluate FFE with test coordination fields understood by weblogs that need them.""" + payload = { + **payload, + "waitForFlag": wait_for_flag, + "flagWaitTimeoutMs": FFE_FLAG_WAIT_TIMEOUT_MS, + } + timeout = (FFE_FLAG_WAIT_TIMEOUT_MS * 2 // 1000) + 5 return weblog.post("/ffe", json=payload, timeout=timeout) From 7b2a6b20d3050a929de21de7a8400e5d842957cb Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 29 Jun 2026 23:59:58 -0400 Subject: [PATCH 13/20] Restore direct FFE exposure requests --- tests/ffe/test_exposures.py | 71 +++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 39 deletions(-) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index c368d113130..69223e3db26 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -4,7 +4,6 @@ from tests.ffe.utils.fixtures import make_exposure_ufc_fixture as make_ufc_fixture from utils import ( - context, weblog, interfaces, scenarios, @@ -17,7 +16,6 @@ RC_PATH = f"datadog/2/{RC_PRODUCT}" EXPOSURES_PATH = "/api/v2/exposures" EXPOSURE_WAIT_TIMEOUT_SECONDS = 30 -FFE_FLAG_WAIT_TIMEOUT_MS = 5000 WaitResult = tuple[bool, str] @@ -75,24 +73,10 @@ def exposure_flag_keys_seen(flag_keys: set[str], subject_id: str | None = None) return seen -def post_ffe(payload: dict, *, wait_for_flag: bool = True): - """Evaluate FFE with test coordination fields understood by weblogs that need them.""" - payload = { - **payload, - "waitForFlag": wait_for_flag, - "flagWaitTimeoutMs": FFE_FLAG_WAIT_TIMEOUT_MS, - } - timeout = (FFE_FLAG_WAIT_TIMEOUT_MS * 2 // 1000) + 5 - return weblog.post("/ffe", json=payload, timeout=timeout) - - def flush_ffe_payloads() -> WaitResult: - """Flush FFE payloads for weblogs that expose a flush endpoint.""" - if context.library not in ("nodejs", "ruby"): - return True, "" - + """Flush buffered payloads when the weblog exposes the common flush endpoint.""" response = weblog.get("/flush", timeout=10) - if response.status_code == 200: + if response.status_code in (200, 404, 405): return True, "" return False, f"Failed to flush FFE payloads with status {response.status_code}: {response.text}" @@ -198,8 +182,9 @@ def setup_ffe_exposure_event_generation(self): self.targeting_key = "test-user" attributes: dict[str, str] = {} - self.r = post_ffe( - { + self.r = weblog.post( + "/ffe", + json={ "flag": self.flag, "variationType": variation_type, "defaultValue": default_value, @@ -326,8 +311,9 @@ def setup_ffe_multiple_remote_config_files(self): self.targeting_key = "test-user-multi" rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).apply() - self.r1 = post_ffe( - { + self.r1 = weblog.post( + "/ffe", + json={ "flag": self.flag_1, "variationType": "STRING", "defaultValue": "default", @@ -339,8 +325,9 @@ def setup_ffe_multiple_remote_config_files(self): rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2).apply() - self.r2 = post_ffe( - { + self.r2 = weblog.post( + "/ffe", + json={ "flag": self.flag_2, "variationType": "BOOLEAN", "defaultValue": False, @@ -475,8 +462,9 @@ def setup_ffe_malformed_remote_config_rejection(self): self.flag = "test-flag-resilient" self.targeting_key = "test-user-resilient" - self.r1 = post_ffe( - { + self.r1 = weblog.post( + "/ffe", + json={ "flag": self.flag, "variationType": "STRING", "defaultValue": "default", @@ -508,8 +496,9 @@ def setup_ffe_malformed_remote_config_rejection(self): rc.tracer_rc_state.set_config(f"{RC_PATH}/{config_id}/config", malformed_rc_config).apply() # Evaluate the flag again after malformed config update - self.r2 = post_ffe( - { + self.r2 = weblog.post( + "/ffe", + json={ "flag": self.flag, "variationType": "STRING", "defaultValue": "default", @@ -595,8 +584,9 @@ def setup_ffe_exposure_caching_same_subject(self): # Evaluate the same flag multiple times with the same subject self.responses = [] for _i in range(5): - r = post_ffe( - { + r = weblog.post( + "/ffe", + json={ "flag": self.flag_key, "variationType": "STRING", "defaultValue": "default", @@ -649,8 +639,9 @@ def setup_ffe_exposure_caching_different_subjects(self): # Evaluate the flag with different subjects self.responses = [] for subject in self.subjects: - r = post_ffe( - { + r = weblog.post( + "/ffe", + json={ "flag": self.flag_key, "variationType": "STRING", "defaultValue": "default", @@ -906,15 +897,15 @@ def setup_ffe_exposure_missing_flag(self): # Evaluate a flag that doesn't exist self.responses = [] for _i in range(3): - r = post_ffe( - { + r = weblog.post( + "/ffe", + json={ "flag": self.flag_key, "variationType": "STRING", "defaultValue": "default-value", "targetingKey": self.targeting_key, "attributes": {}, }, - wait_for_flag=False, ) self.responses.append(r) @@ -983,8 +974,9 @@ def setup_ffe_exposure_dolog_false(self): # Evaluate the flag multiple times self.responses = [] for _i in range(3): - r = post_ffe( - { + r = weblog.post( + "/ffe", + json={ "flag": self.flag_key, "variationType": "STRING", "defaultValue": "default", @@ -1032,8 +1024,9 @@ def setup_ffe_exp_5_missing_targeting_key(self): rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id}/config", make_ufc_fixture(self.flag_key)).apply() # Evaluate the flag with an empty targeting key - self.response = post_ffe( - { + self.response = weblog.post( + "/ffe", + json={ "flag": self.flag_key, "variationType": "STRING", "defaultValue": "default", From 14d5eb98902adbadbab10fc3df860c3ac4c8e402 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 00:01:43 -0400 Subject: [PATCH 14/20] Address FFE exposure review cleanup --- tests/ffe/test_exposures.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index 69223e3db26..85c8ecc3642 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -305,11 +305,11 @@ def setup_ffe_multiple_remote_config_files(self): }, } - # Evaluate both feature flags self.flag_1 = "test-flag-1" self.flag_2 = "test-flag-2" self.targeting_key = "test-user-multi" + # UFC config is replaced, not merged; evaluate each flag while its config is current. rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).apply() self.r1 = weblog.post( "/ffe", @@ -321,6 +321,7 @@ def setup_ffe_multiple_remote_config_files(self): "attributes": {}, }, ) + # Store setup-time wait results; tests run after teardown and cannot produce new payloads. self.exposure_ready_1 = wait_for_exposure_event({self.flag_1}, self.targeting_key) rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2).apply() @@ -752,17 +753,16 @@ def test_ffe_exposure_caching_allocation_cycle(self): """Test that allocation-a → allocation-b → allocation-a generates 3 exposures.""" # Verify step 1: variant-a from default-allocation assert self.response_1.status_code == 200, f"Request 1 failed: {self.response_1.text}" - assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" - assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" - result_1 = json.loads(self.response_1.text) assert result_1["value"] == "value-a", f"Request 1: expected 'value-a', got '{result_1['value']}'" # Verify step 2: variant-a from different-allocation + assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" result_2 = json.loads(self.response_2.text) assert result_2["value"] == "value-a", f"Request 2: expected 'value-a', got '{result_2['value']}'" # Verify step 3: variant-a from default-allocation again + assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" result_3 = json.loads(self.response_3.text) assert result_3["value"] == "value-a", f"Request 3: expected 'value-a', got '{result_3['value']}'" @@ -848,17 +848,16 @@ def test_ffe_exposure_caching_variant_cycle(self): """Test that variant-a → variant-b → variant-a generates 3 exposures.""" # Verify step 1: variant-a assert self.response_1.status_code == 200, f"Request 1 failed: {self.response_1.text}" - assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" - assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" - result_1 = json.loads(self.response_1.text) assert result_1["value"] == "value-a", f"Request 1: expected 'value-a', got '{result_1['value']}'" # Verify step 2: variant-b + assert self.response_2.status_code == 200, f"Request 2 failed: {self.response_2.text}" result_2 = json.loads(self.response_2.text) assert result_2["value"] == "value-b", f"Request 2: expected 'value-b', got '{result_2['value']}'" # Verify step 3: variant-a again + assert self.response_3.status_code == 200, f"Request 3 failed: {self.response_3.text}" result_3 = json.loads(self.response_3.text) assert result_3["value"] == "value-a", f"Request 3: expected 'value-a', got '{result_3['value']}'" @@ -919,6 +918,7 @@ def test_ffe_exposure_missing_flag(self): assert result["value"] == "default-value", ( f"Request {i + 1}: expected 'default-value', got '{result['value']}'" ) + # Count exposure events - should be 0 because flag doesn't exist exposure_count = count_exposure_events(self.flag_key, self.targeting_key) From df3b8000303b6b09b3cff49cdc091beda41a8206 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 00:05:35 -0400 Subject: [PATCH 15/20] Narrow FFE exposure review changes --- docs/understand/weblogs/end-to-end_weblog.md | 11 +---------- tests/ffe/test_exposures.py | 9 +++------ 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/docs/understand/weblogs/end-to-end_weblog.md b/docs/understand/weblogs/end-to-end_weblog.md index 1c3c329c18d..dd941941ab2 100644 --- a/docs/understand/weblogs/end-to-end_weblog.md +++ b/docs/understand/weblogs/end-to-end_weblog.md @@ -1078,17 +1078,8 @@ accept a JSON body with these fields: - `targetingKey`: the evaluation subject key. - `attributes`: flat scalar targeting attributes. -Weblogs may also accept these test-only fields: - -- `waitForFlag`: whether to retry `FLAG_NOT_FOUND` results until the requested - flag is visible in the evaluator. -- `flagWaitTimeoutMs`: timeout in milliseconds. The endpoint caps the value at - 30000. - The response must be JSON and include at least `value` and `reason`. Error -responses should also include `errorCode` and `errorMessage`. Weblogs that -implement `waitForFlag` may include `flagWaitAttempts`, and weblogs that flush -exposure events may include `exposuresFlushed`. +responses should also include `errorCode` and `errorMessage`. ### GET /healthcheck diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index 85c8ecc3642..55a86ab0b73 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -342,10 +342,6 @@ def test_ffe_multiple_remote_config_files(self): """Test that FFE correctly handles multiple remote config files with different flags.""" assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" - result_1 = json.loads(self.r1.text) - result_2 = json.loads(self.r2.text) - assert result_1["value"] == "on", f"First flag expected 'on', got: {self.r1.text}" - assert result_2["value"] is True, f"Second flag expected true, got: {self.r2.text}" assert_wait_results(self.exposure_ready_1, self.exposure_ready_2) # Collect all exposure events for our specific flags @@ -380,8 +376,9 @@ def test_ffe_multiple_remote_config_files(self): ) # Verify that both flags were evaluated and sent exposure events - expected_flags = {self.flag_1, self.flag_2} - assert expected_flags <= flags_found, f"Expected to find flags {expected_flags}, found: {flags_found}" + assert self.flag_1 in flags_found or self.flag_2 in flags_found, ( + f"Expected to find flags '{self.flag_1}' or '{self.flag_2}' in exposure events, found: {flags_found}" + ) @scenarios.feature_flagging_and_experimentation From 9be2205fcc96d1e80246fcc83ae5fba96fce6c6c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 08:29:03 -0400 Subject: [PATCH 16/20] Remove PHP FFE evaluation wait helper --- utils/build/docker/php/common/ffe.php | 67 +-------------------------- 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/utils/build/docker/php/common/ffe.php b/utils/build/docker/php/common/ffe.php index 9efe55cc51d..88aa42fea7c 100644 --- a/utils/build/docker/php/common/ffe.php +++ b/utils/build/docker/php/common/ffe.php @@ -137,52 +137,6 @@ function dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $targetingKey, } } -function dd_ffe_should_wait_for_flag(array $payload) -{ - return isset($payload['waitForFlag']) && $payload['waitForFlag'] === true; -} - -function dd_ffe_flag_wait_timeout_ms(array $payload) -{ - $timeoutMs = isset($payload['flagWaitTimeoutMs']) ? (int) $payload['flagWaitTimeoutMs'] : 5000; - return max(0, min($timeoutMs, 30000)); -} - -function dd_ffe_should_retry_evaluation($details) -{ - if (!method_exists($details, 'getErrorCode')) { - return false; - } - - $errorCode = $details->getErrorCode(); - return $errorCode === 'FLAG_NOT_FOUND' || $errorCode === 'PROVIDER_NOT_READY'; -} - -function dd_ffe_evaluate_until_flag_ready( - $flagKey, - $variationType, - $defaultValue, - $targetingKey, - array $attributes, - $timeoutMs, - &$attempts -) { - $deadline = microtime(true) * 1000 + $timeoutMs; - $attempts = 0; - $details = null; - - do { - $attempts++; - $details = dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $targetingKey, $attributes); - if (!dd_ffe_should_retry_evaluation($details)) { - break; - } - usleep(10000); - } while (microtime(true) * 1000 < $deadline); - - return $details; -} - function dd_ffe_details_payload($details) { $payload = array( @@ -235,30 +189,11 @@ function dd_ffe_details_payload($details) try { $details = null; - $flagWaitAttempts = 0; - $waitForFlag = dd_ffe_should_wait_for_flag($payload); foreach ($targetingKeys as $key) { - if ($waitForFlag) { - $attempts = 0; - $details = dd_ffe_evaluate_until_flag_ready( - $flagKey, - $variationType, - $defaultValue, - $key, - $attributes, - dd_ffe_flag_wait_timeout_ms($payload), - $attempts - ); - $flagWaitAttempts += $attempts; - } else { - $details = dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $key, $attributes); - } + $details = dd_ffe_evaluate($flagKey, $variationType, $defaultValue, $key, $attributes); } if ($details !== null) { $response = dd_ffe_details_payload($details); - if ($waitForFlag) { - $response['flagWaitAttempts'] = $flagWaitAttempts; - } $response['count'] = count($targetingKeys); $response['exposuresFlushed'] = dd_ffe_flush_exposures(); dd_ffe_json_response(200, $response); From 055a024769db492d8f424a25033c6765b1719397 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 09:45:01 -0400 Subject: [PATCH 17/20] Restore FFE exposure setup waits before exact counts --- tests/ffe/test_exposures.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index 55a86ab0b73..8eab7478744 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -745,6 +745,7 @@ def setup_ffe_exposure_caching_allocation_cycle(self): "attributes": {}, }, ) + self.exposure_ready = wait_for_min_exposure_count(self.flag_key, 3, self.targeting_key) def test_ffe_exposure_caching_allocation_cycle(self): """Test that allocation-a → allocation-b → allocation-a generates 3 exposures.""" @@ -767,6 +768,7 @@ def test_ffe_exposure_caching_allocation_cycle(self): # - Exposure #1: default-allocation # - Exposure #2: different-allocation (allocation changed) # - Exposure #3: default-allocation (allocation changed back) + assert_wait_results(self.exposure_ready) exposure_count = count_exposure_events(self.flag_key, self.targeting_key) assert exposure_count == 3, ( @@ -840,6 +842,7 @@ def setup_ffe_exposure_caching_variant_cycle(self): "attributes": {}, }, ) + self.exposure_ready = wait_for_min_exposure_count(self.flag_key, 3, self.targeting_key) def test_ffe_exposure_caching_variant_cycle(self): """Test that variant-a → variant-b → variant-a generates 3 exposures.""" @@ -862,6 +865,7 @@ def test_ffe_exposure_caching_variant_cycle(self): # - Exposure #1: variant-a # - Exposure #2: variant-b (variant changed) # - Exposure #3: variant-a (variant changed back) + assert_wait_results(self.exposure_ready) exposure_count = count_exposure_events(self.flag_key, self.targeting_key) assert exposure_count == 3, ( From bd5f1910fcf44915adf0546795b4a11f771a6b81 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 13:59:29 -0400 Subject: [PATCH 18/20] Wait for late FFE eval metrics during setup --- tests/ffe/test_flag_eval_metrics.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/ffe/test_flag_eval_metrics.py b/tests/ffe/test_flag_eval_metrics.py index 245bfb169a5..890026e6e89 100644 --- a/tests/ffe/test_flag_eval_metrics.py +++ b/tests/ffe/test_flag_eval_metrics.py @@ -12,6 +12,10 @@ RC_PRODUCT = "FFE_FLAGS" RC_PATH = f"datadog/2/{RC_PRODUCT}" +EVAL_METRIC_WAIT_TIMEOUT_SECONDS = 30 +EXPECTED_EVAL_METRIC_FLAGS: set[str] = set() + +WaitResult = tuple[bool, str] def find_eval_metrics(flag_key: str | None = None): @@ -34,6 +38,23 @@ def find_eval_metrics(flag_key: str | None = None): return results +def wait_for_expected_eval_metrics() -> WaitResult: + """Wait for metric payloads that are known to arrive near the end of setup.""" + missing = {flag_key for flag_key in EXPECTED_EVAL_METRIC_FLAGS if len(find_eval_metrics(flag_key)) == 0} + + def has_expected_metrics(_: dict) -> bool: + missing.difference_update(flag_key for flag_key in list(missing) if len(find_eval_metrics(flag_key)) > 0) + return not missing + + if not missing or interfaces.agent.wait_for( + has_expected_metrics, + timeout=EVAL_METRIC_WAIT_TIMEOUT_SECONDS, + ): + return True, "" + + return False, f"Timed out waiting for feature_flag.evaluations metrics for flags {sorted(missing)}" + + def get_tag_value(tags: list[str], key: str): """Extract a tag value from a list of 'key:value' strings.""" prefix = f"{key}:" @@ -617,6 +638,7 @@ def setup_ffe_eval_config_exists_flag_missing(self): "attributes": {}, }, ) + EXPECTED_EVAL_METRIC_FLAGS.add(self.flag_key) def test_ffe_eval_config_exists_flag_missing(self): """Test that missing flag (with config loaded) produces error.type=flag_not_found.""" @@ -979,6 +1001,7 @@ def setup_ffe_eval_nested_attributes_ignored(self): "attributes": {"nested": {"inner": "value"}, "flat": "value"}, # Nested dict should be ignored }, ) + EXPECTED_EVAL_METRIC_FLAGS.add(self.flag_key) def test_ffe_eval_nested_attributes_ignored(self): """Test that nested attributes are ignored and evaluation succeeds (OF.3).""" @@ -1027,6 +1050,7 @@ def setup_ffe_lowercase_reason(self): "attributes": {}, }, ) + EXPECTED_EVAL_METRIC_FLAGS.add(self.flag_key) def test_ffe_lowercase_reason(self): """Test that reason values are lowercase.""" @@ -1060,10 +1084,14 @@ def setup_ffe_lowercase_error_type(self): "attributes": {}, }, ) + EXPECTED_EVAL_METRIC_FLAGS.add(self.error_flag_key) + self.metric_drain_ready = wait_for_expected_eval_metrics() def test_ffe_lowercase_error_type(self): """Test that error.type values are lowercase.""" assert self.r_error.status_code == 200, f"Flag evaluation request failed: {self.r_error.text}" + success, failure_message = self.metric_drain_ready + assert success, failure_message metrics = find_eval_metrics(self.error_flag_key) assert len(metrics) > 0, f"Expected metric for flag '{self.error_flag_key}', found none." From 97d0ca43d94bfc6e16da5cd4a9a5f4d7635ebba3 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 15:25:52 -0400 Subject: [PATCH 19/20] Keep FFE metric drain best effort --- tests/ffe/test_flag_eval_metrics.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/ffe/test_flag_eval_metrics.py b/tests/ffe/test_flag_eval_metrics.py index 890026e6e89..2a79e731570 100644 --- a/tests/ffe/test_flag_eval_metrics.py +++ b/tests/ffe/test_flag_eval_metrics.py @@ -15,8 +15,6 @@ EVAL_METRIC_WAIT_TIMEOUT_SECONDS = 30 EXPECTED_EVAL_METRIC_FLAGS: set[str] = set() -WaitResult = tuple[bool, str] - def find_eval_metrics(flag_key: str | None = None): """Find feature_flag.evaluations metrics in agent data. @@ -38,7 +36,7 @@ def find_eval_metrics(flag_key: str | None = None): return results -def wait_for_expected_eval_metrics() -> WaitResult: +def wait_for_expected_eval_metrics() -> None: """Wait for metric payloads that are known to arrive near the end of setup.""" missing = {flag_key for flag_key in EXPECTED_EVAL_METRIC_FLAGS if len(find_eval_metrics(flag_key)) == 0} @@ -46,13 +44,13 @@ def has_expected_metrics(_: dict) -> bool: missing.difference_update(flag_key for flag_key in list(missing) if len(find_eval_metrics(flag_key)) > 0) return not missing - if not missing or interfaces.agent.wait_for( + if not missing: + return + + interfaces.agent.wait_for( has_expected_metrics, timeout=EVAL_METRIC_WAIT_TIMEOUT_SECONDS, - ): - return True, "" - - return False, f"Timed out waiting for feature_flag.evaluations metrics for flags {sorted(missing)}" + ) def get_tag_value(tags: list[str], key: str): @@ -1085,13 +1083,11 @@ def setup_ffe_lowercase_error_type(self): }, ) EXPECTED_EVAL_METRIC_FLAGS.add(self.error_flag_key) - self.metric_drain_ready = wait_for_expected_eval_metrics() + wait_for_expected_eval_metrics() def test_ffe_lowercase_error_type(self): """Test that error.type values are lowercase.""" assert self.r_error.status_code == 200, f"Flag evaluation request failed: {self.r_error.text}" - success, failure_message = self.metric_drain_ready - assert success, failure_message metrics = find_eval_metrics(self.error_flag_key) assert len(metrics) > 0, f"Expected metric for flag '{self.error_flag_key}', found none." From 23c271092346baa7239320ea7bea18af4261f5b8 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 6 Jul 2026 16:48:20 -0400 Subject: [PATCH 20/20] Assert FFE last-received-config-wins instead of multi-file merge Rework the multiple-remote-config exposure test into an explicit last-received-wins test: apply config 1 and evaluate flag 1, replace it with config 2 and evaluate flag 2, then re-evaluate flag 1 and assert it no longer resolves to its config-1 value (falls through to the caller default) and emits no new exposure. FFE_FLAGS delivers a single canonical UFC document and does not merge multiple config files, confirmed across the Go, PHP, Java and libdatadog SDKs. Drop the PHP flaky(FFL-2676) manifest marker now that the test asserts a defined contract and waits in setup rather than in the test body. --- manifests/php.yml | 1 - tests/ffe/test_exposures.py | 96 ++++++++++++++++++++++--------------- 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/manifests/php.yml b/manifests/php.yml index 14f93e3e087..78d43b2b769 100644 --- a/manifests/php.yml +++ b/manifests/php.yml @@ -717,7 +717,6 @@ manifest: - weblog_declaration: "*": v1.21.0-dev laravel11x: missing_feature (pending to be implemented on this weblog) - 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_metrics.py: - weblog_declaration: diff --git a/tests/ffe/test_exposures.py b/tests/ffe/test_exposures.py index 8eab7478744..28c8cec765c 100644 --- a/tests/ffe/test_exposures.py +++ b/tests/ffe/test_exposures.py @@ -253,9 +253,13 @@ def test_ffe_exposure_event_generation(self): f"Expected subject '{self.targeting_key}', got '{matching_event['subject']['id']}'" ) - def setup_ffe_multiple_remote_config_files(self): - """Set up FFE with multiple remote config files across different target paths.""" - # Set up multiple Remote Config files with different config IDs + def setup_ffe_remote_config_last_received_wins(self): + """Set up FFE where a later UFC config replaces (does not merge with) the earlier one. + + FFE_FLAGS delivers a single canonical UFC document: the last received config wins and + multiple config files are not merged into one flag set (confirmed across the Go, PHP, + Java and libdatadog SDKs). + """ config_id_1 = "ffe-test-config-1" config_id_2 = "ffe-test-config-2" @@ -307,16 +311,19 @@ def setup_ffe_multiple_remote_config_files(self): self.flag_1 = "test-flag-1" self.flag_2 = "test-flag-2" + self.flag_1_variation_value = "on" # value test-flag-1 resolves to under config 1 + self.flag_1_default = "last-received-wins-default" self.targeting_key = "test-user-multi" + self.recheck_key = "test-user-last-received-wins" - # UFC config is replaced, not merged; evaluate each flag while its config is current. + # Apply config 1 and evaluate flag 1 while config 1 is the current config. rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_1}/config", rc_config_1).apply() self.r1 = weblog.post( "/ffe", json={ "flag": self.flag_1, "variationType": "STRING", - "defaultValue": "default", + "defaultValue": self.flag_1_default, "targetingKey": self.targeting_key, "attributes": {}, }, @@ -324,8 +331,8 @@ def setup_ffe_multiple_remote_config_files(self): # Store setup-time wait results; tests run after teardown and cannot produce new payloads. self.exposure_ready_1 = wait_for_exposure_event({self.flag_1}, self.targeting_key) + # Replace config 1 with config 2. Configs are not merged; the last received wins. rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/{config_id_2}/config", rc_config_2).apply() - self.r2 = weblog.post( "/ffe", json={ @@ -338,46 +345,57 @@ def setup_ffe_multiple_remote_config_files(self): ) self.exposure_ready_2 = wait_for_exposure_event({self.flag_2}, self.targeting_key) - def test_ffe_multiple_remote_config_files(self): - """Test that FFE correctly handles multiple remote config files with different flags.""" + # Re-evaluate flag 1 now that config 2 is current. Because config 1 was replaced (not + # merged), flag 1 is no longer known and must fall through to the caller default. + self.r1_after_replace = weblog.post( + "/ffe", + json={ + "flag": self.flag_1, + "variationType": "STRING", + "defaultValue": self.flag_1_default, + "targetingKey": self.recheck_key, + "attributes": {}, + }, + ) + + def test_ffe_remote_config_last_received_wins(self): + """A later UFC config replaces the earlier one; FFE_FLAGS files are not merged.""" assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}" assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}" + assert self.r1_after_replace.status_code == 200, ( + f"Flag re-evaluation after config replacement failed: {self.r1_after_replace.text}" + ) assert_wait_results(self.exposure_ready_1, self.exposure_ready_2) - # Collect all exposure events for our specific flags - flags_found = set() + # Each flag produced an exposure while its own config was the current config. + flag_1_events = find_exposure_events(self.flag_1, self.targeting_key) + flag_2_events = find_exposure_events(self.flag_2, self.targeting_key) + assert flag_1_events, f"Expected an exposure for '{self.flag_1}' while config 1 was current" + assert flag_2_events, f"Expected an exposure for '{self.flag_2}' while config 2 was current" - for data in interfaces.agent.get_data(path_filters=EXPOSURES_PATH): - exposure_data = data["request"]["content"] - assert exposure_data is not None, "No exposure events were sent to agent" - - # Validate context - assert "context" in exposure_data, "Response missing 'context' field" - context = exposure_data["context"] - assert context.get("service") == "weblog", f"Expected service_name 'weblog', got '{context}'" + for event in flag_1_events + flag_2_events: + assert event["subject"]["id"] == self.targeting_key, ( + f"Expected subject '{self.targeting_key}', got '{event['subject']['id']}'" + ) - # Validate exposures array - assert "exposures" in exposure_data, "Response missing 'exposures' field" - assert isinstance(exposure_data["exposures"], list), "Exposures should be a list" + # last-received-wins: after config 2 replaced config 1, flag 1 must no longer resolve to + # its config-1 variation and must fall through to the caller default. Had the configs been + # merged, flag 1 would still return its config-1 value. + value_after_replace = json.loads(self.r1_after_replace.text).get("value") + assert value_after_replace != self.flag_1_variation_value, ( + f"'{self.flag_1}' still resolved to its config-1 value {value_after_replace!r} after its " + f"config was replaced; FFE_FLAGS configs must not be merged (last received wins)" + ) + assert value_after_replace == self.flag_1_default, ( + f"Expected '{self.flag_1}' to fall through to default {self.flag_1_default!r} after config " + f"replacement, got {value_after_replace!r}" + ) - # Collect flag keys and validate events for our test flags - for event in exposure_data["exposures"]: - assert "flag" in event, "Exposure event missing 'flag' field" - assert "key" in event["flag"], "Flag missing 'key' field" - flag_key = event["flag"]["key"] - - # Only validate events for our test flags with our specific targeting_key - if flag_key in (self.flag_1, self.flag_2) and event.get("subject", {}).get("id") == self.targeting_key: - flags_found.add(flag_key) - # Validate subject for our test events - assert "subject" in event, "Exposure event missing 'subject' field" - assert event["subject"]["id"] == self.targeting_key, ( - f"Expected subject '{self.targeting_key}', got '{event['subject']['id']}'" - ) - - # Verify that both flags were evaluated and sent exposure events - assert self.flag_1 in flags_found or self.flag_2 in flags_found, ( - f"Expected to find flags '{self.flag_1}' or '{self.flag_2}' in exposure events, found: {flags_found}" + # No new exposure should be emitted for the replaced flag under the recheck subject. + replaced_flag_events = find_exposure_events(self.flag_1, self.recheck_key) + assert not replaced_flag_events, ( + f"Expected no exposure for replaced flag '{self.flag_1}' under subject '{self.recheck_key}', " + f"but found {len(replaced_flag_events)}" )