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
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -16,4 +17,9 @@ public final class AgentConfigurationSource implements ConfigurationSource {
public AutoCloseable subscribe(final Consumer<byte[]> listener) {
return this.backend.subscribeRemoteConfig(listener);
}

@Override
public AutoCloseable subscribe(final Consumer<byte[]> listener, final Set<String> capabilities) {
return this.backend.subscribeRemoteConfig(listener, capabilities);
}
}
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -13,4 +14,15 @@ public interface ConfigurationSource {
* @return the handle to close to unsubscribe.
*/
AutoCloseable subscribe(Consumer<byte[]> 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<byte[]> listener, Set<String> capabilities) {
return subscribe(listener);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -124,7 +125,8 @@ public void start(final Consumer<ServerConfiguration> 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(),
Expand Down Expand Up @@ -243,14 +245,16 @@ private boolean apply(final HttpResponse<byte[]> 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);
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

Heads up: when the evaluator report is turned on, this gate sends X-DD-FEATURE-FLAGS-CAPABILITIES on direct UFC requests (the report is empty today). The corresponding capability-gated code in DataDog/dd-trace-go#5800 and DataDog/dd-trace-js#11038 now uses the query parameter cap_dependent-flags-variant-key=v1 on direct UFC requests instead of this header (their capability reports are currently empty; DataDog/dd-trace-js#11083 states the reason: keep the capabilities header for client-to-edge negotiation, and negotiate the direct UFC response shape through the cache-keyed URL). If that is the agreed protocol, please switch this path to the same query parameter, so all SDKs negotiate the same way.

}
if (this.apiKey != null) {
request.header("DD-API-KEY", this.apiKey);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,7 +39,8 @@ public synchronized void start(final Consumer<ServerConfiguration> 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.
Expand All @@ -48,7 +50,8 @@ public synchronized void start(final Consumer<ServerConfiguration> listener) {
if (configuration != null) {
listener.accept(configuration);
}
});
},
EvaluatorCapabilities.supported());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> supported() {
return emptySet();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

[P1] Coordination note with #13166: nothing turns this report on, and #13166 sends the header by itself.

  • This method returns emptySet(), so this PR sends neither the header nor bit 50.
  • feat: Dependent Flags #13166 implements the evaluator, but it adds the header unconditionally in CdnConfigurationSource and never sets bit 50.
  • A three-way merge of the two PRs' versions of CdnConfigurationSource.java and its test has no textual conflict, but the merged test asserts both that the header is present and that it is absent.

Suggested order: land this PR first. Then #13166 deletes its own header and constants, and this method returns singleton(DEPENDENT_FLAG_VARIANT_KEY_V1) (with import static java.util.Collections.singleton;), with EvaluatorCapabilitiesTest and the header assertions updated. Before that change, RemoteConfigServiceImpl must request the configuration again when the capability bit changes. This is a sketch; I did not build it.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -58,6 +59,12 @@ public interface Backend {
*/
AutoCloseable subscribeRemoteConfig(Consumer<byte[]> listener);

/** Subscribes with the SDK evaluator's optional UFC capabilities. */
default AutoCloseable subscribeRemoteConfig(
Consumer<byte[]> listener, Set<String> capabilities) {
return subscribeRemoteConfig(listener);
}

/**
* @return whether the Datadog Agent event platform proxy is available.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -121,6 +122,12 @@ public AutoCloseable subscribeRemoteConfig(final Consumer<byte[]> listener) {
return remoteConfig().subscribe(listener);
}

@Override
public AutoCloseable subscribeRemoteConfig(
final Consumer<byte[]> listener, final Set<String> capabilities) {
return remoteConfig().subscribe(listener, capabilities);
}

private synchronized RemoteConfigServiceImpl remoteConfig() {
if (this.remoteConfig == null) {
final ConfigurationPoller poller = this.sco.configurationPoller(this.config);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -32,6 +37,8 @@ public class RemoteConfigServiceImpl implements ConfigurationChangesTypedListene
private boolean unvalidated;

private boolean started;
private final Map<Consumer<byte[]>, Boolean> dependentFlagSubscribers = new IdentityHashMap<>();
private boolean dependentFlagsAdvertised;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

[P2] Possible causes of the red spotless and test_base jobs (from reading the code; I did not run Gradle or read the job logs):

  • spotless: the @TableTest rows in CdnConfigurationSourceTest were split into "…" + "…". The repository's Spotless chain runs googleJavaFormat and then tableTestFormatter, which formats those tables itself. Running ./gradlew spotlessApply and committing the result should settle it.
  • test_base: this job runs with -PcheckCoverage. The new default method on FeatureFlaggingGateway.Backend and the two-argument FeatureFlagsBackend.subscribeRemoteConfig have no tests. A test that calls each and checks that it delegates would cover them.


public RemoteConfigServiceImpl(final ConfigurationPoller configurationPoller) {
this.configurationPoller = configurationPoller;
Expand Down Expand Up @@ -64,11 +71,25 @@ private void listen() {
* @return the handle to close to unsubscribe.
*/
public AutoCloseable subscribe(final Consumer<byte[]> subscriber) {
return subscribe(subscriber, emptySet());
}

/** Subscribes with the evaluator capabilities reported by this SDK instance. */
public AutoCloseable subscribe(
final Consumer<byte[]> subscriber, final Set<String> capabilities) {
start();
// A distinct forwarding listener keeps repeated registrations independently removable.
final Consumer<byte[]> 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 {
Expand All @@ -81,10 +102,39 @@ public AutoCloseable subscribe(final Consumer<byte[]> 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

Heads up: This becomes a bug when EvaluatorCapabilities.supported() is turned on. Please fix it before that change lands.

When the advertised state changes, updateDependentFlagCapability clears current and then only adds or removes the capability bit. It does not request the configuration again, although the PR description says that subscription changes "request configuration again".

Scenario, assuming the Remote Config payload does not change when bit 50 is added (for example, an org without dependent flags), so the poller does not call accept again:

  1. The first supported subscriber registers, receives the cached document, and the flip to "advertised" clears current.
  2. Later, that subscription closes (for example, the last provider shuts down and the runtime releases its subscription). The flip back clears current again.
  3. A new runtime subscribes. It gets no replay, so its provider waits for a document until its initialization timeout. An unsupported subscriber that joins while the bit is advertised waits in the same way.

Fix: after the capability bit changes, call requestAgain() outside the subscribers lock, so the poller registers the product again and the next poll delivers the document with the new bits. Add a test for the sequence above that asserts the listener is registered again (or simulates the next poll and its accept) before asserting that the new subscriber receives the document.

I found this by reading the code; I did not run it.

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);
}
}

/**
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
Loading
Loading