diff --git a/dd-openfeature/README.md b/dd-openfeature/README.md new file mode 100644 index 00000000000..cffba91a752 --- /dev/null +++ b/dd-openfeature/README.md @@ -0,0 +1,67 @@ +# 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`. +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 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. diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/ExperimentationOptOutBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/ExperimentationOptOutBenchmark.java new file mode 100644 index 00000000000..51a0bf2f8ef --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/ExperimentationOptOutBenchmark.java @@ -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.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.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 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 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 evaluate() { + return provider.getBooleanEvaluation("flag", false, context); + } + + @TearDown + public void tearDown() { + provider.shutdown(); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index 8c6ced76c65..da9f400e14b 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -84,12 +84,22 @@ class DDEvaluator implements Evaluator, Consumer { 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 diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/ExperimentationOptOutMode.java b/dd-openfeature/src/main/java/com/datadog/openfeature/ExperimentationOptOutMode.java new file mode 100644 index 00000000000..683c45929ea --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExperimentationOptOutMode.java @@ -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 +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java index e3ac093b7ad..5015c2e3fa4 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -7,6 +7,7 @@ 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; @@ -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 = new AtomicReference<>(InitializationState.NOT_STARTED); private final FlagEvalMetrics flagEvalMetrics; @@ -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 { @@ -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() { @@ -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} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java index b9dc8a63c0d..e1535f9acc4 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -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. */ diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOut.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOut.java new file mode 100644 index 00000000000..295d5f4281b --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOut.java @@ -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 same timestamp parser as allocation startAt/endAt. + 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 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); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java index dd4b4956492..4358eb52649 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -12,6 +12,7 @@ import static com.datadog.openfeature.internal.evaluation.MetadataKeys.SPLIT_SERIAL_ID; import static java.util.Arrays.asList; +import com.datadog.openfeature.ExperimentationOptOutMode; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.ConditionConfiguration; import com.datadog.openfeature.internal.ufc.ConditionOperator; @@ -59,6 +60,7 @@ public final class FlagEvaluator { static final Set WARNED_FEATURE_DESTINATIONS = ConcurrentHashMap.newKeySet(); private final boolean spanEnrichmentEnabled; private final ExposureLookup exposureLookup; + private final ExperimentationOptOutMode experimentationOptOutMode; /** * Creates an evaluator. @@ -67,8 +69,16 @@ public final class FlagEvaluator { * @param exposureLookup the lookup telling whether an exposure was already recorded. */ public FlagEvaluator(final boolean spanEnrichmentEnabled, final ExposureLookup exposureLookup) { + this(spanEnrichmentEnabled, exposureLookup, ExperimentationOptOutMode.DISABLED); + } + + public FlagEvaluator( + final boolean spanEnrichmentEnabled, + final ExposureLookup exposureLookup, + final ExperimentationOptOutMode experimentationOptOutMode) { this.spanEnrichmentEnabled = spanEnrichmentEnabled; this.exposureLookup = exposureLookup; + this.experimentationOptOutMode = experimentationOptOutMode; } public ProviderEvaluation evaluate( @@ -93,6 +103,20 @@ public ProviderEvaluation evaluate( return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); } + final Instant experimentationCutoff; + try { + experimentationCutoff = ExperimentationOptOut.cutoff(experimentationOptOutMode, context); + } catch (final IllegalArgumentException e) { + // This diagnostic is fixed SDK text. It contains no customer context values. + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.INVALID_CONTEXT) + .errorMessage(ExperimentationOptOut.INVALID_TIMESTAMP) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + } + final Flag flag = config.flags.get(key); if (flag == null) { if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { @@ -105,6 +129,16 @@ public ProviderEvaluation evaluate( return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, observeFullEvaluationData); } + if (experimentationCutoff != null + && experimentationOptOutMode == ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY + && ExperimentationOptOut.hasInvalidActivation(flag.allocations)) { + return error( + defaultValue, + ErrorCode.PARSE_ERROR, + "invalid randomizationActivatedAt for flag " + key, + observeFullEvaluationData); + } + if (!flag.enabled) { return ProviderEvaluation.builder() .value(defaultValue) @@ -126,6 +160,11 @@ public ProviderEvaluation evaluate( final String targetingKey = context.getTargetingKey(); for (final Allocation allocation : flag.allocations) { + if (experimentationCutoff != null + && ExperimentationOptOut.excludes( + experimentationOptOutMode, experimentationCutoff, allocation)) { + continue; + } if (!isAllocationActive(allocation, now)) { continue; } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java index b37a676571a..7e96c16f199 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java @@ -11,6 +11,12 @@ public final class Allocation { public final List splits; public final Boolean doLog; + /** First activation of this allocation's randomization; null means unknown. */ + public final Instant randomizationActivatedAt; + + /** Invalid activation is retained for future-mode evaluation errors. */ + public final boolean invalidRandomizationActivation; + public Allocation( final String key, final List rules, @@ -18,11 +24,36 @@ public Allocation( final Instant endAt, final List splits, final Boolean doLog) { + this(key, rules, startAt, endAt, splits, doLog, null); + } + + public Allocation( + final String key, + final List rules, + final Instant startAt, + final Instant endAt, + final List splits, + final Boolean doLog, + final Instant randomizationActivatedAt) { + this(key, rules, startAt, endAt, splits, doLog, randomizationActivatedAt, false); + } + + public Allocation( + final String key, + final List rules, + final Instant startAt, + final Instant endAt, + final List splits, + final Boolean doLog, + final Instant randomizationActivatedAt, + final boolean invalidRandomizationActivation) { this.key = key; this.rules = rules; this.startAt = startAt; this.endAt = endAt; this.splits = splits; this.doLog = doLog; + this.randomizationActivatedAt = randomizationActivatedAt; + this.invalidRandomizationActivation = invalidRandomizationActivation; } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index 2721be5bfef..1c7477dd22a 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -235,13 +235,17 @@ private static Allocation toAllocation(@Nullable final Object value) { return null; } final Map json = asObject(value, "allocation"); + final Object activation = json.get("randomizationActivatedAt"); + final Instant parsedActivation = activation instanceof String ? toInstant(activation) : null; return new Allocation( asString(json.get("key"), "key"), toList(json.get("rules"), "rules", UniversalFlagConfigParser::toRule), toInstant(json.get("startAt")), toInstant(json.get("endAt")), toList(json.get("splits"), "splits", UniversalFlagConfigParser::toSplit), - lenientBoolean(json.get("doLog"))); + lenientBoolean(json.get("doLog")), + parsedActivation, + activation != null && parsedActivation == null); } @Nullable diff --git a/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java index ba8a1f9968f..14a739423ed 100644 --- a/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -39,6 +39,13 @@ public Provider(final Options options) { */ @Deprecated public static class Options extends com.datadog.openfeature.Provider.Options { + @Override + public Options experimentationOptOutMode( + final com.datadog.openfeature.ExperimentationOptOutMode mode) { + super.experimentationOptOutMode(mode); + return this; + } + @Override public Options enableEvaluationTelemetryOptOutParameter(final boolean enabled) { super.enableEvaluationTelemetryOptOutParameter(enabled); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java index 9dd40371799..e61d9246166 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java @@ -4,11 +4,14 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.greaterThan; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.mock; import com.datadog.openfeature.internal.JsonReading; import com.datadog.openfeature.internal.bridge.v1.AgentBridge; import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.evaluation.ExperimentationOptOut; import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; @@ -44,17 +47,65 @@ void canonicalFixturesArePresent() throws IOException { @MethodSource("canonicalTestCases") @ParameterizedTest(name = "{0}") void evaluateCanonicalFixture(final FixtureCase testCase) throws IOException { + evaluateCanonicalFixture(testCase, ExperimentationOptOutMode.DISABLED); + } + + @MethodSource("immediateOptOutTestCases") + @ParameterizedTest(name = "immediate: {0}") + void evaluateImmediateOptOutFixture(final FixtureCase testCase) throws IOException { + evaluateCanonicalFixture(testCase, ExperimentationOptOutMode.IMMEDIATE); + } + + @MethodSource("futureOptOutTestCases") + @ParameterizedTest(name = "future: {0}") + void evaluateFutureOptOutFixture(final FixtureCase testCase) throws IOException { + evaluateCanonicalFixture(testCase, ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY); + } + + static List futureOptOutTestCases() throws IOException { + return optOutTestCases("experimentationOptOutFutureExperimentsOnly", 44); + } + + static List immediateOptOutTestCases() throws IOException { + return optOutTestCases("experimentationOptOutImmediate", 28); + } + + @SuppressWarnings("unchecked") + private static List optOutTestCases(final String capability, final int expectedCount) + throws IOException { + final List cases = new ArrayList<>(); + for (final FixtureCase testCase : canonicalTestCases()) { + final Map capabilities = + (Map) testCase.json.get("capabilities"); + if (capabilities != null && capabilities.containsKey(capability)) { + final Map json = new java.util.LinkedHashMap<>(testCase.json); + json.put("result", capabilities.get(capability)); + cases.add(new FixtureCase(testCase.fileName, testCase.index, json)); + } + } + assertEquals(expectedCount, cases.size(), "The opt-out capability fixtures must execute"); + return cases; + } + + private void evaluateCanonicalFixture( + final FixtureCase testCase, final ExperimentationOptOutMode mode) throws IOException { final DDEvaluator evaluator = - new DDEvaluator(mock(Runnable.class), AgentBridge.NONE, TestSettings.of()); + new DDEvaluator(mock(Runnable.class), AgentBridge.NONE, TestSettings.of(), mode); evaluator.accept( UniversalFlagConfigParser.parse( Files.readAllBytes(fixtureRoot().resolve("ufc-config.json")))); final Class targetType = targetType(testCase.variationType); - final Object defaultValue = DDEvaluator.mapValue(targetType, testCase.defaultValue); - final Object expectedValue = DDEvaluator.mapValue(targetType, testCase.resultValue); - final ProviderEvaluation details = - evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); + final Object defaultValue = fixtureValue(targetType, testCase.defaultValue); + final Object expectedValue = fixtureValue(targetType, testCase.resultValue); + final Provider provider = + new Provider(new Provider.Options().experimentationOptOutMode(mode), evaluator, false); + final ProviderEvaluation details; + try { + details = evaluate(provider, targetType, testCase.flag, defaultValue, context(testCase)); + } finally { + provider.shutdown(); + } assertThat(details.getValue(), equalTo(expectedValue)); assertThat(details.getReason(), equalTo(testCase.resultReason)); @@ -63,6 +114,11 @@ void evaluateCanonicalFixture(final FixtureCase testCase) throws IOException { } if (testCase.resultErrorCode != null) { assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.resultErrorCode))); + if ("INVALID_CONTEXT".equals(testCase.resultErrorCode)) { + assertEquals(ExperimentationOptOut.INVALID_TIMESTAMP, details.getErrorMessage()); + } + } else if (!"ERROR".equals(testCase.resultReason)) { + assertNull(details.getErrorCode()); } if (testCase.resultAllocationKey != null) { assertThat( @@ -71,14 +127,41 @@ void evaluateCanonicalFixture(final FixtureCase testCase) throws IOException { } } - @SuppressWarnings({"rawtypes", "unchecked"}) + private static Object fixtureValue(final Class type, final Object value) { + if (value == null) { + return null; + } + if (type == Value.class) { + return Value.objectToValue(value); + } + if (type == Integer.class) { + return ((Number) value).intValue(); + } + if (type == Double.class) { + return ((Number) value).doubleValue(); + } + return value; + } + private static ProviderEvaluation evaluate( - final DDEvaluator evaluator, + final Provider provider, final Class targetType, final String flag, final Object defaultValue, final EvaluationContext context) { - return evaluator.evaluate((Class) targetType, flag, defaultValue, context); + if (targetType == Boolean.class) { + return provider.getBooleanEvaluation(flag, (Boolean) defaultValue, context); + } + if (targetType == Integer.class) { + return provider.getIntegerEvaluation(flag, (Integer) defaultValue, context); + } + if (targetType == Double.class) { + return provider.getDoubleEvaluation(flag, (Double) defaultValue, context); + } + if (targetType == String.class) { + return provider.getStringEvaluation(flag, (String) defaultValue, context); + } + return provider.getObjectEvaluation(flag, (Value) defaultValue, context); } @SuppressWarnings("unchecked") @@ -164,6 +247,7 @@ private static Object normalize(final Object value) { } static final class FixtureCase { + final Map json; final String fileName; final int index; final Map attributes; @@ -179,6 +263,7 @@ static final class FixtureCase { @SuppressWarnings("unchecked") FixtureCase(final String fileName, final int index, final Map json) { + this.json = json; this.fileName = fileName; this.index = index; final Object attributes = normalize(json.get("attributes")); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java index b85b8dff282..d09f88c0a71 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/EvaluationTelemetryOptOutTest.java @@ -75,6 +75,7 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; +import org.tabletest.junit.TableTest; /** * Exercises real OpenFeature resolution and the provider telemetry hooks, capturing each output @@ -599,6 +600,70 @@ void missingConfigurationKeepsTheDefaultWithoutEmittingTelemetry() throws Except assertEquals(1, this.customerEvaluations.size()); } + @TableTest({ + "scenario | mode | exclude experimentation | suppress telemetry", + "neither | DISABLED | false | false ", + "telemetry only | DISABLED | false | true ", + "experiments only | IMMEDIATE | true | false ", + "both | IMMEDIATE | true | true ", + "retained future | FUTURE_EXPERIMENTS_ONLY | false | false ", + "retained private | FUTURE_EXPERIMENTS_ONLY | false | true " + }) + void experimentationAndEvaluationTelemetryControlsRemainIndependent( + final ExperimentationOptOutMode mode, + final boolean excludeExperimentation, + final boolean suppressTelemetry) + throws Exception { + final ServerConfiguration configuration = defaultConfiguration(); + final Flag flag = configuration.flags.get("flag"); + final Allocation original = flag.allocations.get(0); + final Allocation experiment = + new Allocation( + original.key, + original.rules, + original.startAt, + original.endAt, + original.splits, + original.doLog, + java.time.Instant.parse("2026-10-19T11:59:59.999999999Z")); + final Allocation ordinary = + new Allocation( + "ordinary", + null, + null, + null, + singletonList(new Split(emptyList(), "off", emptyMap(), 8)), + false); + final Map variants = new HashMap<>(flag.variations); + variants.put("off", new Variant("off", 2)); + final Flag withFallback = + new Flag("flag", true, ValueType.INTEGER, variants, asList(experiment, ordinary)); + this.evaluator = + new CapturingEvaluator( + new ServerConfiguration("", "", false, null, singletonMap("flag", withFallback)), mode); + this.api.setProviderAndWait( + provider( + new Provider.Options() + .experimentationOptOutMode(mode) + .enableEvaluationTelemetryOptOutParameter(suppressTelemetry), + this.evaluator)); + final FlagEvaluationDetails details = + this.api + .getClient() + .getIntegerDetails( + "flag", + 0, + new MutableContext("subject-1") + .add(PARAMETER, true) + .add("dd_experimentation_opt_out_at", "2026-10-19T12:00:00Z")); + + assertNull(details.getErrorCode()); + assertEquals(excludeExperimentation ? 2 : 1, details.getValue()); + assertEquals(excludeExperimentation ? 0 : 1, this.exposures.size()); + assertTelemetryCounts(suppressTelemetry ? 0 : 1); + assertEquals(1, this.customerEvaluations.size()); + } + private void start() throws Exception { start(new Provider.Options().enableEvaluationTelemetryOptOutParameter(true)); } @@ -709,10 +774,16 @@ private void assertTelemetryCounts(final int expected) { */ private final class CapturingEvaluator extends DDEvaluator { CapturingEvaluator(final ServerConfiguration configuration) { + this(configuration, ExperimentationOptOutMode.DISABLED); + } + + CapturingEvaluator( + final ServerConfiguration configuration, final ExperimentationOptOutMode mode) { super( () -> {}, AgentBridge.NONE, - TestSettings.of("experimental.flagging.provider.span.enrichment.enabled", "true")); + TestSettings.of("experimental.flagging.provider.span.enrichment.enabled", "true"), + mode); accept(configuration); } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ExperimentationOptOutProviderTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ExperimentationOptOutProviderTest.java new file mode 100644 index 00000000000..23130f758b5 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExperimentationOptOutProviderTest.java @@ -0,0 +1,259 @@ +package com.datadog.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.bridge.v1.AgentBridge; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.tabletest.junit.TableTest; + +/** Exercises experimentation opt-out through the production parser and evaluator. */ +class ExperimentationOptOutProviderTest { + private static final String ATTRIBUTE = "dd_experimentation_opt_out_at"; + private static final String CUTOFF = "2026-10-19T12:00:00Z"; + + @SuppressWarnings("unchecked") + static Stream policyCases() throws Exception { + final byte[] bytes = Files.readAllBytes(fixture("cases.json")); + final List> cases = (List>) JsonReading.read(bytes); + assertEquals(90, cases.size()); + final Set ids = new HashSet<>(); + for (final Map testCase : cases) { + assertTrue(ids.add(testCase.get("id")), "duplicate policy case"); + } + return cases.stream().map(testCase -> Arguments.of(testCase.get("id"), testCase)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("policyCases") + @SuppressWarnings("unchecked") + void policy(final String id, final Map testCase) throws Exception { + final Map args = (Map) testCase.get("args"); + final Map expected = (Map) testCase.get("expected"); + final String mode = (String) args.get("mode"); + final ProviderEvaluation result = + evaluate( + configuration( + activation((String) args.get("activation")), args.get("doLog").toString(), false), + mode, + (String) args.get("attribute"), + "target"); + final String error = (String) expected.get("error"); + if (!"NONE".equals(error)) { + assertEquals(ErrorCode.valueOf(error), result.getErrorCode(), id); + assertEquals("default", result.getValue(), id); + } else { + assertNull(result.getErrorCode(), id); + final String allocation = + Boolean.TRUE.equals(expected.get("exclude")) ? "ordinary" : "experiment"; + assertEquals(allocation, result.getValue(), id); + assertEquals(allocation, result.getFlagMetadata().getString("allocationKey"), id); + } + } + + @ParameterizedTest + @ValueSource(strings = {"immediate", "future_experiments_only"}) + void missingAndNullDoLogPreserveOrdinaryAllocation(final String mode) throws Exception { + for (final String doLog : new String[] {null, "null", "false"}) { + final ProviderEvaluation result = + evaluate(configuration(null, doLog, false), mode, "valid", "target"); + assertNull(result.getErrorCode()); + assertEquals("experiment", result.getValue()); + assertEquals("experiment", result.getFlagMetadata().getString("allocationKey")); + } + } + + @Test + void unreachableInvalidActivationFailsOnlyOptedOutFutureMode() throws Exception { + final String json = configuration("\"bad\"", "true", true); + for (final String mode : new String[] {"disabled", "immediate", "future_experiments_only"}) { + for (final String attribute : new String[] {"absent", "valid"}) { + final ProviderEvaluation result = evaluate(json, mode, attribute, "target"); + if ("future_experiments_only".equals(mode) && "valid".equals(attribute)) { + assertEquals(ErrorCode.PARSE_ERROR, result.getErrorCode()); + assertEquals("default", result.getValue()); + } else { + assertNull(result.getErrorCode()); + assertEquals("ordinary", result.getValue()); + } + final ProviderEvaluation neighbor = evaluate(json, mode, attribute, "neighbor"); + assertNull(neighbor.getErrorCode()); + assertEquals("ordinary", neighbor.getValue()); + } + } + } + + @Test + void invalidSubjectPrecedesMissingFlagButNotReadiness() throws Exception { + final ProviderEvaluation missing = + evaluate( + configuration(null, "true", false), "future_experiments_only", "invalid", "missing"); + assertEquals(ErrorCode.INVALID_CONTEXT, missing.getErrorCode()); + final ProviderEvaluation notReady = + evaluate(null, "future_experiments_only", "invalid", "missing"); + assertEquals(ErrorCode.PROVIDER_NOT_READY, notReady.getErrorCode()); + } + + @TableTest({ + "Scenario | deprecatedAlias | mode | expectedValue", + "SDK disabled | false | DISABLED | experiment ", + "SDK immediate | false | IMMEDIATE | ordinary ", + "SDK future | false | FUTURE_EXPERIMENTS_ONLY | ordinary ", + "alias disabled | true | DISABLED | experiment ", + "alias immediate | true | IMMEDIATE | ordinary ", + "alias future | true | FUTURE_EXPERIMENTS_ONLY | ordinary " + }) + @SuppressWarnings("deprecation") + void publicProvidersApplyOptOutAfterHttpConfiguration( + final boolean deprecatedAlias, + final ExperimentationOptOutMode mode, + final String expectedValue) + throws Exception { + try (final LocalHttpServer server = new LocalHttpServer()) { + server.otherwise( + 200, + "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + configuration(activation("equal"), "true", false) + + "}}"); + final Map previousProperties = new HashMap<>(); + final Map properties = + Map.of( + "dd.feature.flags.enabled", "true", + "dd.feature.flags.configuration.source", "agentless", + "dd.feature.flags.configuration.source.agentless.base.url", + server.uri("/").toString(), + "dd.feature.flags.experimentation.opt.out.mode", "invalid-overridden-mode", + "dd.api.key", ""); + properties.forEach( + (key, value) -> previousProperties.put(key, System.setProperty(key, value))); + try { + final Provider.Options options = + deprecatedAlias + ? new datadog.trace.api.openfeature.Provider.Options() + : new Provider.Options(); + options.initTimeout(5, SECONDS).experimentationOptOutMode(mode); + final Provider provider = + deprecatedAlias + ? new datadog.trace.api.openfeature.Provider( + (datadog.trace.api.openfeature.Provider.Options) options) + : new Provider(options); + // The policy is resolved at construction, before initialization or later option changes. + options.experimentationOptOutMode(ExperimentationOptOutMode.DEFAULT); + final MutableContext context = new MutableContext("subject").add(ATTRIBUTE, CUTOFF); + try { + provider.initialize(context); + final ProviderEvaluation result = + provider.getStringEvaluation("target", "default", context); + assertNull(result.getErrorCode()); + assertEquals(expectedValue, result.getValue()); + assertEquals(expectedValue, result.getFlagMetadata().getString("allocationKey")); + assertTrue(server.requests().size() > 0); + } finally { + provider.shutdown(); + } + } finally { + previousProperties.forEach( + (key, value) -> { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + }); + } + } + } + + private static ProviderEvaluation evaluate( + final String json, final String configuredMode, final String attribute, final String flag) + throws Exception { + final ExperimentationOptOutMode mode = + ExperimentationOptOutMode.valueOf(configuredMode.toUpperCase(Locale.ROOT)); + final DDEvaluator evaluator = + new DDEvaluator(() -> {}, AgentBridge.NONE, TestSettings.of(), mode); + if (json != null) { + evaluator.accept(UniversalFlagConfigParser.parse(json.getBytes(UTF_8))); + } + final MutableContext context = new MutableContext("subject"); + if (!"absent".equals(attribute)) { + context.add(ATTRIBUTE, "valid".equals(attribute) ? CUTOFF : "bad"); + } + return evaluator.evaluate(String.class, flag, "default", context); + } + + private static String activation(final String state) { + switch (state) { + case "missing": + return null; + case "invalid": + return "\"bad\""; + case "before": + return "\"2026-10-19T11:59:59.999999999Z\""; + case "equal": + return "\"2026-10-19T12:00:00Z\""; + case "after": + return "\"2026-10-19T12:00:00.000000001Z\""; + default: + throw new IllegalArgumentException(state); + } + } + + private static String configuration( + final String activation, final String doLog, final boolean unreachable) { + final String ordinary = + "{\"key\":\"ordinary\",\"doLog\":false,\"splits\":[{\"variationKey\":\"ordinary\",\"shards\":[]}]}"; + final String experiment = + "{\"key\":\"experiment\"" + + (doLog == null ? "" : ",\"doLog\":" + doLog) + + (activation == null ? "" : ",\"randomizationActivatedAt\":" + activation) + + ",\"splits\":[{\"variationKey\":\"experiment\",\"shards\":[]}]}"; + final String prefix = + "{\"enabled\":true,\"variationType\":\"STRING\",\"variations\":{\"experiment\":{\"key\":\"experiment\",\"value\":\"experiment\"},\"ordinary\":{\"key\":\"ordinary\",\"value\":\"ordinary\"}},\"allocations\":["; + return "{\"flags\":{\"target\":" + + prefix + + (unreachable ? ordinary + "," + experiment : experiment + "," + ordinary) + + "]},\"neighbor\":" + + prefix + + ordinary + + "]}}}"; + } + + private static Path fixture(final String name) { + Path directory = Paths.get("").toAbsolutePath(); + while (directory != null) { + final Path candidate = + directory + .resolve("dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out") + .resolve(name); + if (Files.isRegularFile(candidate)) { + return candidate; + } + directory = directory.getParent(); + } + throw new IllegalStateException("Missing experimentation opt-out fixture " + name); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java index 9e6675a1edc..ee515860e78 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java @@ -89,6 +89,29 @@ void appliesDefaults() { assertFalse(settings.isSpanEnrichmentEnabled()); assertTrue(settings.isEvaluationCountsEnabled()); assertTrue(settings.isExposuresDatadogLoggingEnabled()); + assertEquals("disabled", settings.experimentationOptOutMode()); + } + + @TableTest({ + "scenario | agent | property | environment | expected ", + "default | | | | disabled ", + "environment enables | | | immediate | immediate ", + "property wins | | disabled | immediate | disabled ", + "agent wins | immediate | disabled | disabled | immediate ", + "invalid is preserved | | unsupported | immediate | unsupported" + }) + void resolvesExperimentationOptOutSource( + final String agent, final String property, final String environment, final String expected) { + final String key = "feature.flags.experimentation.opt.out.mode"; + final Map bridge = new HashMap<>(); + final Map properties = new HashMap<>(); + final Map variables = new HashMap<>(); + putIfNotNull(bridge, key, agent); + putIfNotNull(properties, "dd." + key, property); + putIfNotNull(variables, "DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE", environment); + final Settings settings = + new Settings(new SettingAgentBridge(bridge), properties::get, variables::get); + assertEquals(expected, settings.experimentationOptOutMode()); } @TableTest({ diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOutTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOutTest.java new file mode 100644 index 00000000000..a5ef2460191 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOutTest.java @@ -0,0 +1,212 @@ +package com.datadog.openfeature.internal.evaluation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.datadog.openfeature.ExperimentationOptOutMode; +import com.datadog.openfeature.Provider; +import com.datadog.openfeature.internal.bridge.v1.AgentBridge; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.ufc.Allocation; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.Value; +import java.time.DateTimeException; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.tabletest.junit.TableTest; + +class ExperimentationOptOutTest { + private static final String PROPERTY = "dd.feature.flags.experimentation.opt.out.mode"; + + @ParameterizedTest + @ValueSource( + strings = { + "", + "disabled", + "immediate", + " IMMEDIATE ", + "future_experiments_only", + " FUTURE_EXPERIMENTS_ONLY ", + "unsupported" + }) + void providerConfigurationPrecedence(final String configured) { + final String previous = System.getProperty(PROPERTY); + System.setProperty(PROPERTY, configured); + try { + for (final ExperimentationOptOutMode override : + new ExperimentationOptOutMode[] { + ExperimentationOptOutMode.DISABLED, + ExperimentationOptOutMode.IMMEDIATE, + ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY + }) { + assertEquals( + override, ExperimentationOptOut.resolve(override, Settings.load(AgentBridge.NONE))); + new Provider(new Provider.Options().experimentationOptOutMode(override)).shutdown(); + } + if ("unsupported".equals(configured)) { + assertThrows(IllegalArgumentException.class, Provider::new); + } else { + final ExperimentationOptOutMode expected = + configured.trim().equalsIgnoreCase("immediate") + ? ExperimentationOptOutMode.IMMEDIATE + : configured.trim().equalsIgnoreCase("future_experiments_only") + ? ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY + : ExperimentationOptOutMode.DISABLED; + assertEquals( + expected, + ExperimentationOptOut.resolve( + ExperimentationOptOutMode.DEFAULT, Settings.load(AgentBridge.NONE))); + new Provider().shutdown(); + } + } finally { + if (previous == null) { + System.clearProperty(PROPERTY); + } else { + System.setProperty(PROPERTY, previous); + } + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "2026-10-19T15:30:00Z", + "2026-10-19T15:30:00.123456789Z", + "2026-10-19T15:30:00+05:30", + "2026-10-19T15:30:00-04:00", + "9999-12-31T23:59:59Z", + "2024-02-29T00:00:00Z" + }) + void validTimestampProvidesCutoff(final String timestamp) { + assertNotNull( + ExperimentationOptOut.cutoff( + ExperimentationOptOutMode.IMMEDIATE, + new MutableContext().add(ExperimentationOptOut.ATTRIBUTE, timestamp))); + } + + static Stream invalidValues() { + return Stream.of( + new Value(), + new Value(""), + new Value("not a timestamp"), + new Value("2026-10-19"), + new Value("2026-10-19T15:30:00"), + new Value("2026-02-30T00:00:00Z"), + new Value("2026-10-19T24:00:00Z"), + new Value("2026-10-19T15:30:60Z"), + new Value("2026-10-19T15:30:00+99:00"), + new Value(" 2026-10-19T15:30:00Z"), + new Value(true), + new Value(42)) + .map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("invalidValues") + void invalidValuesAreRejectedOnlyWhenEnabled(final Value value) { + final MutableContext context = + new MutableContext(Collections.singletonMap(ExperimentationOptOut.ATTRIBUTE, value)); + for (final ExperimentationOptOutMode mode : + new ExperimentationOptOutMode[] { + ExperimentationOptOutMode.IMMEDIATE, ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY + }) { + final IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, () -> ExperimentationOptOut.cutoff(mode, context)); + assertEquals(ExperimentationOptOut.INVALID_TIMESTAMP, error.getMessage()); + } + assertNull(ExperimentationOptOut.cutoff(ExperimentationOptOutMode.DISABLED, context)); + } + + @Test + void missingAttributePreservesParticipation() { + for (final ExperimentationOptOutMode mode : ExperimentationOptOutMode.values()) { + assertNull(ExperimentationOptOut.cutoff(mode, new MutableContext())); + // An inactive cutoff must return without inspecting the allocation. + assertFalse(ExperimentationOptOut.excludes(mode, null, null)); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "2026-10-19T15:30:00Z", + "2026-10-19T15:30:00.123456789Z", + "2026-10-19T15:30:00.123456789123Z", + "2026-10-19T15:30:00+05:30", + "2026-10-19T15:30:00+24:00", + "2026-10-19T15:30:00+00:60", + "2026-10-19T15:30:60Z", + "2026-02-30T00:00:00Z", + "2026-10-19t15:30:00z", + "2026-10-19T5:30:00Z", + "2026-10-19" + }) + void timestampValidationMatchesAllocationTimestamps(final String timestamp) { + boolean accepted; + try { + DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(timestamp, Instant::from); + accepted = true; + } catch (final DateTimeException e) { + accepted = false; + } + final MutableContext context = + new MutableContext().add(ExperimentationOptOut.ATTRIBUTE, timestamp); + if (accepted) { + assertNotNull(ExperimentationOptOut.cutoff(ExperimentationOptOutMode.IMMEDIATE, context)); + } else { + assertThrows( + IllegalArgumentException.class, + () -> ExperimentationOptOut.cutoff(ExperimentationOptOutMode.IMMEDIATE, context)); + } + } + + @TableTest({ + "mode | doLog | activation | excluded", + "IMMEDIATE | true | 2026-10-19T11:59:59.999999999Z | true ", + "FUTURE_EXPERIMENTS_ONLY | true | 2026-10-19T11:59:59.999999999Z | false ", + "FUTURE_EXPERIMENTS_ONLY | true | 2026-10-19T12:00:00Z | true ", + "FUTURE_EXPERIMENTS_ONLY | true | 2026-10-19T12:00:00.000000001Z | true ", + "FUTURE_EXPERIMENTS_ONLY | true | 2026-10-19T08:00:00-04:00 | true ", + "FUTURE_EXPERIMENTS_ONLY | true | 2026-10-19T07:59:59.999999999-04:00 | false ", + "FUTURE_EXPERIMENTS_ONLY | true | | true ", + "FUTURE_EXPERIMENTS_ONLY | false | | false ", + "IMMEDIATE | false | | false " + }) + void appliesActivationCutoff( + final ExperimentationOptOutMode mode, + final boolean doLog, + final String activation, + final boolean excluded) { + final Allocation allocation = + new Allocation( + "allocation", + null, + null, + null, + Collections.emptyList(), + doLog, + activation == null + ? null + : DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(activation, Instant::from)); + assertEquals( + excluded, + ExperimentationOptOut.excludes(mode, Instant.parse("2026-10-19T12:00:00Z"), allocation)); + } + + @Test + void nullProgrammaticModeIsRejected() { + assertThrows( + NullPointerException.class, () -> new Provider.Options().experimentationOptOutMode(null)); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/RandomizationActivationTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/RandomizationActivationTest.java new file mode 100644 index 00000000000..c7e82046f52 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/RandomizationActivationTest.java @@ -0,0 +1,56 @@ +package com.datadog.openfeature.internal.ufc; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class RandomizationActivationTest { + @ParameterizedTest + @ValueSource(strings = {"true", "42", "[]", "{}", "\"bad\"", "\"\"", "\"2026-02-30T12:00:00Z\""}) + void preservesMalformedActivationForEvaluationPolicy(final String activation) throws Exception { + final ServerConfiguration config = parse(activation); + assertTrue(config.flags.containsKey("target")); + assertTrue(config.flags.get("target").allocations.get(0).invalidRandomizationActivation); + assertNull(config.flags.get("target").allocations.get(0).randomizationActivatedAt); + assertTrue(config.flags.containsKey("neighbor")); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"null"}) + void acceptsUnknownActivation(final String activation) throws Exception { + final ServerConfiguration config = parse(activation); + assertTrue(config.invalidFlags.isEmpty()); + assertNull(config.flags.get("target").allocations.get(0).randomizationActivatedAt); + assertFalse(config.flags.get("target").allocations.get(0).invalidRandomizationActivation); + } + + @ParameterizedTest + @ValueSource( + strings = {"\"2026-10-19T12:00:00.123456789Z\"", "\"2026-10-19T08:00:00.123456789-04:00\""}) + void retainsPrecisionAndOffsets(final String activation) throws Exception { + final ServerConfiguration config = parse(activation); + assertTrue(config.invalidFlags.isEmpty()); + assertEquals( + Instant.parse("2026-10-19T12:00:00.123456789Z"), + config.flags.get("target").allocations.get(0).randomizationActivatedAt); + } + + private static ServerConfiguration parse(final String activation) throws Exception { + final String json = + "{\"flags\":{" + + "\"target\":{\"key\":\"target\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{},\"allocations\":[{\"key\":\"experiment\",\"doLog\":true" + + (activation == null ? "" : ",\"randomizationActivatedAt\":" + activation) + + "}]},\"neighbor\":{\"key\":\"neighbor\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{},\"allocations\":[]}}}"; + return UniversalFlagConfigParser.parse(json.getBytes(UTF_8)); + } +} diff --git a/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java index f7b06ea9033..7ddca1bf1aa 100644 --- a/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -39,7 +39,10 @@ void aliasOptionsKeepTheirType() { final Provider.Options options = new Provider.Options(); final Provider.Options configured = - options.initTimeout(5, SECONDS).enableEvaluationTelemetryOptOutParameter(true); + options + .initTimeout(5, SECONDS) + .enableEvaluationTelemetryOptOutParameter(true) + .experimentationOptOutMode(com.datadog.openfeature.ExperimentationOptOutMode.IMMEDIATE); assertSame(options, configured); assertEquals(5, configured.getTimeout()); diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy index 12115cb407d..789818ae4d8 100644 --- a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy @@ -81,14 +81,14 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { setup: setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) final request = new Request.Builder() - .url("http://localhost:${httpPort}/openfeature/evaluate") - .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson([ - flag: 'flag-that-does-not-exist', - variationType: 'STRING', - defaultValue: 'fallback', - targetingKey: 'span-enrichment-smoke-test' - ]))) - .build() + .url("http://localhost:${httpPort}/openfeature/evaluate") + .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson([ + flag: 'flag-that-does-not-exist', + variationType: 'STRING', + defaultValue: 'fallback', + targetingKey: 'span-enrichment-smoke-test' + ]))) + .build() when: final response = client.newCall(request).execute() @@ -98,13 +98,44 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { new JsonSlurper().parse(response.body().byteStream()).value == 'fallback' waitForSpan(defaultPoll) { span -> span.parentId == 0 && - span.meta['ffe_runtime_defaults'] == '{"flag-that-does-not-exist":"fallback"}' + span.meta['ffe_runtime_defaults'] == '{"flag-that-does-not-exist":"fallback"}' } cleanup: response.close() } + void 'test exposures retain allocation changes and suppress repeats'() { + setup: + final subject = 'exposure-cache-smoke-test' + final standard = [flag: 'serial-id-targeting-flag', allocation: 'default-allocation', + variant: 'standard', targetingKey: subject, serialId: 200] + final vip = [flag: 'serial-id-targeting-flag', allocation: 'vip-allocation', + variant: 'vip', targetingKey: subject, serialId: 201] + + when: + final results = [[:], [:], [segment: 'vip'], [segment: 'vip'], [:], [:]].collect { attributes -> + final testCase = [flag: 'serial-id-targeting-flag', variationType: 'STRING', + defaultValue: 'fallback', targetingKey: subject, attributes: attributes] + final request = new Request.Builder() + .url("http://localhost:${httpPort}/openfeature/evaluate") + .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) + .build() + final response = client.newCall(request).execute() + try { + assert response.code() == 200 + return [testCase: testCase, body: new JsonSlurper().parse(response.body().byteStream())] + } finally { + response.close() + } + } + final expectedExposures = buildExpectedExposures(results) + + then: + expectedExposures == [standard, vip, standard] + assertExposures(expectedExposures) + } + void 'test open feature exposures'() { setup: setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) @@ -113,8 +144,7 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { assert !testCases.isEmpty() when: - final results = testCases.collect { - testCase -> + final results = testCases.collect { testCase -> final request = new Request.Builder() .url(url) .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) @@ -123,28 +153,14 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { final responseBody = new JsonSlurper().parse(response.body().byteStream()) return [testCase: testCase, response: response, body: responseBody] } - final expectedExposures = uniqueExpectedExposures(results) + final expectedExposures = buildExpectedExposures(results) then: results.every { it.response.code() == 200 } !expectedExposures.isEmpty() - new PollingConditions(timeout: 10).eventually { - final requests = evpProxyMessages*.getV2() as List> - final events = requests*.exposures.flatten() - assert events.size() == expectedExposures.size() - expectedExposures.each { - expected -> - assert events.find { - event -> - event.flag.key == expected.flag && - event.allocation.key == expected.allocation && - event.variant.key == expected.variant && - event.subject.id == expected.targetingKey - } != null : "Unable to find exposure ${expected}" - } - } + assertExposures(expectedExposures) } @Unroll("test open feature evaluation - #testCase.fileName[#testCase.index] - flag=#testCase.flag") @@ -206,9 +222,25 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { return result } - private List> uniqueExpectedExposures(final List> results) { + private void assertExposures(final List> expectedExposures) { + new PollingConditions(timeout: 10).eventually { + final requests = evpProxyMessages*.getV2() as List> + final events = requests*.exposures.flatten().collect { + event -> + [flag: event.flag.key, allocation: event.allocation.key, variant: event.variant.key, + targetingKey: event.subject.id, serialId: event.serial_id] + } + assert events.countBy { + it + } == expectedExposures.countBy { + it + } + } + } + + private List> buildExpectedExposures(final List> results) { final expected = [] - final seen = [] as Set + final previousBySubject = [:] results.each { result -> final testCase = result.testCase as Map final body = result.body as Map @@ -223,10 +255,12 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { flag: flag, allocation: allocation, variant: variant, - targetingKey: testCase.targetingKey + targetingKey: testCase.targetingKey, + serialId: body.flagMetadata?.__dd_split_serial_id ] - final key = "${exposure.flag}\u0000${exposure.targetingKey}\u0000${exposure.allocation}\u0000${exposure.variant}" - if (seen.add(key)) { + // The cache stores the last assignment per flag and subject. A -> B -> A emits three events. + final key = [flag, testCase.targetingKey] + if (previousBySubject.put(key, exposure) != exposure) { expected.add(exposure) } } diff --git a/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/README.md b/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/README.md new file mode 100644 index 00000000000..3d1cb242bd3 --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/README.md @@ -0,0 +1,5 @@ +# Experimentation opt-out test cases + +`cases.json` covers 90 combinations of opt-out mode, subject timestamp, allocation activation, +and exposure logging. `ExperimentationOptOutProviderTest` runs each case through the production +parser and evaluator and checks the selected allocation or evaluation error. diff --git a/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/cases.json b/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/cases.json new file mode 100644 index 00000000000..c9d8feb3b0f --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/cases.json @@ -0,0 +1,1172 @@ +[ + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/absent/missing/0" + }, + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/absent/missing/1" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/absent/invalid/0" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/absent/invalid/1" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/absent/before/0" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/absent/before/1" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/absent/equal/0" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/absent/equal/1" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/absent/after/0" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/absent/after/1" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/valid/missing/0" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/valid/missing/1" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/valid/invalid/0" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/valid/invalid/1" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/valid/before/0" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/valid/before/1" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/valid/equal/0" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/valid/equal/1" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/valid/after/0" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/valid/after/1" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/invalid/missing/0" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/invalid/missing/1" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/invalid/invalid/0" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/invalid/invalid/1" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/invalid/before/0" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/invalid/before/1" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/invalid/equal/0" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/invalid/equal/1" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": false, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/invalid/after/0" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": true, + "mode": "disabled" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "disabled/invalid/after/1" + }, + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/absent/missing/0" + }, + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/absent/missing/1" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/absent/invalid/0" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/absent/invalid/1" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/absent/before/0" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/absent/before/1" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/absent/equal/0" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/absent/equal/1" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/absent/after/0" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/absent/after/1" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/valid/missing/0" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "immediate/valid/missing/1" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/valid/invalid/0" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "immediate/valid/invalid/1" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/valid/before/0" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "immediate/valid/before/1" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/valid/equal/0" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "immediate/valid/equal/1" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "immediate/valid/after/0" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "immediate/valid/after/1" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "immediate/invalid/missing/0" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "immediate/invalid/missing/1" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "immediate/invalid/invalid/0" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "immediate/invalid/invalid/1" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "immediate/invalid/before/0" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "immediate/invalid/before/1" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "immediate/invalid/equal/0" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "immediate/invalid/equal/1" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": false, + "mode": "immediate" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "immediate/invalid/after/0" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": true, + "mode": "immediate" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "immediate/invalid/after/1" + }, + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/absent/missing/0" + }, + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/absent/missing/1" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/absent/invalid/0" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/absent/invalid/1" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/absent/before/0" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/absent/before/1" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/absent/equal/0" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/absent/equal/1" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/absent/after/0" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/absent/after/1" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/valid/missing/0" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "future_experiments_only/valid/missing/1" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "PARSE_ERROR", + "exclude": false + }, + "id": "future_experiments_only/valid/invalid/0" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "PARSE_ERROR", + "exclude": false + }, + "id": "future_experiments_only/valid/invalid/1" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/valid/before/0" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/valid/before/1" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/valid/equal/0" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "future_experiments_only/valid/equal/1" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "future_experiments_only/valid/after/0" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "future_experiments_only/valid/after/1" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "future_experiments_only/invalid/missing/0" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "future_experiments_only/invalid/missing/1" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "future_experiments_only/invalid/invalid/0" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "future_experiments_only/invalid/invalid/1" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "future_experiments_only/invalid/before/0" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "future_experiments_only/invalid/before/1" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "future_experiments_only/invalid/equal/0" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "future_experiments_only/invalid/equal/1" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": false, + "mode": "future_experiments_only" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "future_experiments_only/invalid/after/0" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": true, + "mode": "future_experiments_only" + }, + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "future_experiments_only/invalid/after/1" + } +] diff --git a/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data b/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data index 0c467473aa8..8c4d2410aa6 160000 --- a/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data +++ b/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data @@ -1 +1 @@ -Subproject commit 0c467473aa8dc1f1cbfc0ef9a60d5c1c7a8826e6 +Subproject commit 8c4d2410aa643aa6b97c8547ae5fe358c46b7f6f diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 4b6663c7fe0..4b71411c15d 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -1612,6 +1612,14 @@ "aliases": [] } ], + "DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE": [ + { + "version": "A", + "type": "string", + "default": "disabled", + "aliases": [] + } + ], "DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED": [ { "version": "A",