Skip to content

Commit ca12c9a

Browse files
committed
test(ffe): cover billed source precedence
1 parent 5cc6fb0 commit ca12c9a

6 files changed

Lines changed: 184 additions & 30 deletions

File tree

tests/parametric/test_dynamic_configuration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
# Decrease the heartbeat/poll intervals to speed up the tests
4242
"DD_TELEMETRY_HEARTBEAT_INTERVAL": "0.2",
4343
"DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS": "0.2",
44+
# This suite validates RC capabilities; Feature Flagging RC is explicit opt-in.
45+
"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": "remote_config",
4446
# Disable CSS which is enabled by default on Go
4547
"DD_TRACE_STATS_COMPUTATION_ENABLED": "false",
4648
}

tests/parametric/test_ffe/test_configuration_sources.py

Lines changed: 158 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Parametric FFE configuration-source coverage for a mocked FFE agentless backend.
22
3-
Feature under test: server SDKs can select where UFC flag definitions come from.
4-
The agentless path fetches from an HTTP backend, while explicit ``remote_config``
5-
keeps the existing Agent RC path.
3+
Feature under test: server SDKs preserve legacy Remote Configuration adopters,
4+
load UFC flag definitions from the agentless HTTP backend by default only after
5+
application provider access, and honor explicit source selection and the stable
6+
provider kill switch.
67
78
Test strategy: drive SDKs through public configuration-source env vars and
89
OpenFeature evaluation endpoints, then use the mock FFE agentless backend for
@@ -22,7 +23,7 @@
2223
from tests.parametric.conftest import APMLibrary
2324
from tests.parametric.test_ffe.test_dynamic_evaluation import _set_and_wait_ffe_rc, _ffe_evaluate_with_rc_retry
2425
from utils import features, scenarios
25-
from utils.dd_constants import RemoteConfigApplyState
26+
from utils.dd_constants import Capabilities, RemoteConfigApplyState
2627
from utils.docker_fixtures import TestAgentAPI
2728
from utils.docker_fixtures._mock_ffe_agentless_backend import (
2829
CONFIG_PATH,
@@ -42,13 +43,12 @@
4243
AGENTLESS_BASE_URL = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL"
4344

4445
BASE_ENVVARS = {
45-
"DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": "true",
46-
"DD_TELEMETRY_HEARTBEAT_INTERVAL": "0.2",
46+
"DD_INSTRUMENTATION_TELEMETRY_ENABLED": "false",
4747
"DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS": "0.2",
4848
}
4949

5050
AGENTLESS_ENVVARS = {
51-
"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": "0.2",
51+
"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS": "1",
5252
"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS": "1",
5353
}
5454

@@ -87,6 +87,11 @@ def library_env(
8787
responses = params.get("responses")
8888
api_key = params.get("api_key", TEST_API_KEY)
8989

90+
if "provider_enabled" in params:
91+
env["DD_FEATURE_FLAGS_ENABLED"] = str(params["provider_enabled"]).lower()
92+
if "legacy_provider_enabled" in params:
93+
env["DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"] = str(params["legacy_provider_enabled"]).lower()
94+
9095
if responses is not None:
9196
mock_ffe_agentless_backend.set_responses(responses)
9297
elif response is not None:
@@ -141,6 +146,27 @@ def _assert_no_mock_requests(mock_ffe_agentless_backend: MockFFEAgentlessBackend
141146
time.sleep(MOCK_STATUS_INTERVAL_SECONDS)
142147

143148

149+
def _remote_config_products(test_agent: TestAgentAPI) -> set[str]:
150+
products: set[str] = set()
151+
for request in test_agent.rc_requests(post_only=True):
152+
client = request["body"].get("client", {})
153+
products.update(client.get("products", []))
154+
return products
155+
156+
157+
def _assert_ffe_remote_config_activation(test_agent: TestAgentAPI) -> None:
158+
test_agent.assert_rc_capabilities({Capabilities.FFE_FLAG_CONFIGURATION_RULES})
159+
assert RC_PRODUCT in _remote_config_products(test_agent)
160+
161+
162+
def _assert_no_ffe_remote_config_activation(test_agent: TestAgentAPI) -> None:
163+
for _ in range(NO_MOCK_REQUEST_ATTEMPTS):
164+
capabilities = test_agent.wait_for_rc_capabilities()
165+
assert Capabilities.FFE_FLAG_CONFIGURATION_RULES not in capabilities
166+
assert RC_PRODUCT not in _remote_config_products(test_agent)
167+
time.sleep(MOCK_STATUS_INTERVAL_SECONDS)
168+
169+
144170
def _evaluate(test_library: APMLibrary) -> dict[str, Any]:
145171
return _ffe_evaluate_with_rc_retry(
146172
test_library,
@@ -199,25 +225,51 @@ def test_remote_config_positive_ignores_agentless_env(
199225
apply_state = _set_and_wait_ffe_rc(test_agent, UFC_VALID_DATA)
200226
assert apply_state["apply_state"] == RemoteConfigApplyState.ACKNOWLEDGED.value
201227
assert apply_state["product"] == RC_PRODUCT
228+
_assert_ffe_remote_config_activation(test_agent)
202229

203230
assert test_library.ffe_start(), "failed to start FFE provider in remote_config mode"
204231
_assert_expected_value(_evaluate(test_library))
205232

206233
_assert_no_mock_requests(mock_ffe_agentless_backend)
207234

235+
@parametrize(
236+
"library_env",
237+
[{"configuration_source": "remote_config", "provider_enabled": False, "response": "valid"}],
238+
indirect=True,
239+
)
240+
def test_provider_kill_switch_stops_remote_config_subscription(
241+
self,
242+
test_agent: TestAgentAPI,
243+
test_library: APMLibrary,
244+
mock_ffe_agentless_backend: MockFFEAgentlessBackendServer,
245+
) -> None:
246+
test_library.ffe_start()
247+
_assert_no_ffe_remote_config_activation(test_agent)
248+
_assert_no_mock_requests(mock_ffe_agentless_backend)
249+
208250
@parametrize("library_env", [{"configuration_source": "remote_config", "response": "valid"}], indirect=True)
209251
def test_remote_config_without_rc_does_not_fallback_to_agentless(
210-
self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer
252+
self,
253+
test_agent: TestAgentAPI,
254+
test_library: APMLibrary,
255+
mock_ffe_agentless_backend: MockFFEAgentlessBackendServer,
211256
) -> None:
212-
_assert_cold_not_ready(test_library, started=test_library.ffe_start())
257+
del test_library # fixture starts the tracer; no RC payload is delivered
213258

259+
_assert_ffe_remote_config_activation(test_agent)
214260
_assert_no_mock_requests(mock_ffe_agentless_backend)
215261

216-
@parametrize("library_env", [{"configuration_source": "agentless", "response": "valid"}], indirect=True)
217-
def test_explicit_agentless_positive(
218-
self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer
262+
@parametrize("library_env", [{"configuration_source": None, "response": "valid"}], indirect=True)
263+
def test_default_agentless_positive(
264+
self,
265+
test_agent: TestAgentAPI,
266+
test_library: APMLibrary,
267+
mock_ffe_agentless_backend: MockFFEAgentlessBackendServer,
219268
) -> None:
220-
assert test_library.ffe_start(), "failed to start FFE provider in explicit agentless mode"
269+
_assert_no_mock_requests(mock_ffe_agentless_backend)
270+
_assert_no_ffe_remote_config_activation(test_agent)
271+
272+
assert test_library.ffe_start(), "failed to start FFE provider in default agentless mode"
221273
_assert_expected_value(_evaluate(test_library))
222274

223275
status = _wait_for_status(
@@ -227,29 +279,111 @@ def test_explicit_agentless_positive(
227279
)
228280
assert status["last_auth_present"] is True
229281
assert status["last_path"] == CONFIG_PATH
282+
_assert_no_ffe_remote_config_activation(test_agent)
230283

231-
@parametrize("library_env", [{"configuration_source": None, "response": "valid"}], indirect=True)
232-
def test_default_agentless_positive(
233-
self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer
284+
@parametrize(
285+
"library_env",
286+
[{"configuration_source": None, "legacy_provider_enabled": True, "response": "valid"}],
287+
indirect=True,
288+
)
289+
def test_legacy_true_preserves_remote_config(
290+
self,
291+
test_agent: TestAgentAPI,
292+
test_library: APMLibrary,
293+
mock_ffe_agentless_backend: MockFFEAgentlessBackendServer,
234294
) -> None:
235-
assert test_library.ffe_start(), "failed to start FFE provider in default agentless mode"
295+
apply_state = _set_and_wait_ffe_rc(test_agent, UFC_VALID_DATA)
296+
assert apply_state["apply_state"] == RemoteConfigApplyState.ACKNOWLEDGED.value
297+
assert apply_state["product"] == RC_PRODUCT
298+
_assert_ffe_remote_config_activation(test_agent)
299+
300+
assert test_library.ffe_start(), "failed to start grandfathered Remote Config provider"
236301
_assert_expected_value(_evaluate(test_library))
302+
_assert_no_mock_requests(mock_ffe_agentless_backend)
237303

238-
status = _wait_for_status(
304+
@parametrize(
305+
"library_env",
306+
[{"configuration_source": None, "legacy_provider_enabled": False, "response": "valid"}],
307+
indirect=True,
308+
)
309+
def test_legacy_false_keeps_provider_disabled(
310+
self,
311+
test_agent: TestAgentAPI,
312+
test_library: APMLibrary,
313+
mock_ffe_agentless_backend: MockFFEAgentlessBackendServer,
314+
) -> None:
315+
test_library.ffe_start()
316+
_assert_no_mock_requests(mock_ffe_agentless_backend)
317+
_assert_no_ffe_remote_config_activation(test_agent)
318+
319+
@parametrize(
320+
"library_env",
321+
[{"configuration_source": "agentless", "legacy_provider_enabled": True, "response": "valid"}],
322+
indirect=True,
323+
)
324+
def test_explicit_agentless_wins_over_legacy_true(
325+
self,
326+
test_agent: TestAgentAPI,
327+
test_library: APMLibrary,
328+
mock_ffe_agentless_backend: MockFFEAgentlessBackendServer,
329+
) -> None:
330+
_assert_no_mock_requests(mock_ffe_agentless_backend)
331+
_assert_no_ffe_remote_config_activation(test_agent)
332+
333+
assert test_library.ffe_start(), "failed to start explicit agentless provider"
334+
_assert_expected_value(_evaluate(test_library))
335+
_wait_for_status(
239336
mock_ffe_agentless_backend,
240337
lambda current: current["requests_total"] > 0 and current["last_status_code"] == 200,
241-
"default agentless request",
338+
"explicit agentless response request",
242339
)
243-
assert status["last_path"] == CONFIG_PATH
340+
_assert_no_ffe_remote_config_activation(test_agent)
341+
342+
@parametrize(
343+
"library_env",
344+
[{"configuration_source": "remote_config", "legacy_provider_enabled": False, "response": "valid"}],
345+
indirect=True,
346+
)
347+
def test_explicit_remote_config_wins_over_legacy_false(
348+
self,
349+
test_agent: TestAgentAPI,
350+
test_library: APMLibrary,
351+
mock_ffe_agentless_backend: MockFFEAgentlessBackendServer,
352+
) -> None:
353+
apply_state = _set_and_wait_ffe_rc(test_agent, UFC_VALID_DATA)
354+
assert apply_state["apply_state"] == RemoteConfigApplyState.ACKNOWLEDGED.value
355+
assert apply_state["product"] == RC_PRODUCT
356+
_assert_ffe_remote_config_activation(test_agent)
357+
358+
assert test_library.ffe_start(), "failed to start explicit Remote Config provider"
359+
_assert_expected_value(_evaluate(test_library))
360+
_assert_no_mock_requests(mock_ffe_agentless_backend)
361+
362+
@parametrize(
363+
"library_env",
364+
[{"configuration_source": None, "provider_enabled": False, "response": "valid"}],
365+
indirect=True,
366+
)
367+
def test_provider_kill_switch_stops_agentless_polling(
368+
self,
369+
test_agent: TestAgentAPI,
370+
test_library: APMLibrary,
371+
mock_ffe_agentless_backend: MockFFEAgentlessBackendServer,
372+
) -> None:
373+
test_library.ffe_start()
374+
_assert_no_mock_requests(mock_ffe_agentless_backend)
375+
_assert_no_ffe_remote_config_activation(test_agent)
244376

245377
@parametrize("library_env", [{"configuration_source": "invalid", "response": "valid"}], indirect=True)
246378
def test_invalid_configuration_source_fails_closed(
247-
self, test_library: APMLibrary, mock_ffe_agentless_backend: MockFFEAgentlessBackendServer
379+
self,
380+
test_agent: TestAgentAPI,
381+
test_library: APMLibrary,
382+
mock_ffe_agentless_backend: MockFFEAgentlessBackendServer,
248383
) -> None:
249-
started = test_library.ffe_start()
250-
if started:
251-
_assert_default_or_not_ready(_evaluate(test_library))
384+
test_library.ffe_start()
252385
_assert_no_mock_requests(mock_ffe_agentless_backend)
386+
_assert_no_ffe_remote_config_activation(test_agent)
253387

254388

255389
@scenarios.parametric

tests/test_the_test/test_mock_ffe_agentless_backend.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def test_mock_ffe_agentless_backend_serves_fixture_and_tracks_metadata(worker_id
3434
timeout=5,
3535
)
3636
response.raise_for_status()
37+
assert response.headers["Content-Length"] == str(len(response.content))
3738

3839
payload = response.json()
3940
assert payload["data"]["type"] == UFC_RESPONSE_TYPE

utils/build/docker/java/parametric/src/main/java/com/datadoghq/trace/controller/FeatureFlagEvaluatorController.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.HashMap;
2929
import java.util.List;
3030
import java.util.Map;
31+
import java.util.concurrent.TimeUnit;
3132

3233
@RestController
3334
@RequestMapping("/ffe")
@@ -42,10 +43,14 @@ public static class FeatureFlagEvaluatorConfig {
4243
@Bean
4344
public Client client() {
4445
final OpenFeatureAPI api = OpenFeatureAPI.getInstance();
45-
final String envProperty = System.getenv("DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED");
46+
final boolean featureFlaggingConfigured =
47+
System.getenv("DD_FEATURE_FLAGS_ENABLED") != null
48+
|| System.getenv("DD_FEATURE_FLAGS_CONFIGURATION_SOURCE") != null
49+
|| System.getenv("DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL") != null
50+
|| Boolean.parseBoolean(System.getenv("DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"));
4651
final FeatureProvider provider;
47-
if (Boolean.parseBoolean(envProperty)) {
48-
provider = new Provider();
52+
if (featureFlaggingConfigured) {
53+
provider = new Provider(new Provider.Options().initTimeout(100, TimeUnit.MILLISECONDS));
4954
} else {
5055
provider = new NoOpProvider() {
5156
@Override

utils/build/docker/nodejs/parametric/server.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -509,8 +509,17 @@ app.post("/trace/otel/otel_set_baggage", (req, res) => {
509509

510510
// Feature Flag & Experimentation endpoints
511511
app.post('/ffe/start', async (req, res) => {
512-
const { openfeature } = tracer
513-
await OpenFeature.setProviderAndWait(openfeature)
512+
const hasFeatureFlaggingConfiguration = [
513+
'DD_FEATURE_FLAGS_ENABLED',
514+
'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE',
515+
'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL',
516+
'DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED'
517+
].some(name => process.env[name] !== undefined)
518+
519+
if (hasFeatureFlaggingConfiguration) {
520+
const { openfeature } = tracer
521+
await OpenFeature.setProviderAndWait(openfeature)
522+
}
514523
openFeatureClient = OpenFeature.getClient()
515524
res.json({})
516525
})

utils/docker_fixtures/_mock_ffe_agentless_backend.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def _handle_config(self) -> None:
194194
self.send_response(status_code)
195195
for key, value in headers.items():
196196
self.send_header(key, value)
197+
self.send_header("Content-Length", str(len(body)))
197198
self.end_headers()
198199
if body:
199200
self.wfile.write(body)
@@ -228,11 +229,13 @@ def _handle_responses_control(self) -> None:
228229
self._write_json(HTTPStatus.OK, self.server.state.status())
229230

230231
def _write_json(self, status_code: HTTPStatus, payload: dict[str, Any] | MockFFEAgentlessBackendStatus) -> None:
232+
body = json.dumps(payload).encode("utf-8")
231233
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
232234
self.send_response(status_code)
233235
self.send_header("Content-Type", "application/json")
236+
self.send_header("Content-Length", str(len(body)))
234237
self.end_headers()
235-
self.wfile.write(json.dumps(payload).encode("utf-8"))
238+
self.wfile.write(body)
236239

237240

238241
def _has_auth(headers: Mapping[str, str]) -> bool:

0 commit comments

Comments
 (0)