From 7732d049fcd4a0114449648cd09c380d7622aee2 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 19 Oct 2026 23:10:55 -0400 Subject: [PATCH 1/8] Support immediate per-subject experimentation opt-out --- .../src/test/resources/ffe-system-test-data | 2 +- metadata/supported-configurations.json | 8 ++ .../feature-flagging-api/README.md | 35 ++++++ .../trace/api/openfeature/DDEvaluator.java | 18 +++ .../openfeature/ExperimentationOptOut.java | 74 ++++++++++++ .../ExperimentationOptOutMode.java | 14 +++ .../trace/api/openfeature/Provider.java | 19 ++- .../api/openfeature/DDEvaluatorTest.java | 48 ++++++-- .../ExperimentationOptOutTest.java | 109 ++++++++++++++++++ .../config/FeatureFlaggingConfig.java | 3 + 10 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExperimentationOptOut.java create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExperimentationOptOutMode.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExperimentationOptOutTest.java 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..71bb9069b84 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 71bb9069b8472b98570efca32a0c2c0b14dfffe2 diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 45e12e44c85..faafc0537ec 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -1604,6 +1604,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 7afc0cd0359..fec3753080b 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -202,3 +202,38 @@ 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 + +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(athleteId); +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. +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. 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..47a3cb41704 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,14 @@ public ProviderEvaluation evaluate( return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); } + final boolean excludeExperiments; + try { + excludeExperiments = ExperimentationOptOut.exclude(experimentationOptOutMode, context); + } catch (final IllegalArgumentException e) { + return error( + defaultValue, ErrorCode.INVALID_CONTEXT, e.getMessage(), observeFullEvaluationData); + } + final Flag flag = config.flags.get(key); if (flag == null) { if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { @@ -305,6 +320,9 @@ public ProviderEvaluation evaluate( final String targetingKey = context.getTargetingKey(); for (final Allocation allocation : flag.allocations) { + if (excludeExperiments && !Boolean.FALSE.equals(allocation.doLog)) { + 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..5d69054c862 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExperimentationOptOut.java @@ -0,0 +1,74 @@ +package datadog.trace.api.openfeature; + +import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; +import datadog.trace.bootstrap.config.provider.ConfigProvider; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.Value; +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class ExperimentationOptOut { + static final String ATTRIBUTE = "dd_experimentation_opt_out_at"; + private static final Pattern TIMESTAMP = + Pattern.compile( + "([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})" + + "(?:\\.[0-9]+)?(?:Z|[+-]([0-9]{2}):([0-9]{2}))"); + + private ExperimentationOptOut() {} + + static ExperimentationOptOutMode resolve(final ExperimentationOptOutMode override) { + Objects.requireNonNull(override, "experimentationOptOutMode"); + if (override != ExperimentationOptOutMode.DEFAULT) { + return override; + } + final String configured = + ConfigProvider.getInstance() + .getString( + FeatureFlaggingConfig.FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE, "disabled"); + switch (configured.trim().toLowerCase(Locale.ROOT)) { + case "": + case "disabled": + return ExperimentationOptOutMode.DISABLED; + case "immediate": + return ExperimentationOptOutMode.IMMEDIATE; + default: + throw new IllegalArgumentException( + "DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE must be disabled or immediate"); + } + } + + static boolean exclude(final ExperimentationOptOutMode mode, final EvaluationContext context) { + if (mode != ExperimentationOptOutMode.IMMEDIATE || !context.keySet().contains(ATTRIBUTE)) { + return false; + } + final Value value = context.getValue(ATTRIBUTE); + if (value != null && value.isString() && validTimestamp(value.asString())) { + return true; + } + throw new IllegalArgumentException(ATTRIBUTE + " must be an RFC3339 timestamp string"); + } + + private static boolean validTimestamp(final String value) { + final Matcher match = TIMESTAMP.matcher(value); + if (!match.matches()) { + return false; + } + try { + LocalDateTime.of( + Integer.parseInt(match.group(1)), + Integer.parseInt(match.group(2)), + Integer.parseInt(match.group(3)), + Integer.parseInt(match.group(4)), + Integer.parseInt(match.group(5)), + Integer.parseInt(match.group(6))); + return match.group(7) == null + || (Integer.parseInt(match.group(7)) < 24 && Integer.parseInt(match.group(8)) < 60); + } catch (final DateTimeException e) { + return false; + } + } +} 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..4e4f6c5d276 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExperimentationOptOutMode.java @@ -0,0 +1,14 @@ +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 +} 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 436f4facb4a..5644163f260 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 @@ -33,6 +33,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; @@ -64,6 +65,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; @@ -222,8 +225,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 @@ -314,6 +319,16 @@ public static class Options { private long timeout; private TimeUnit unit; + private ExperimentationOptOutMode experimentationOptOutMode = ExperimentationOptOutMode.DEFAULT; + + /** + * 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; + } public Options initTimeout(final long timeout, final TimeUnit unit) { this.timeout = timeout; 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..cb401d5b75d 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 @@ -1015,28 +1015,55 @@ 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(18, cases.size(), "The immediate 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 Object expectedValue = mapFixtureValue(targetType, expected.value); final ProviderEvaluation details = evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); 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))); + } 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")))); } } @@ -1227,6 +1254,7 @@ private static final class FixtureCase { Object defaultValue; String flag; FixtureResult result; + Map capabilities = emptyMap(); String targetingKey; String variationType; transient String fileName; 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..2d420a62777 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExperimentationOptOutTest.java @@ -0,0 +1,109 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.Value; +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; + +class ExperimentationOptOutTest { + private static final String PROPERTY = "dd.feature.flags.experimentation.opt.out.mode"; + + @ParameterizedTest + @ValueSource(strings = {"", "disabled", "immediate", " IMMEDIATE ", "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 + }) { + 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 + : 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.123456789123Z", + "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 validTimestampExcludesImmediately(final String timestamp) { + assertTrue( + ExperimentationOptOut.exclude( + 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)); + assertThrows( + IllegalArgumentException.class, + () -> ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + assertFalse(ExperimentationOptOut.exclude(ExperimentationOptOutMode.DISABLED, context)); + } + + @Test + void missingAttributePreservesParticipation() { + assertFalse( + ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, new MutableContext())); + } + + @Test + void nullProgrammaticModeIsRejected() { + assertThrows( + NullPointerException.class, () -> new Provider.Options().experimentationOptOutMode(null)); + } +} 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 a8897c412d9..93cc5ccf07f 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"; From 71c521aaf8edaf8823a6400e79a90e95f3c6b793 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 19 Oct 2026 23:18:59 -0400 Subject: [PATCH 2/8] Preserve provider construction without the Java agent --- .../openfeature/ExperimentationOptOut.java | 20 +++++++++++++++---- .../ExposureHookWithoutAgentTest.java | 13 ++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) 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 index 5d69054c862..e92a0e633ee 100644 --- 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 @@ -25,10 +25,7 @@ static ExperimentationOptOutMode resolve(final ExperimentationOptOutMode overrid if (override != ExperimentationOptOutMode.DEFAULT) { return override; } - final String configured = - ConfigProvider.getInstance() - .getString( - FeatureFlaggingConfig.FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE, "disabled"); + final String configured = configuredMode(); switch (configured.trim().toLowerCase(Locale.ROOT)) { case "": case "disabled": @@ -41,6 +38,21 @@ static ExperimentationOptOutMode resolve(final ExperimentationOptOutMode overrid } } + 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. + 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 boolean exclude(final ExperimentationOptOutMode mode, final EvaluationContext context) { if (mode != ExperimentationOptOutMode.IMMEDIATE || !context.keySet().contains(ATTRIBUTE)) { return false; 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); From c3846c946abcf539338caa3dc2f40599079c4ede Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 19 Oct 2026 23:55:07 -0400 Subject: [PATCH 3/8] Keep no-agent configuration fallback independent of agent classes --- .../api/openfeature/ExperimentationOptOut.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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 index e92a0e633ee..007d9c08479 100644 --- 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 @@ -2,6 +2,7 @@ import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; 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; @@ -44,15 +45,20 @@ private static String configuredMode() { .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. - 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"; + 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 boolean exclude(final ExperimentationOptOutMode mode, final EvaluationContext context) { if (mode != ExperimentationOptOutMode.IMMEDIATE || !context.keySet().contains(ATTRIBUTE)) { return false; From bcbaa28ddd594d7fd8ddc5a9239d7bfe719b7dae Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 20 Oct 2026 11:13:24 -0400 Subject: [PATCH 4/8] Verify independent opt-out controls after telemetry merge --- .../trace/api/openfeature/EvaluationTelemetryOptOutTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 ba6760f494f..c988658446d 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 @@ -235,7 +235,10 @@ void experimentationAndEvaluationTelemetryControlsRemainIndependent( assertNull(details.getErrorCode()); assertEquals(excludeExperimentation ? 2 : 1, details.getValue()); assertEquals(excludeExperimentation ? 0 : 1, exposures.size()); - assertTelemetryCounts(suppressTelemetry ? 0 : 1); + 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 From 00ad1fda57a07f552874258b17b3d8ed0f19b5c4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 20 Oct 2026 18:17:53 -0400 Subject: [PATCH 5/8] fix: align immediate opt-out with allocation timestamps and shared fixtures --- .../src/test/resources/ffe-system-test-data | 2 +- .../feature-flagging-api/README.md | 1 + .../trace/api/openfeature/DDEvaluator.java | 10 ++- .../openfeature/ExperimentationOptOut.java | 31 +++----- .../api/openfeature/DDEvaluatorTest.java | 74 +++++++++++++++++-- .../ExperimentationOptOutTest.java | 47 +++++++++++- 6 files changed, 130 insertions(+), 35 deletions(-) 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 71bb9069b84..52e43dc2693 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 71bb9069b8472b98570efca32a0c2c0b14dfffe2 +Subproject commit 52e43dc2693e5af14fd60210f9471d0dc08ce73f diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 5138c4a96fa..095dc441710 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -273,6 +273,7 @@ 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. 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 47a3cb41704..f3844230bcc 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 @@ -283,8 +283,14 @@ public ProviderEvaluation evaluate( try { excludeExperiments = ExperimentationOptOut.exclude(experimentationOptOutMode, context); } catch (final IllegalArgumentException e) { - return error( - defaultValue, ErrorCode.INVALID_CONTEXT, e.getMessage(), observeFullEvaluationData); + // 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); 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 index 007d9c08479..4ee6a988334 100644 --- 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 @@ -6,18 +6,17 @@ import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.Value; import java.time.DateTimeException; -import java.time.LocalDateTime; +import java.time.Instant; +import java.time.format.DateTimeFormatter; import java.util.Locale; import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; final class ExperimentationOptOut { static final String ATTRIBUTE = "dd_experimentation_opt_out_at"; - private static final Pattern TIMESTAMP = - Pattern.compile( - "([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})" - + "(?:\\.[0-9]+)?(?:Z|[+-]([0-9]{2}):([0-9]{2}))"); + 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() {} @@ -67,24 +66,14 @@ static boolean exclude(final ExperimentationOptOutMode mode, final EvaluationCon if (value != null && value.isString() && validTimestamp(value.asString())) { return true; } - throw new IllegalArgumentException(ATTRIBUTE + " must be an RFC3339 timestamp string"); + throw new IllegalArgumentException(INVALID_TIMESTAMP); } private static boolean validTimestamp(final String value) { - final Matcher match = TIMESTAMP.matcher(value); - if (!match.matches()) { - return false; - } try { - LocalDateTime.of( - Integer.parseInt(match.group(1)), - Integer.parseInt(match.group(2)), - Integer.parseInt(match.group(3)), - Integer.parseInt(match.group(4)), - Integer.parseInt(match.group(5)), - Integer.parseInt(match.group(6))); - return match.group(7) == null - || (Integer.parseInt(match.group(7)) < 24 && Integer.parseInt(match.group(8)) < 60); + // Use the allocation startAt/endAt parser's timestamp contract. + DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(value, Instant::from); + return true; } catch (final DateTimeException e) { return false; } 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 cb401d5b75d..c275f02321b 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 @@ -639,6 +639,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")); @@ -1032,7 +1056,7 @@ private static List immediateOptOutTestCases() throws IOException { canonicalTestCases().stream() .filter(testCase -> testCase.capabilities.containsKey("experimentationOptOutImmediate")) .collect(Collectors.toList()); - assertEquals(18, cases.size(), "The immediate opt-out capability fixtures must execute"); + assertEquals(26, cases.size(), "The immediate opt-out capability fixtures must execute"); return cases; } @@ -1047,8 +1071,14 @@ private void evaluateCanonicalFixture( final Class targetType = targetType(testCase.variationType); final Object defaultValue = mapFixtureValue(targetType, testCase.defaultValue); final Object expectedValue = mapFixtureValue(targetType, expected.value); - final ProviderEvaluation details = - evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); + 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(expected.reason)); @@ -1057,6 +1087,9 @@ private void evaluateCanonicalFixture( } 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()); } @@ -1067,14 +1100,25 @@ private void evaluateCanonicalFixture( } } - @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); + 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); } private static ServerConfiguration loadCanonicalConfiguration() throws IOException { @@ -1235,7 +1279,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) { 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 index 2d420a62777..b3c4ab2069e 100644 --- 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 @@ -7,6 +7,9 @@ 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; @@ -54,7 +57,7 @@ void providerConfigurationPrecedence(final String configured) { @ValueSource( strings = { "2026-10-19T15:30:00Z", - "2026-10-19T15:30:00.123456789123Z", + "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", @@ -89,9 +92,11 @@ static Stream invalidValues() { void invalidValuesAreRejectedOnlyWhenEnabled(final Value value) { final MutableContext context = new MutableContext(Collections.singletonMap(ExperimentationOptOut.ATTRIBUTE, value)); - assertThrows( - IllegalArgumentException.class, - () -> ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + final IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + assertEquals(ExperimentationOptOut.INVALID_TIMESTAMP, error.getMessage()); assertFalse(ExperimentationOptOut.exclude(ExperimentationOptOutMode.DISABLED, context)); } @@ -101,6 +106,40 @@ void missingAttributePreservesParticipation() { ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, new MutableContext())); } + @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) { + assertTrue(ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + } else { + assertThrows( + IllegalArgumentException.class, + () -> ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + } + } + @Test void nullProgrammaticModeIsRejected() { assertThrows( From f483c71299f1c0b14c132c8f487b35d3e9c7b93e Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 21 Oct 2026 18:11:52 -0400 Subject: [PATCH 6/8] Support immediate experimentation opt-out in the standalone provider --- dd-openfeature/README.md | 43 +++++ .../ExperimentationOptOutBenchmark.java | 100 +++++++++++ .../com/datadog/openfeature/DDEvaluator.java | 12 +- .../ExperimentationOptOutMode.java | 14 ++ .../com/datadog/openfeature/Provider.java | 19 ++- .../openfeature/internal/config/Settings.java | 6 + .../evaluation/ExperimentationOptOut.java | 60 +++++++ .../internal/evaluation/FlagEvaluator.java | 27 +++ .../openfeature/CanonicalFixtureTest.java | 86 +++++++++- .../EvaluationTelemetryOptOutTest.java | 63 ++++++- .../internal/config/SettingsTest.java | 23 +++ .../evaluation/ExperimentationOptOutTest.java | 156 ++++++++++++++++++ .../feature-flagging-api/README.md | 6 +- 13 files changed, 603 insertions(+), 12 deletions(-) create mode 100644 dd-openfeature/README.md create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/ExperimentationOptOutBenchmark.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/ExperimentationOptOutMode.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOut.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOutTest.java diff --git a/dd-openfeature/README.md b/dd-openfeature/README.md new file mode 100644 index 00000000000..a4e1d05b9ee --- /dev/null +++ b/dd-openfeature/README.md @@ -0,0 +1,43 @@ +# 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. 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..129bebff37c --- /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"}) + 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.IMMEDIATE && 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..1ab4011f1b1 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExperimentationOptOutMode.java @@ -0,0 +1,14 @@ +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 +} 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..00eb65d225b --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOut.java @@ -0,0 +1,60 @@ +package com.datadog.openfeature.internal.evaluation; + +import com.datadog.openfeature.ExperimentationOptOutMode; +import com.datadog.openfeature.internal.config.Settings; +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.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; + default: + throw new IllegalArgumentException( + "DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE must be disabled or immediate"); + } + } + + /** Returns whether the context requests exclusion; rejects a malformed enabled attribute. */ + static boolean exclude(final ExperimentationOptOutMode mode, final EvaluationContext context) { + if (mode != ExperimentationOptOutMode.IMMEDIATE || !context.keySet().contains(ATTRIBUTE)) { + return false; + } + final Value value = context.getValue(ATTRIBUTE); + if (value != null && value.isString()) { + try { + // Use the allocation startAt/endAt parser's timestamp contract. + DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(value.asString(), Instant::from); + return true; + } catch (final DateTimeException e) { + // Do not expose the supplied context value in the diagnostic. + } + } + throw new IllegalArgumentException(INVALID_TIMESTAMP); + } +} 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..373d319bf3a 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 boolean excludeExperiments; + try { + excludeExperiments = ExperimentationOptOut.exclude(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)) { @@ -126,6 +150,9 @@ public ProviderEvaluation evaluate( final String targetingKey = context.getTargetingKey(); for (final Allocation allocation : flag.allocations) { + if (excludeExperiments && !Boolean.FALSE.equals(allocation.doLog)) { + continue; + } if (!isAllocationActive(allocation, now)) { continue; } 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..7ec68c035f6 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,50 @@ 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); + } + + @SuppressWarnings("unchecked") + static List immediateOptOutTestCases() 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("experimentationOptOutImmediate")) { + final Map json = new java.util.LinkedHashMap<>(testCase.json); + json.put("result", capabilities.get("experimentationOptOutImmediate")); + cases.add(new FixtureCase(testCase.fileName, testCase.index, json)); + } + } + assertEquals(26, cases.size(), "The immediate 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 +99,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 +112,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 +232,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 +248,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..8f75bf477e4 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,60 @@ void missingConfigurationKeepsTheDefaultWithoutEmittingTelemetry() throws Except assertEquals(1, this.customerEvaluations.size()); } + @TableTest({ + "scenario | exclude experimentation | suppress telemetry", + "neither | false | false ", + "telemetry only | false | true ", + "experiments only | true | false ", + "both | true | true " + }) + void experimentationAndEvaluationTelemetryControlsRemainIndependent( + final boolean excludeExperimentation, final boolean suppressTelemetry) throws Exception { + final ExperimentationOptOutMode mode = + excludeExperimentation + ? ExperimentationOptOutMode.IMMEDIATE + : ExperimentationOptOutMode.DISABLED; + final ServerConfiguration configuration = defaultConfiguration(); + final Flag flag = configuration.flags.get("flag"); + 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(flag.allocations.get(0), 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 +764,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/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..e20aea3d536 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOutTest.java @@ -0,0 +1,156 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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 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; + +class ExperimentationOptOutTest { + private static final String PROPERTY = "dd.feature.flags.experimentation.opt.out.mode"; + + @ParameterizedTest + @ValueSource(strings = {"", "disabled", "immediate", " IMMEDIATE ", "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 + }) { + 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 + : 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 validTimestampExcludesImmediately(final String timestamp) { + assertTrue( + ExperimentationOptOut.exclude( + 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)); + final IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + assertEquals(ExperimentationOptOut.INVALID_TIMESTAMP, error.getMessage()); + assertFalse(ExperimentationOptOut.exclude(ExperimentationOptOutMode.DISABLED, context)); + } + + @Test + void missingAttributePreservesParticipation() { + assertFalse( + ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, new MutableContext())); + } + + @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) { + assertTrue(ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + } else { + assertThrows( + IllegalArgumentException.class, + () -> ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + } + } + + @Test + void nullProgrammaticModeIsRejected() { + assertThrows( + NullPointerException.class, () -> new Provider.Options().experimentationOptOutMode(null)); + } +} diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 095dc441710..f431d272a49 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -256,6 +256,10 @@ This setting stops exposures only. Flag evaluation events have their own setting ## 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 @@ -267,7 +271,7 @@ Pass the subject's stored preference time with each evaluation. Omit the attribu ```java OpenFeatureAPI.getInstance().setProviderAndWait(new Provider()); Client client = OpenFeatureAPI.getInstance().getClient(); -MutableContext context = new MutableContext(athleteId); +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); ``` From 660b5b96114cc29f2edc8c806b9a621621ba552e Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 21 Oct 2026 18:35:04 -0400 Subject: [PATCH 7/8] Support future-experiments-only opt-out --- dd-openfeature/README.md | 20 ++++ .../ExperimentationOptOutBenchmark.java | 4 +- .../ExperimentationOptOutMode.java | 8 +- .../evaluation/ExperimentationOptOut.java | 32 ++++-- .../internal/evaluation/FlagEvaluator.java | 7 +- .../openfeature/internal/ufc/Allocation.java | 15 +++ .../ufc/UniversalFlagConfigParser.java | 15 ++- .../openfeature/CanonicalFixtureTest.java | 23 +++- .../evaluation/ExperimentationOptOutTest.java | 90 +++++++++++++--- .../ufc/RandomizationActivationTest.java | 54 ++++++++++ .../src/test/resources/ffe-system-test-data | 2 +- .../feature-flagging-api/README.md | 21 ++++ .../trace/api/openfeature/DDEvaluator.java | 7 +- .../openfeature/ExperimentationOptOut.java | 49 +++++++-- .../ExperimentationOptOutMode.java | 8 +- .../api/openfeature/DDEvaluatorTest.java | 36 +++++++ .../ExperimentationOptOutTest.java | 100 +++++++++++++++--- .../api/featureflag/ufc/v1/Allocation.java | 24 ++++- .../UniversalFlagConfigParser.java | 18 +++- .../RandomizationActivationTest.java | 55 ++++++++++ 20 files changed, 517 insertions(+), 71 deletions(-) create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/RandomizationActivationTest.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RandomizationActivationTest.java diff --git a/dd-openfeature/README.md b/dd-openfeature/README.md index a4e1d05b9ee..e46d87c0388 100644 --- a/dd-openfeature/README.md +++ b/dd-openfeature/README.md @@ -41,3 +41,23 @@ The provider resolves the mode at construction. Unsupported values throw `Illega 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 rejects the affected flag with `PARSE_ERROR` in every mode. +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 index 129bebff37c..a912f51e492 100644 --- a/dd-openfeature/src/jmh/java/com/datadog/openfeature/ExperimentationOptOutBenchmark.java +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/ExperimentationOptOutBenchmark.java @@ -43,7 +43,7 @@ @OutputTimeUnit(NANOSECONDS) @Fork(1) public class ExperimentationOptOutBenchmark { - @Param({"DISABLED", "IMMEDIATE"}) + @Param({"DISABLED", "IMMEDIATE", "FUTURE_EXPERIMENTS_ONLY"}) public ExperimentationOptOutMode mode; @Param({"false", "true"}) @@ -74,7 +74,7 @@ public void setUp() { context.add("dd_experimentation_opt_out_at", "2026-10-19T12:00:00Z"); } final ProviderEvaluation result = evaluate(); - final boolean excluded = mode == ExperimentationOptOutMode.IMMEDIATE && optedOut; + final boolean excluded = mode != ExperimentationOptOutMode.DISABLED && optedOut; if (result.getErrorCode() != null || result.getValue() != excluded || !(excluded ? "ordinary" : "experiment") diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/ExperimentationOptOutMode.java b/dd-openfeature/src/main/java/com/datadog/openfeature/ExperimentationOptOutMode.java index 1ab4011f1b1..683c45929ea 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExperimentationOptOutMode.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExperimentationOptOutMode.java @@ -10,5 +10,11 @@ public enum ExperimentationOptOutMode { * Skips exposure-logging allocations when dd_experimentation_opt_out_at is an RFC3339 timestamp. * Applies immediately, including to experiments already in progress. */ - IMMEDIATE + 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/internal/evaluation/ExperimentationOptOut.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ExperimentationOptOut.java index 00eb65d225b..28f89a12707 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -34,27 +35,44 @@ public static ExperimentationOptOutMode resolve( 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 or immediate"); + "DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE must be disabled, immediate, or future_experiments_only"); } } - /** Returns whether the context requests exclusion; rejects a malformed enabled attribute. */ - static boolean exclude(final ExperimentationOptOutMode mode, final EvaluationContext context) { - if (mode != ExperimentationOptOutMode.IMMEDIATE || !context.keySet().contains(ATTRIBUTE)) { - return false; + /** 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. - DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(value.asString(), Instant::from); - return true; + 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); } + + /** 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.FALSE.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 373d319bf3a..9af9fe0862f 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 @@ -103,9 +103,9 @@ public ProviderEvaluation evaluate( return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); } - final boolean excludeExperiments; + final Instant experimentationCutoff; try { - excludeExperiments = ExperimentationOptOut.exclude(experimentationOptOutMode, context); + experimentationCutoff = ExperimentationOptOut.cutoff(experimentationOptOutMode, context); } catch (final IllegalArgumentException e) { // This diagnostic is fixed SDK text. It contains no customer context values. return ProviderEvaluation.builder() @@ -150,7 +150,8 @@ public ProviderEvaluation evaluate( final String targetingKey = context.getTargetingKey(); for (final Allocation allocation : flag.allocations) { - if (excludeExperiments && !Boolean.FALSE.equals(allocation.doLog)) { + if (ExperimentationOptOut.excludes( + experimentationOptOutMode, experimentationCutoff, allocation)) { continue; } if (!isAllocationActive(allocation, now)) { 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..a75101ce797 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,9 @@ 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; + public Allocation( final String key, final List rules, @@ -18,11 +21,23 @@ 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 = key; this.rules = rules; this.startAt = startAt; this.endAt = endAt; this.splits = splits; this.doLog = doLog; + this.randomizationActivatedAt = randomizationActivatedAt; } } 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..8bc213e4d63 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 @@ -241,7 +241,20 @@ private static Allocation toAllocation(@Nullable final Object value) { toInstant(json.get("startAt")), toInstant(json.get("endAt")), toList(json.get("splits"), "splits", UniversalFlagConfigParser::toSplit), - lenientBoolean(json.get("doLog"))); + lenientBoolean(json.get("doLog")), + toActivation(json.get("randomizationActivatedAt"))); + } + + @Nullable + private static Instant toActivation(@Nullable final Object value) { + if (value == null) { + return null; + } + final Instant instant = value instanceof String ? toInstant(value) : null; + if (instant == null) { + throw new IllegalArgumentException("flag contains an invalid randomizationActivatedAt value"); + } + return instant; } @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 7ec68c035f6..addd195d2a8 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java @@ -56,19 +56,34 @@ void evaluateImmediateOptOutFixture(final FixtureCase testCase) throws IOExcepti evaluateCanonicalFixture(testCase, ExperimentationOptOutMode.IMMEDIATE); } - @SuppressWarnings("unchecked") + @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", 42); + } + static List immediateOptOutTestCases() throws IOException { + return optOutTestCases("experimentationOptOutImmediate", 26); + } + + @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("experimentationOptOutImmediate")) { + if (capabilities != null && capabilities.containsKey(capability)) { final Map json = new java.util.LinkedHashMap<>(testCase.json); - json.put("result", capabilities.get("experimentationOptOutImmediate")); + json.put("result", capabilities.get(capability)); cases.add(new FixtureCase(testCase.fileName, testCase.index, json)); } } - assertEquals(26, cases.size(), "The immediate opt-out capability fixtures must execute"); + assertEquals(expectedCount, cases.size(), "The opt-out capability fixtures must execute"); return cases; } 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 index e20aea3d536..f520ba40658 100644 --- 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 @@ -2,13 +2,15 @@ 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 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; @@ -21,19 +23,31 @@ 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 ", "unsupported"}) + @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.DISABLED, + ExperimentationOptOutMode.IMMEDIATE, + ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY }) { assertEquals( override, ExperimentationOptOut.resolve(override, Settings.load(AgentBridge.NONE))); @@ -45,7 +59,9 @@ void providerConfigurationPrecedence(final String configured) { final ExperimentationOptOutMode expected = configured.trim().equalsIgnoreCase("immediate") ? ExperimentationOptOutMode.IMMEDIATE - : ExperimentationOptOutMode.DISABLED; + : configured.trim().equalsIgnoreCase("future_experiments_only") + ? ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY + : ExperimentationOptOutMode.DISABLED; assertEquals( expected, ExperimentationOptOut.resolve( @@ -71,9 +87,9 @@ void providerConfigurationPrecedence(final String configured) { "9999-12-31T23:59:59Z", "2024-02-29T00:00:00Z" }) - void validTimestampExcludesImmediately(final String timestamp) { - assertTrue( - ExperimentationOptOut.exclude( + void validTimestampProvidesCutoff(final String timestamp) { + assertNotNull( + ExperimentationOptOut.cutoff( ExperimentationOptOutMode.IMMEDIATE, new MutableContext().add(ExperimentationOptOut.ATTRIBUTE, timestamp))); } @@ -100,18 +116,25 @@ static Stream invalidValues() { void invalidValuesAreRejectedOnlyWhenEnabled(final Value value) { final MutableContext context = new MutableContext(Collections.singletonMap(ExperimentationOptOut.ATTRIBUTE, value)); - final IllegalArgumentException error = - assertThrows( - IllegalArgumentException.class, - () -> ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); - assertEquals(ExperimentationOptOut.INVALID_TIMESTAMP, error.getMessage()); - assertFalse(ExperimentationOptOut.exclude(ExperimentationOptOutMode.DISABLED, context)); + 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() { - assertFalse( - ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, new MutableContext())); + 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 @@ -140,14 +163,47 @@ void timestampValidationMatchesAllocationTimestamps(final String timestamp) { final MutableContext context = new MutableContext().add(ExperimentationOptOut.ATTRIBUTE, timestamp); if (accepted) { - assertTrue(ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + assertNotNull(ExperimentationOptOut.cutoff(ExperimentationOptOutMode.IMMEDIATE, context)); } else { assertThrows( IllegalArgumentException.class, - () -> ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + () -> 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( 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..ff229d3b8ed --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/RandomizationActivationTest.java @@ -0,0 +1,54 @@ +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 rejectsMalformedActivationOnlyForAffectedFlag(final String activation) throws Exception { + final ServerConfiguration config = parse(activation); + assertFalse(config.flags.containsKey("target")); + assertEquals("invalid_flag", config.invalidFlags.get("target")); + 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); + } + + @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/resources/ffe-system-test-data b/dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data index 52e43dc2693..b2887620015 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 52e43dc2693e5af14fd60210f9471d0dc08ce73f +Subproject commit b28876200152f00c7d5eb04616f721afa5955c1d diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index f431d272a49..8742d057583 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -293,3 +293,24 @@ Provider provider = new Provider(new Provider.Options() `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 rejects the affected flag with `PARSE_ERROR` in every mode. +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/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 f3844230bcc..2acdd64c826 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 @@ -279,9 +279,9 @@ public ProviderEvaluation evaluate( return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); } - final boolean excludeExperiments; + final Instant experimentationCutoff; try { - excludeExperiments = ExperimentationOptOut.exclude(experimentationOptOutMode, context); + experimentationCutoff = ExperimentationOptOut.cutoff(experimentationOptOutMode, context); } catch (final IllegalArgumentException e) { // This diagnostic is fixed SDK text. It contains no customer context values. return ProviderEvaluation.builder() @@ -326,7 +326,8 @@ public ProviderEvaluation evaluate( final String targetingKey = context.getTargetingKey(); for (final Allocation allocation : flag.allocations) { - if (excludeExperiments && !Boolean.FALSE.equals(allocation.doLog)) { + if (ExperimentationOptOut.excludes( + experimentationOptOutMode, experimentationCutoff, allocation)) { continue; } if (!isAllocationActive(allocation, now)) { 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 index 4ee6a988334..47e8da710e3 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -32,9 +33,11 @@ static ExperimentationOptOutMode resolve(final ExperimentationOptOutMode overrid 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 or immediate"); + "DD_FEATURE_FLAGS_EXPERIMENTATION_OPT_OUT_MODE must be disabled, immediate, or future_experiments_only"); } } @@ -58,24 +61,48 @@ private static String configuredModeWithoutAgent() { return environment != null ? environment : "disabled"; } - static boolean exclude(final ExperimentationOptOutMode mode, final EvaluationContext context) { - if (mode != ExperimentationOptOutMode.IMMEDIATE || !context.keySet().contains(ATTRIBUTE)) { - return false; + 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() && validTimestamp(value.asString())) { - return true; + 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); } - private static boolean validTimestamp(final String value) { + static boolean excludes( + final ExperimentationOptOutMode mode, final Instant cutoff, final Allocation allocation) { + if (cutoff == null) { + return false; + } + if (Boolean.FALSE.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 { - // Use the allocation startAt/endAt parser's timestamp contract. - DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(value, Instant::from); - return true; - } catch (final DateTimeException e) { + return allocationType.getField("randomizationActivatedAt").getType() == Instant.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); + } } 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 index 4e4f6c5d276..594a24c352e 100644 --- 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 @@ -10,5 +10,11 @@ public enum ExperimentationOptOutMode { * Skips exposure-logging allocations when dd_experimentation_opt_out_at is an RFC3339 timestamp. * Applies immediately, including to experiments already in progress. */ - IMMEDIATE + 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/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 c275f02321b..569481eb31d 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 @@ -80,6 +80,7 @@ public class DDEvaluatorTest { private static final Moshi MOSHI = new Moshi.Builder() .add(Date.class, new DateAdapter()) + .add(Instant.class, new InstantAdapter()) .add(ShardAdapter.FACTORY) .add(FlagMapAdapter.FACTORY) .build(); @@ -1060,6 +1061,26 @@ private static List immediateOptOutTestCases() throws IOException { 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(42, cases.size(), "The future opt-out capability fixtures must execute"); + return cases; + } + private void evaluateCanonicalFixture( final FixtureCase testCase, final FixtureResult expected, @@ -1533,6 +1554,21 @@ public void toJson(final JsonWriter writer, final Map value) { } } + private static final class InstantAdapter extends JsonAdapter { + @Override + public Instant fromJson(final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(reader.nextString(), Instant::from); + } + + @Override + public void toJson(final JsonWriter writer, final Instant value) throws IOException { + writer.value(value == null ? null : value.toString()); + } + } + private static final class DateAdapter extends JsonAdapter { @Override public Date fromJson(final JsonReader reader) throws IOException { 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 index b3c4ab2069e..32aa6a81e52 100644 --- 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 @@ -2,9 +2,12 @@ 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; @@ -17,19 +20,31 @@ 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 ", "unsupported"}) + @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.DISABLED, + ExperimentationOptOutMode.IMMEDIATE, + ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY }) { assertEquals(override, ExperimentationOptOut.resolve(override)); new Provider(new Provider.Options().experimentationOptOutMode(override)).shutdown(); @@ -40,7 +55,9 @@ void providerConfigurationPrecedence(final String configured) { final ExperimentationOptOutMode expected = configured.trim().equalsIgnoreCase("immediate") ? ExperimentationOptOutMode.IMMEDIATE - : ExperimentationOptOutMode.DISABLED; + : configured.trim().equalsIgnoreCase("future_experiments_only") + ? ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY + : ExperimentationOptOutMode.DISABLED; assertEquals(expected, ExperimentationOptOut.resolve(ExperimentationOptOutMode.DEFAULT)); new Provider().shutdown(); } @@ -63,9 +80,9 @@ void providerConfigurationPrecedence(final String configured) { "9999-12-31T23:59:59Z", "2024-02-29T00:00:00Z" }) - void validTimestampExcludesImmediately(final String timestamp) { - assertTrue( - ExperimentationOptOut.exclude( + void validTimestampProvidesCutoff(final String timestamp) { + assertNotNull( + ExperimentationOptOut.cutoff( ExperimentationOptOutMode.IMMEDIATE, new MutableContext().add(ExperimentationOptOut.ATTRIBUTE, timestamp))); } @@ -92,18 +109,25 @@ static Stream invalidValues() { void invalidValuesAreRejectedOnlyWhenEnabled(final Value value) { final MutableContext context = new MutableContext(Collections.singletonMap(ExperimentationOptOut.ATTRIBUTE, value)); - final IllegalArgumentException error = - assertThrows( - IllegalArgumentException.class, - () -> ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); - assertEquals(ExperimentationOptOut.INVALID_TIMESTAMP, error.getMessage()); - assertFalse(ExperimentationOptOut.exclude(ExperimentationOptOutMode.DISABLED, context)); + 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() { - assertFalse( - ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, new MutableContext())); + 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 @@ -132,17 +156,61 @@ void timestampValidationMatchesAllocationTimestamps(final String timestamp) { final MutableContext context = new MutableContext().add(ExperimentationOptOut.ATTRIBUTE, timestamp); if (accepted) { - assertTrue(ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + assertNotNull(ExperimentationOptOut.cutoff(ExperimentationOptOutMode.IMMEDIATE, context)); } else { assertThrows( IllegalArgumentException.class, - () -> ExperimentationOptOut.exclude(ExperimentationOptOutMode.IMMEDIATE, context)); + () -> 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)); + } + + public static class WrongActivationType { + public String randomizationActivatedAt; + } } 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..12cbabc1a79 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,9 @@ 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; + private final transient Instant preciseStartAt; private final transient Instant preciseEndAt; @@ -30,7 +33,8 @@ public Allocation( splits, doLog, startAt == null ? null : startAt.toInstant(), - endAt == null ? null : endAt.toInstant()); + endAt == null ? null : endAt.toInstant(), + null); } private Allocation( @@ -41,13 +45,15 @@ private Allocation( final List splits, final Boolean doLog, final Instant preciseStartAt, - final Instant preciseEndAt) { + final Instant preciseEndAt, + final Instant randomizationActivatedAt) { this.key = key; this.rules = rules; this.startAt = startAt; this.endAt = endAt; this.splits = splits; this.doLog = doLog; + this.randomizationActivatedAt = randomizationActivatedAt; this.preciseStartAt = preciseStartAt; this.preciseEndAt = preciseEndAt; } @@ -59,6 +65,17 @@ 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 new Allocation( key, rules, @@ -67,7 +84,8 @@ public static Allocation fromInstants( splits, doLog, startAt, - endAt); + endAt, + randomizationActivatedAt); } public Instant startAtInstant() { 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..4ab54b60201 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 @@ -610,7 +610,8 @@ public Allocation fromJson(@Nonnull final JsonReader reader) throws IOException allocation.startAt, allocation.endAt, allocation.splits, - allocation.doLog); + allocation.doLog, + parseActivation(allocation.randomizationActivatedAt)); } @Override @@ -627,6 +628,21 @@ static final class AllocationJson { Instant endAt; List splits; Boolean doLog; + Object randomizationActivatedAt; + } + + @Nullable + private static Instant parseActivation(@Nullable final Object value) { + if (value == null) { + return null; + } + if (value instanceof String) { + final Instant instant = parseInstant((String) value); + if (instant != null) { + return instant; + } + } + throw new InvalidFlagException("flag contains an invalid randomizationActivatedAt value"); } @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..40e6a2966fa --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RandomizationActivationTest.java @@ -0,0 +1,55 @@ +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 rejectsMalformedActivationOnlyForAffectedFlag(final String activation) throws Exception { + final ServerConfiguration config = parse(activation); + assertFalse(config.flags.containsKey("target")); + assertEquals("invalid_flag", config.invalidFlags.get("target")); + 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); + } + + @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)); + } +} From 578ffa77cbc52e6d5d8950657a05863a449f59ab Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 22 Oct 2026 23:14:03 -0400 Subject: [PATCH 8/8] Fix OpenFeature smoke exposure expectations --- .../OpenFeatureProviderSmokeTest.groovy | 98 +++++++++++++------ 1 file changed, 66 insertions(+), 32 deletions(-) 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) } }