From 03ab8fffc59ca3c045e3a41a3bceed42999df64b Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 27 May 2026 00:05:03 -0400 Subject: [PATCH] fix(openfeature): subscribe to FFE on first RC request --- .../openfeature/_remoteconfiguration.py | 4 +- .../internal/remoteconfig/products/client.py | 13 ++++- ddtrace/internal/remoteconfig/worker.py | 9 ++- ...emote-config-request-9f0b7a6c1d2e3f45.yaml | 6 ++ .../remoteconfig/test_remoteconfig.py | 57 +++++++++++++++++++ 5 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 releasenotes/notes/fix-openfeature-first-remote-config-request-9f0b7a6c1d2e3f45.yaml diff --git a/ddtrace/internal/openfeature/_remoteconfiguration.py b/ddtrace/internal/openfeature/_remoteconfiguration.py index b30ddd761bb..f1c8697ffa1 100644 --- a/ddtrace/internal/openfeature/_remoteconfiguration.py +++ b/ddtrace/internal/openfeature/_remoteconfiguration.py @@ -66,14 +66,14 @@ def __call__(self, payloads: t.Sequence[Payload]) -> None: _featureflag_rc_callback = FeatureFlagCallback() -def enable_featureflags_rc() -> None: +def enable_featureflags_rc(start_poller: bool = True) -> None: log.debug("[%s][P: %s] Register FFE Remote Config Callback", os.getpid(), os.getppid()) remoteconfig_poller.register_callback( FFE_FLAGS_PRODUCT, _featureflag_rc_callback, capabilities=[FFECapabilities.FFE_FLAG_CONFIGURATION_RULES], ) - remoteconfig_poller.enable_product(FFE_FLAGS_PRODUCT) + remoteconfig_poller.enable_product(FFE_FLAGS_PRODUCT, start_poller=start_poller) def disable_featureflags_rc() -> None: diff --git a/ddtrace/internal/remoteconfig/products/client.py b/ddtrace/internal/remoteconfig/products/client.py index bfb98691594..ece1ff71e14 100644 --- a/ddtrace/internal/remoteconfig/products/client.py +++ b/ddtrace/internal/remoteconfig/products/client.py @@ -29,9 +29,16 @@ def _register_rc_products() -> None: # Register for both AGENT_CONFIG and AGENT_TASK products (they share the same callback) remoteconfig_poller.register_callback("AGENT_CONFIG", flare_callback) - remoteconfig_poller.enable_product("AGENT_CONFIG") + remoteconfig_poller.enable_product("AGENT_CONFIG", start_poller=False) remoteconfig_poller.register_callback("AGENT_TASK", flare_callback) - remoteconfig_poller.enable_product("AGENT_TASK") + remoteconfig_poller.enable_product("AGENT_TASK", start_poller=False) + + from ddtrace.internal.settings.openfeature import config as ffe_config + + if ffe_config.experimental_flagging_provider_enabled: + from ddtrace.internal.openfeature._remoteconfiguration import enable_featureflags_rc + + enable_featureflags_rc(start_poller=False) def post_preload(): @@ -45,8 +52,8 @@ def enabled(): def start(): from ddtrace.internal.remoteconfig.worker import remoteconfig_poller - remoteconfig_poller.enable() _register_rc_products() + remoteconfig_poller.enable() def restart(join=False): diff --git a/ddtrace/internal/remoteconfig/worker.py b/ddtrace/internal/remoteconfig/worker.py index eff42588374..da92ed06ad7 100644 --- a/ddtrace/internal/remoteconfig/worker.py +++ b/ddtrace/internal/remoteconfig/worker.py @@ -163,10 +163,6 @@ def register_callback( capabilities: list of capabilities to register for this product """ try: - # Enable if this is the first product being registered - if not self._client._product_callbacks: - self.enable() - self._client.register_callback(product, callback) # Check for potential conflicts in capabilities @@ -189,7 +185,7 @@ def register_callback( except Exception: log.debug("error starting the RCM client", exc_info=True) - def enable_product(self, product: str) -> None: + def enable_product(self, product: str, start_poller: bool = True) -> None: """Enable a product to be included in client payloads. When a product is enabled, it will be added to the 'products' list @@ -198,8 +194,11 @@ def enable_product(self, product: str) -> None: Args: product: Product name to enable + start_poller: Whether to start the Remote Config poller after enabling the product """ self._client.enable_product(product) + if start_poller: + self.enable() def disable_product(self, product: str) -> None: """Disable a product, removing it from client payloads. diff --git a/releasenotes/notes/fix-openfeature-first-remote-config-request-9f0b7a6c1d2e3f45.yaml b/releasenotes/notes/fix-openfeature-first-remote-config-request-9f0b7a6c1d2e3f45.yaml new file mode 100644 index 00000000000..6a5feae82d5 --- /dev/null +++ b/releasenotes/notes/fix-openfeature-first-remote-config-request-9f0b7a6c1d2e3f45.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + openfeature: Fixes an issue where the OpenFeature provider could miss feature flag rules in the + tracer's first Remote Configuration request, causing applications to use default flag values + until a later poll received the configuration. diff --git a/tests/internal/remoteconfig/test_remoteconfig.py b/tests/internal/remoteconfig/test_remoteconfig.py index 4baef5a4cd8..730936f746b 100644 --- a/tests/internal/remoteconfig/test_remoteconfig.py +++ b/tests/internal/remoteconfig/test_remoteconfig.py @@ -137,6 +137,10 @@ def get_mock_encoded_msg_with_signed_errors(msg, path, signed_errors): } +def _decode_capabilities(payload): + return int.from_bytes(base64.b64decode(payload["client"]["capabilities"]), "big") + + def test_remote_config_register_auto_enable(remote_config_worker): # ASM_FEATURES product is enabled by default, but LIVE_DEBUGGER isn't class MockCallback(RCCallback): @@ -157,6 +161,59 @@ def __call__(self, payloads): remoteconfig_poller.disable() +def test_remote_config_enable_product_starts_after_product_is_in_payload(remote_config_worker): + class MockCallback(RCCallback): + def __call__(self, payloads): + pass + + captured_payloads = [] + + def capture_initial_payload(): + captured_payloads.append(remoteconfig_poller._client._build_payload({})) + + with override_global_config(dict(_remote_config_enabled=True)): + with ( + patch.object(remoteconfig_poller, "start", side_effect=capture_initial_payload), + patch.object(remoteconfig_poller._client, "start_subscriber"), + ): + remoteconfig_poller.register_callback("LIVE_DEBUGGER", MockCallback()) + + assert captured_payloads == [] + + remoteconfig_poller.enable_product("LIVE_DEBUGGER") + + assert "LIVE_DEBUGGER" in captured_payloads[0]["client"]["products"] + + +@pytest.mark.parametrize("ffe_enabled", [False, True]) +def test_remote_config_start_registers_initial_payload_products(remote_config_worker, ffe_enabled): + from ddtrace.internal.openfeature._remoteconfiguration import FFE_FLAGS_PRODUCT + from ddtrace.internal.openfeature._remoteconfiguration import FFECapabilities + from ddtrace.internal.remoteconfig.products import client as remote_config_product + + captured_payloads = [] + + def capture_initial_payload(): + captured_payloads.append(remoteconfig_poller._client._build_payload({})) + + with override_global_config(dict(_remote_config_enabled=True, experimental_flagging_provider_enabled=ffe_enabled)): + with ( + patch.object(remoteconfig_poller, "start", side_effect=capture_initial_payload), + patch.object(remoteconfig_poller._client, "start_subscriber"), + ): + remote_config_product.start() + + assert captured_payloads + products = set(captured_payloads[0]["client"]["products"]) + assert {"AGENT_CONFIG", "AGENT_TASK"} <= products + + if ffe_enabled: + assert FFE_FLAGS_PRODUCT in products + assert _decode_capabilities(captured_payloads[0]) & FFECapabilities.FFE_FLAG_CONFIGURATION_RULES + else: + assert FFE_FLAGS_PRODUCT not in products + + def test_remote_config_register_validate_rc_disabled(remote_config_worker): remoteconfig_poller.disable()