diff --git a/dd-openfeature/README.md b/dd-openfeature/README.md new file mode 100644 index 00000000000..d1fac3beb60 --- /dev/null +++ b/dd-openfeature/README.md @@ -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. 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..a912f51e492 --- /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.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 384fadb9585..41cda0d13c8 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 b8b72493c9d..78f944b6bb2 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.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; @@ -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 1213c5d2ca4..f82c39144d2 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..f5b63768830 --- /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 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 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/test/java/com/datadog/openfeature/CanonicalFixtureTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java index b2496d0f3dd..76dad2c7431 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.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 d5e3ab218c0..a967dda2e77 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/ExperimentationOptOutConformanceTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ExperimentationOptOutConformanceTest.java new file mode 100644 index 00000000000..50ccbad6d02 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExperimentationOptOutConformanceTest.java @@ -0,0 +1,200 @@ +package com.datadog.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; +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.bridge.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.security.MessageDigest; +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; + +/** Runs pinned Lean policy cases through the production parser and evaluator. */ +class ExperimentationOptOutConformanceTest { + 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("java-policy-v1-cases.json")); + assertEquals("289a5d2dca4a7e5dd1e0428b4c56e9c1660668375aab67b71c91b27b9391f4e2", sha256(bytes)); + final List> cases = (List>) JsonReading.read(bytes); + assertEquals(90, cases.size()); + final byte[] profileBytes = Files.readAllBytes(fixture("java-policy-v1.json")); + assertEquals( + "ad281f827a0e57a0aa4528157d5ab4a967bc0233cbae5d563f7408fe9f4017ff", sha256(profileBytes)); + final Map profile = (Map) JsonReading.read(profileBytes); + final Set ids = new HashSet<>(); + for (final Map testCase : cases) { + assertTrue(ids.add(testCase.get("id")), "duplicate policy case"); + } + assertEquals(new HashSet<>((List) profile.get("included_ids")), ids); + 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()); + } + + 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 pinned experimentation opt-out fixture " + name); + } + + private static String sha256(final byte[] bytes) throws Exception { + final StringBuilder hash = new StringBuilder(); + for (final byte value : MessageDigest.getInstance("SHA-256").digest(bytes)) { + hash.append(String.format(Locale.ROOT, "%02x", value & 0xff)); + } + return hash.toString(); + } +} 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 80c3a9258b3..4b1500f0a27 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..f520ba40658 --- /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.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-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..4d97b41cd37 --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/README.md @@ -0,0 +1,12 @@ +# Experimentation opt-out policy cases + +These 90 cases come from `DataDog/ffe-system-test-data@0520866bedb8c059df9c493cfe27ad294390c603`, +under `contracts/experimentation-opt-out/evidence/sdk-refresh-20261022/`. +Both Java implementation owners run the same cases through their production parser and evaluator. + +The profile covers D002, D004, D010 and D011. It excludes browser timestamp grammar and transport behavior. +The Java parser grammar and existing provider initialization behavior remain unchanged. +Native tests separately cover omitted/null `doLog`, invalid activation on unreachable allocations, and telemetry. + +Profile SHA256: `ad281f827a0e57a0aa4528157d5ab4a967bc0233cbae5d563f7408fe9f4017ff`. +Cases SHA256: `289a5d2dca4a7e5dd1e0428b4c56e9c1660668375aab67b71c91b27b9391f4e2`. diff --git a/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/java-policy-v1-cases.json b/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/java-policy-v1-cases.json new file mode 100644 index 00000000000..ebacef629d1 --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/java-policy-v1-cases.json @@ -0,0 +1,1802 @@ +[ + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/absent/missing/0", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/absent/missing/1", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/absent/invalid/0", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/absent/invalid/1", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/absent/before/0", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/absent/before/1", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/absent/equal/0", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/absent/equal/1", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/absent/after/0", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/absent/after/1", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/valid/missing/0", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/valid/missing/1", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/valid/invalid/0", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/valid/invalid/1", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/valid/before/0", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/valid/before/1", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/valid/equal/0", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/valid/equal/1", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/valid/after/0", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/valid/after/1", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/invalid/missing/0", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/invalid/missing/1", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/invalid/invalid/0", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/invalid/invalid/1", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/invalid/before/0", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/invalid/before/1", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/invalid/equal/0", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/invalid/equal/1", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": false, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/invalid/after/0", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": true, + "mode": "disabled" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/disabled/invalid/after/1", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/absent/missing/0", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/absent/missing/1", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/absent/invalid/0", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/absent/invalid/1", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/absent/before/0", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/absent/before/1", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/absent/equal/0", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/absent/equal/1", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/absent/after/0", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/absent/after/1", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/valid/missing/0", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "policy/immediate/valid/missing/1", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/valid/invalid/0", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "policy/immediate/valid/invalid/1", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/valid/before/0", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "policy/immediate/valid/before/1", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/valid/equal/0", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "policy/immediate/valid/equal/1", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/immediate/valid/after/0", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "policy/immediate/valid/after/1", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/immediate/invalid/missing/0", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/immediate/invalid/missing/1", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/immediate/invalid/invalid/0", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/immediate/invalid/invalid/1", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/immediate/invalid/before/0", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/immediate/invalid/before/1", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/immediate/invalid/equal/0", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/immediate/invalid/equal/1", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": false, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/immediate/invalid/after/0", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": true, + "mode": "immediate" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/immediate/invalid/after/1", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/absent/missing/0", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "absent", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/absent/missing/1", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/absent/invalid/0", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "absent", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/absent/invalid/1", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/absent/before/0", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "absent", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/absent/before/1", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/absent/equal/0", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "absent", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/absent/equal/1", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/absent/after/0", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "absent", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/absent/after/1", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/valid/missing/0", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "valid", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "policy/future_experiments_only/valid/missing/1", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "PARSE_ERROR", + "exclude": false + }, + "id": "policy/future_experiments_only/valid/invalid/0", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "valid", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "PARSE_ERROR", + "exclude": false + }, + "id": "policy/future_experiments_only/valid/invalid/1", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/valid/before/0", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "valid", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/valid/before/1", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/valid/equal/0", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "valid", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "policy/future_experiments_only/valid/equal/1", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": false + }, + "id": "policy/future_experiments_only/valid/after/0", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "valid", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "NONE", + "exclude": true + }, + "id": "policy/future_experiments_only/valid/after/1", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/future_experiments_only/invalid/missing/0", + "kind": "policy" + }, + { + "args": { + "activation": "missing", + "attribute": "invalid", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/future_experiments_only/invalid/missing/1", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/future_experiments_only/invalid/invalid/0", + "kind": "policy" + }, + { + "args": { + "activation": "invalid", + "attribute": "invalid", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/future_experiments_only/invalid/invalid/1", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/future_experiments_only/invalid/before/0", + "kind": "policy" + }, + { + "args": { + "activation": "before", + "attribute": "invalid", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/future_experiments_only/invalid/before/1", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/future_experiments_only/invalid/equal/0", + "kind": "policy" + }, + { + "args": { + "activation": "equal", + "attribute": "invalid", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/future_experiments_only/invalid/equal/1", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": false, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/future_experiments_only/invalid/after/0", + "kind": "policy" + }, + { + "args": { + "activation": "after", + "attribute": "invalid", + "doLog": true, + "mode": "future_experiments_only" + }, + "decision_ids": [ + "D002", + "D004", + "D010", + "D011" + ], + "expected": { + "error": "INVALID_CONTEXT", + "exclude": false + }, + "id": "policy/future_experiments_only/invalid/after/1", + "kind": "policy" + } +] diff --git a/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/java-policy-v1.json b/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/java-policy-v1.json new file mode 100644 index 00000000000..3b13a0f667d --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/resources/experimentation-opt-out/java-policy-v1.json @@ -0,0 +1,109 @@ +{ + "id": "java-policy-v1", + "decision_revisions": { + "D002": 1, + "D004": 2, + "D010": 1, + "D011": 1 + }, + "source_corpus_sha256": "c4a1b9c9eed7197b8d66c1d3e46fcaaa4ec70143a2583b31baa04086c8f652dd", + "included_kinds": [ + "policy" + ], + "included_count": 90, + "included_ids": [ + "policy/disabled/absent/missing/0", + "policy/disabled/absent/missing/1", + "policy/disabled/absent/invalid/0", + "policy/disabled/absent/invalid/1", + "policy/disabled/absent/before/0", + "policy/disabled/absent/before/1", + "policy/disabled/absent/equal/0", + "policy/disabled/absent/equal/1", + "policy/disabled/absent/after/0", + "policy/disabled/absent/after/1", + "policy/disabled/valid/missing/0", + "policy/disabled/valid/missing/1", + "policy/disabled/valid/invalid/0", + "policy/disabled/valid/invalid/1", + "policy/disabled/valid/before/0", + "policy/disabled/valid/before/1", + "policy/disabled/valid/equal/0", + "policy/disabled/valid/equal/1", + "policy/disabled/valid/after/0", + "policy/disabled/valid/after/1", + "policy/disabled/invalid/missing/0", + "policy/disabled/invalid/missing/1", + "policy/disabled/invalid/invalid/0", + "policy/disabled/invalid/invalid/1", + "policy/disabled/invalid/before/0", + "policy/disabled/invalid/before/1", + "policy/disabled/invalid/equal/0", + "policy/disabled/invalid/equal/1", + "policy/disabled/invalid/after/0", + "policy/disabled/invalid/after/1", + "policy/immediate/absent/missing/0", + "policy/immediate/absent/missing/1", + "policy/immediate/absent/invalid/0", + "policy/immediate/absent/invalid/1", + "policy/immediate/absent/before/0", + "policy/immediate/absent/before/1", + "policy/immediate/absent/equal/0", + "policy/immediate/absent/equal/1", + "policy/immediate/absent/after/0", + "policy/immediate/absent/after/1", + "policy/immediate/valid/missing/0", + "policy/immediate/valid/missing/1", + "policy/immediate/valid/invalid/0", + "policy/immediate/valid/invalid/1", + "policy/immediate/valid/before/0", + "policy/immediate/valid/before/1", + "policy/immediate/valid/equal/0", + "policy/immediate/valid/equal/1", + "policy/immediate/valid/after/0", + "policy/immediate/valid/after/1", + "policy/immediate/invalid/missing/0", + "policy/immediate/invalid/missing/1", + "policy/immediate/invalid/invalid/0", + "policy/immediate/invalid/invalid/1", + "policy/immediate/invalid/before/0", + "policy/immediate/invalid/before/1", + "policy/immediate/invalid/equal/0", + "policy/immediate/invalid/equal/1", + "policy/immediate/invalid/after/0", + "policy/immediate/invalid/after/1", + "policy/future_experiments_only/absent/missing/0", + "policy/future_experiments_only/absent/missing/1", + "policy/future_experiments_only/absent/invalid/0", + "policy/future_experiments_only/absent/invalid/1", + "policy/future_experiments_only/absent/before/0", + "policy/future_experiments_only/absent/before/1", + "policy/future_experiments_only/absent/equal/0", + "policy/future_experiments_only/absent/equal/1", + "policy/future_experiments_only/absent/after/0", + "policy/future_experiments_only/absent/after/1", + "policy/future_experiments_only/valid/missing/0", + "policy/future_experiments_only/valid/missing/1", + "policy/future_experiments_only/valid/invalid/0", + "policy/future_experiments_only/valid/invalid/1", + "policy/future_experiments_only/valid/before/0", + "policy/future_experiments_only/valid/before/1", + "policy/future_experiments_only/valid/equal/0", + "policy/future_experiments_only/valid/equal/1", + "policy/future_experiments_only/valid/after/0", + "policy/future_experiments_only/valid/after/1", + "policy/future_experiments_only/invalid/missing/0", + "policy/future_experiments_only/invalid/missing/1", + "policy/future_experiments_only/invalid/invalid/0", + "policy/future_experiments_only/invalid/invalid/1", + "policy/future_experiments_only/invalid/before/0", + "policy/future_experiments_only/invalid/before/1", + "policy/future_experiments_only/invalid/equal/0", + "policy/future_experiments_only/invalid/equal/1", + "policy/future_experiments_only/invalid/after/0", + "policy/future_experiments_only/invalid/after/1" + ], + "excluded_count": 223, + "scope": "User requested applying existing browser/Go decisions to these target PRs. Native transport/lifecycle/telemetry and parser compatibility require separate evidence.", + "excluded_reason": "Mobile does not evaluate allocations locally. Java is rules-based; browser parser, pending, request and acknowledgement families do not define its native parser contract." +} 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 6cf8b38afe7..d5a2066e1a2 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", diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 5aa7153906c..22b1ae0723f 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -253,3 +253,66 @@ This setting stops exposures only. Flag evaluation events have their own setting Configuration path. `offline` is reserved for startup-provided UFC bytes; until those bytes are implemented, no network source starts and evaluations use defaults. + +## Per-subject experimentation opt-out + +The standalone provider supports the same behavior through +`com.datadog.openfeature.Provider` and `com.datadog.openfeature.ExperimentationOptOutMode`. +The examples below use the legacy provider package. + +Set the mode before constructing the provider: + +```sh +DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE=immediate +``` + +Pass the subject's stored preference time with each evaluation. Omit the attribute for subjects who can participate. + +```java +OpenFeatureAPI.getInstance().setProviderAndWait(new Provider()); +Client client = OpenFeatureAPI.getInstance().getClient(); +MutableContext context = new MutableContext(subjectId); +context.add("dd_experimentation_opt_out_at", "2026-10-19T15:30:00Z"); +boolean enabled = client.getBooleanValue("new-route-planner", false, context); +``` + +The default mode is `disabled`. In `immediate` mode, a valid RFC3339 timestamp excludes the subject from exposure-logging allocations. +Timestamp validation uses the same parser as allocation `startAt` and `endAt` timestamps. +Evaluation continues through ordinary feature allocations. If none match, the SDK returns the application's default value. +A future timestamp also excludes immediately. It does not schedule exclusion. +Invalid, null, empty, or non-string attribute values return `INVALID_CONTEXT` and the application's default value. +Disabled mode ignores the attribute, including invalid values. + +Provider options override the environment setting: + +```java +Provider provider = new Provider(new Provider.Options() + .experimentationOptOutMode(ExperimentationOptOutMode.DISABLED)); +``` + +`DEFAULT` uses central SDK configuration. The provider resolves the mode at construction. +Unsupported configuration values throw `IllegalArgumentException`. Evaluation telemetry remains independent. +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. +Upgrade the Java agent with the provider. An older agent without activation metadata treats all exposure-logging allocations as unknown. +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/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index ea58de3cd84..8c2da0b32cb 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -56,6 +56,8 @@ dependencies { testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) testImplementation(libs.moshi) + // Conformance tests exercise the agent parser and provider together. + testImplementation(project(":products:feature-flagging:feature-flagging-lib")) // The main source set gets the bootstrap/config types as compileOnly, so the JMH source set // needs them on its own compile and runtime classpath to drive the hook end to end. diff --git a/products/feature-flagging/feature-flagging-api/gradle.lockfile b/products/feature-flagging/feature-flagging-api/gradle.lockfile index fa0bfccc495..c591312fd42 100644 --- a/products/feature-flagging/feature-flagging-api/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-api/gradle.lockfile @@ -2,9 +2,25 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :products:feature-flagging:feature-flagging-api:dependencies --write-locks +cafe.cryptography:curve25519-elisabeth:0.1.0=jmhRuntimeClasspath,testRuntimeClasspath +cafe.cryptography:ed25519-elisabeth:0.1.0=jmhRuntimeClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq.okhttp3:okhttp:3.12.15=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq.okio:okio:1.17.6=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-instrument-java:0.0.6=jmhRuntimeClasspath,testRuntimeClasspath +com.datadoghq:dd-javac-plugin-client:0.2.2=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:java-dogstatsd-client:4.4.5=jmhRuntimeClasspath,testRuntimeClasspath +com.datadoghq:sketches-java:0.8.3=jmhRuntimeClasspath,testRuntimeClasspath com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.jnr:jffi:1.3.15=jmhRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-a64asm:1.0.0=jmhRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-constants:0.10.4=jmhRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-enxio:0.32.20=jmhRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-ffi:2.2.19=jmhRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-posix:3.1.22=jmhRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-unixsocket:0.38.25=jmhRuntimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-x86asm:1.0.2=jmhRuntimeClasspath,testRuntimeClasspath com.github.spotbugs:spotbugs-annotations:4.10.4=compileClasspath,jmhCompileClasspath,spotbugs com.github.spotbugs:spotbugs:4.10.4=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs @@ -51,6 +67,8 @@ org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt org.jacoco:org.jacoco.core:0.8.15=jacocoAnt org.jacoco:org.jacoco.report:0.8.15=jacocoAnt +org.jctools:jctools-core-jdk11:4.0.6=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core:4.0.6=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:5.14.1=jmhRuntimeClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -68,11 +86,16 @@ org.openjdk.jmh:jmh-generator-bytecode:1.37=jmh,jmhCompileClasspath,jmhRuntimeCl org.openjdk.jmh:jmh-generator-reflection:1.37=jmh,jmhCompileClasspath,jmhRuntimeClasspath org.opentest4j:opentest4j:1.3.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-analysis:9.7.1=jmhRuntimeClasspath,testRuntimeClasspath org.ow2.asm:asm-commons:9.10.1=jacocoAnt,spotbugs +org.ow2.asm:asm-commons:9.7.1=jmhRuntimeClasspath,testRuntimeClasspath org.ow2.asm:asm-tree:9.10.1=jacocoAnt,spotbugs +org.ow2.asm:asm-tree:9.7.1=jmhRuntimeClasspath,testRuntimeClasspath org.ow2.asm:asm-util:9.10.1=spotbugs -org.ow2.asm:asm:9.0=jmh,jmhCompileClasspath,jmhRuntimeClasspath +org.ow2.asm:asm-util:9.7.1=jmhRuntimeClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.0=jmh,jmhCompileClasspath org.ow2.asm:asm:9.10.1=jacocoAnt,spotbugs +org.ow2.asm:asm:9.7.1=jmhRuntimeClasspath,testRuntimeClasspath org.slf4j:jcl-over-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:log4j-over-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java index 2afec791851..91cfce343c7 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java @@ -213,11 +213,18 @@ static boolean exposureSerialIdSupported(final Class eventClass) { private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled(); private final Runnable configCallback; + private final ExperimentationOptOutMode experimentationOptOutMode; private final AtomicReference configuration = new AtomicReference<>(); private final CountDownLatch initializationLatch = new CountDownLatch(1); public DDEvaluator(final Runnable configCallback) { + this(configCallback, ExperimentationOptOutMode.DISABLED); + } + + public DDEvaluator( + final Runnable configCallback, final ExperimentationOptOutMode experimentationOptOutMode) { this.configCallback = configCallback; + this.experimentationOptOutMode = experimentationOptOutMode; } @Override @@ -272,6 +279,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)) { @@ -284,6 +305,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) @@ -305,6 +336,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/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExperimentationOptOut.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExperimentationOptOut.java new file mode 100644 index 00000000000..c5682080e59 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExperimentationOptOut.java @@ -0,0 +1,132 @@ +package datadog.trace.api.openfeature; + +import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.bootstrap.config.provider.ConfigProvider; +import de.thetaphi.forbiddenapis.SuppressForbidden; +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; + +final class ExperimentationOptOut { + static final String ATTRIBUTE = "dd_experimentation_opt_out_at"; + 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() {} + + static ExperimentationOptOutMode resolve(final ExperimentationOptOutMode override) { + Objects.requireNonNull(override, "experimentationOptOutMode"); + if (override != ExperimentationOptOutMode.DEFAULT) { + return override; + } + final String configured = configuredMode(); + 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"); + } + } + + private static String configuredMode() { + try { + return ConfigProvider.getInstance() + .getString(FeatureFlaggingConfig.FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE, "disabled"); + } catch (final NoClassDefFoundError unavailableAgent) { + // The provider can be constructed without an agent. Initialization still reports its absence. + return configuredModeWithoutAgent(); + } + } + + @SuppressForbidden // ConfigHelper and ConfigProvider are not present without the Java agent. + private static String configuredModeWithoutAgent() { + final String property = System.getProperty("dd.feature.flags.experimentation.opt.out.mode"); + if (property != null) { + return property; + } + final String environment = System.getenv("DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE"); + return environment != null ? environment : "disabled"; + } + + 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 customer context values in the diagnostic. + } + } + throw new IllegalArgumentException(INVALID_TIMESTAMP); + } + + /** Checks prepared activation states before selecting an allocation. */ + static boolean hasInvalidActivation(final List allocations) { + if (!ActivationSupport.INVALID_STATE_AVAILABLE || allocations == null) { + return false; + } + for (final Allocation allocation : allocations) { + if (allocation != null && allocation.invalidRandomizationActivation) { + return true; + } + } + return false; + } + + 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 + || !ActivationSupport.AVAILABLE + || allocation.randomizationActivatedAt == null + || !allocation.randomizationActivatedAt.isBefore(cutoff); + } + + static boolean supportsActivation(final Class allocationType) { + try { + return allocationType.getField("randomizationActivatedAt").getType() == Instant.class; + } catch (ReflectiveOperationException | LinkageError | SecurityException unavailable) { + return false; + } + } + + static boolean supportsInvalidActivation(final Class allocationType) { + try { + return allocationType.getField("invalidRandomizationActivation").getType() == boolean.class; + } catch (ReflectiveOperationException | LinkageError | SecurityException unavailable) { + return false; + } + } + + /** Probe only when future mode is evaluated; older agents have unknown activation. */ + private static final class ActivationSupport { + private static final boolean AVAILABLE = supportsActivation(Allocation.class); + private static final boolean INVALID_STATE_AVAILABLE = + supportsInvalidActivation(Allocation.class); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExperimentationOptOutMode.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExperimentationOptOutMode.java new file mode 100644 index 00000000000..594a24c352e --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExperimentationOptOutMode.java @@ -0,0 +1,20 @@ +package datadog.trace.api.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/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index b5dc224392c..66a537a2372 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -35,6 +35,7 @@ public class Provider extends EventProvider implements Metadata { private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); private volatile Evaluator evaluator; private final Options options; + private final ExperimentationOptOutMode experimentationOptOutMode; private final AtomicReference initializationState = new AtomicReference<>(InitializationState.NOT_STARTED); private final FlagEvalMetrics flagEvalMetrics; @@ -66,6 +67,8 @@ public Provider(final Options options) { final Evaluator evaluator, final Boolean spanEnrichmentEnabledOverride) { this.options = options; + this.experimentationOptOutMode = + ExperimentationOptOut.resolve(options.experimentationOptOutMode); this.evaluator = evaluator; FlagEvalMetrics metrics = null; FlagEvalMetricsHook hook = null; @@ -230,8 +233,10 @@ private Evaluator buildEvaluator() throws Exception { return evaluator; } final Class evaluatorClass = loadEvaluatorClass(); - final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); - return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); + final Constructor ctor = + evaluatorClass.getConstructor(Runnable.class, ExperimentationOptOutMode.class); + return (Evaluator) + ctor.newInstance((Runnable) this::onConfigurationChange, experimentationOptOutMode); } @Override @@ -322,8 +327,18 @@ public static class Options { private long timeout = 30; private TimeUnit unit = SECONDS; + private ExperimentationOptOutMode experimentationOptOutMode = ExperimentationOptOutMode.DEFAULT; private Boolean evaluationTelemetryOptOutParameterEnabled; + /** + * Overrides DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE. DEFAULT uses central configuration. + * The provider resolves the mode at construction. + */ + 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} * context attribute. Overrides {@code diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java index 953450807f7..d096b70ebeb 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java @@ -23,8 +23,6 @@ import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; import com.squareup.moshi.Moshi; import com.squareup.moshi.Types; import datadog.trace.api.featureflag.FeatureFlaggingGateway; @@ -57,7 +55,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.Instant; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; @@ -75,23 +72,15 @@ public class DDEvaluatorTest { + private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + private static final String CANONICAL_FIXTURE_PATH = "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; - private static final Moshi MOSHI = - new Moshi.Builder() - .add(Date.class, new DateAdapter()) - .add(ShardAdapter.FACTORY) - .add(FlagMapAdapter.FACTORY) - .build(); - private static final JsonAdapter CONFIG_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); + private static final Moshi MOSHI = new Moshi.Builder().build(); private static final Type FIXTURE_LIST_TYPE = Types.newParameterizedType(List.class, FixtureCase.class); private static final JsonAdapter> FIXTURE_LIST_ADAPTER = MOSHI.adapter(FIXTURE_LIST_TYPE); - private static final ThreadLocal> INVALID_FLAGS_HOLDER = - ThreadLocal.withInitial(HashMap::new); - private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; @Test public void testInitializeSignalsApplicationProviderActivation() throws Exception { @@ -639,6 +628,30 @@ private static ProviderEvaluation evaluateWithNumericRuleOnId( return evaluator.evaluate(Integer.class, "num-rule", 23, ctx); } + @Test + public void invalidOptOutReportsFixedDiagnosticWithoutCustomerValues() { + final String suppliedValue = "private-customer-preference"; + for (final boolean observeFullEvaluationData : new boolean[] {false, true}) { + final DDEvaluator evaluator = + new DDEvaluator(mock(Runnable.class), ExperimentationOptOutMode.IMMEDIATE); + evaluator.accept( + new ServerConfiguration("", "", observeFullEvaluationData, null, emptyMap())); + final ProviderEvaluation details = + evaluator.evaluate( + Boolean.class, + "unknown-flag", + false, + new MutableContext("subject").add(ExperimentationOptOut.ATTRIBUTE, suppliedValue)); + assertEquals(ErrorCode.INVALID_CONTEXT, details.getErrorCode()); + assertEquals(false, details.getValue()); + assertEquals(ExperimentationOptOut.INVALID_TIMESTAMP, details.getErrorMessage()); + assertFalse(details.getErrorMessage().contains(suppliedValue)); + assertEquals( + observeFullEvaluationData, + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA)); + } + } + @Test public void testAllocationDateAbiAndInstantAccessors() throws Exception { final Date startAt = Date.from(Instant.parse("2024-01-01T00:00:00Z")); @@ -1015,123 +1028,111 @@ public void testCanonicalFixturesArePresent() throws IOException { @MethodSource("canonicalTestCases") @ParameterizedTest(name = "{0}") public void testEvaluateCanonicalFixture(final FixtureCase testCase) throws IOException { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluateCanonicalFixture(testCase, testCase.result, ExperimentationOptOutMode.DISABLED); + } + + @MethodSource("immediateOptOutTestCases") + @ParameterizedTest(name = "immediate: {0}") + public void testImmediateOptOutCanonicalFixture(final FixtureCase testCase) throws IOException { + evaluateCanonicalFixture( + testCase, + testCase.capabilities.get("experimentationOptOutImmediate"), + ExperimentationOptOutMode.IMMEDIATE); + } + + private static List immediateOptOutTestCases() throws IOException { + final List cases = + canonicalTestCases().stream() + .filter(testCase -> testCase.capabilities.containsKey("experimentationOptOutImmediate")) + .collect(Collectors.toList()); + assertEquals(28, cases.size(), "The immediate opt-out capability fixtures must execute"); + return cases; + } + + @MethodSource("futureOptOutTestCases") + @ParameterizedTest(name = "future: {0}") + void futureOptOutCanonicalFixture(final FixtureCase testCase) throws IOException { + evaluateCanonicalFixture( + testCase, + testCase.capabilities.get("experimentationOptOutFutureExperimentsOnly"), + ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY); + } + + private static List futureOptOutTestCases() throws IOException { + final List cases = + canonicalTestCases().stream() + .filter( + testCase -> + testCase.capabilities.containsKey("experimentationOptOutFutureExperimentsOnly")) + .collect(Collectors.toList()); + assertEquals(44, cases.size(), "The future opt-out capability fixtures must execute"); + return cases; + } + + private void evaluateCanonicalFixture( + final FixtureCase testCase, + final FixtureResult expected, + final ExperimentationOptOutMode mode) + throws IOException { + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class), mode); evaluator.accept(loadCanonicalConfiguration()); final Class targetType = targetType(testCase.variationType); final Object defaultValue = mapFixtureValue(targetType, testCase.defaultValue); - final Object expectedValue = mapFixtureValue(targetType, testCase.result.value); - final ProviderEvaluation details = - evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); + final Object expectedValue = mapFixtureValue(targetType, expected.value); + 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.result.reason)); - if (testCase.result.variant != null) { - assertThat(details.getVariant(), equalTo(testCase.result.variant)); + assertThat(details.getReason(), equalTo(expected.reason)); + if (expected.variant != null) { + assertThat(details.getVariant(), equalTo(expected.variant)); } - if (testCase.result.errorCode != null) { - assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.result.errorCode))); + if (expected.errorCode != null) { + assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(expected.errorCode))); + if ("INVALID_CONTEXT".equals(expected.errorCode)) { + assertEquals(ExperimentationOptOut.INVALID_TIMESTAMP, details.getErrorMessage()); + } + } else if (!"ERROR".equals(expected.reason)) { + assertNull(details.getErrorCode()); } - if (testCase.result.flagMetadata != null - && testCase.result.flagMetadata.get("allocationKey") != null) { + if (expected.flagMetadata != null && expected.flagMetadata.get("allocationKey") != null) { assertThat( details.getFlagMetadata().getString("allocationKey"), - equalTo(String.valueOf(testCase.result.flagMetadata.get("allocationKey")))); + equalTo(String.valueOf(expected.flagMetadata.get("allocationKey")))); } } - @SuppressWarnings({"rawtypes", "unchecked"}) 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); - } - - private static ServerConfiguration loadCanonicalConfiguration() throws IOException { - INVALID_FLAGS_HOLDER.get().clear(); - try { - final ServerConfiguration config = - CONFIG_ADAPTER.fromJson(read(fixtureRoot().resolve("ufc-config.json"))); - final Map invalidFlags = new HashMap<>(INVALID_FLAGS_HOLDER.get()); - if (!invalidFlags.isEmpty()) { - config.invalidFlags = invalidFlags; - } - validateAndCacheSemverComparands(config); - return config; - } finally { - INVALID_FLAGS_HOLDER.get().clear(); - } - } - - /** - * Validates and caches SemVer comparands for all SEMVER_* conditions in the configuration. Flags - * with invalid comparands are removed from the flags map and tracked in invalidFlags, matching - * the behavior of {@link com.datadog.featureflag.UniversalFlagConfigParser}. - */ - private static void validateAndCacheSemverComparands(final ServerConfiguration config) { - if (config.flags == null) { - return; + if (targetType == Boolean.class) { + return provider.getBooleanEvaluation(flag, (Boolean) defaultValue, context); } - final Map invalidFlags = - config.invalidFlags == null ? new HashMap<>() : new HashMap<>(config.invalidFlags); - final Map flagsToRemove = new HashMap<>(); - for (final Map.Entry entry : config.flags.entrySet()) { - final String flagKey = entry.getKey(); - final Flag flag = entry.getValue(); - if (flag.allocations == null) { - continue; - } - boolean invalid = false; - for (final Allocation allocation : flag.allocations) { - if (allocation.rules == null) { - continue; - } - for (final Rule rule : allocation.rules) { - if (rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition.operator == null) { - continue; - } - switch (condition.operator) { - case SEMVER_EQ: - case SEMVER_NEQ: - case SEMVER_LT: - case SEMVER_LTE: - case SEMVER_GT: - case SEMVER_GTE: - if (!(condition.value instanceof String)) { - invalid = true; - break; - } - final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); - if (parsed == null) { - invalid = true; - break; - } - condition.semverComparand = parsed; - break; - default: - break; - } - } - } - } - if (invalid) { - flagsToRemove.put(flagKey, flag); - invalidFlags.put(flagKey, "invalid_semver_comparand"); - } + if (targetType == Integer.class) { + return provider.getIntegerEvaluation(flag, (Integer) defaultValue, context); } - for (final String flagKey : flagsToRemove.keySet()) { - config.flags.remove(flagKey); + if (targetType == Double.class) { + return provider.getDoubleEvaluation(flag, (Double) defaultValue, context); } - if (!invalidFlags.isEmpty()) { - config.invalidFlags = invalidFlags; + if (targetType == String.class) { + return provider.getStringEvaluation(flag, (String) defaultValue, context); } + return provider.getObjectEvaluation(flag, (Value) defaultValue, context); + } + + private static ServerConfiguration loadCanonicalConfiguration() throws IOException { + return ProductionConfiguration.parse( + Files.readAllBytes(fixtureRoot().resolve("ufc-config.json"))); } private static List canonicalTestCases() throws IOException { @@ -1208,7 +1209,23 @@ private static Class targetType(final String variationType) { } private static Object mapFixtureValue(final Class targetType, final Object value) { - return DDEvaluator.mapValue(targetType, value); + // Keep expected values independent of production conversion and preserve JSON types. + if (targetType == Value.class) { + return Value.objectToValue(value); + } + if (targetType == Integer.class) { + assertTrue(value instanceof Number); + final double number = ((Number) value).doubleValue(); + assertEquals(number, Math.rint(number)); + assertTrue(number >= Integer.MIN_VALUE && number <= Integer.MAX_VALUE); + return ((Number) value).intValue(); + } + if (targetType == Double.class) { + assertTrue(value instanceof Number); + return ((Number) value).doubleValue(); + } + assertTrue(targetType.isInstance(value)); + return value; } private static Map mapOf(final Object... props) { @@ -1227,6 +1244,7 @@ private static final class FixtureCase { Object defaultValue; String flag; FixtureResult result; + Map capabilities = emptyMap(); String targetingKey; String variationType; transient String fileName; @@ -1245,227 +1263,4 @@ private static final class FixtureResult { String variant; Map flagMetadata = emptyMap(); } - - /** - * Mirrors the production parser's binary-compatible uint32 representation for fixture parsing. - */ - private static final class ShardAdapter extends JsonAdapter { - private static final JsonAdapter.Factory FACTORY = - (type, annotations, moshi) -> { - if (!annotations.isEmpty() || type != Shard.class) { - return null; - } - return new ShardAdapter(moshi.adapter(ShardJson.class)); - }; - - private final JsonAdapter delegate; - - private ShardAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Override - public Shard fromJson(final JsonReader reader) throws IOException { - final ShardJson shard = delegate.fromJson(reader); - if (shard == null) { - return null; - } - final List ranges; - if (shard.ranges == null) { - ranges = null; - } else { - ranges = new ArrayList<>(); - for (final ShardRangeJson range : shard.ranges) { - ranges.add( - range == null - ? null - : new ShardRange( - toUnsignedInt(range.start, "range start"), - toUnsignedInt(range.end, "range end"))); - } - } - return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); - } - - @Override - public void toJson(final JsonWriter writer, final Shard value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - private static int toUnsignedInt(final Long value, final String fieldName) { - if (value == null) { - return 0; - } - if (value < 0 || value > MAX_UNSIGNED_INT) { - throw new IllegalArgumentException("flag contains an invalid " + fieldName + " value"); - } - return value.intValue(); - } - - private static final class ShardJson { - String salt; - List ranges; - Long totalShards; - } - - private static final class ShardRangeJson { - Long start; - Long end; - } - - /** Reads the flags map with per-flag failure isolation, matching the production parser. */ - private static final class FlagMapAdapter extends JsonAdapter> { - private static final Type FLAGS_TYPE = - Types.newParameterizedType(Map.class, String.class, Flag.class); - - private static final JsonAdapter.Factory FACTORY = - (type, annotations, moshi) -> { - if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { - return null; - } - return new FlagMapAdapter(moshi.adapter(Flag.class)); - }; - - private final JsonAdapter flagAdapter; - - private FlagMapAdapter(final JsonAdapter flagAdapter) { - this.flagAdapter = flagAdapter; - } - - @Override - public Map fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - final Map flags = new HashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String flagKey = reader.nextName(); - final Object rawFlag = reader.readJsonValue(); - try { - final Flag flag = flagAdapter.fromJsonValue(rawFlag); - if (flag != null) { - validateFlag(flagKey, flag); - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException ignored) { - INVALID_FLAGS_HOLDER.get().put(flagKey, "invalid_flag"); - // A malformed flag must not prevent valid flags in the same configuration from loading. - } - } - reader.endObject(); - return flags; - } - - private static void validateFlag(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (final Allocation allocation : flag.allocations) { - if (allocation == null) { - continue; - } - validateConditionOperands(flagKey, allocation); - if (allocation.splits == null) { - continue; - } - for (final Split split : allocation.splits) { - if (split == null) { - continue; - } - if (split.shards == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains a split with missing shards"); - } - for (final Shard shard : split.shards) { - if (shard == null - || Integer.toUnsignedLong(shard.totalShards) == 0 - || shard.ranges == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains invalid shards"); - } - for (final ShardRange range : shard.ranges) { - if (range == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains an invalid shard range"); - } - } - } - } - } - } - - private static void validateConditionOperands( - final String flagKey, final Allocation allocation) { - if (allocation.rules == null) { - return; - } - for (final Rule rule : allocation.rules) { - if (rule == null || rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition == null || condition.operator == null) { - continue; - } - switch (condition.operator) { - case LT: - case LTE: - case GT: - case GTE: - if (!(condition.value instanceof Number)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-numeric condition"); - } - break; - case ONE_OF: - case NOT_ONE_OF: - if (!(condition.value instanceof List)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-list condition"); - } - break; - case IS_NULL: - if (!(condition.value instanceof Boolean)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-boolean condition"); - } - break; - default: - break; - } - } - } - } - - @Override - public void toJson(final JsonWriter writer, final Map value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - private static final class DateAdapter extends JsonAdapter { - @Override - public Date fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - try { - return Date.from( - DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(reader.nextString(), Instant::from)); - } catch (final Exception ignored) { - return null; - } - } - - @Override - public void toJson(final JsonWriter writer, final Date value) throws IOException { - if (value == null) { - writer.nullValue(); - return; - } - writer.value(value.toInstant().toString()); - } - } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutTest.java index 34cb51bbbba..38e9a5ec3fd 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutTest.java @@ -66,6 +66,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 telemetry hooks, with capture at each output boundary. @@ -180,6 +181,68 @@ void environmentDefaultsToDisabled(final String value) throws Exception { assertEquals(1, exposures.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 Allocation experiment = + Allocation.fromInstants( + "experiment", + null, + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 7)), + true, + 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<>(); + variants.put("on", new Variant("on", 1)); + variants.put("off", new Variant("off", 2)); + final Flag flag = + new Flag("flag", true, ValueType.INTEGER, variants, asList(experiment, ordinary)); + FeatureFlaggingGateway.dispatch( + new ServerConfiguration("", "", false, null, singletonMap("flag", flag))); + final Provider.Options options = + new Provider.Options() + .experimentationOptOutMode(mode) + .enableEvaluationTelemetryOptOutParameter(suppressTelemetry); + api.setProviderAndWait(new Provider(options, new DDEvaluator(() -> {}, mode), true)); + + final FlagEvaluationDetails details = + api.getClient() + .getIntegerDetails( + "flag", + 0, + new MutableContext("subject-1") + .add(PARAMETER, true) + .add(ExperimentationOptOut.ATTRIBUTE, "2026-10-19T12:00:00Z")); + + assertNull(details.getErrorCode()); + assertEquals(excludeExperimentation ? 2 : 1, details.getValue()); + assertEquals(excludeExperimentation ? 0 : 1, exposures.size()); + assertEquals(suppressTelemetry ? 0 : 1, events.size()); + assertEquals(suppressTelemetry ? 0 : 1, metrics.get()); + // Without the evaluator's span gate, only logged allocations carry serial-ID metadata. + assertEquals(suppressTelemetry || excludeExperimentation ? 0 : 1, spans.size()); + } + @Test void explicitFalseOverridesEnabledEnvironment() throws Exception { configuredEnvironmentValue = "true"; diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExperimentationOptOutConformanceTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExperimentationOptOutConformanceTest.java new file mode 100644 index 00000000000..a790f6e156e --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExperimentationOptOutConformanceTest.java @@ -0,0 +1,203 @@ +package datadog.trace.api.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; +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.squareup.moshi.Moshi; +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.security.MessageDigest; +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; + +/** Runs pinned Lean policy cases through the production parser and evaluator. */ +class ExperimentationOptOutConformanceTest { + 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("java-policy-v1-cases.json")); + assertEquals("289a5d2dca4a7e5dd1e0428b4c56e9c1660668375aab67b71c91b27b9391f4e2", sha256(bytes)); + final List> cases = + (List>) + new Moshi.Builder().build().adapter(List.class).fromJson(new String(bytes, UTF_8)); + assertEquals(90, cases.size()); + final byte[] profileBytes = Files.readAllBytes(fixture("java-policy-v1.json")); + assertEquals( + "ad281f827a0e57a0aa4528157d5ab4a967bc0233cbae5d563f7408fe9f4017ff", sha256(profileBytes)); + final Map profile = + (Map) + new Moshi.Builder() + .build() + .adapter(Map.class) + .fromJson(new String(profileBytes, UTF_8)); + final Set ids = new HashSet<>(); + for (final Map testCase : cases) { + assertTrue(ids.add(testCase.get("id")), "duplicate policy case"); + } + assertEquals(new HashSet<>((List) profile.get("included_ids")), ids); + 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()); + } + + 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(() -> {}, mode); + if (json != null) { + evaluator.accept(ProductionConfiguration.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 pinned experimentation opt-out fixture " + name); + } + + private static String sha256(final byte[] bytes) throws Exception { + final StringBuilder hash = new StringBuilder(); + for (final byte value : MessageDigest.getInstance("SHA-256").digest(bytes)) { + hash.append(String.format(Locale.ROOT, "%02x", value & 0xff)); + } + return hash.toString(); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExperimentationOptOutTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExperimentationOptOutTest.java new file mode 100644 index 00000000000..0d4f1dedb33 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExperimentationOptOutTest.java @@ -0,0 +1,225 @@ +package datadog.trace.api.openfeature; + +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 static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.featureflag.ufc.v1.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)); + 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)); + 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 = + Allocation.fromInstants( + "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)); + } + + @Test + void probesActivationSupportWithoutRequiringItOnOlderAgents() { + assertTrue(ExperimentationOptOut.supportsActivation(Allocation.class)); + assertFalse(ExperimentationOptOut.supportsActivation(Object.class)); + assertFalse(ExperimentationOptOut.supportsActivation(WrongActivationType.class)); + assertTrue(ExperimentationOptOut.supportsInvalidActivation(Allocation.class)); + assertFalse(ExperimentationOptOut.supportsInvalidActivation(Object.class)); + assertFalse(ExperimentationOptOut.supportsInvalidActivation(WrongActivationType.class)); + assertTrue(ExperimentationOptOut.supportsActivation(PreviousActivationType.class)); + assertFalse(ExperimentationOptOut.supportsInvalidActivation(PreviousActivationType.class)); + } + + public static class PreviousActivationType { + public Instant randomizationActivatedAt; + } + + public static class WrongActivationType { + public String randomizationActivatedAt; + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookWithoutAgentTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookWithoutAgentTest.java index efd42915b6d..aed42721a26 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookWithoutAgentTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookWithoutAgentTest.java @@ -21,6 +21,15 @@ */ class ExposureHookWithoutAgentTest { + @Test + void providerConstructionWorksWithoutAgentConfiguration() throws Exception { + final ClassLoader loader = new AgentlessClassLoader(getClass().getClassLoader()); + final Class providerClass = + Class.forName("datadog.trace.api.openfeature.Provider", true, loader); + final Object provider = providerClass.getConstructor().newInstance(); + providerClass.getMethod("shutdown").invoke(provider); + } + @Test void exposureHookWorksWithoutTheAgentClasses() throws Exception { final ClassLoader loader = new AgentlessClassLoader(getClass().getClassLoader()); @@ -64,11 +73,11 @@ protected Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { if (name.startsWith("datadog.trace.api.featureflag.") + || name.startsWith("datadog.trace.bootstrap.config.provider.") || name.startsWith("datadog.trace.api.openfeature.DDEvaluator")) { throw new ClassNotFoundException(name); } - if (name.startsWith("datadog.trace.api.openfeature.ExposureHook") - || name.equals("datadog.trace.api.openfeature.SplitFeatureMetadata")) { + if (name.startsWith("datadog.trace.api.openfeature.")) { Class loaded = findLoadedClass(name); if (loaded == null) { final byte[] bytes = classBytes(name); diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProductionConfiguration.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProductionConfiguration.java new file mode 100644 index 00000000000..78a1f4e6f6c --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProductionConfiguration.java @@ -0,0 +1,24 @@ +package datadog.trace.api.openfeature; + +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +/** Reads test input through the package-private production parser. */ +final class ProductionConfiguration { + private ProductionConfiguration() {} + + static ServerConfiguration parse(final byte[] bytes) throws IOException { + try { + final Class parser = Class.forName("com.datadog.featureflag.UniversalFlagConfigParser"); + final Field instance = parser.getDeclaredField("INSTANCE"); + instance.setAccessible(true); + final Method deserialize = parser.getDeclaredMethod("deserialize", byte[].class); + deserialize.setAccessible(true); + return (ServerConfiguration) deserialize.invoke(instance.get(null), (Object) bytes); + } catch (ReflectiveOperationException error) { + throw new IOException("Cannot invoke the production configuration parser", error); + } + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java index b572e7e5aff..fc38511c25b 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java @@ -12,6 +12,12 @@ public 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; + private final transient Instant preciseStartAt; private final transient Instant preciseEndAt; @@ -30,7 +36,9 @@ public Allocation( splits, doLog, startAt == null ? null : startAt.toInstant(), - endAt == null ? null : endAt.toInstant()); + endAt == null ? null : endAt.toInstant(), + null, + false); } private Allocation( @@ -41,13 +49,17 @@ private Allocation( final List splits, final Boolean doLog, final Instant preciseStartAt, - final Instant preciseEndAt) { + final Instant preciseEndAt, + 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; this.preciseStartAt = preciseStartAt; this.preciseEndAt = preciseEndAt; } @@ -59,6 +71,29 @@ public static Allocation fromInstants( final Instant endAt, final List splits, final Boolean doLog) { + return fromInstants(key, rules, startAt, endAt, splits, doLog, null); + } + + public static Allocation fromInstants( + final String key, + final List rules, + final Instant startAt, + final Instant endAt, + final List splits, + final Boolean doLog, + final Instant randomizationActivatedAt) { + return fromInstants(key, rules, startAt, endAt, splits, doLog, randomizationActivatedAt, false); + } + + public static Allocation fromInstants( + final String key, + final List rules, + final Instant startAt, + final Instant endAt, + final List splits, + final Boolean doLog, + final Instant randomizationActivatedAt, + final boolean invalidRandomizationActivation) { return new Allocation( key, rules, @@ -67,7 +102,9 @@ public static Allocation fromInstants( splits, doLog, startAt, - endAt); + endAt, + randomizationActivatedAt, + invalidRandomizationActivation); } public Instant startAtInstant() { diff --git a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java index d95088b20c9..5fd08c48e9a 100644 --- a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java +++ b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java @@ -2,6 +2,9 @@ public class FeatureFlaggingConfig { + public static final String FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE = + "feature.flags.experimentation.opt.out.mode"; + public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java index 27fd3f70c70..21d9c984d16 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java @@ -604,13 +604,18 @@ public Allocation fromJson(@Nonnull final JsonReader reader) throws IOException if (allocation == null) { return null; } + final Object activation = allocation.randomizationActivatedAt; + final Instant parsedActivation = + activation instanceof String ? parseInstant((String) activation) : null; return Allocation.fromInstants( allocation.key, allocation.rules, allocation.startAt, allocation.endAt, allocation.splits, - allocation.doLog); + allocation.doLog, + parsedActivation, + activation != null && parsedActivation == null); } @Override @@ -627,6 +632,7 @@ static final class AllocationJson { Instant endAt; List splits; Boolean doLog; + Object randomizationActivatedAt; } @Nullable diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RandomizationActivationTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RandomizationActivationTest.java new file mode 100644 index 00000000000..f37fe0d9a21 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RandomizationActivationTest.java @@ -0,0 +1,57 @@ +package com.datadog.featureflag; + +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 datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +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 == null || 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 == null || 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.INSTANCE.deserialize(json.getBytes(UTF_8)); + } +}