Skip to content
Closed
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
8 changes: 8 additions & 0 deletions metadata/supported-configurations.json
Original file line number Diff line number Diff line change
Expand Up @@ -1604,6 +1604,14 @@
"aliases": []
}
],
"DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED": [
{
"version": "A",
"type": "boolean",
"default": "false",
"aliases": []
}
],
"DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED": [
{
"version": "A",
Expand Down
81 changes: 81 additions & 0 deletions products/feature-flagging/feature-flagging-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,87 @@ boolean enabled = client.getBooleanValue("my-feature", false,
new MutableContext("user-123"));
```

## Per-subject evaluation telemetry opt-out

Applications can keep flag evaluation enabled while excluding selected subjects from evaluation
telemetry. Configure the provider once, then pass the decision with each evaluation:

```java
import datadog.trace.api.openfeature.Provider;
import dev.openfeature.sdk.Client;
import dev.openfeature.sdk.ImmutableContext;
import dev.openfeature.sdk.OpenFeatureAPI;
import dev.openfeature.sdk.Value;
import java.util.Collections;

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

// In a request handler, the application owns its consent and eligibility rules.
boolean optedOut = user.hasDeclinedEvaluationTelemetry();
boolean enabled = client.getBooleanValue("my-feature", false,
new ImmutableContext(user.getId(), Collections.singletonMap(
"dd_evaluation_telemetry_opt_out", new Value(optedOut))));
```

Use the per-evaluation context for concurrent requests. Do not replace the global context for each
subject. The key is exactly `dd_evaluation_telemetry_opt_out`, as in other Datadog SDKs.

Alternatively, enable support through deployment configuration and use `new Provider()`:

```shell
DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED=true
```

The equivalent JVM property is `-Ddd.feature.flags.evaluation.telemetry.per.subject.opt.out.enabled=true`.
The provider reads the normal Datadog configuration sources at construction. The default is `false`.
An explicit provider option overrides these sources, including an explicit `false`.
This setting only enables support for the subject attribute. It does not suppress telemetry for all subjects.

Only boolean `true` suppresses telemetry. Missing, false, null, and other types preserve telemetry.
The provider ignores the parameter unless the option is enabled before provider construction.

Opt-out suppresses new evaluation EVP events and counts, OpenTelemetry evaluation metrics, and
automatic flag-related span enrichment. It does not change flag values, evaluation details,
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 @@ -58,6 +58,7 @@ dependencies {
// branch cannot be driven.
testImplementation(project(":products:feature-flagging:feature-flagging-config"))
testImplementation(project(":utils:config-utils"))
testImplementation(project(":components:environment"))
testImplementation("io.opentelemetry:opentelemetry-api:1.47.0")
testImplementation(libs.bundles.junit5)
testImplementation(libs.bundles.mockito)
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
@@ -0,0 +1,55 @@
package datadog.trace.api.openfeature;

import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.FlagEvaluationDetails;
import dev.openfeature.sdk.Hook;
import dev.openfeature.sdk.HookContext;
import dev.openfeature.sdk.Value;
import java.util.Map;
import java.util.Optional;

/** Gates a Datadog finally-only evaluation telemetry hook, not exposure or application hooks. */
final class EvaluationTelemetryOptOutHook implements Hook<Object> {
private static final String PARAMETER = "dd_evaluation_telemetry_opt_out";
private static final String DECISION = "datadog.evaluationTelemetryOptOut";
private final Hook<Object> delegate;

EvaluationTelemetryOptOutHook(final Hook<Object> delegate) {
this.delegate = delegate;
}

@Override
public Optional<EvaluationContext> before(
final HookContext<Object> context, final Map<String, Object> hints) {
// Provider before hooks see the merged context, including application before hooks.
// HookData belongs to this evaluation, including nested and concurrent calls.
context.getHookData().set(DECISION, isOptedOut(context.getCtx()));
return Optional.empty();
}

@Override
public void error(
final HookContext<Object> context, final Exception error, final Map<String, Object> hints) {
// A failing application before hook can prevent our before hook from running.
// Preserve its decision before application error hooks can change the context.
if (context.getHookData().get(DECISION) == null) {
context.getHookData().set(DECISION, isOptedOut(context.getCtx()));
}
}

@Override
public void finallyAfter(
final HookContext<Object> context,
final FlagEvaluationDetails<Object> details,
final Map<String, Object> hints) {
if (Boolean.TRUE.equals(context.getHookData().get(DECISION)) || isOptedOut(context.getCtx())) {
return;
}
delegate.finallyAfter(context, details, hints);
}

private static boolean isOptedOut(final EvaluationContext context) {
final Value value = context == null ? null : context.getValue(PARAMETER);
return value != null && value.isBoolean() && Boolean.TRUE.equals(value.asBoolean());
}
}
Loading
Loading