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 @@ -12,7 +12,11 @@ public class OpenFeatureConfiguration {
@Bean
public Client openFeatureClient() {
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProviderAndWait(new Provider());
api.setProviderAndWait(
new Provider(
new Provider.Options()
.enableExperimentationOptOutParameter(
Boolean.getBoolean("smoketest.experimentationOptOutParameterEnabled"))));
return api.getClient();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest {
command.addAll(defaultJavaProperties)
command.add('-Ddd.trace.debug=true')
command.add('-Ddd.remote_config.enabled=true')
command.add('-Dsmoketest.experimentationOptOutParameterEnabled=true')
command.add("-Ddd.remote_config.url=http://localhost:${server.address.port}/v0.7/config".toString())
command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()])
final builder = new ProcessBuilder(command).directory(new File(buildDirectory))
Expand Down Expand Up @@ -179,6 +180,77 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest {
testCase << parseTestCases()
}

@Unroll("test experimentation opt-out contract - #testCase.id")
void 'test experimentation opt-out through the provider and agent'() {
setup:
final configuration = new JsonSlurper().parse(fetchResource(
'ffe-system-test-data/experimentation-opt-out/v1/configurations/supported.json'))
setRemoteConfig('datadog/2/FFE_FLAGS/1/config', JsonOutput.toJson(configuration))
final request = new Request.Builder()
.url("http://localhost:${httpPort}/openfeature/evaluate")
.post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson([
flag: testCase.evaluation.flag,
variationType: testCase.evaluation.variationType,
defaultValue: testCase.evaluation.defaultValue,
targetingKey: testCase.context.targetingKey,
attributes: testCase.context.attributes
])))
.build()

expect:
// Wait for Remote Configuration delivery. These fixtures have no time-dependent allocations.
new PollingConditions(timeout: 10).eventually {
final response = client.newCall(request).execute()
try {
assert response.code() == 200
final body = new JsonSlurper().parse(response.body().byteStream())
assert body.value == testCase.expected.value
assert body.errorCode == null
final assignment = testCase.expected.assignment
if (assignment == null) {
assert body.flagMetadata?.allocationKey == null
assert body.reason == 'DEFAULT'
} else {
assert body.variant == assignment.variationKey
assert body.flagMetadata.allocationKey == assignment.allocationKey
assert body.flagMetadata.__dd_split_serial_id == assignment.serialId
assert body.flagMetadata.__dd_do_log == assignment.doLog
final prefixes = [HOOK: '__dd_feature.', EXPOSURE: '__dd_exposure_feature.', EVALUATION: '__dd_evaluation_feature.']
final expectedFeatures = expectedFeatureMetadata(assignment, prefixes)
assert body.flagMetadata.findAll { key, value ->
prefixes.values().any { key.startsWith(it) }
} == expectedFeatures
}
} finally {
response.close()
}
}

where:
testCase << parseExperimentationOptOutCases()
}

private static List<Map<String, Object>> parseExperimentationOptOutCases() {
final slurper = new JsonSlurper()
final cases = ['activation', 'evaluation', 'holdouts'].collectMany { name ->
slurper.parse(fetchResource("ffe-system-test-data/experimentation-opt-out/v1/cases/${name}.json"))
}.findAll {
it.configuration == 'supported' && it.providerOptions.enableExperimentationOptOutParameter == true
}
assert !cases.isEmpty()
return cases
}

private static Map<String, Object> expectedFeatureMetadata(final Map assignment, final Map prefixes) {
final result = [:]
assignment.features?.each { feature ->
feature.destinations.each { destination ->
result[prefixes[destination] + feature.key] = feature.value
}
}
return result
}

private static URL fetchResource(final String name) {
return Thread.currentThread().getContextClassLoader().getResource(name)
}
Expand Down
Submodule ffe-system-test-data updated 55 files
+39 −0 .github/workflows/validate-experimentation-opt-out.yml
+19 −0 README.md
+219 −0 ci/test-validate-experimentation-opt-out.py
+424 −0 ci/validate-experimentation-opt-out.py
+288 −0 experimentation-opt-out/v1/README.md
+1,247 −0 experimentation-opt-out/v1/cases/activation.json
+234 −0 experimentation-opt-out/v1/cases/compatibility.json
+1,749 −0 experimentation-opt-out/v1/cases/compiler-generated.json
+2,825 −0 experimentation-opt-out/v1/cases/evaluation.json
+1,340 −0 experimentation-opt-out/v1/cases/holdouts.json
+210 −0 experimentation-opt-out/v1/cases/telemetry.json
+52 −0 experimentation-opt-out/v1/compiler-generated/PROVENANCE.md
+6 −0 experimentation-opt-out/v1/compiler-generated/compiler-active-100-single-arm.pb
+ − experimentation-opt-out/v1/compiler-generated/compiler-active-50-50.pb
+5 −0 experimentation-opt-out/v1/compiler-generated/compiler-completed-control.pb
+5 −0 experimentation-opt-out/v1/compiler-generated/compiler-completed-treatment.pb
+ − experimentation-opt-out/v1/compiler-generated/compiler-holdout-active.pb
+ − experimentation-opt-out/v1/compiler-generated/compiler-holdout-completed-control.pb
+ − experimentation-opt-out/v1/compiler-generated/compiler-holdout-completed-treatment.pb
+426 −0 experimentation-opt-out/v1/compiler-generated/compiler-scenarios.json
+109 −0 experimentation-opt-out/v1/compiler-generated/hash-vectors.json
+56 −0 experimentation-opt-out/v1/configurations/compiler-active-100-single-arm.json
+88 −0 experimentation-opt-out/v1/configurations/compiler-active-50-50.json
+39 −0 experimentation-opt-out/v1/configurations/compiler-completed-control.json
+39 −0 experimentation-opt-out/v1/configurations/compiler-completed-treatment.json
+196 −0 experimentation-opt-out/v1/configurations/compiler-holdout-active.json
+147 −0 experimentation-opt-out/v1/configurations/compiler-holdout-completed-control.json
+151 −0 experimentation-opt-out/v1/configurations/compiler-holdout-completed-treatment.json
+152 −0 experimentation-opt-out/v1/configurations/legacy-without-capability.json
+86 −0 experimentation-opt-out/v1/configurations/missing-dolog.json
+1,507 −0 experimentation-opt-out/v1/configurations/supported.json
+560 −0 experimentation-opt-out/v1/manifest.json
+643 −0 experimentation-opt-out/v1/protocol/request-and-response.json
+90 −0 experimentation-opt-out/v1/sequences/cache-environment-identity.json
+108 −0 experimentation-opt-out/v1/sequences/cache-participation-and-subject-identity.json
+68 −0 experimentation-opt-out/v1/sequences/cache-targeting-context-identity.json
+75 −0 experimentation-opt-out/v1/sequences/failed-refresh-after-opt-out.json
+78 −0 experimentation-opt-out/v1/sequences/late-response-after-opt-out.json
+56 −0 experimentation-opt-out/v1/sequences/logout-rejects-authenticated-response.json
+48 −0 experimentation-opt-out/v1/sequences/missing-ack-never-publishes.json
+85 −0 experimentation-opt-out/v1/sequences/opt-out-does-not-create-exposure-dedup.json
+60 −0 experimentation-opt-out/v1/sequences/same-state-compatible-cache-on-failure.json
+1 −0 precomputed-assignments/cases/emissions/all-doLog-false.json
+1 −0 precomputed-assignments/cases/emissions/duplicate-evaluation.json
+1 −0 precomputed-assignments/cases/evaluator/all-types-success.json
+1 −0 precomputed-assignments/cases/evaluator/defaults-and-emission-gates.json
+1 −0 precomputed-assignments/cases/evaluator/empty-flags-response.json
+1 −0 precomputed-assignments/cases/evaluator/falsy-values.json
+1 −0 precomputed-assignments/cases/evaluator/integer-precision-native-only.json
+1 −0 precomputed-assignments/cases/evaluator/multi-flag-set.json
+1 −0 precomputed-assignments/cases/evaluator/multiple-reasons.json
+1 −0 precomputed-assignments/cases/evaluator/object-nested-deep.json
+1 −0 precomputed-assignments/cases/evaluator/special-characters-in-keys.json
+1 −0 precomputed-assignments/cases/evaluator/type-mismatch-all-types.json
+1,027 −0 schemas/experimentation-opt-out.schema.json
34 changes: 34 additions & 0 deletions products/feature-flagging/feature-flagging-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,40 @@ automatic flag-related span enrichment. It does not change flag values, evaluati
application defaults, experiment assignment, or exposure logging. Application hooks and unrelated
tracing remain active. Previously recorded or queued data is not removed.

## Per-subject experimentation opt-out

Enable experimentation opt-out once. Pass the subject's decision with each evaluation:

```java
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProviderAndWait(new Provider(new Provider.Options()
.enableExperimentationOptOutParameter(true)));
Client client = api.getClient();

boolean enabled = client.getBooleanValue("my-feature", false,
new ImmutableContext(user.getId(), Collections.singletonMap(
"dd_experimentation_opt_out", new Value(user.hasDeclinedExperimentation()))));
```

Only boolean `true` activates exclusion. Missing, false, null, and other values preserve ordinary
evaluation. The provider ignores the attribute unless the option is enabled at construction.

An excluded subject skips entire experimental allocations, including control, treatment, and
holdout groups. Evaluation continues with ordinary allocations. A completed winner rollout remains
available. Exclusion does not force the flag value to `false` or select experimental control.
Skipped allocations produce no exposures. Evaluation telemetry remains enabled unless its separate
opt-out control applies. Applications can enable both controls independently.

The configuration must declare `experimentation-opt-out-v1` in `evaluationCapabilities`.
If this compiler guarantee is absent, an opted-out evaluation returns an error and the application's
default value. Use compatible Datadog agent and provider versions; an older agent cannot preserve
this guarantee. Ordinary evaluations keep their existing behavior.

The evaluator tests use the fixture submodule at
`dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data`. Its
`experimentation-opt-out/v1/manifest.json` defines all 136 evaluation cases and eligible-subject
parity checks. The Java runner tests JSON, which is the Java agent's configuration format.

## Evaluation metrics

When `DD_METRICS_OTEL_ENABLED=true` and the OpenTelemetry API is on the classpath, the provider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.util.AbstractMap;
import java.util.ArrayList;
Expand Down Expand Up @@ -213,11 +214,17 @@ static boolean exposureSerialIdSupported(final Class<?> eventClass) {
private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled();

private final Runnable configCallback;
private final Clock clock;
private final AtomicReference<ServerConfiguration> configuration = new AtomicReference<>();
private final CountDownLatch initializationLatch = new CountDownLatch(1);

public DDEvaluator(final Runnable configCallback) {
this(configCallback, Clock.systemUTC());
}

DDEvaluator(final Runnable configCallback, final Clock clock) {
this.configCallback = configCallback;
this.clock = clock;
}

@Override
Expand Down Expand Up @@ -255,6 +262,16 @@ public <T> ProviderEvaluation<T> evaluate(
final String key,
final T defaultValue,
final EvaluationContext context) {
return evaluate(target, key, defaultValue, context, false);
}

@Override
public <T> ProviderEvaluation<T> evaluate(
final Class<T> target,
final String key,
final T defaultValue,
final EvaluationContext context,
final boolean experimentationOptOut) {
// Snapshot the config once and thread observeFullEvaluationData through every
// ProviderEvaluation returned, so the hook's consent decision is pinned to this evaluation's
// config and cannot drift on a concurrent Remote Config swap.
Expand All @@ -272,6 +289,14 @@ public <T> ProviderEvaluation<T> evaluate(
return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData);
}

if (experimentationOptOut && !supportsExperimentationOptOut(config)) {
return error(
defaultValue,
ErrorCode.GENERAL,
"Configuration does not support experimentation opt-out",
observeFullEvaluationData);
}

final Flag flag = config.flags.get(key);
if (flag == null) {
if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) {
Expand Down Expand Up @@ -300,11 +325,28 @@ public <T> ProviderEvaluation<T> evaluate(
observeFullEvaluationData);
}

final Instant now = Instant.now();
if (experimentationOptOut) {
// Validate the whole flag before resolving an earlier ordinary allocation. Missing
// classification must not silently allow participation with a malformed configuration.
for (final Allocation allocation : flag.allocations) {
if (allocation == null || allocation.doLog == null) {
return error(
defaultValue,
ErrorCode.PARSE_ERROR,
"Missing experimentation classification for flag " + key,
observeFullEvaluationData);
}
}
}

final Instant now = clock.instant();
final long evalTimestampMs = now.toEpochMilli();
final String targetingKey = context.getTargetingKey();

for (final Allocation allocation : flag.allocations) {
if (experimentationOptOut && Boolean.TRUE.equals(allocation.doLog)) {
continue;
}
if (!isAllocationActive(allocation, now)) {
continue;
}
Expand Down Expand Up @@ -375,6 +417,16 @@ public <T> ProviderEvaluation<T> evaluate(
}
}

private static boolean supportsExperimentationOptOut(final ServerConfiguration config) {
try {
return config.evaluationCapabilities != null
&& config.evaluationCapabilities.contains("experimentation-opt-out-v1");
} catch (final NoSuchFieldError e) {
// An older agent cannot preserve the compiler capability. Do not admit an opted-out subject.
return false;
}
}

private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) {
return ImmutableMetadata.builder()
.addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package datadog.trace.api.openfeature;

import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.Reason;
import java.util.concurrent.TimeUnit;

interface Evaluator {
Expand All @@ -14,4 +16,21 @@ interface Evaluator {

<T> ProviderEvaluation<T> evaluate(
Class<T> target, String key, T defaultValue, EvaluationContext context);

default <T> ProviderEvaluation<T> evaluate(
Class<T> target,
String key,
T defaultValue,
EvaluationContext context,
boolean experimentationOptOut) {
if (experimentationOptOut) {
return ProviderEvaluation.<T>builder()
.value(defaultValue)
.reason(Reason.ERROR.name())
.errorCode(ErrorCode.GENERAL)
.errorMessage("The evaluator does not support experimentation opt-out")
.build();
}
return evaluate(target, key, defaultValue, context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public class Provider extends EventProvider implements Metadata {
private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS);
private volatile Evaluator evaluator;
private final Options options;
private final boolean experimentationOptOutParameterEnabled;
private final AtomicReference<InitializationState> initializationState =
new AtomicReference<>(InitializationState.NOT_STARTED);
private final FlagEvalMetrics flagEvalMetrics;
Expand Down Expand Up @@ -66,6 +67,7 @@ public Provider(final Options options) {
final Evaluator evaluator,
final Boolean spanEnrichmentEnabledOverride) {
this.options = options;
this.experimentationOptOutParameterEnabled = options.experimentationOptOutParameterEnabled;
this.evaluator = evaluator;
FlagEvalMetrics metrics = null;
FlagEvalMetricsHook hook = null;
Expand Down Expand Up @@ -278,31 +280,45 @@ public String getName() {
@Override
public ProviderEvaluation<Boolean> getBooleanEvaluation(
final String key, final Boolean defaultValue, final EvaluationContext ctx) {
return evaluator.evaluate(Boolean.class, key, defaultValue, ctx);
return evaluate(Boolean.class, key, defaultValue, ctx);
}

@Override
public ProviderEvaluation<String> getStringEvaluation(
final String key, final String defaultValue, final EvaluationContext ctx) {
return evaluator.evaluate(String.class, key, defaultValue, ctx);
return evaluate(String.class, key, defaultValue, ctx);
}

@Override
public ProviderEvaluation<Integer> getIntegerEvaluation(
final String key, final Integer defaultValue, final EvaluationContext ctx) {
return evaluator.evaluate(Integer.class, key, defaultValue, ctx);
return evaluate(Integer.class, key, defaultValue, ctx);
}

@Override
public ProviderEvaluation<Double> getDoubleEvaluation(
final String key, final Double defaultValue, final EvaluationContext ctx) {
return evaluator.evaluate(Double.class, key, defaultValue, ctx);
return evaluate(Double.class, key, defaultValue, ctx);
}

@Override
public ProviderEvaluation<Value> getObjectEvaluation(
final String key, final Value defaultValue, final EvaluationContext ctx) {
return evaluator.evaluate(Value.class, key, defaultValue, ctx);
return evaluate(Value.class, key, defaultValue, ctx);
}

private <T> ProviderEvaluation<T> evaluate(
final Class<T> target,
final String key,
final T defaultValue,
final EvaluationContext context) {
if (experimentationOptOutParameterEnabled && context != null) {
final Value value = context.getValue("dd_experimentation_opt_out");
if (value != null && value.isBoolean() && Boolean.TRUE.equals(value.asBoolean())) {
return evaluator.evaluate(target, key, defaultValue, context, true);
}
}
return evaluator.evaluate(target, key, defaultValue, context);
}

@SuppressForbidden // Class#forName(String) used to lazy-load the evaluator implementation
Expand All @@ -323,6 +339,25 @@ public static class Options {
private long timeout = 30;
private TimeUnit unit = SECONDS;
private Boolean evaluationTelemetryOptOutParameterEnabled;
private boolean experimentationOptOutParameterEnabled;

/**
* Enables experimentation opt-out through the {@code dd_experimentation_opt_out} context
* attribute. Disabled by default.
*
* <p>Only boolean {@code true} excludes the subject from experimental allocations. Evaluation
* continues with ordinary allocations, including completed winner rollouts. Skipped allocations
* do not produce exposures. Evaluation telemetry remains independent. Incompatible
* configuration produces an error and the application's default value instead of an
* experimental assignment.
*
* @param enabled whether to honor the attribute; configure before constructing the provider
* @return these options
*/
public Options enableExperimentationOptOutParameter(final boolean enabled) {
this.experimentationOptOutParameterEnabled = enabled;
return this;
}

/**
* Enables per-evaluation telemetry opt-out through the {@code dd_evaluation_telemetry_opt_out}
Expand Down
Loading
Loading