Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7732d04
Support immediate per-subject experimentation opt-out
leoromanovsky Oct 20, 2026
71c521a
Preserve provider construction without the Java agent
leoromanovsky Oct 20, 2026
c3846c9
Keep no-agent configuration fallback independent of agent classes
leoromanovsky Oct 20, 2026
beebb7b
Merge remote-tracking branch 'origin/master' into leoromanovsky/exper…
leoromanovsky Oct 20, 2026
bcbaa28
Verify independent opt-out controls after telemetry merge
leoromanovsky Oct 20, 2026
736ffef
Merge remote-tracking branch 'origin/master' into leoromanovsky/exper…
leoromanovsky Oct 20, 2026
00ad1fd
fix: align immediate opt-out with allocation timestamps and shared fi…
leoromanovsky Oct 20, 2026
85fade1
Merge remote-tracking branch 'origin/master' into leoromanovsky/exper…
leoromanovsky Oct 21, 2026
f483c71
Support immediate experimentation opt-out in the standalone provider
leoromanovsky Oct 21, 2026
660b5b9
Support future-experiments-only opt-out
leoromanovsky Oct 21, 2026
a4d3dfb
Align experimentation opt-out with shared decisions
leoromanovsky Oct 23, 2026
578ffa7
Fix OpenFeature smoke exposure expectations
leoromanovsky Oct 23, 2026
a7d8e4e
Merge master and align experimentation opt-out with the shared Java c…
leoromanovsky Oct 26, 2026
55ba0bd
Remove internal metadata from experimentation opt-out tests
leoromanovsky Oct 26, 2026
8835453
Merge master into experimentation opt-out branch
leoromanovsky Oct 29, 2026
22bc261
Address experimentation opt-out review follow-ups
leoromanovsky Oct 29, 2026
b1c3cd3
Document experimentation opt-out configuration and align timestamp di…
leoromanovsky Oct 30, 2026
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
87 changes: 87 additions & 0 deletions dd-openfeature/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Settings are read from the Datadog Java agent configuration when attached, then
| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS` | `5` | CDN request timeout. |
| `DD_FLAGGING_EVALUATION_COUNTS_ENABLED` | `true` | Sends aggregated flag evaluation counts. |
| `DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED` | `true` | Sends exposures to Datadog. See [Stop sending exposures to Datadog](#stop-sending-exposures-to-datadog). |
| `DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE` | `disabled` | Controls per-subject experimentation opt-out: `disabled`, `immediate`, or `future_experiments_only`. See [Per-subject experimentation opt-out](#per-subject-experimentation-opt-out). |
| `DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED` | `false` | Lets the `dd_evaluation_telemetry_opt_out` context attribute opt subjects out of evaluation telemetry. See [Per-subject evaluation telemetry opt-out](#per-subject-evaluation-telemetry-opt-out). |
| `DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED` | `false` | Adds flag evaluations to the local root span. Requires `dd-java-agent`. |
| `DD_API_KEY`, `DD_SITE`, `DD_ENV`, `DD_SERVICE`, `DD_VERSION` | | Credentials and event context. |
Expand Down Expand Up @@ -133,6 +134,92 @@ The provider then sends no exposures to Datadog. `ExposureHook` callbacks still

This setting stops exposures only. Flag evaluation events have their own setting, `DD_FLAGGING_EVALUATION_COUNTS_ENABLED`. The Java provider does not read dd-trace-py's `DD_FFE_INTAKE_ENABLED`.

## Per-subject experimentation opt-out

Set `DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE=immediate` before constructing the provider.
The default is `disabled`. Existing configuration delivery and credentials are still required.

```java
import com.datadog.openfeature.Provider;
import dev.openfeature.sdk.MutableContext;
import dev.openfeature.sdk.OpenFeatureAPI;

OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProviderAndWait(new Provider());
boolean enabled = api.getClient().getBooleanValue("new-checkout", false,
new MutableContext("subject-123")
.add("dd_experimentation_opt_out_at", "2026-10-19T12:00:00Z"));
```

In immediate mode, a valid timestamp string excludes the subject from allocations with `doLog=true`.
This includes completed-experiment winners that still log exposures; omitted, null, and false `doLog` retain Java's ordinary-allocation behavior.
Evaluation continues through ordinary feature allocations. If none match, the provider returns the application's default.
A future timestamp also takes effect immediately. It does not schedule exclusion.

Omit the attribute for subjects who can participate. OpenFeature merges API, transaction, client, and invocation
contexts. Omitting it from an invocation does not remove an inherited value; remove it from the layer that owns it.
Setting it to null does not restore participation.

Invalid, null, empty, or non-string values return the application's default with `INVALID_CONTEXT` and a diagnostic.
This validation runs after configuration readiness and before flag lookup, so it also applies to ordinary and missing
flags. Before configuration is ready the result remains `PROVIDER_NOT_READY`. Disabled mode ignores the attribute,
including invalid values. Supply `Instant.toString()` as a string; passing an `Instant` object is invalid.

Java uses `DateTimeFormatter.ISO_OFFSET_DATE_TIME`, also used for allocation `startAt`, `endAt`, and activation.
It validates calendar dates, accepts up to nine fractional digits and offsets through ±18:00, and rejects leap
seconds, missing offsets, and surrounding whitespace. Its ISO syntax also accepts lowercase `t`/`z`, omitted
seconds, an empty fractional part, hour-only or second-bearing offsets, and signed extended years. This is Java's
existing grammar, not the browser SDK's timestamp grammar.

An explicit option overrides configuration:

```java
import com.datadog.openfeature.ExperimentationOptOutMode;

Provider provider = new Provider(new Provider.Options()
.experimentationOptOutMode(ExperimentationOptOutMode.DISABLED));
```

`DEFAULT` reads the normal SDK configuration sources, including
`-Ddd.feature.flags.experimentation.opt.out.mode=immediate`.
The provider resolves the mode at construction; later changes to options or configuration do not change it.
Unsupported values throw `IllegalArgumentException` when Feature Flags is enabled. A disabled product skips this
unused setting's validation and retains its existing initialization behavior. Passing null to the option throws
`NullPointerException`.
The deprecated `datadog.trace.api.openfeature.Provider` alias uses the same evaluator
and accepts the same `com.datadog.openfeature.ExperimentationOptOutMode` options.

This setting is independent of evaluation telemetry opt-out.
Immediate withdrawal can change an enrolled subject's experience. Account for this change in experiment analysis.

Use `future_experiments_only` or `ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY` to exclude newer experiments.
Supply the same stored `dd_experimentation_opt_out_at` timestamp with each evaluation.
For exposure-logging allocations, the provider compares `randomizationActivatedAt` with that timestamp:

| Allocation activation | Behavior |
| --- | --- |
| Before the subject's opt-out time | Evaluate normally, including exposure logging |
| Equal to or after the opt-out time | Skip the allocation and continue ordinary delivery |
| Missing or null | Skip the allocation because its activation time is unknown |

Ordinary allocations remain available. Missing subject attributes preserve normal evaluation.
Both enabled modes reject invalid subject timestamps.
Malformed activation metadata on any allocation, including an ordinary, unreachable, or disabled flag's allocation,
returns `PARSE_ERROR` for the affected flag only in future mode with a valid subject timestamp.
Disabled mode, immediate mode, and evaluations without a subject timestamp preserve ordinary evaluation.
Invalid regex patterns and unconvertible numeric variant values are configuration errors regardless of opt-out:
the parser rejects only the affected flag, including when the malformed allocation would be skipped.
Java's existing regex syntax and numeric coercions remain supported.
Comparisons retain nanosecond precision and account for timezone offsets.
The cutoff does not depend on the SDK clock. It is not a scheduled change or proof of prior enrollment.
An older allocation can still assign a subject for the first time.

Enable future mode after the configuration service supplies stable activation timestamps and preserves ordinary fallback allocations.
Keep the subject's stored opt-out time stable across sessions and SDK instances.
The provider stores no consent history and emits no new event type. The opt-out attribute remains in the evaluation
context: existing exposure reports can include it, and evaluation events can include it when full evaluation data
reporting is enabled. Existing reporting controls and consent settings still apply.

## Per-subject evaluation telemetry opt-out

Applications can keep flag evaluation enabled while excluding selected subjects from evaluation
Expand Down
14 changes: 14 additions & 0 deletions dd-openfeature/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,17 @@ val japicmpPublicApi = tasks.register<JapicmpTask>("japicmpPublicApi") {
tasks.named("check") {
dependsOn(japicmpPublicApi)
}

// These suites read shared fixtures directly from the smoke-test resources.
tasks.withType<Test>().configureEach {
inputs.dir(
rootProject.layout.projectDirectory.dir(
"dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"
)
).withPathSensitivity(PathSensitivity.RELATIVE)
inputs.dir(
rootProject.layout.projectDirectory.dir(
"dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out"
)
).withPathSensitivity(PathSensitivity.RELATIVE)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.datadog.openfeature;

import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.Collections.emptyMap;
import static java.util.Collections.singletonList;
import static java.util.Collections.singletonMap;
import static java.util.concurrent.TimeUnit.NANOSECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;

import com.datadog.openfeature.internal.bridge.v1.AgentBridge;
import com.datadog.openfeature.internal.config.Settings;
import com.datadog.openfeature.internal.ufc.Allocation;
import com.datadog.openfeature.internal.ufc.Flag;
import com.datadog.openfeature.internal.ufc.ServerConfiguration;
import com.datadog.openfeature.internal.ufc.Split;
import com.datadog.openfeature.internal.ufc.ValueType;
import com.datadog.openfeature.internal.ufc.Variant;
import dev.openfeature.sdk.ImmutableContext;
import dev.openfeature.sdk.LayeredEvaluationContext;
import dev.openfeature.sdk.MutableContext;
import dev.openfeature.sdk.ProviderEvaluation;
import java.util.HashMap;
import java.util.Map;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.annotations.Warmup;

/**
* Measures provider resolution, including timestamp validation and ordinary allocation fallback.
*/
@State(Scope.Thread)
@Warmup(iterations = 3, time = 1, timeUnit = SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = SECONDS)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(NANOSECONDS)
@Fork(1)
public class ExperimentationOptOutBenchmark {
@Param({"DISABLED", "IMMEDIATE", "FUTURE_EXPERIMENTS_ONLY"})
public ExperimentationOptOutMode mode;

@Param({"false", "true"})
public boolean optedOut;

@Param({"0", "16"})
public int extraAttributes;

private Provider provider;
private MutableContext context;

@Setup
public void setUp() {
final DDEvaluator evaluator =
new DDEvaluator(() -> {}, AgentBridge.NONE, Settings.load(AgentBridge.NONE), mode);
final Map<String, Variant> variants = new HashMap<>();
variants.put("experiment", new Variant("experiment", false));
variants.put("ordinary", new Variant("ordinary", true));
final Flag flag =
new Flag(
"flag",
true,
ValueType.BOOLEAN,
variants,
asList(allocation("experiment", true), allocation("ordinary", false)));
evaluator.accept(new ServerConfiguration("", "", false, null, singletonMap("flag", flag)));
provider =
new Provider(new Provider.Options().experimentationOptOutMode(mode), evaluator, false);
context = new MutableContext("subject");
for (int i = 0; i < extraAttributes; i++) {
context.add("attribute" + i, "value" + i);
}
if (optedOut) {
context.add("dd_experimentation_opt_out_at", "2026-10-19T12:00:00Z");
}
final ProviderEvaluation<Boolean> result = evaluate();
final boolean excluded = mode != ExperimentationOptOutMode.DISABLED && optedOut;
if (result.getErrorCode() != null
|| result.getValue() != excluded
|| !(excluded ? "ordinary" : "experiment")
.equals(result.getFlagMetadata().getString("allocationKey"))) {
throw new IllegalStateException("Benchmark did not resolve the expected allocation");
}
}

private static Allocation allocation(final String key, final boolean doLog) {
return new Allocation(
key, null, null, null, singletonList(new Split(emptyList(), key, emptyMap(), 1)), doLog);
}

@Benchmark
public ProviderEvaluation<Boolean> evaluate() {
return provider.getBooleanEvaluation("flag", false, context);
}

@Benchmark
public ProviderEvaluation<Boolean> evaluateFreshLayeredContext() {
return provider.getBooleanEvaluation(
"flag",
false,
new LayeredEvaluationContext(
ImmutableContext.EMPTY, ImmutableContext.EMPTY, null, context));
}

@TearDown
public void tearDown() {
provider.shutdown();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,22 @@ class DDEvaluator implements Evaluator, Consumer<ServerConfiguration> {
private volatile FeatureFlagsRuntime runtime;

DDEvaluator(final Runnable configCallback, final AgentBridge bridge, final Settings settings) {
this(configCallback, bridge, settings, ExperimentationOptOutMode.DISABLED);
}

DDEvaluator(
final Runnable configCallback,
final AgentBridge bridge,
final Settings settings,
final ExperimentationOptOutMode experimentationOptOutMode) {
this.configCallback = configCallback;
this.bridge = bridge;
this.settings = settings;
// When span enrichment is off, the __dd_* span-enrichment metadata is attached only to
// evaluations of allocations logging exposures, for the exposure hook.
this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled(), this::isExposed);
this.core =
new FlagEvaluator(
settings.isSpanEnrichmentEnabled(), this::isExposed, experimentationOptOutMode);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.datadog.openfeature;

/**
* Controls per-subject exclusion from experimentation.
*
* @see Provider.Options#experimentationOptOutMode(ExperimentationOptOutMode)
*/
public enum ExperimentationOptOutMode {
/** Reads DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE, which defaults to disabled. */
DEFAULT,
Comment thread
leoromanovsky marked this conversation as resolved.
Resolved
/** Ignores dd_experimentation_opt_out_at, overriding the environment setting. */
DISABLED,
/**
* Skips exposure-logging allocations when dd_experimentation_opt_out_at is a timestamp String
* accepted by {@link java.time.format.DateTimeFormatter#ISO_OFFSET_DATE_TIME}. Applies
* immediately, including to experiments already in progress.
*/
IMMEDIATE,
/**
* Skips exposure-logging allocations activated at or after dd_experimentation_opt_out_at. Unknown
* activation is skipped. Older allocations continue normally, including exposures. This is an
* allocation activation cutoff, not a record of prior subject enrollment.
*/
FUTURE_EXPERIMENTS_ONLY
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.datadog.openfeature;

import static java.util.Objects.requireNonNull;
import static java.util.concurrent.TimeUnit.SECONDS;

import com.datadog.openfeature.internal.FeatureFlagsRuntime;
import com.datadog.openfeature.internal.FeatureFlagsRuntime.RemoteConfigurationUnavailableException;
import com.datadog.openfeature.internal.bridge.v1.AgentBridge;
import com.datadog.openfeature.internal.bridge.v1.AgentBridges;
import com.datadog.openfeature.internal.config.Settings;
import com.datadog.openfeature.internal.evaluation.ExperimentationOptOut;
import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter;
import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.EvaluationContext;
Expand All @@ -23,6 +25,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
Expand All @@ -39,6 +42,7 @@ public class Provider extends EventProvider implements Metadata {
private final Options options;
private final AgentBridge bridge;
private final Settings settings;
private final ExperimentationOptOutMode experimentationOptOutMode;
private final AtomicReference<InitializationState> initializationState =
new AtomicReference<>(InitializationState.NOT_STARTED);
private final FlagEvalMetrics flagEvalMetrics;
Expand Down Expand Up @@ -79,6 +83,10 @@ public Provider(final Options options) {
this.evaluator = evaluator;
this.bridge = AgentBridges.detect();
this.settings = Settings.load(this.bridge);
this.experimentationOptOutMode =
this.settings.isEnabled()
? ExperimentationOptOut.resolve(options.experimentationOptOutMode, this.settings)
: ExperimentationOptOutMode.DISABLED;
FlagEvalMetrics metrics = null;
FlagEvalMetricsHook hook = null;
try {
Expand Down Expand Up @@ -145,6 +153,10 @@ public Provider(final Options options) {
} else {
log.info("{} span enrichment disabled", METADATA);
}
log.info(
"{} experimentation opt-out mode: {}",
METADATA,
this.experimentationOptOutMode.name().toLowerCase(Locale.ROOT));
}

@Override
Expand Down Expand Up @@ -281,7 +293,8 @@ private Evaluator buildEvaluator() {
if (evaluator != null) {
return evaluator;
}
return new DDEvaluator(this::onConfigurationChange, this.bridge, this.settings);
return new DDEvaluator(
this::onConfigurationChange, this.bridge, this.settings, this.experimentationOptOutMode);
}

private FlagEvaluationWriter flagEvaluationPipeline() {
Expand Down Expand Up @@ -376,6 +389,35 @@ public static class Options {
private long timeout = 30;
private TimeUnit unit = SECONDS;
private Boolean evaluationTelemetryOptOutParameterEnabled;
private ExperimentationOptOutMode experimentationOptOutMode = ExperimentationOptOutMode.DEFAULT;

/**
* Sets per-subject experimentation opt-out. {@link ExperimentationOptOutMode#DEFAULT} reads
* {@code DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE}; every other mode, including {@code
* DISABLED}, overrides configuration. The provider captures the mode at construction.
* Unsupported configured modes throw {@link IllegalArgumentException} unless Feature Flags is
* disabled.
*
* <p>Enabled modes read {@code dd_experimentation_opt_out_at} from the merged evaluation
* context. Supply a timestamp String accepted by {@link
* java.time.format.DateTimeFormatter#ISO_OFFSET_DATE_TIME}, for example {@code
* Instant.now().toString()}. An {@code Instant} object, null, or any other invalid value
* returns the application's default with {@code INVALID_CONTEXT} after configuration is ready,
* even for ordinary or missing flags. Omit the attribute to permit experimentation; disabled
* mode ignores it.
*
* <p>This controls experiment allocation selection, independently of evaluation telemetry
* opt-out. The attribute remains available for targeting and reporting under the existing
* exposure and evaluation-data settings.
*
* @param mode the mode; configure before constructing the provider.
* @return these options.
* @throws NullPointerException if mode is null.
*/
public Options experimentationOptOutMode(final ExperimentationOptOutMode mode) {
this.experimentationOptOutMode = requireNonNull(mode, "mode");
return this;
}

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