Skip to content
Open
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
65 changes: 65 additions & 0 deletions dd-openfeature/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Datadog OpenFeature provider

## 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 RFC3339 timestamp excludes the subject from allocations that record experiment exposures.
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.
Invalid, null, empty, or non-string values return the application's default with `INVALID_CONTEXT` and a diagnostic.
Timestamp validation uses the same parser as allocation `startAt` and `endAt`.
Disabled mode ignores the attribute, including invalid values.

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. Unsupported values throw `IllegalArgumentException`.

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 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.
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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.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.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;

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

@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,20 @@
package com.datadog.openfeature;

/** Controls per-subject exclusion from experimentation. */
public enum ExperimentationOptOutMode {
/** Reads DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE, which defaults to disabled. */
DEFAULT,
/** Ignores dd_experimentation_opt_out_at, overriding the environment setting. */
DISABLED,
/**
* Skips exposure-logging allocations when dd_experimentation_opt_out_at is an RFC3339 timestamp.
* 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
Expand Up @@ -7,6 +7,7 @@
import com.datadog.openfeature.internal.bridge.AgentBridge;
import com.datadog.openfeature.internal.bridge.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 Down Expand Up @@ -39,6 +40,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 +81,8 @@ public Provider(final Options options) {
this.evaluator = evaluator;
this.bridge = AgentBridges.detect();
this.settings = Settings.load(this.bridge);
this.experimentationOptOutMode =
ExperimentationOptOut.resolve(options.experimentationOptOutMode, this.settings);
FlagEvalMetrics metrics = null;
FlagEvalMetricsHook hook = null;
try {
Expand Down Expand Up @@ -242,7 +246,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 @@ -335,6 +340,18 @@ 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. Overrides configuration when not DEFAULT.
*
* @param mode the mode; configure before constructing the provider.
* @return these options.
*/
public Options experimentationOptOutMode(final ExperimentationOptOutMode mode) {
this.experimentationOptOutMode = java.util.Objects.requireNonNull(mode, "mode");
return this;
}

/**
* Enables per-evaluation telemetry opt-out through the {@code dd_evaluation_telemetry_opt_out}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,12 @@ public boolean isEvaluationTelemetryPerSubjectOptOutEnabled() {
return Boolean.TRUE.equals(getBoolean(EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED));
}

/** Returns the configured experimentation opt-out mode, disabled by default. */
public String experimentationOptOutMode() {
final String mode = get("feature.flags.experimentation.opt.out.mode");
return mode == null ? "disabled" : mode;
}

/**
* @return the Datadog API key, or {@code null} if not set.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.datadog.openfeature.internal.evaluation;

import com.datadog.openfeature.ExperimentationOptOutMode;
import com.datadog.openfeature.internal.config.Settings;
import com.datadog.openfeature.internal.ufc.Allocation;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.Value;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Objects;

/** Validates the subject's opt-out timestamp before allocation selection. */
public final class ExperimentationOptOut {
public static final String ATTRIBUTE = "dd_experimentation_opt_out_at";
public static final String INVALID_TIMESTAMP =
ATTRIBUTE
+ " must be an RFC3339 timestamp string; supply a valid timestamp to opt out,"
+ " or omit the attribute to permit experimentation";

private ExperimentationOptOut() {}

/** Resolves the provider option before reading central configuration. */
public static ExperimentationOptOutMode resolve(
final ExperimentationOptOutMode override, final Settings settings) {
Objects.requireNonNull(override, "experimentationOptOutMode");
if (override != ExperimentationOptOutMode.DEFAULT) {
return override;
}
final String configured = settings.experimentationOptOutMode();
switch (configured.trim().toLowerCase(Locale.ROOT)) {
case "":
case "disabled":
return ExperimentationOptOutMode.DISABLED;
case "immediate":
return ExperimentationOptOutMode.IMMEDIATE;
case "future_experiments_only":
return ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY;
default:
throw new IllegalArgumentException(
"DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE must be disabled, immediate, or future_experiments_only");
}
}

/** Parses the cutoff once per evaluation; null means no exclusion. */
static Instant cutoff(final ExperimentationOptOutMode mode, final EvaluationContext context) {
if ((mode != ExperimentationOptOutMode.IMMEDIATE
&& mode != ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY)
|| !context.keySet().contains(ATTRIBUTE)) {
return null;
}
final Value value = context.getValue(ATTRIBUTE);
if (value != null && value.isString()) {
try {
// Use the allocation startAt/endAt parser's timestamp contract.
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(value.asString(), Instant::from);
} catch (final DateTimeException e) {
// Do not expose the supplied context value in the diagnostic.
}
}
throw new IllegalArgumentException(INVALID_TIMESTAMP);
}

/** Checks prepared activation states before selecting an allocation. */
static boolean hasInvalidActivation(final List<Allocation> allocations) {
if (allocations == null) {
return false;
}
for (final Allocation allocation : allocations) {
if (allocation != null && allocation.invalidRandomizationActivation) {
return true;
}
}
return false;
}

/** Excludes before targeting; unknown activation does not qualify as older. */
static boolean excludes(
final ExperimentationOptOutMode mode, final Instant cutoff, final Allocation allocation) {
if (cutoff == null) {
return false;
}
if (!Boolean.TRUE.equals(allocation.doLog)) {
return false;
}
return mode == ExperimentationOptOutMode.IMMEDIATE
|| allocation.randomizationActivatedAt == null
|| !allocation.randomizationActivatedAt.isBefore(cutoff);
}
}
Loading
Loading