diff --git a/tests/parametric/test_dynamic_configuration.py b/tests/parametric/test_dynamic_configuration.py index 79e5e7c9c96..57be43391bd 100644 --- a/tests/parametric/test_dynamic_configuration.py +++ b/tests/parametric/test_dynamic_configuration.py @@ -41,6 +41,8 @@ # Decrease the heartbeat/poll intervals to speed up the tests "DD_TELEMETRY_HEARTBEAT_INTERVAL": "0.2", "DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS": "0.2", + # This suite validates RC capabilities; Feature Flagging RC is explicit opt-in. + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": "remote_config", # Disable CSS which is enabled by default on Go "DD_TRACE_STATS_COMPUTATION_ENABLED": "false", } diff --git a/tests/parametric/test_ffe/test_configuration_sources.py b/tests/parametric/test_ffe/test_configuration_sources.py index f6cf26431ac..12e524c06df 100644 --- a/tests/parametric/test_ffe/test_configuration_sources.py +++ b/tests/parametric/test_ffe/test_configuration_sources.py @@ -1,8 +1,9 @@ """Parametric FFE configuration-source coverage for a mocked FFE agentless backend. -Feature under test: server SDKs can select where UFC flag definitions come from. -The agentless path fetches from an HTTP backend, while explicit ``remote_config`` -keeps the existing Agent RC path. +Feature under test: server SDKs preserve legacy Remote Configuration adopters, +load UFC flag definitions from the agentless HTTP backend by default only after +application provider access, and honor explicit source selection and the stable +provider kill switch. Test strategy: drive SDKs through public configuration-source env vars and OpenFeature evaluation endpoints, then use the mock FFE agentless backend for @@ -22,7 +23,7 @@ from tests.parametric.conftest import APMLibrary from tests.parametric.test_ffe.test_dynamic_evaluation import _set_and_wait_ffe_rc, _ffe_evaluate_with_rc_retry from utils import features, scenarios -from utils.dd_constants import RemoteConfigApplyState +from utils.dd_constants import Capabilities, RemoteConfigApplyState from utils.docker_fixtures import TestAgentAPI from utils.docker_fixtures._mock_ffe_agentless_backend import ( CONFIG_PATH, @@ -38,17 +39,17 @@ TEST_API_KEY = "system-tests-mock-api-key" MOCK_STATUS_ATTEMPTS = 25 MOCK_STATUS_INTERVAL_SECONDS = 0.2 -NO_MOCK_REQUEST_ATTEMPTS = 5 +NO_MOCK_REQUEST_ATTEMPTS = 7 AGENTLESS_BASE_URL = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL" +SEMANTICALLY_UNSET_CONFIGURATION_SOURCES: tuple[str | None, ...] = (None, "", " ") BASE_ENVVARS = { - "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": "true", - "DD_TELEMETRY_HEARTBEAT_INTERVAL": "0.2", + "DD_INSTRUMENTATION_TELEMETRY_ENABLED": "false", "DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS": "0.2", } AGENTLESS_ENVVARS = { - "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": "0.2", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": "1", "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS": "1", } @@ -87,6 +88,15 @@ def library_env( responses = params.get("responses") api_key = params.get("api_key", TEST_API_KEY) + if "provider_enabled" in params: + env["DD_FEATURE_FLAGS_ENABLED"] = str(params["provider_enabled"]).lower() + if "legacy_provider_enabled" in params: + env["DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"] = str(params["legacy_provider_enabled"]).lower() + if "provider_initialization_timeout_ms" in params: + env["DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS"] = str( + params["provider_initialization_timeout_ms"] + ) + if responses is not None: mock_ffe_agentless_backend.set_responses(responses) elif response is not None: @@ -135,12 +145,35 @@ def _wait_for_status( def _assert_no_mock_requests(mock_ffe_agentless_backend: MockFFEAgentlessBackendServer) -> None: status: MockFFEAgentlessBackendStatus | None = None + # Seven samples span more than the configured one-second polling interval, so an + # incorrect eager poll cannot occur just after this assertion and before provider access. for _ in range(NO_MOCK_REQUEST_ATTEMPTS): status = mock_ffe_agentless_backend.status() assert status["requests_total"] == 0, f"unexpected mock FFE agentless backend request: status={status}" time.sleep(MOCK_STATUS_INTERVAL_SECONDS) +def _remote_config_products(test_agent: TestAgentAPI) -> set[str]: + products: set[str] = set() + for request in test_agent.rc_requests(post_only=True): + client = request["body"].get("client", {}) + products.update(client.get("products", [])) + return products + + +def _assert_ffe_remote_config_activation(test_agent: TestAgentAPI) -> None: + test_agent.assert_rc_capabilities({Capabilities.FFE_FLAG_CONFIGURATION_RULES}) + assert RC_PRODUCT in _remote_config_products(test_agent) + + +def _assert_no_ffe_remote_config_activation(test_agent: TestAgentAPI) -> None: + for _ in range(NO_MOCK_REQUEST_ATTEMPTS): + capabilities = test_agent.wait_for_rc_capabilities() + assert Capabilities.FFE_FLAG_CONFIGURATION_RULES not in capabilities + assert RC_PRODUCT not in _remote_config_products(test_agent) + time.sleep(MOCK_STATUS_INTERVAL_SECONDS) + + def _evaluate(test_library: APMLibrary) -> dict[str, Any]: return _ffe_evaluate_with_rc_retry( test_library, @@ -196,28 +229,82 @@ def test_remote_config_positive_ignores_agentless_env( test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, ) -> None: + # Explicit remote_config is combined with the agentless URL and poller inputs that + # library_env supplies by default, distinguishing source selection from CDN availability. + # The RC ACK, product/capability, and evaluation prove RC supplied the UFC data; zero mock + # requests proves the configured agentless endpoint was not used. apply_state = _set_and_wait_ffe_rc(test_agent, UFC_VALID_DATA) assert apply_state["apply_state"] == RemoteConfigApplyState.ACKNOWLEDGED.value assert apply_state["product"] == RC_PRODUCT + _assert_ffe_remote_config_activation(test_agent) assert test_library.ffe_start(), "failed to start FFE provider in remote_config mode" _assert_expected_value(_evaluate(test_library)) _assert_no_mock_requests(mock_ffe_agentless_backend) - @parametrize("library_env", [{"configuration_source": "remote_config", "response": "valid"}], indirect=True) + @parametrize( + "library_env", + [{"configuration_source": "remote_config", "provider_enabled": False, "response": "valid"}], + indirect=True, + ) + def test_provider_kill_switch_stops_remote_config_subscription( + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, + ) -> None: + # remote_config requests the eager RC path, while DD_FEATURE_FLAGS_ENABLED=false supplies + # the conflicting global kill-switch input and a valid agentless backend remains available. + # Absence of the FFE RC capability/product and zero mock requests after provider access + # prove the kill switch prevents both billed delivery paths. + test_library.ffe_start() + _assert_no_ffe_remote_config_activation(test_agent) + _assert_no_mock_requests(mock_ffe_agentless_backend) + + @parametrize( + "library_env", + [{"configuration_source": "remote_config", "provider_initialization_timeout_ms": 100, "response": "valid"}], + indirect=True, + ) def test_remote_config_without_rc_does_not_fallback_to_agentless( - self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, ) -> None: - _assert_cold_not_ready(test_library, started=test_library.ffe_start()) + # Explicit remote_config is tested with usable agentless inputs but without delivering an + # RC payload, making a tempting fallback destination available when RC has no configuration. + # A short provider timeout bounds this no-payload test without changing source selection; + # provider access then crosses the lazy agentless activation boundary without delivering RC. + # The advertised FFE RC capability proves RC stayed selected, while zero mock requests + # proves payload absence never changes the configured source to agentless. + started = test_library.ffe_start() + _assert_ffe_remote_config_activation(test_agent) _assert_no_mock_requests(mock_ffe_agentless_backend) + _assert_cold_not_ready(test_library, started=started) - @parametrize("library_env", [{"configuration_source": "agentless", "response": "valid"}], indirect=True) - def test_explicit_agentless_positive( - self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer + @parametrize( + "library_env", + [{"configuration_source": source, "response": "valid"} for source in SEMANTICALLY_UNSET_CONFIGURATION_SOURCES], + indirect=True, + ids=["source-absent", "source-empty", "source-whitespace"], + ) + def test_default_agentless_positive( + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, ) -> None: - assert test_library.ffe_start(), "failed to start FFE provider in explicit agentless mode" + # An absent, empty, or whitespace-only source with no legacy/stable enablement variable + # models a new customer whose source is semantically unset; the usable CDN inputs ensure + # default source selection can complete. Zero startup traffic proves agentless is lazy; + # evaluation, authenticated CONFIG_PATH traffic, and no RC capability prove CDN activation. + _assert_no_mock_requests(mock_ffe_agentless_backend) + _assert_no_ffe_remote_config_activation(test_agent) + + assert test_library.ffe_start(), "failed to start FFE provider in default agentless mode" _assert_expected_value(_evaluate(test_library)) status = _wait_for_status( @@ -227,29 +314,214 @@ def test_explicit_agentless_positive( ) assert status["last_auth_present"] is True assert status["last_path"] == CONFIG_PATH + _assert_no_ffe_remote_config_activation(test_agent) - @parametrize("library_env", [{"configuration_source": None, "response": "valid"}], indirect=True) - def test_default_agentless_positive( - self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer + @parametrize( + "library_env", + [{"configuration_source": None, "provider_enabled": True, "response": "valid"}], + indirect=True, + ) + def test_stable_true_preserves_default_agentless( + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, ) -> None: - assert test_library.ffe_start(), "failed to start FFE provider in default agentless mode" + # DD_FEATURE_FLAGS_ENABLED=true is explicit while source and legacy inputs are absent, + # proving the stable enable switch does not implicitly select the historical RC source. + # Startup silence proves agentless remains lazy; the expected value and CDN request after + # provider access, plus the absent RC capability, prove default agentless remains selected. + _assert_no_mock_requests(mock_ffe_agentless_backend) + _assert_no_ffe_remote_config_activation(test_agent) + + assert test_library.ffe_start(), "failed to start stable-enabled default agentless provider" _assert_expected_value(_evaluate(test_library)) status = _wait_for_status( mock_ffe_agentless_backend, lambda current: current["requests_total"] > 0 and current["last_status_code"] == 200, - "default agentless request", + "stable-enabled default agentless response request", ) + assert status["last_auth_present"] is True assert status["last_path"] == CONFIG_PATH + _assert_no_ffe_remote_config_activation(test_agent) + + @parametrize( + "library_env", + [ + {"configuration_source": source, "legacy_provider_enabled": True, "response": "valid"} + for source in SEMANTICALLY_UNSET_CONFIGURATION_SOURCES + ], + indirect=True, + ids=["source-absent", "source-empty", "source-whitespace"], + ) + def test_legacy_true_preserves_remote_config( + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, + ) -> None: + # An absent, empty, or whitespace-only source is semantically unset, while legacy true + # represents an existing customer whose historical opt-in selected Remote Configuration; + # valid CDN inputs make accidental migration observable. RC ACK/capability and evaluation + # prove grandfathering, while zero mock requests proves CDN was never selected. + apply_state = _set_and_wait_ffe_rc(test_agent, UFC_VALID_DATA) + assert apply_state["apply_state"] == RemoteConfigApplyState.ACKNOWLEDGED.value + assert apply_state["product"] == RC_PRODUCT + _assert_ffe_remote_config_activation(test_agent) + + assert test_library.ffe_start(), "failed to start grandfathered Remote Config provider" + _assert_expected_value(_evaluate(test_library)) + _assert_no_mock_requests(mock_ffe_agentless_backend) + + @parametrize( + "library_env", + [ + { + "configuration_source": None, + "provider_enabled": False, + "legacy_provider_enabled": True, + "response": "valid", + } + ], + indirect=True, + ) + def test_provider_kill_switch_overrides_legacy_true( + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, + ) -> None: + # Legacy true would grandfather the customer onto RC, while DD_FEATURE_FLAGS_ENABLED=false + # supplies the conflicting stable global kill switch and a valid CDN remains available. + # No FFE RC capability/product and zero CDN requests after provider access prove the stable + # switch wins before legacy source resolution and disables both billed delivery paths. + test_library.ffe_start() + _assert_no_ffe_remote_config_activation(test_agent) + _assert_no_mock_requests(mock_ffe_agentless_backend) + + @parametrize( + "library_env", + [ + {"configuration_source": source, "legacy_provider_enabled": False, "response": "valid"} + for source in SEMANTICALLY_UNSET_CONFIGURATION_SOURCES + ], + indirect=True, + ids=["source-absent", "source-empty", "source-whitespace"], + ) + def test_legacy_false_keeps_provider_disabled( + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, + ) -> None: + # An absent, empty, or whitespace-only source is semantically unset, while legacy false + # models a customer who explicitly disabled the provider despite a usable CDN endpoint. + # Zero agentless requests and no FFE RC capability/product after provider access prove the + # legacy false value remains disabled instead of adopting the new default. + test_library.ffe_start() + _assert_no_mock_requests(mock_ffe_agentless_backend) + _assert_no_ffe_remote_config_activation(test_agent) + + @parametrize( + "library_env", + [{"configuration_source": "agentless", "legacy_provider_enabled": True, "response": "valid"}], + indirect=True, + ) + def test_explicit_agentless_wins_over_legacy_true( + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, + ) -> None: + # DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless conflicts intentionally with legacy true, + # proving that the stable explicit source can migrate a grandfathered RC customer to CDN. + # Startup silence proves lazy activation; the expected value and backend request after access, + # together with the absent RC capability, prove agentless wins exclusively. + _assert_no_mock_requests(mock_ffe_agentless_backend) + _assert_no_ffe_remote_config_activation(test_agent) + + assert test_library.ffe_start(), "failed to start explicit agentless provider" + _assert_expected_value(_evaluate(test_library)) + _wait_for_status( + mock_ffe_agentless_backend, + lambda current: current["requests_total"] > 0 and current["last_status_code"] == 200, + "explicit agentless response request", + ) + _assert_no_ffe_remote_config_activation(test_agent) + + @parametrize( + "library_env", + [{"configuration_source": "remote_config", "legacy_provider_enabled": False, "response": "valid"}], + indirect=True, + ) + def test_explicit_remote_config_wins_over_legacy_false( + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, + ) -> None: + # DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=remote_config conflicts intentionally with legacy + # false, proving explicit stable source selection takes precedence over compatibility state. + # RC ACK/capability and the expected evaluation prove RC is active, while zero mock requests + # proves the available agentless endpoint is not consulted. + apply_state = _set_and_wait_ffe_rc(test_agent, UFC_VALID_DATA) + assert apply_state["apply_state"] == RemoteConfigApplyState.ACKNOWLEDGED.value + assert apply_state["product"] == RC_PRODUCT + _assert_ffe_remote_config_activation(test_agent) + + assert test_library.ffe_start(), "failed to start explicit Remote Config provider" + _assert_expected_value(_evaluate(test_library)) + _assert_no_mock_requests(mock_ffe_agentless_backend) + + @parametrize( + "library_env", + [{"configuration_source": None, "provider_enabled": False, "response": "valid"}], + indirect=True, + ) + def test_provider_kill_switch_stops_agentless_polling( + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, + ) -> None: + # An absent source would normally choose default agentless, but DD_FEATURE_FLAGS_ENABLED=false + # supplies the global override while a fully configured mock CDN remains reachable. + # Zero mock requests and no FFE RC capability/product after provider access prove the stable + # kill switch blocks both default CDN activation and any RC subscription. + test_library.ffe_start() + _assert_no_mock_requests(mock_ffe_agentless_backend) + _assert_no_ffe_remote_config_activation(test_agent) @parametrize("library_env", [{"configuration_source": "invalid", "response": "valid"}], indirect=True) def test_invalid_configuration_source_fails_closed( - self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, ) -> None: - started = test_library.ffe_start() - if started: - _assert_default_or_not_ready(_evaluate(test_library)) + # An invalid explicit source tests configuration-error handling while valid agentless URL, + # API-key, and response inputs ensure silence cannot be explained by an unavailable backend. + # Zero mock requests and no FFE RC capability/product after provider access prove the SDK + # fails closed instead of guessing a billed delivery path. + test_library.ffe_start() _assert_no_mock_requests(mock_ffe_agentless_backend) + _assert_no_ffe_remote_config_activation(test_agent) + + @parametrize("library_env", [{"configuration_source": "offline", "response": "valid"}], indirect=True) + def test_reserved_offline_configuration_source_fails_closed( + self, + test_agent: TestAgentAPI, + test_library: APMLibrary, + mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, + ) -> None: + # The reserved offline value is intentionally unsupported today, but it may eventually take + # a distinct startup-bytes path from an arbitrary invalid source. Valid agentless inputs make + # a network fallback available, while provider access exercises source resolution. Zero CDN + # requests and no FFE RC capability/product prove the reserved value fails closed for now. + test_library.ffe_start() + _assert_no_mock_requests(mock_ffe_agentless_backend) + _assert_no_ffe_remote_config_activation(test_agent) @scenarios.parametric @@ -265,6 +537,10 @@ class Test_Feature_Flag_Configuration_Source_Cold_Failure_And_Recovery: def test_missing_auth_cold( self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer ) -> None: + # Explicit agentless plus api_key=None removes authentication without changing the valid base + # URL or polling inputs, isolating authentication as the initial-load failure. + # A 401/403 on CONFIG_PATH with no auth header proves the intended request was rejected, and + # the default/not-ready evaluation proves unauthenticated bytes never initialize the provider. started = test_library.ffe_start() status = _wait_for_status( @@ -284,6 +560,10 @@ def test_missing_auth_cold( def test_malformed_cold( self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer ) -> None: + # Explicit agentless with response=malformed keeps transport, authentication, and HTTP status + # successful while making the first UFC payload invalid, isolating payload validation. + # The authenticated 200 request to CONFIG_PATH proves delivery occurred, while the + # default/not-ready evaluation proves malformed data was not installed. started = test_library.ffe_start() status = _wait_for_status( @@ -303,6 +583,10 @@ def test_malformed_cold( def test_request_timeout_cold( self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer ) -> None: + # response=timeout with a one-second request timeout and five-second poll interval makes the + # first fetch exceed its per-request budget before another scheduled poll can interfere. + # The observed CONFIG_PATH request proves the correct endpoint was attempted, while the + # default/not-ready evaluation proves a timed-out cold fetch cannot initialize the provider. started = test_library.ffe_start() status = _wait_for_status( @@ -331,6 +615,10 @@ def test_bad_to_good_cold_recovery( mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, expected_status_codes: list[int], ) -> None: + # The explicit agentless response sequence [server_error, valid] drives a cold 500 followed by + # a successful retry; expected_status_codes makes that transport order part of the test input. + # Observing 500 then 200 proves retry behavior, and the expected evaluation plus CONFIG_PATH + # prove the later valid UFC payload recovers an initially unready provider. assert test_library.ffe_start(), "failed to start FFE provider for bad_to_good" status = _wait_for_status( @@ -358,6 +646,10 @@ def test_bad_to_unchanged_cold_preserves_not_ready( mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, expected_status_codes: list[int], ) -> None: + # The explicit agentless sequence [server_error, not_modified] supplies a cold 500 followed by + # 304 before any last-known-good configuration exists; expected_status_codes fixes that order. + # Observing the sequence on CONFIG_PATH proves both polls occurred, while the default/not-ready + # evaluation proves 304 cannot invent state after a failed initial load. started = test_library.ffe_start() status = _wait_for_status( @@ -378,6 +670,10 @@ class Test_Feature_Flag_Configuration_Source_Warm_State_Preservation: def test_missing_auth_warm( self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer ) -> None: + # Explicit agentless with an initially valid response establishes last-known-good state before + # the backend switches to unauthorized, isolating a later authentication failure. + # The 401/403 and additional CONFIG_PATH request prove the failing poll occurred, while the + # unchanged expected evaluation proves warm state survives the rejection. assert test_library.ffe_start(), "failed to start FFE provider before missing_auth_warm" _assert_expected_value(_evaluate(test_library)) @@ -408,6 +704,10 @@ def test_good_to_bad_warm_preservation( mock_ffe_agentless_backend: MockFFEAgentlessBackendServer, expected_status_codes: list[int], ) -> None: + # The explicit agentless response sequence [valid, server_error] creates last-known-good state + # and then a 500; expected_status_codes makes the successful-to-failing transition observable. + # The 200-to-500 sequence on CONFIG_PATH proves the failure followed a valid load, while the + # expected evaluation proves the later server error did not replace warm state. assert test_library.ffe_start(), "failed to start FFE provider for good_to_bad" status = _wait_for_status( @@ -424,6 +724,10 @@ def test_good_to_bad_warm_preservation( def test_good_to_unchanged_etag_sequence( self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer ) -> None: + # Explicit agentless responses [valid, not_modified] exercise conditional polling after a + # successful load, making the fixture ETag and 304 behavior observable inputs. + # The 200-to-304 sequence and If-None-Match value prove protocol reuse, while the expected + # evaluation proves not-modified correctly preserves the installed configuration. assert test_library.ffe_start(), "failed to start FFE provider for good_to_unchanged" status = _wait_for_status( @@ -439,6 +743,10 @@ def test_good_to_unchanged_etag_sequence( def test_malformed_warm( self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer ) -> None: + # Explicit agentless first loads valid UFC data, then the runtime response switch supplies a + # malformed HTTP-200 payload so parsing failure is tested after initialization. + # A higher request count and CONFIG_PATH prove the malformed poll occurred, while the unchanged + # expected evaluation proves invalid bytes cannot replace last-known-good state. assert test_library.ffe_start(), "failed to start FFE provider before malformed_warm" _assert_expected_value(_evaluate(test_library)) @@ -458,22 +766,39 @@ def test_malformed_warm( class Test_Feature_Flag_Configuration_Source_Poller_Concurrency: """Validate that agentless polling does not overlap requests under slow responses.""" - @parametrize("library_env", [{"configuration_source": "agentless", "response": "delayed_valid"}], indirect=True) + @parametrize( + "library_env", + [ + { + "configuration_source": "agentless", + "response": "valid", + "poll_interval": "1", + "request_timeout": "3", + } + ], + indirect=True, + ) def test_delayed_no_overlap( self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer ) -> None: + # An initial valid response makes the provider ready before subsequent responses take 1.5 + # seconds, longer than the one-second poll interval but below the three-second timeout. + # Two new request starts and one completion prove repeated slow polling exercised an overlap + # opportunity; max_in_flight == 1 proves the poller serialized those configuration fetches. assert test_library.ffe_start(), "failed to start FFE provider for delayed_no_overlap" _assert_expected_value(_evaluate(test_library)) + mock_ffe_agentless_backend.set_response("delayed_valid") + requests_before = mock_ffe_agentless_backend.status()["requests_total"] status = _wait_for_status( mock_ffe_agentless_backend, lambda current: ( - current["requests_total"] > 0 - and current["last_status_code"] == 200 - and current["in_flight"] == 0 + current["requests_total"] >= requests_before + 2 + and len(current["status_codes"]) >= 1 and current["max_in_flight"] >= 1 ), - "delayed_no_overlap completion", + "at least two delayed_no_overlap request starts and one completion", ) + assert status["requests_total"] >= requests_before + 2 assert status["max_in_flight"] == 1 assert status["last_path"] == CONFIG_PATH diff --git a/tests/test_the_test/test_mock_ffe_agentless_backend.py b/tests/test_the_test/test_mock_ffe_agentless_backend.py index e6f5f4d93d1..057997de3b7 100644 --- a/tests/test_the_test/test_mock_ffe_agentless_backend.py +++ b/tests/test_the_test/test_mock_ffe_agentless_backend.py @@ -34,6 +34,7 @@ def test_mock_ffe_agentless_backend_serves_fixture_and_tracks_metadata(worker_id timeout=5, ) response.raise_for_status() + assert response.headers["Content-Length"] == str(len(response.content)) payload = response.json() assert payload["data"]["type"] == UFC_RESPONSE_TYPE diff --git a/utils/build/docker/java/parametric/src/main/java/com/datadoghq/trace/controller/FeatureFlagEvaluatorController.java b/utils/build/docker/java/parametric/src/main/java/com/datadoghq/trace/controller/FeatureFlagEvaluatorController.java index 4fef048ef59..40bd69d869a 100644 --- a/utils/build/docker/java/parametric/src/main/java/com/datadoghq/trace/controller/FeatureFlagEvaluatorController.java +++ b/utils/build/docker/java/parametric/src/main/java/com/datadoghq/trace/controller/FeatureFlagEvaluatorController.java @@ -28,6 +28,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; @RestController @RequestMapping("/ffe") @@ -42,10 +43,14 @@ public static class FeatureFlagEvaluatorConfig { @Bean public Client client() { final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final String envProperty = System.getenv("DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"); + final boolean featureFlaggingConfigured = + System.getenv("DD_FEATURE_FLAGS_ENABLED") != null + || System.getenv("DD_FEATURE_FLAGS_CONFIGURATION_SOURCE") != null + || System.getenv("DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL") != null + || Boolean.parseBoolean(System.getenv("DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED")); final FeatureProvider provider; - if (Boolean.parseBoolean(envProperty)) { - provider = new Provider(); + if (featureFlaggingConfigured) { + provider = new Provider(new Provider.Options().initTimeout(100, TimeUnit.MILLISECONDS)); } else { provider = new NoOpProvider() { @Override diff --git a/utils/build/docker/nodejs/parametric/server.js b/utils/build/docker/nodejs/parametric/server.js index 354264c876a..f23e4a670a9 100644 --- a/utils/build/docker/nodejs/parametric/server.js +++ b/utils/build/docker/nodejs/parametric/server.js @@ -509,8 +509,23 @@ app.post("/trace/otel/otel_set_baggage", (req, res) => { // Feature Flag & Experimentation endpoints app.post('/ffe/start', async (req, res) => { - const { openfeature } = tracer - await OpenFeature.setProviderAndWait(openfeature) + const hasFeatureFlaggingConfiguration = [ + 'DD_FEATURE_FLAGS_ENABLED', + 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE', + 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL', + 'DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED' + ].some(name => process.env[name] !== undefined) + + if (hasFeatureFlaggingConfiguration) { + const { openfeature } = tracer + try { + await OpenFeature.setProviderAndWait(openfeature) + } catch { + openFeatureClient = OpenFeature.getClient() + res.status(500).json({}) + return + } + } openFeatureClient = OpenFeature.getClient() res.json({}) }) diff --git a/utils/docker_fixtures/_mock_ffe_agentless_backend.py b/utils/docker_fixtures/_mock_ffe_agentless_backend.py index 8a77d0eb33b..33add7880c0 100644 --- a/utils/docker_fixtures/_mock_ffe_agentless_backend.py +++ b/utils/docker_fixtures/_mock_ffe_agentless_backend.py @@ -41,7 +41,7 @@ DEFAULT_RESPONSE = "valid" UFC_ETAG = '"ufc-v1"' EXPECTED_API_KEY = "system-tests-mock-api-key" -DELAYED_RESPONSE_SECONDS = 0.5 +DELAYED_RESPONSE_SECONDS = 1.5 TIMEOUT_RESPONSE_SECONDS = 1.5 MAX_CONTROL_BODY_BYTES = 512 CONFIG_PATH = "/api/v2/feature-flagging/config/rules-based/server" @@ -194,6 +194,7 @@ def _handle_config(self) -> None: self.send_response(status_code) for key, value in headers.items(): self.send_header(key, value) + self.send_header("Content-Length", str(len(body))) self.end_headers() if body: self.wfile.write(body) @@ -228,11 +229,13 @@ def _handle_responses_control(self) -> None: self._write_json(HTTPStatus.OK, self.server.state.status()) def _write_json(self, status_code: HTTPStatus, payload: dict[str, Any] | MockFFEAgentlessBackendStatus) -> None: + body = json.dumps(payload).encode("utf-8") with contextlib.suppress(BrokenPipeError, ConnectionResetError): self.send_response(status_code) self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(json.dumps(payload).encode("utf-8")) + self.wfile.write(body) def _has_auth(headers: Mapping[str, str]) -> bool: