diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-1.69/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-1.69/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java index 4ae21393a20..ae3d192cddb 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-1.69/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-1.69/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java @@ -2,6 +2,7 @@ import com.datadog.openfeature.internal.bridge.v1.ConfigurationSource; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import java.util.Set; import java.util.function.Consumer; /** Receives the flag configuration from the agent Remote Configuration client. */ @@ -16,4 +17,9 @@ public final class AgentConfigurationSource implements ConfigurationSource { public AutoCloseable subscribe(final Consumer listener) { return this.backend.subscribeRemoteConfig(listener); } + + @Override + public AutoCloseable subscribe(final Consumer listener, final Set capabilities) { + return this.backend.subscribeRemoteConfig(listener, capabilities); + } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/v1/ConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/v1/ConfigurationSource.java index 79505f916c1..cd7bf44d991 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/v1/ConfigurationSource.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/v1/ConfigurationSource.java @@ -1,5 +1,6 @@ package com.datadog.openfeature.internal.bridge.v1; +import java.util.Set; import java.util.function.Consumer; /** A source of Universal Flag Configuration (UFC) documents. */ @@ -13,4 +14,15 @@ public interface ConfigurationSource { * @return the handle to close to unsubscribe. */ AutoCloseable subscribe(Consumer listener); + + /** + * Subscribes with the evaluator's optional UFC capabilities. Older agents remain conservative. + * + * @param listener the configuration listener. + * @param capabilities features supported by evaluation and exposure reporting. + * @return the subscription handle. + */ + default AutoCloseable subscribe(Consumer listener, Set capabilities) { + return subscribe(listener); + } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java index 7eed50bb8d2..ad7efef8bd7 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java @@ -5,6 +5,7 @@ import com.datadog.openfeature.internal.ConfigurationService; import com.datadog.openfeature.internal.RuntimeServices; import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.evaluation.EvaluatorCapabilities; import com.datadog.openfeature.internal.ufc.ServerConfiguration; import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; import java.io.IOException; @@ -124,7 +125,8 @@ public void start(final Consumer listener) { } // Logged last: a diagnostic must not keep the polling from starting. LOGGER.debug( - "Feature Flags agentless configuration source started: endpoint={}://{}{}, pollIntervalMillis={}, requestTimeout={}", + "Feature Flags agentless configuration source started: endpoint={}://{}{}," + + " pollIntervalMillis={}, requestTimeout={}", // The origin only: the userinfo, the path and the query of a custom endpoint can carry a // credential. this.endpoint.getScheme(), @@ -243,14 +245,16 @@ private boolean apply(final HttpResponse response) { configuration = UniversalFlagConfigParser.parseJsonApi(Gzip.decode(response)); } catch (final IOException | RuntimeException e) { LOGGER.debug( - "Feature Flags configuration endpoint returned a malformed payload ({}); retaining the current configuration", + "Feature Flags configuration endpoint returned a malformed payload ({}); retaining the" + + " current configuration", e.getClass().getSimpleName(), e); return false; } if (configuration == null) { LOGGER.debug( - "Feature Flags configuration endpoint returned no flag data; retaining the current configuration"); + "Feature Flags configuration endpoint returned no flag data; retaining the current" + + " configuration"); return false; } this.listener.accept(configuration); @@ -298,6 +302,11 @@ private HttpRequest buildRequest() { .timeout(this.requestTimeout) .header("Accept-Encoding", "gzip"); Headers.addMetadata(request); + if (EvaluatorCapabilities.supported() + .contains(EvaluatorCapabilities.DEPENDENT_FLAG_VARIANT_KEY_V1)) { + request.header( + "X-DD-FEATURE-FLAGS-CAPABILITIES", EvaluatorCapabilities.DEPENDENT_FLAG_VARIANT_KEY_V1); + } if (this.apiKey != null) { request.header("DD-API-KEY", this.apiKey); } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java index ec63935162a..16055f00129 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java @@ -2,6 +2,7 @@ import com.datadog.openfeature.internal.ConfigurationService; import com.datadog.openfeature.internal.bridge.v1.ConfigurationSource; +import com.datadog.openfeature.internal.evaluation.EvaluatorCapabilities; import com.datadog.openfeature.internal.ufc.ServerConfiguration; import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; import java.io.IOException; @@ -38,7 +39,8 @@ public synchronized void start(final Consumer listener) { configuration = UniversalFlagConfigParser.parse(content); } catch (final IOException | RuntimeException e) { LOGGER.debug( - "Remote Configuration returned a malformed flag configuration ({}); retaining the current configuration", + "Remote Configuration returned a malformed flag configuration ({}); retaining" + + " the current configuration", e.getClass().getSimpleName(), e); // Rejects the document so the agent reports it as not applied. @@ -48,7 +50,8 @@ public synchronized void start(final Consumer listener) { if (configuration != null) { listener.accept(configuration); } - }); + }, + EvaluatorCapabilities.supported()); } @Override diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluatorCapabilities.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluatorCapabilities.java new file mode 100644 index 00000000000..d4ea1e64a28 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluatorCapabilities.java @@ -0,0 +1,17 @@ +package com.datadog.openfeature.internal.evaluation; + +import static java.util.Collections.emptySet; + +import java.util.Set; + +/** Capabilities supported by both local evaluation and exposure reporting. */ +public final class EvaluatorCapabilities { + public static final String DEPENDENT_FLAG_VARIANT_KEY_V1 = "dependent-flag-variant-key-v1"; + + private EvaluatorCapabilities() {} + + /** Keep empty until prerequisite evaluation, errors, and exposures are integration-tested. */ + public static Set supported() { + return emptySet(); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java index 9dc8168dd5c..32dbf46dba4 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java @@ -97,13 +97,20 @@ void rejectsInvalidSiteForDatadogUfcCdnEndpoint() { } @TableTest({ - "scenario | baseUrl | endpoint ", - "host only | 'http://localhost:8080' | 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server' ", - "root path | 'http://localhost:8080/' | 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server' ", - "root query | 'https://proxy.example/?token=a%2Bb' | 'https://proxy.example/api/v2/feature-flagging/config/rules-based/server?token=a%2Bb'", - "host query | 'https://proxy.example?token=a' | 'https://proxy.example/api/v2/feature-flagging/config/rules-based/server?token=a' ", - "custom path | 'https://proxy.example/ufc.json' | 'https://proxy.example/ufc.json' ", - "trimmed | ' https://proxy.example/ufc ' | 'https://proxy.example/ufc' " + "scenario | baseUrl | endpoint " + + " ", + "host only | 'http://localhost:8080' |" + + " 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server' ", + "root path | 'http://localhost:8080/' |" + + " 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server' ", + "root query | 'https://proxy.example/?token=a%2Bb' |" + + " 'https://proxy.example/api/v2/feature-flagging/config/rules-based/server?token=a%2Bb'", + "host query | 'https://proxy.example?token=a' |" + + " 'https://proxy.example/api/v2/feature-flagging/config/rules-based/server?token=a' ", + "custom path | 'https://proxy.example/ufc.json' | 'https://proxy.example/ufc.json' " + + " ", + "trimmed | ' https://proxy.example/ufc ' | 'https://proxy.example/ufc' " + + " " }) void resolvesConfiguredAgentlessBaseUrl(final String baseUrl, final String endpoint) { assertEquals( @@ -164,6 +171,7 @@ void sendsApiKeyAndMetadataHeaders() throws Exception { assertEquals("gzip", request.header("Accept-Encoding")); assertEquals("java", request.header("Datadog-Meta-Lang")); assertNull(request.header("If-None-Match")); + assertNull(request.header("X-DD-FEATURE-FLAGS-CAPABILITIES")); } @Test @@ -207,6 +215,7 @@ void usesEtagAndSkipsDispatchOnUnchangedConfig() { assertEquals(1, this.dispatched.size()); assertEquals("\"v1\"", this.server.requests().get(1).header("If-None-Match")); + assertNull(this.server.requests().get(1).header("X-DD-FEATURE-FLAGS-CAPABILITIES")); } @Test @@ -223,6 +232,7 @@ void ignoresBlankEtagAndClearsPreviousEtag() { } assertEquals("\"v1\"", this.server.requests().get(1).header("If-None-Match")); + assertNull(this.server.requests().get(1).header("X-DD-FEATURE-FLAGS-CAPABILITIES")); assertNull(this.server.requests().get(2).header("If-None-Match")); assertNull(this.server.requests().get(3).header("If-None-Match")); } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/EvaluatorCapabilitiesTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/EvaluatorCapabilitiesTest.java new file mode 100644 index 00000000000..41bdfaec5c7 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/EvaluatorCapabilitiesTest.java @@ -0,0 +1,12 @@ +package com.datadog.openfeature.internal.evaluation; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class EvaluatorCapabilitiesTest { + @Test + void doesNotAdvertiseUnsupportedDependentFlags() { + assertTrue(EvaluatorCapabilities.supported().isEmpty()); + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java index 7b6d9730548..2c0be453448 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java @@ -1,6 +1,7 @@ package datadog.trace.api.featureflag; import java.io.IOException; +import java.util.Set; import java.util.function.Consumer; import javax.annotation.Nullable; @@ -58,6 +59,12 @@ public interface Backend { */ AutoCloseable subscribeRemoteConfig(Consumer listener); + /** Subscribes with the SDK evaluator's optional UFC capabilities. */ + default AutoCloseable subscribeRemoteConfig( + Consumer listener, Set capabilities) { + return subscribeRemoteConfig(listener); + } + /** * @return whether the Datadog Agent event platform proxy is available. */ diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java index d6f9a72cb78..e8cbe6fc323 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java @@ -14,6 +14,7 @@ import datadog.trace.api.telemetry.CoreMetricCollector; import java.io.IOException; import java.util.List; +import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @@ -121,6 +122,12 @@ public AutoCloseable subscribeRemoteConfig(final Consumer listener) { return remoteConfig().subscribe(listener); } + @Override + public AutoCloseable subscribeRemoteConfig( + final Consumer listener, final Set capabilities) { + return remoteConfig().subscribe(listener, capabilities); + } + private synchronized RemoteConfigServiceImpl remoteConfig() { if (this.remoteConfig == null) { final ConfigurationPoller poller = this.sco.configurationPoller(this.config); diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index 8922955d373..7ff1c846864 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -1,13 +1,18 @@ package com.datadog.featureflag; import static datadog.remoteconfig.Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES; +import static datadog.remoteconfig.Capabilities.CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1; import static datadog.remoteconfig.Product.FFE_FLAGS; +import static java.util.Collections.emptySet; import datadog.remoteconfig.ConfigurationChangesTypedListener; import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.PollingRateHinter; +import java.util.IdentityHashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; import javax.annotation.Nullable; @@ -32,6 +37,8 @@ public class RemoteConfigServiceImpl implements ConfigurationChangesTypedListene private boolean unvalidated; private boolean started; + private final Map, Boolean> dependentFlagSubscribers = new IdentityHashMap<>(); + private boolean dependentFlagsAdvertised; public RemoteConfigServiceImpl(final ConfigurationPoller configurationPoller) { this.configurationPoller = configurationPoller; @@ -64,11 +71,25 @@ private void listen() { * @return the handle to close to unsubscribe. */ public AutoCloseable subscribe(final Consumer subscriber) { + return subscribe(subscriber, emptySet()); + } + + /** Subscribes with the evaluator capabilities reported by this SDK instance. */ + public AutoCloseable subscribe( + final Consumer subscriber, final Set capabilities) { start(); + // A distinct forwarding listener keeps repeated registrations independently removable. + final Consumer registered = subscriber::accept; boolean requestAgain = false; // Keep registration and replay atomic with notification snapshots. synchronized (this.subscribers) { - this.subscribers.add(subscriber); + this.subscribers.add(registered); + this.dependentFlagSubscribers.put( + registered, capabilities.contains("dependent-flag-variant-key-v1")); + if (this.dependentFlagsAdvertised + && !capabilities.contains("dependent-flag-variant-key-v1")) { + this.current = null; + } final byte[] document = this.current; if (document != null) { try { @@ -81,10 +102,39 @@ public AutoCloseable subscribe(final Consumer subscriber) { this.unvalidated = false; } } + updateDependentFlagCapability(); if (requestAgain) { requestAgain(); } - return () -> this.subscribers.remove(subscriber); + return () -> { + synchronized (this.subscribers) { + this.subscribers.remove(registered); + this.dependentFlagSubscribers.remove(registered); + } + updateDependentFlagCapability(); + }; + } + + private synchronized void updateDependentFlagCapability() { + final boolean supported; + synchronized (this.subscribers) { + supported = + this.started + && !this.dependentFlagSubscribers.isEmpty() + && !this.dependentFlagSubscribers.containsValue(false); + if (supported == this.dependentFlagsAdvertised) { + return; + } + this.dependentFlagsAdvertised = supported; + this.current = null; + } + // The poller invokes callbacks under its lock; never acquire it with subscribers locked. + if (supported) { + this.configurationPoller.addCapabilities(CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1); + } else { + this.configurationPoller.removeCapabilities( + CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1); + } } /** @@ -114,7 +164,11 @@ public synchronized void close() { this.configurationPoller.removeCapabilities(CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); this.configurationPoller.removeListeners(FFE_FLAGS); this.configurationPoller.stop(); - this.subscribers.clear(); + synchronized (this.subscribers) { + this.subscribers.clear(); + this.dependentFlagSubscribers.clear(); + } + updateDependentFlagCapability(); this.current = null; } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java index f4083331c6c..3c2da0a6ebe 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java @@ -27,6 +27,7 @@ import datadog.remoteconfig.PollingRateHinter; import datadog.remoteconfig.Product; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; @@ -45,6 +46,38 @@ class RemoteConfigServiceImplTest { private static final byte[] DOCUMENT = "{\"flags\":{}}".getBytes(UTF_8); + @Test + void gatesDependentFlagsOnEverySubscriberAndRemovesOnClose() throws Exception { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); + service.start(); + verify(poller, never()) + .addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1); + final AutoCloseable supported = + service.subscribe(content -> {}, Collections.singleton("dependent-flag-variant-key-v1")); + verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1); + final AutoCloseable legacy = service.subscribe(content -> {}); + verify(poller) + .removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1); + legacy.close(); + verify(poller, times(2)) + .addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1); + supported.close(); + verify(poller, times(2)) + .removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1); + service.close(); + } + + @Test + void unknownEvaluatorCapabilitiesDoNotOptIn() { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); + service.subscribe(content -> {}, Collections.singleton("unknown-v1")); + verify(poller, never()) + .addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1); + service.close(); + } + @Test @SuppressWarnings("unchecked") void subscriptionRegistersTheProductWithARawDeserializer() throws Exception { diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java index ce76e59000a..d0956d08364 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java @@ -47,4 +47,5 @@ public interface Capabilities { long CAPABILITY_ASM_EXTENDED_DATA_COLLECTION = 1L << 44; long CAPABILITY_APM_TRACING_MULTICONFIG = 1L << 45; long CAPABILITY_FFE_FLAG_CONFIGURATION_RULES = 1L << 46; + long CAPABILITY_FFE_FLAG_DEPENDENT_FLAG_VARIANT_KEY_V1 = 1L << 50; }