Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions manifests/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,7 @@ manifest:
tests/docker_ssi/test_docker_ssi_appsec.py::TestDockerSSIAppsecFeatures::test_telemetry_source_ssi: v3.36.0
tests/ffe/test_dynamic_evaluation.py: v3.36.0
tests/ffe/test_dynamic_evaluation.py::Test_FFE_Flag_Parse_Error_Isolation: bug (FFL-2184)
tests/ffe/test_dynamic_evaluation.py::Test_FFE_RC_Down_Then_Up: bug (FFL-2339)
tests/ffe/test_dynamic_evaluation.py::Test_FFE_Unknown_Operator_Tolerance: bug (FFL-2184)
tests/ffe/test_exposures.py: v3.36.0
tests/ffe/test_flag_eval_metrics.py: missing_feature (FFL-2257 - Requires Datadog.FeatureFlags.OpenFeature 2.3.0 NuGet package to be published)
Expand Down
2 changes: 2 additions & 0 deletions manifests/java.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3154,7 +3154,9 @@ manifest:
- weblog_declaration:
"*": irrelevant
spring-boot: v1.56.0
tests/ffe/test_dynamic_evaluation.py::Test_FFE_First_Remote_Config_Request: v1.63.0-SNAPSHOT
tests/ffe/test_dynamic_evaluation.py::Test_FFE_Flag_Parse_Error_Isolation::test_valid_flag_unaffected: bug (FFL-2184)
tests/ffe/test_dynamic_evaluation.py::Test_FFE_RC_Down_Then_Up: v1.63.0-SNAPSHOT
tests/ffe/test_dynamic_evaluation.py::Test_FFE_Unknown_Operator_Tolerance: bug (FFL-2184)
tests/ffe/test_exposures.py:
- weblog_declaration:
Expand Down
2 changes: 2 additions & 0 deletions manifests/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1273,7 +1273,9 @@ manifest:
tests/docker_ssi/test_docker_ssi_crash.py::TestDockerSSICrash::test_crash:
- declaration: bug (INPLAT-603)
component_version: '>=3.0.0-dev'
tests/ffe/test_dynamic_evaluation.py::Test_FFE_First_Remote_Config_Request: bug (FFL-2339)
tests/ffe/test_dynamic_evaluation.py::Test_FFE_RC_Down_From_Start: v4.0.0
tests/ffe/test_dynamic_evaluation.py::Test_FFE_RC_Down_Then_Up: v4.11.0-dev
tests/ffe/test_dynamic_evaluation.py::Test_FFE_RC_Unavailable: flaky (FFL-1622)
tests/ffe/test_exposures.py: v4.2.0-dev
tests/ffe/test_flag_eval_metrics.py: v4.8.0-dev
Expand Down
112 changes: 112 additions & 0 deletions tests/ffe/test_dynamic_evaluation.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Test feature flags dynamic evaluation via Remote Config."""

import base64
import copy
import json
import uuid
from http import HTTPStatus

from utils.dd_constants import Capabilities
from utils import (
weblog,
interfaces,
Expand All @@ -19,6 +21,11 @@
RC_PATH = f"datadog/2/{RC_PRODUCT}"


def _decode_capabilities(capabilities: list[int] | str) -> int:
raw_capabilities = bytes(capabilities) if isinstance(capabilities, list) else base64.b64decode(capabilities)
return int.from_bytes(raw_capabilities, byteorder="big")


# Simple UFC fixture for testing with doLog: true
UFC_FIXTURE_DATA = {
"createdAt": "2024-04-17T19:40:53.716Z",
Expand All @@ -43,6 +50,111 @@
}


@scenarios.feature_flagging_and_experimentation
@features.feature_flags_dynamic_evaluation
class Test_FFE_First_Remote_Config_Request:
"""The tracer must subscribe to FFE on its first RC request.

This covers the warm-Agent case: the Agent can already be running without
any FFE cache, then a tracer starts. If the tracer only adds FFE on a later
RC poll, the Agent can miss the fast new-client backend fetch and the app
may keep default flag values during startup.
"""

def test_first_remote_config_request_subscribes_to_ffe(self) -> None:
remote_config_requests = list(interfaces.library.get_data(path_filters="/v0.7/config"))
assert remote_config_requests, "Expected the tracer to send at least one remote config request"

first_request = remote_config_requests[0]
client = first_request["request"]["content"]["client"]
products = client.get("products", [])
assert RC_PRODUCT in products, (
f"Expected first remote config request to subscribe to {RC_PRODUCT}, got products={products}. "
"If FFE appears only on a later poll, an already-running Agent can miss its new-client backend fetch."
)

capabilities = client.get("capabilities")
assert capabilities is not None, "Expected first remote config request to include capabilities"
decoded_capabilities = _decode_capabilities(capabilities)
assert (decoded_capabilities >> Capabilities.FFE_FLAG_CONFIGURATION_RULES) & 1 == 1, (
f"Expected first remote config request to advertise {Capabilities.FFE_FLAG_CONFIGURATION_RULES.name}."
)


@scenarios.feature_flagging_and_experimentation
@features.feature_flags_dynamic_evaluation
class Test_FFE_RC_Down_Then_Up:
"""FFE must recover when RC is unavailable before the tracer receives flags.

This covers the startup race we care about: the app starts, the tracer cannot
get FFE config from the Agent/RC endpoint yet, and evaluations correctly use
defaults. Once RC becomes available and sends FFE_FLAGS, the same app must
start using the delivered flag value without a restart.
"""

def setup_ffe_rc_down_then_up_recovers(self):
self.config_request_data = None
self.flag_key = "test-flag"
self.default_before_config = "default-before-config"
self.default_after_config = "default-after-config"

def wait_for_config_503(data: dict) -> bool:
if data["path"] == "/v0.7/config" and data["response"]["status_code"] == HTTPStatus.SERVICE_UNAVAILABLE:
self.config_request_data = data
return True
return False

StaticJsonMockedTracerResponse(
path="/v0.7/config", mocked_json={"error": "Service Unavailable"}, status_code=503
).send()

interfaces.library.wait_for(wait_for_config_503, timeout=60)

self.default_eval = weblog.post(
"/ffe",
json={
"flag": self.flag_key,
"variationType": "STRING",
"defaultValue": self.default_before_config,
"targetingKey": "user-before-rc-recovery",
"attributes": {},
},
)

self.config_state = (
rc.tracer_rc_state.reset().set_config(f"{RC_PATH}/ffe-rc-down-then-up/config", UFC_FIXTURE_DATA).apply()
)

self.recovered_eval = weblog.post(
"/ffe",
json={
"flag": self.flag_key,
"variationType": "STRING",
"defaultValue": self.default_after_config,
"targetingKey": "user-after-rc-recovery",
"attributes": {},
},
)

def test_ffe_rc_down_then_up_recovers(self):
assert self.config_request_data is not None, "No /v0.7/config 503 response was captured"
assert self.config_request_data["response"]["status_code"] == HTTPStatus.SERVICE_UNAVAILABLE, (
f"Expected 503, got {self.config_request_data['response']['status_code']}"
)

assert self.default_eval.status_code == 200, f"Default evaluation failed: {self.default_eval.text}"
default_result = json.loads(self.default_eval.text)
assert default_result["value"] == self.default_before_config, (
f"Expected default before config recovery, got '{default_result['value']}'"
)

assert self.recovered_eval.status_code == 200, f"Recovered evaluation failed: {self.recovered_eval.text}"
recovered_result = json.loads(self.recovered_eval.text)
assert recovered_result["value"] == "on", (
f"Expected delivered flag value after config recovery, got '{recovered_result['value']}'"
)


@scenarios.feature_flagging_and_experimentation
@features.feature_flags_dynamic_evaluation
class Test_FFE_Unknown_Operator_Tolerance:
Expand Down
Loading