From 468be5fca8f6a8fc01e896f00d209b54050afc78 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 29 Jul 2026 21:37:10 -0600 Subject: [PATCH 01/16] Add reusable Feature Flagging evaluator and CDN source --- .../feature-flagging-api/build.gradle.kts | 52 +++ .../feature-flagging-core/build.gradle.kts | 57 +++ .../feature-flagging-core/gradle.lockfile | 79 +++++ .../internal/core/ApplyResult.java | 8 + .../internal/core/ConfigurationSink.java | 9 + .../internal/core/ConfigurationSnapshot.java | 160 +++++++++ .../internal/core/ConfigurationSource.java | 12 + .../internal/core/ConfigurationStore.java | 96 +++++ .../internal/core/EvaluationContext.java | 59 ++++ .../internal/core/EvaluationResult.java | 86 +++++ .../internal/core/FlagEvaluator.java | 310 +++++++++++++++++ .../internal/core/SourceStatus.java | 10 + .../openfeature/internal/core/UfcParser.java | 307 ++++++++++++++++ .../internal/core/ConfigurationStoreTest.java | 87 +++++ .../openfeature/internal/core/Fixtures.java | 27 ++ .../internal/core/FlagEvaluatorTest.java | 301 ++++++++++++++++ .../internal/core/UfcParserTest.java | 141 ++++++++ .../feature-flagging-http/build.gradle.kts | 48 +++ .../feature-flagging-http/gradle.lockfile | 79 +++++ .../internal/http/CdnConfigurationSource.java | 329 ++++++++++++++++++ .../internal/http/CdnEndpointResolver.java | 41 +++ .../http/HttpConfigurationOptions.java | 72 ++++ .../http/CdnConfigurationSourceTest.java | 188 ++++++++++ .../http/CdnEndpointResolverTest.java | 47 +++ .../http/HttpConfigurationOptionsTest.java | 53 +++ .../internal/http/Java11TransportTest.java | 180 ++++++++++ settings.gradle.kts | 2 + 27 files changed, 2840 insertions(+) create mode 100644 products/feature-flagging/feature-flagging-core/build.gradle.kts create mode 100644 products/feature-flagging/feature-flagging-core/gradle.lockfile create mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ApplyResult.java create mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSink.java create mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSnapshot.java create mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSource.java create mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java create mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java create mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationResult.java create mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java create mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/SourceStatus.java create mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java create mode 100644 products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java create mode 100644 products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/Fixtures.java create mode 100644 products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java create mode 100644 products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java create mode 100644 products/feature-flagging/feature-flagging-http/build.gradle.kts create mode 100644 products/feature-flagging/feature-flagging-http/gradle.lockfile create mode 100644 products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java create mode 100644 products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnEndpointResolver.java create mode 100644 products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/HttpConfigurationOptions.java create mode 100644 products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java create mode 100644 products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnEndpointResolverTest.java create mode 100644 products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/HttpConfigurationOptionsTest.java create mode 100644 products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index 88531ceaeed..277aa5a3ce7 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -1,5 +1,7 @@ import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension import groovy.lang.Closure +import org.gradle.api.tasks.SourceSetContainer +import java.util.zip.ZipFile plugins { `java-library` @@ -42,13 +44,19 @@ java { dependencies { api("dev.openfeature:sdk:1.20.1") + implementation(libs.moshi) + implementation(libs.slf4j) compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap")) compileOnly(project(":products:feature-flagging:feature-flagging-config")) + compileOnly(project(":products:feature-flagging:feature-flagging-core")) + compileOnly(project(":products:feature-flagging:feature-flagging-http")) compileOnly(project(":utils:config-utils")) compileOnly("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) + testImplementation(project(":products:feature-flagging:feature-flagging-core")) + testImplementation(project(":products:feature-flagging:feature-flagging-http")) testImplementation(project(":utils:config-utils")) testImplementation("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(libs.bundles.junit5) @@ -78,8 +86,52 @@ tasks.withType().configureEach { javadocTool = javaToolchains.javadocToolFor(java.toolchain) } +val coreProject = project(":products:feature-flagging:feature-flagging-core") +val httpProject = project(":products:feature-flagging:feature-flagging-http") + +tasks.named("jar") { + from(coreProject.extensions.getByType().named("main").map { it.output }) + from(httpProject.extensions.getByType().named("main").map { it.output }) +} + +tasks.withType().configureEach { + dependsOn(tasks.named("jar")) + systemProperty( + "dd.openfeature.test.jar", + tasks.named("jar").get().archiveFile.get().asFile.absolutePath + ) +} + // The dd-openfeature provider jar is not produced by the CI `build` job, so there is no reference // artifact to compare against. Disable the release jar comparison gate registered by publish.gradle. tasks.named("compareToReferenceJar") { enabled = false } + +tasks.register("verifyDdOpenfeatureArtifact") { + dependsOn(tasks.named("jar"), tasks.named("generatePomFileForMavenPublication")) + doLast { + val providerJar = tasks.named("jar").get().archiveFile.get().asFile + val requiredEntries = setOf( + "datadog/openfeature/internal/core/ConfigurationStore.class", + "datadog/openfeature/internal/core/FlagEvaluator.class", + "datadog/openfeature/internal/http/CdnConfigurationSource.class", + "datadog/openfeature/internal/http/HttpConfigurationOptions.class" + ) + ZipFile(providerJar).use { zip -> + val entryNames = zip.entries().asSequence().map { it.name }.toSet() + val missing = requiredEntries - entryNames + check(missing.isEmpty()) { + "dd-openfeature is missing embedded standalone classes: $missing" + } + } + + val pom = layout.buildDirectory.file("publications/maven/pom-default.xml").get().asFile + check(pom.isFile) { "Generated dd-openfeature Maven POM is missing" } + val pomText = pom.readText() + check(pomText.contains("sdk")) + check(pomText.contains("moshi")) + check(!pomText.contains("feature-flagging-core")) + check(!pomText.contains("feature-flagging-http")) + } +} diff --git a/products/feature-flagging/feature-flagging-core/build.gradle.kts b/products/feature-flagging/feature-flagging-core/build.gradle.kts new file mode 100644 index 00000000000..5161ecf3a82 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/build.gradle.kts @@ -0,0 +1,57 @@ +import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension +import groovy.lang.Closure + +plugins { + `java-library` +} + +apply(from = "$rootDir/gradle/java.gradle") + +description = "Provider-owned Feature Flagging model, parser, evaluator, and configuration state" + +// Defensive parser and evaluator branches reject malformed customer input. +// The tests cover each supported operation and the main rejection paths. +extra["minimumBranchCoverage"] = 0.7 + +extra["excludedClassesCoverage"] = listOf( + // Immutable data transfer types + "datadog.openfeature.internal.core.ConfigurationSnapshot", + "datadog.openfeature.internal.core.ConfigurationSnapshot.*", + "datadog.openfeature.internal.core.EvaluationResult", + "datadog.openfeature.internal.core.EvaluationResult.*", + "datadog.openfeature.internal.core.ApplyResult", + "datadog.openfeature.internal.core.SourceStatus", +) + +configure { + minJavaVersion.set(JavaVersion.VERSION_11) +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(11) + } +} + +fun AbstractCompile.configureCompiler( + javaVersionInteger: Int, + compatibilityVersion: JavaVersion? = null, + unsetReleaseFlagReason: String? = null +) { + (project.extra["configureCompiler"] as Closure<*>).call( + this, + javaVersionInteger, + compatibilityVersion, + unsetReleaseFlagReason + ) +} + +tasks.withType().configureEach { + configureCompiler(11, JavaVersion.VERSION_11) +} + +dependencies { + implementation(libs.moshi) + + testImplementation(libs.bundles.junit5) +} diff --git a/products/feature-flagging/feature-flagging-core/gradle.lockfile b/products/feature-flagging/feature-flagging-core/gradle.lockfile new file mode 100644 index 00000000000..676e4412a25 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/gradle.lockfile @@ -0,0 +1,79 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :products:feature-flagging:feature-flagging-core:dependencies --write-locks +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,spotbugs +com.github.spotbugs:spotbugs:4.9.8=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.squareup.moshi:moshi:1.11.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-io:commons-io:2.20.0=spotbugs +de.thetaphi:forbiddenapis:3.10=compileClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.12.8=testRuntimeClasspath +net.bytebuddy:byte-buddy:1.12.8=testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.11.0=spotbugs +org.apache.commons:commons-lang3:3.19.0=spotbugs +org.apache.commons:commons-text:1.14.0=spotbugs +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.codehaus.groovy:groovy-ant:3.0.23=codenarc +org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc +org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt +org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt +org.jacoco:org.jacoco.core:0.8.15=jacocoAnt +org.jacoco:org.jacoco.report:0.8.15=jacocoAnt +org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.9=spotbugs +org.ow2.asm:asm-commons:9.10.1=jacocoAnt +org.ow2.asm:asm-commons:9.9=spotbugs +org.ow2.asm:asm-tree:9.10.1=jacocoAnt +org.ow2.asm:asm-tree:9.9=spotbugs +org.ow2.asm:asm-util:9.9=spotbugs +org.ow2.asm:asm:9.10.1=jacocoAnt +org.ow2.asm:asm:9.9=spotbugs +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.32=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ApplyResult.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ApplyResult.java new file mode 100644 index 00000000000..b9a9d67d5d5 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ApplyResult.java @@ -0,0 +1,8 @@ +package datadog.openfeature.internal.core; + +/** Result of applying configuration bytes to the provider-owned configuration state. */ +public enum ApplyResult { + ACCEPTED, + REJECTED, + CLEARED +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSink.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSink.java new file mode 100644 index 00000000000..4d15b40e5f5 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSink.java @@ -0,0 +1,9 @@ +package datadog.openfeature.internal.core; + +/** Accepts raw UFC bytes from a configuration source. */ +public interface ConfigurationSink { + + ApplyResult apply(byte[] content); + + ApplyResult clear(); +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSnapshot.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSnapshot.java new file mode 100644 index 00000000000..d8a3fb01658 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSnapshot.java @@ -0,0 +1,160 @@ +package datadog.openfeature.internal.core; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** Immutable UFC configuration used by the provider-owned evaluator. */ +public final class ConfigurationSnapshot { + + public final String createdAt; + public final String format; + public final String environmentName; + public final Map flags; + + public ConfigurationSnapshot( + final String createdAt, + final String format, + final String environmentName, + final Map flags) { + this.createdAt = createdAt; + this.format = format; + this.environmentName = environmentName; + this.flags = Collections.unmodifiableMap(flags); + } + + public enum ValueType { + BOOLEAN, + INTEGER, + NUMERIC, + STRING, + JSON + } + + public enum ConditionOperator { + LT, + LTE, + GT, + GTE, + MATCHES, + NOT_MATCHES, + ONE_OF, + NOT_ONE_OF, + IS_NULL + } + + public static final class Flag { + public final String key; + public final boolean enabled; + public final ValueType variationType; + public final Map variations; + public final List allocations; + + public Flag( + final String key, + final boolean enabled, + final ValueType variationType, + final Map variations, + final List allocations) { + this.key = key; + this.enabled = enabled; + this.variationType = variationType; + this.variations = Collections.unmodifiableMap(variations); + this.allocations = allocations == null ? null : Collections.unmodifiableList(allocations); + } + } + + public static final class Variant { + public final String key; + public final Object value; + + public Variant(final String key, final Object value) { + this.key = key; + this.value = value; + } + } + + public static final class Allocation { + public final String key; + public final List rules; + public final Long startAtMillis; + public final Long endAtMillis; + public final List splits; + public final boolean doLog; + + public Allocation( + final String key, + final List rules, + final Long startAtMillis, + final Long endAtMillis, + final List splits, + final boolean doLog) { + this.key = key; + this.rules = rules == null ? null : Collections.unmodifiableList(rules); + this.startAtMillis = startAtMillis; + this.endAtMillis = endAtMillis; + this.splits = splits == null ? null : Collections.unmodifiableList(splits); + this.doLog = doLog; + } + } + + public static final class Rule { + public final List conditions; + + public Rule(final List conditions) { + this.conditions = conditions == null ? null : Collections.unmodifiableList(conditions); + } + } + + public static final class Condition { + public final ConditionOperator operator; + public final String attribute; + public final Object value; + + public Condition(final ConditionOperator operator, final String attribute, final Object value) { + this.operator = operator; + this.attribute = attribute; + this.value = value; + } + } + + public static final class Split { + public final List shards; + public final String variationKey; + public final Map extraLogging; + public final Integer serialId; + + public Split( + final List shards, + final String variationKey, + final Map extraLogging, + final Integer serialId) { + this.shards = shards == null ? null : Collections.unmodifiableList(shards); + this.variationKey = variationKey; + this.extraLogging = extraLogging == null ? null : Collections.unmodifiableMap(extraLogging); + this.serialId = serialId; + } + } + + public static final class Shard { + public final String salt; + public final List ranges; + public final int totalShards; + + public Shard(final String salt, final List ranges, final int totalShards) { + this.salt = salt; + this.ranges = Collections.unmodifiableList(ranges); + this.totalShards = totalShards; + } + } + + public static final class ShardRange { + public final int start; + public final int end; + + public ShardRange(final int start, final int end) { + this.start = start; + this.end = end; + } + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSource.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSource.java new file mode 100644 index 00000000000..1de614d36ed --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSource.java @@ -0,0 +1,12 @@ +package datadog.openfeature.internal.core; + +/** Delivers UFC bytes to a {@link ConfigurationSink}. */ +public interface ConfigurationSource extends AutoCloseable { + + void start(); + + SourceStatus status(); + + @Override + void close(); +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java new file mode 100644 index 00000000000..23233fd192a --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java @@ -0,0 +1,96 @@ +package datadog.openfeature.internal.core; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** Thread-safe last-known-good configuration state. */ +public final class ConfigurationStore implements ConfigurationSink { + + private final UfcParser parser; + private final AtomicReference current = new AtomicReference<>(); + private final List> listeners = new CopyOnWriteArrayList<>(); + private final Object changeMonitor = new Object(); + + public ConfigurationStore() { + this(new UfcParser()); + } + + ConfigurationStore(final UfcParser parser) { + this.parser = parser; + } + + @Override + public ApplyResult apply(final byte[] content) { + final ConfigurationSnapshot next; + try { + next = parser.parse(content); + } catch (final IOException | RuntimeException ignored) { + return ApplyResult.REJECTED; + } + current.set(next); + signalChange(next); + return ApplyResult.ACCEPTED; + } + + @Override + public ApplyResult clear() { + current.set(null); + signalChange(null); + return ApplyResult.CLEARED; + } + + public ConfigurationSnapshot current() { + return current.get(); + } + + public boolean hasConfiguration() { + return current.get() != null; + } + + public void addListener(final Consumer listener) { + listeners.add(listener); + final ConfigurationSnapshot snapshot = current.get(); + if (snapshot != null) { + listener.accept(snapshot); + } + } + + public void removeListener(final Consumer listener) { + listeners.remove(listener); + } + + public boolean awaitConfiguration(final long timeout, final TimeUnit unit) + throws InterruptedException { + if (hasConfiguration()) { + return true; + } + final long deadline = System.nanoTime() + unit.toNanos(timeout); + synchronized (changeMonitor) { + while (!hasConfiguration()) { + final long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + return false; + } + TimeUnit.NANOSECONDS.timedWait(changeMonitor, remaining); + } + return true; + } + } + + @SuppressFBWarnings( + value = "NN_NAKED_NOTIFY", + justification = "The caller updates the atomic snapshot before it signals waiting readers") + private void signalChange(final ConfigurationSnapshot snapshot) { + synchronized (changeMonitor) { + changeMonitor.notifyAll(); + } + for (final Consumer listener : listeners) { + listener.accept(snapshot); + } + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java new file mode 100644 index 00000000000..7f57a947fe9 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java @@ -0,0 +1,59 @@ +package datadog.openfeature.internal.core; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** OpenFeature-independent evaluation context. */ +public final class EvaluationContext { + + private final String targetingKey; + private final AttributeProvider attributes; + + public EvaluationContext(final String targetingKey, final Map attributes) { + final Map retained = + attributes == null ? Map.of() : new LinkedHashMap<>(attributes); + this.targetingKey = targetingKey; + this.attributes = + new AttributeProvider() { + @Override + public boolean contains(final String name) { + return retained.containsKey(name); + } + + @Override + public Object get(final String name) { + return retained.get(name); + } + }; + } + + private EvaluationContext(final String targetingKey, final AttributeProvider attributeProvider) { + this.targetingKey = targetingKey; + this.attributes = attributeProvider; + } + + public static EvaluationContext lazy( + final String targetingKey, final AttributeProvider attributeProvider) { + if (attributeProvider == null) { + throw new IllegalArgumentException("Attribute provider is required"); + } + return new EvaluationContext(targetingKey, attributeProvider); + } + + public String targetingKey() { + return targetingKey; + } + + public Object attribute(final String name) { + if ("id".equals(name) && !attributes.contains(name)) { + return targetingKey; + } + return attributes.get(name); + } + + public interface AttributeProvider { + boolean contains(String name); + + Object get(String name); + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationResult.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationResult.java new file mode 100644 index 00000000000..66567ff7306 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationResult.java @@ -0,0 +1,86 @@ +package datadog.openfeature.internal.core; + +/** OpenFeature-independent evaluation result. */ +public final class EvaluationResult { + + public enum Reason { + ERROR, + DISABLED, + TARGETING_MATCH, + SPLIT, + STATIC, + DEFAULT + } + + public enum Error { + PROVIDER_NOT_READY, + INVALID_CONTEXT, + FLAG_NOT_FOUND, + TARGETING_KEY_MISSING, + TYPE_MISMATCH, + PARSE_ERROR, + GENERAL + } + + public final Object value; + public final Reason reason; + public final Error error; + public final String errorMessage; + public final String variant; + public final String flagKey; + public final String variationType; + public final String allocationKey; + public final Integer splitSerialId; + public final boolean doLog; + + private EvaluationResult( + final Object value, + final Reason reason, + final Error error, + final String errorMessage, + final String variant, + final String flagKey, + final String variationType, + final String allocationKey, + final Integer splitSerialId, + final boolean doLog) { + this.value = value; + this.reason = reason; + this.error = error; + this.errorMessage = errorMessage; + this.variant = variant; + this.flagKey = flagKey; + this.variationType = variationType; + this.allocationKey = allocationKey; + this.splitSerialId = splitSerialId; + this.doLog = doLog; + } + + public static EvaluationResult value( + final Object value, + final Reason reason, + final String variant, + final String flagKey, + final String variationType, + final String allocationKey, + final Integer splitSerialId, + final boolean doLog) { + return new EvaluationResult( + value, + reason, + null, + null, + variant, + flagKey, + variationType, + allocationKey, + splitSerialId, + doLog); + } + + public static EvaluationResult error( + final Object defaultValue, final Error error, final String message) { + return new EvaluationResult( + defaultValue, Reason.ERROR, error, message, null, null, null, null, null, false); + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java new file mode 100644 index 00000000000..6a447aefe43 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java @@ -0,0 +1,310 @@ +package datadog.openfeature.internal.core; + +import datadog.openfeature.internal.core.ConfigurationSnapshot.Allocation; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Condition; +import datadog.openfeature.internal.core.ConfigurationSnapshot.ConditionOperator; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Flag; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Rule; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Shard; +import datadog.openfeature.internal.core.ConfigurationSnapshot.ShardRange; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Split; +import datadog.openfeature.internal.core.ConfigurationSnapshot.ValueType; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Variant; +import datadog.openfeature.internal.core.EvaluationResult.Error; +import datadog.openfeature.internal.core.EvaluationResult.Reason; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** Evaluates immutable UFC snapshots without OpenFeature or agent classes. */ +public final class FlagEvaluator { + + public enum ValueKind { + BOOLEAN, + STRING, + INTEGER, + DOUBLE, + OBJECT + } + + public EvaluationResult evaluate( + final ConfigurationSnapshot snapshot, + final ValueKind target, + final String key, + final Object defaultValue, + final EvaluationContext context) { + try { + if (snapshot == null) { + return EvaluationResult.error(defaultValue, Error.PROVIDER_NOT_READY, null); + } + if (context == null) { + return EvaluationResult.error(defaultValue, Error.INVALID_CONTEXT, null); + } + final Flag flag = snapshot.flags.get(key); + if (flag == null) { + return EvaluationResult.error(defaultValue, Error.FLAG_NOT_FOUND, null); + } + if (!flag.enabled) { + return EvaluationResult.value( + defaultValue, Reason.DISABLED, null, flag.key, typeName(flag), null, null, false); + } + if (flag.allocations == null) { + return EvaluationResult.error( + defaultValue, Error.GENERAL, "Missing allocations for flag " + key); + } + + final long now = System.currentTimeMillis(); + for (final Allocation allocation : flag.allocations) { + if (!isActive(allocation, now) + || (!isEmpty(allocation.rules) && !evaluateRules(allocation.rules, context))) { + continue; + } + if (isEmpty(allocation.splits)) { + continue; + } + for (final Split split : allocation.splits) { + if (isEmpty(split.shards)) { + return resolve(target, key, defaultValue, flag, allocation, split); + } + if (context.targetingKey() == null) { + return EvaluationResult.error(defaultValue, Error.TARGETING_KEY_MISSING, null); + } + boolean matches = true; + for (final Shard shard : split.shards) { + if (!matchesShard(shard, context.targetingKey())) { + matches = false; + break; + } + } + if (matches) { + return resolve(target, key, defaultValue, flag, allocation, split); + } + } + } + return EvaluationResult.value( + defaultValue, Reason.DEFAULT, null, flag.key, typeName(flag), null, null, false); + } catch (final PatternSyntaxException e) { + return EvaluationResult.error(defaultValue, Error.PARSE_ERROR, e.getMessage()); + } catch (final NumberFormatException e) { + return EvaluationResult.error(defaultValue, Error.TYPE_MISMATCH, e.getMessage()); + } catch (final RuntimeException e) { + return EvaluationResult.error(defaultValue, Error.GENERAL, e.getMessage()); + } + } + + private static EvaluationResult resolve( + final ValueKind target, + final String key, + final Object defaultValue, + final Flag flag, + final Allocation allocation, + final Split split) { + final Variant variant = flag.variations.get(split.variationKey); + if (variant == null) { + return EvaluationResult.error( + defaultValue, Error.GENERAL, "Variant not found for: " + split.variationKey); + } + if (!isCompatible(target, flag.variationType)) { + return EvaluationResult.error( + defaultValue, + Error.TYPE_MISMATCH, + "Requested type " + target + " does not match " + flag.variationType); + } + + final Object value; + try { + value = mapValue(target, variant.value); + } catch (final NumberFormatException e) { + return EvaluationResult.error( + defaultValue, + Error.PARSE_ERROR, + "Variant '" + variant.key + "' does not match " + flag.variationType); + } + + final Reason reason = + !isEmpty(allocation.rules) + ? Reason.TARGETING_MATCH + : !isEmpty(split.shards) ? Reason.SPLIT : Reason.STATIC; + return EvaluationResult.value( + value, + reason, + variant.key, + key, + typeName(flag), + allocation.key, + split.serialId, + allocation.doLog); + } + + public static Object mapValue(final ValueKind target, final Object value) { + if (value == null || target == ValueKind.OBJECT) { + return value; + } + switch (target) { + case STRING: + return String.valueOf(value); + case BOOLEAN: + return value instanceof Number + ? Boolean.valueOf(((Number) value).doubleValue() != 0) + : Boolean.valueOf(String.valueOf(value)); + case INTEGER: + return value instanceof Number + ? ((Number) value).intValue() + : (int) Double.parseDouble(String.valueOf(value)); + case DOUBLE: + return value instanceof Number + ? ((Number) value).doubleValue() + : Double.parseDouble(String.valueOf(value)); + default: + throw new IllegalArgumentException("Unsupported value kind: " + target); + } + } + + private static boolean isActive(final Allocation allocation, final long now) { + return (allocation.startAtMillis == null || now >= allocation.startAtMillis) + && (allocation.endAtMillis == null || now <= allocation.endAtMillis); + } + + private static boolean evaluateRules(final List rules, final EvaluationContext context) { + for (final Rule rule : rules) { + if (isEmpty(rule.conditions)) { + continue; + } + boolean matches = true; + for (final Condition condition : rule.conditions) { + if (!evaluateCondition(condition, context)) { + matches = false; + break; + } + } + if (matches) { + return true; + } + } + return false; + } + + private static boolean evaluateCondition( + final Condition condition, final EvaluationContext context) { + final Object attribute = context.attribute(condition.attribute); + if (condition.operator == ConditionOperator.IS_NULL) { + final boolean expectedNull = + !(condition.value instanceof Boolean) || (Boolean) condition.value; + return (attribute == null) == expectedNull; + } + if (attribute == null) { + return false; + } + switch (condition.operator) { + case MATCHES: + return Pattern.compile(String.valueOf(condition.value)) + .matcher(String.valueOf(attribute)) + .find(); + case NOT_MATCHES: + return !Pattern.compile(String.valueOf(condition.value)) + .matcher(String.valueOf(attribute)) + .find(); + case ONE_OF: + return oneOf(attribute, condition.value); + case NOT_ONE_OF: + return !oneOf(attribute, condition.value); + case GTE: + return compare(attribute, condition.value, (a, b) -> a >= b); + case GT: + return compare(attribute, condition.value, (a, b) -> a > b); + case LTE: + return compare(attribute, condition.value, (a, b) -> a <= b); + case LT: + return compare(attribute, condition.value, (a, b) -> a < b); + default: + return false; + } + } + + private static boolean oneOf(final Object attribute, final Object values) { + if (!(values instanceof Iterable)) { + return false; + } + for (final Object value : (Iterable) values) { + if (Objects.equals(attribute, value) + || (attribute instanceof Number || value instanceof Number) + && compare(attribute, value, (first, second) -> first == second) + || String.valueOf(attribute).equals(String.valueOf(value))) { + return true; + } + } + return false; + } + + private static boolean compare( + final Object first, final Object second, final NumberPredicate predicate) { + return predicate.test(number(first), number(second)); + } + + private static double number(final Object value) { + return value instanceof Number + ? ((Number) value).doubleValue() + : Double.parseDouble(String.valueOf(value)); + } + + private static boolean matchesShard(final Shard shard, final String targetingKey) { + if (shard.totalShards <= 0 || shard.ranges == null) { + return false; + } + final String input = shard.salt + "-" + targetingKey; + final byte[] digest; + try { + digest = MessageDigest.getInstance("MD5").digest(input.getBytes(StandardCharsets.UTF_8)); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("MD5 algorithm not available", e); + } + long firstFourBytes = 0; + for (int i = 0; i < 4; i++) { + firstFourBytes = (firstFourBytes << 8) | (digest[i] & 0xffL); + } + final int assigned = (int) (firstFourBytes % shard.totalShards); + for (final ShardRange range : shard.ranges) { + if (assigned >= range.start && assigned < range.end) { + return true; + } + } + return false; + } + + private static boolean isCompatible(final ValueKind target, final ValueType type) { + if (type == null) { + return true; + } + switch (type) { + case BOOLEAN: + return target == ValueKind.BOOLEAN; + case STRING: + return target == ValueKind.STRING; + case INTEGER: + return target == ValueKind.INTEGER; + case NUMERIC: + return target == ValueKind.DOUBLE; + case JSON: + return target == ValueKind.OBJECT; + default: + return false; + } + } + + private static String typeName(final Flag flag) { + return flag.variationType == null ? null : flag.variationType.name(); + } + + private static boolean isEmpty(final List value) { + return value == null || value.isEmpty(); + } + + @FunctionalInterface + private interface NumberPredicate { + boolean test(double first, double second); + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/SourceStatus.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/SourceStatus.java new file mode 100644 index 00000000000..297b1c70d4e --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/SourceStatus.java @@ -0,0 +1,10 @@ +package datadog.openfeature.internal.core; + +/** Lifecycle status for a configuration source. */ +public enum SourceStatus { + NEW, + STARTING, + READY, + ERROR, + CLOSED +} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java new file mode 100644 index 00000000000..ffec1125399 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java @@ -0,0 +1,307 @@ +package datadog.openfeature.internal.core; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.Moshi; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Parses raw UFC documents and JSON API UFC responses without agent-owned model classes. */ +public final class UfcParser { + + private static final String UFC_TYPE = "universal-flag-configuration"; + private static final JsonAdapter JSON_ADAPTER = + new Moshi.Builder().build().adapter(Object.class); + + public ConfigurationSnapshot parse(final byte[] content) throws IOException { + if (content == null || content.length == 0) { + throw new IOException("UFC payload is empty"); + } + + final Object decoded; + try { + decoded = JSON_ADAPTER.fromJson(new String(content, StandardCharsets.UTF_8)); + } catch (final JsonDataException | IllegalArgumentException e) { + throw new IOException("UFC payload is malformed", e); + } + + final Map root = map(decoded, "UFC document"); + final Map configuration = unwrapJsonApi(root); + final Map rawFlags = map(configuration.get("flags"), "flags"); + final Map flags = new LinkedHashMap<>(); + for (final Map.Entry entry : rawFlags.entrySet()) { + try { + flags.put(entry.getKey(), parseFlag(entry.getKey(), map(entry.getValue(), "flag"))); + } catch (final IOException | RuntimeException ignored) { + // A malformed flag must not prevent other flags in the same UFC document from loading. + } + } + + final Map environment = optionalMap(configuration.get("environment")); + return new ConfigurationSnapshot( + optionalString(configuration.get("createdAt")), + optionalString(configuration.get("format")), + environment == null ? null : optionalString(environment.get("name")), + flags); + } + + private static Map unwrapJsonApi(final Map root) + throws IOException { + if (!root.containsKey("data")) { + return root; + } + final Map data = map(root.get("data"), "data"); + if (!UFC_TYPE.equals(data.get("type"))) { + throw new IOException("JSON API response does not contain UFC data"); + } + return map(data.get("attributes"), "attributes"); + } + + private static ConfigurationSnapshot.Flag parseFlag( + final String mapKey, final Map value) throws IOException { + final String flagKey = defaultString(value.get("key"), mapKey); + final boolean enabled = bool(value.get("enabled"), "enabled"); + final ConfigurationSnapshot.ValueType variationType = + enumValue( + ConfigurationSnapshot.ValueType.class, + string(value.get("variationType"), "variationType")); + + final Map rawVariants = map(value.get("variations"), "variations"); + final Map variants = new LinkedHashMap<>(); + for (final Map.Entry entry : rawVariants.entrySet()) { + final Map variant = map(entry.getValue(), "variant"); + variants.put( + entry.getKey(), + new ConfigurationSnapshot.Variant( + defaultString(variant.get("key"), entry.getKey()), freeze(variant.get("value")))); + } + + final List rawAllocations = optionalList(value.get("allocations")); + final List allocations; + if (rawAllocations == null) { + allocations = null; + } else { + allocations = new ArrayList<>(rawAllocations.size()); + for (final Object rawAllocation : rawAllocations) { + allocations.add(parseAllocation(map(rawAllocation, "allocation"))); + } + } + return new ConfigurationSnapshot.Flag(flagKey, enabled, variationType, variants, allocations); + } + + private static ConfigurationSnapshot.Allocation parseAllocation(final Map value) + throws IOException { + return new ConfigurationSnapshot.Allocation( + string(value.get("key"), "allocation key"), + parseRules(optionalList(value.get("rules"))), + parseDate(optionalString(value.get("startAt"))), + parseDate(optionalString(value.get("endAt"))), + parseSplits(optionalList(value.get("splits"))), + Boolean.TRUE.equals(value.get("doLog"))); + } + + private static List parseRules(final List values) + throws IOException { + if (values == null) { + return null; + } + final List rules = new ArrayList<>(values.size()); + for (final Object rawRule : values) { + final Map rule = map(rawRule, "rule"); + final List rawConditions = optionalList(rule.get("conditions")); + final List conditions; + if (rawConditions == null) { + conditions = null; + } else { + conditions = new ArrayList<>(rawConditions.size()); + for (final Object rawCondition : rawConditions) { + final Map condition = map(rawCondition, "condition"); + conditions.add( + new ConfigurationSnapshot.Condition( + enumValue( + ConfigurationSnapshot.ConditionOperator.class, + string(condition.get("operator"), "condition operator")), + string(condition.get("attribute"), "condition attribute"), + freeze(condition.get("value")))); + } + } + rules.add(new ConfigurationSnapshot.Rule(conditions)); + } + return rules; + } + + private static List parseSplits(final List values) + throws IOException { + if (values == null) { + return null; + } + final List splits = new ArrayList<>(values.size()); + for (final Object rawSplit : values) { + final Map split = map(rawSplit, "split"); + splits.add( + new ConfigurationSnapshot.Split( + parseShards(optionalList(split.get("shards"))), + string(split.get("variationKey"), "variationKey"), + stringMap(optionalMap(split.get("extraLogging"))), + optionalInteger(split.get("serialId")))); + } + return splits; + } + + private static List parseShards(final List values) + throws IOException { + if (values == null) { + return null; + } + final List shards = new ArrayList<>(values.size()); + for (final Object rawShard : values) { + final Map shard = map(rawShard, "shard"); + final List rawRanges = list(shard.get("ranges"), "ranges"); + final List ranges = new ArrayList<>(rawRanges.size()); + for (final Object rawRange : rawRanges) { + final Map range = map(rawRange, "range"); + ranges.add( + new ConfigurationSnapshot.ShardRange( + integer(range.get("start"), "range start"), + integer(range.get("end"), "range end"))); + } + shards.add( + new ConfigurationSnapshot.Shard( + string(shard.get("salt"), "shard salt"), + ranges, + integer(shard.get("totalShards"), "totalShards"))); + } + return shards; + } + + private static Long parseDate(final String value) { + if (value == null) { + return null; + } + try { + return Instant.parse(value).toEpochMilli(); + } catch (final DateTimeParseException ignored) { + return null; + } + } + + private static Object freeze(final Object value) throws IOException { + if (value instanceof Map) { + final Map frozen = new LinkedHashMap<>(); + for (final Map.Entry entry : map(value, "object").entrySet()) { + frozen.put(entry.getKey(), freeze(entry.getValue())); + } + return Collections.unmodifiableMap(frozen); + } + if (value instanceof List) { + final List frozen = new ArrayList<>(); + for (final Object element : (List) value) { + frozen.add(freeze(element)); + } + return Collections.unmodifiableList(frozen); + } + return value; + } + + private static Map stringMap(final Map value) { + if (value == null) { + return null; + } + final Map strings = new LinkedHashMap<>(); + for (final Map.Entry entry : value.entrySet()) { + strings.put(entry.getKey(), String.valueOf(entry.getValue())); + } + return strings; + } + + private static > T enumValue(final Class type, final String value) { + return Enum.valueOf(type, value.toUpperCase(Locale.ROOT)); + } + + private static String defaultString(final Object value, final String defaultValue) { + final String string = optionalString(value); + return string == null ? defaultValue : string; + } + + private static String string(final Object value, final String field) throws IOException { + final String string = optionalString(value); + if (string == null) { + throw new IOException("Missing UFC " + field); + } + return string; + } + + private static String optionalString(final Object value) { + return value instanceof String && !((String) value).isEmpty() ? (String) value : null; + } + + private static boolean bool(final Object value, final String field) throws IOException { + if (!(value instanceof Boolean)) { + throw new IOException("Invalid UFC " + field); + } + return (Boolean) value; + } + + private static int integer(final Object value, final String field) throws IOException { + if (!(value instanceof Number)) { + throw new IOException("Invalid UFC " + field); + } + return ((Number) value).intValue(); + } + + private static Integer optionalInteger(final Object value) throws IOException { + return value == null ? null : integer(value, "integer"); + } + + private static List list(final Object value, final String field) throws IOException { + final List list = optionalList(value); + if (list == null) { + throw new IOException("Invalid UFC " + field); + } + return list; + } + + @SuppressWarnings("unchecked") + private static List optionalList(final Object value) throws IOException { + if (value == null) { + return null; + } + if (!(value instanceof List)) { + throw new IOException("Expected UFC array"); + } + return (List) value; + } + + private static Map map(final Object value, final String field) + throws IOException { + final Map map = optionalMap(value); + if (map == null) { + throw new IOException("Invalid UFC " + field); + } + return map; + } + + @SuppressWarnings("unchecked") + private static Map optionalMap(final Object value) throws IOException { + if (value == null) { + return null; + } + if (!(value instanceof Map)) { + throw new IOException("Expected UFC object"); + } + for (final Object key : ((Map) value).keySet()) { + if (!(key instanceof String)) { + throw new IOException("UFC object key is not a string"); + } + } + return (Map) value; + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java new file mode 100644 index 00000000000..9b1e5d89814 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java @@ -0,0 +1,87 @@ +package datadog.openfeature.internal.core; + +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +class ConfigurationStoreTest { + + @Test + void preservesLastKnownGoodAfterRejectedPayload() { + final ConfigurationStore store = new ConfigurationStore(); + final AtomicInteger changes = new AtomicInteger(); + store.addListener(ignored -> changes.incrementAndGet()); + assertEquals(ApplyResult.ACCEPTED, store.apply(Fixtures.UFC.getBytes(UTF_8))); + final ConfigurationSnapshot accepted = store.current(); + + assertEquals(ApplyResult.REJECTED, store.apply("{".getBytes(UTF_8))); + assertSame(accepted, store.current()); + assertEquals(1, changes.get()); + + assertEquals( + ApplyResult.ACCEPTED, + store.apply(Fixtures.UFC.replace("hello", "recovered").getBytes(UTF_8))); + assertEquals(2, changes.get()); + assertTrue(store.hasConfiguration()); + } + + @Test + void clearsConfigurationExplicitly() { + final ConfigurationStore store = new ConfigurationStore(); + store.apply(Fixtures.UFC.getBytes(UTF_8)); + + assertEquals(ApplyResult.CLEARED, store.clear()); + assertEquals(null, store.current()); + assertFalse(store.hasConfiguration()); + } + + @Test + void sendsCurrentAndDeletedSnapshotsToListeners() { + final ConfigurationStore store = new ConfigurationStore(); + store.apply(Fixtures.UFC.getBytes(UTF_8)); + final AtomicReference current = new AtomicReference<>(); + final Consumer listener = current::set; + + store.addListener(listener); + assertSame(store.current(), current.get()); + + store.clear(); + assertEquals(null, current.get()); + store.removeListener(listener); + } + + @Test + void waitsForConfigurationAndTimesOut() throws Exception { + final ConfigurationStore store = new ConfigurationStore(); + assertFalse(store.awaitConfiguration(1, TimeUnit.MILLISECONDS)); + final CountDownLatch waiting = new CountDownLatch(1); + final AtomicReference result = new AtomicReference<>(); + final Thread thread = + new Thread( + () -> { + waiting.countDown(); + try { + result.set(store.awaitConfiguration(1, TimeUnit.SECONDS)); + } catch (final InterruptedException error) { + Thread.currentThread().interrupt(); + } + }); + thread.start(); + assertTrue(waiting.await(1, TimeUnit.SECONDS)); + + store.apply(Fixtures.UFC.getBytes(UTF_8)); + thread.join(1_000); + + assertEquals(true, result.get()); + assertTrue(store.awaitConfiguration(0, TimeUnit.MILLISECONDS)); + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/Fixtures.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/Fixtures.java new file mode 100644 index 00000000000..d03e5b76b2d --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/Fixtures.java @@ -0,0 +1,27 @@ +package datadog.openfeature.internal.core; + +final class Fixtures { + + static final String UFC = + "{" + + "\"createdAt\":\"2026-01-01T00:00:00Z\"," + + "\"format\":\"SERVER\"," + + "\"environment\":{\"name\":\"test\"}," + + "\"flags\":{" + + "\"message\":{" + + "\"key\":\"message\"," + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"hello\"}}," + + "\"allocations\":[{" + + "\"key\":\"allocation\"," + + "\"rules\":[]," + + "\"splits\":[{\"variationKey\":\"on\",\"shards\":[],\"serialId\":7}]," + + "\"doLog\":true" + + "}]" + + "}" + + "}" + + "}"; + + private Fixtures() {} +} diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java new file mode 100644 index 00000000000..a8ba419ecb1 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java @@ -0,0 +1,301 @@ +package datadog.openfeature.internal.core; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import datadog.openfeature.internal.core.ConfigurationSnapshot.Allocation; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Condition; +import datadog.openfeature.internal.core.ConfigurationSnapshot.ConditionOperator; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Flag; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Rule; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Shard; +import datadog.openfeature.internal.core.ConfigurationSnapshot.ShardRange; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Split; +import datadog.openfeature.internal.core.ConfigurationSnapshot.ValueType; +import datadog.openfeature.internal.core.ConfigurationSnapshot.Variant; +import datadog.openfeature.internal.core.EvaluationResult.Error; +import datadog.openfeature.internal.core.EvaluationResult.Reason; +import datadog.openfeature.internal.core.FlagEvaluator.ValueKind; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FlagEvaluatorTest { + + private final FlagEvaluator evaluator = new FlagEvaluator(); + + @Test + void evaluatesStaticValuesAndMetadata() { + final EvaluationResult result = + evaluate(staticFlag(ValueType.STRING, "hello"), ValueKind.STRING, context("subject")); + + assertEquals("hello", result.value); + assertEquals("on", result.variant); + assertEquals(Reason.STATIC, result.reason); + assertEquals("allocation", result.allocationKey); + assertEquals(7, result.splitSerialId); + assertEquals(true, result.doLog); + } + + @Test + void mapsAllSupportedValueKinds() { + assertEquals( + true, evaluate(staticFlag(ValueType.BOOLEAN, 1), ValueKind.BOOLEAN, context("id")).value); + assertEquals( + 42, + evaluate(staticFlag(ValueType.INTEGER, "42.9"), ValueKind.INTEGER, context("id")).value); + assertEquals( + 42D, evaluate(staticFlag(ValueType.NUMERIC, "42"), ValueKind.DOUBLE, context("id")).value); + assertEquals( + "42", evaluate(staticFlag(ValueType.STRING, 42), ValueKind.STRING, context("id")).value); + assertEquals( + Map.of("nested", true), + evaluate( + staticFlag(ValueType.JSON, Map.of("nested", true)), ValueKind.OBJECT, context("id")) + .value); + assertNull(FlagEvaluator.mapValue(ValueKind.STRING, null)); + } + + @Test + void reportsInputAndConfigurationErrors() { + assertError(null, ValueKind.STRING, context("id"), Error.PROVIDER_NOT_READY); + assertError( + snapshot(staticFlag(ValueType.STRING, "value")), + ValueKind.STRING, + null, + Error.INVALID_CONTEXT); + + final EvaluationResult missing = + evaluator.evaluate( + snapshot(staticFlag(ValueType.STRING, "value")), + ValueKind.STRING, + "missing", + "default", + context("id")); + assertEquals(Error.FLAG_NOT_FOUND, missing.error); + + assertError( + snapshot( + new Flag( + "flag", true, ValueType.STRING, Map.of("on", new Variant("on", "value")), null)), + ValueKind.STRING, + context("id"), + Error.GENERAL); + assertError( + snapshot(staticFlag(ValueType.STRING, "value")), + ValueKind.BOOLEAN, + context("id"), + Error.TYPE_MISMATCH); + assertError( + snapshot( + flag( + ValueType.STRING, + Map.of("on", new Variant("on", "value")), + List.of(split("missing", emptyList())))), + ValueKind.STRING, + context("id"), + Error.GENERAL); + assertError( + snapshot(staticFlag(ValueType.INTEGER, "not-a-number")), + ValueKind.INTEGER, + context("id"), + Error.PARSE_ERROR); + } + + @Test + void returnsDisabledAndDefaultResults() { + final Flag disabled = + new Flag( + "flag", false, ValueType.STRING, Map.of("on", new Variant("on", "value")), emptyList()); + assertEquals(Reason.DISABLED, evaluate(disabled, ValueKind.STRING, context("id")).reason); + + final Flag noSplits = + flag(ValueType.STRING, Map.of("on", new Variant("on", "value")), emptyList()); + assertEquals(Reason.DEFAULT, evaluate(noSplits, ValueKind.STRING, context("id")).reason); + + final long now = System.currentTimeMillis(); + final Flag inactive = + new Flag( + "flag", + true, + ValueType.STRING, + Map.of("on", new Variant("on", "value")), + List.of( + new Allocation( + "future", + emptyList(), + now + 60_000, + null, + List.of(split("on", emptyList())), + false), + new Allocation( + "past", + emptyList(), + null, + now - 60_000, + List.of(split("on", emptyList())), + false))); + assertEquals(Reason.DEFAULT, evaluate(inactive, ValueKind.STRING, context("id")).reason); + } + + @Test + void evaluatesAllRuleOperators() { + final Rule matchingRule = + new Rule( + List.of( + condition(ConditionOperator.MATCHES, "country", "^U"), + condition(ConditionOperator.NOT_MATCHES, "country", "^C"), + condition(ConditionOperator.ONE_OF, "tier", List.of("free", "paid")), + condition(ConditionOperator.NOT_ONE_OF, "tier", List.of("blocked")), + condition(ConditionOperator.GTE, "age", 18), + condition(ConditionOperator.GT, "age", 17), + condition(ConditionOperator.LTE, "age", 18), + condition(ConditionOperator.LT, "age", 19), + condition(ConditionOperator.IS_NULL, "missing", true), + condition(ConditionOperator.IS_NULL, "country", false), + condition(ConditionOperator.ONE_OF, "score", List.of("18")))); + final Flag flag = + new Flag( + "flag", + true, + ValueType.STRING, + Map.of("on", new Variant("on", "matched")), + List.of( + new Allocation( + "targeted", + List.of(new Rule(emptyList()), matchingRule), + null, + null, + List.of(split("on", emptyList())), + false))); + final EvaluationContext matching = + new EvaluationContext( + "subject", Map.of("country", "US", "tier", "paid", "age", 18, "score", 18)); + + assertEquals(Reason.TARGETING_MATCH, evaluate(flag, ValueKind.STRING, matching).reason); + assertEquals( + Reason.DEFAULT, + evaluate( + flag, + ValueKind.STRING, + new EvaluationContext( + "subject", Map.of("country", "CA", "tier", "paid", "age", 18, "score", 18))) + .reason); + } + + @Test + void mapsInvalidRulesToEvaluationErrors() { + final Flag invalidRegex = targetedFlag(condition(ConditionOperator.MATCHES, "value", "[")); + assertEquals( + Error.PARSE_ERROR, + evaluate( + invalidRegex, + ValueKind.STRING, + new EvaluationContext("id", Map.of("value", "text"))) + .error); + + final Flag invalidNumber = targetedFlag(condition(ConditionOperator.GT, "value", "number")); + assertEquals( + Error.TYPE_MISMATCH, + evaluate(invalidNumber, ValueKind.STRING, new EvaluationContext("id", Map.of("value", 2))) + .error); + } + + @Test + void evaluatesShardsAndRequiresTargetingKey() { + final Shard matchingShard = new Shard("salt", List.of(new ShardRange(0, 1)), 1); + final Flag sharded = + flag( + ValueType.STRING, + Map.of("on", new Variant("on", "value")), + List.of(split("on", List.of(matchingShard)))); + + assertEquals(Reason.SPLIT, evaluate(sharded, ValueKind.STRING, context("id")).reason); + assertEquals( + Error.TARGETING_KEY_MISSING, evaluate(sharded, ValueKind.STRING, context(null)).error); + + final Shard invalidShard = new Shard("salt", emptyList(), 0); + final Flag unmatched = + flag( + ValueType.STRING, + Map.of("on", new Variant("on", "value")), + List.of(split("on", List.of(invalidShard)))); + assertEquals(Reason.DEFAULT, evaluate(unmatched, ValueKind.STRING, context("id")).reason); + } + + @Test + void contextUsesTargetingKeyAsDefaultIdAndCopiesAttributes() { + final java.util.Map mutable = new java.util.LinkedHashMap<>(); + mutable.put("name", "value"); + final EvaluationContext context = new EvaluationContext("subject", mutable); + mutable.put("name", "changed"); + + assertEquals("subject", context.attribute("id")); + assertEquals("value", context.attribute("name")); + assertEquals( + "explicit", new EvaluationContext("subject", Map.of("id", "explicit")).attribute("id")); + assertNull(new EvaluationContext("subject", null).attribute("missing")); + } + + private EvaluationResult evaluate( + final Flag flag, final ValueKind kind, final EvaluationContext context) { + return evaluator.evaluate(snapshot(flag), kind, "flag", "default", context); + } + + private void assertError( + final ConfigurationSnapshot snapshot, + final ValueKind kind, + final EvaluationContext context, + final Error error) { + assertEquals(error, evaluator.evaluate(snapshot, kind, "flag", "default", context).error); + } + + private static ConfigurationSnapshot snapshot(final Flag flag) { + return new ConfigurationSnapshot(null, "SERVER", "test", Map.of("flag", flag)); + } + + private static EvaluationContext context(final String targetingKey) { + return new EvaluationContext(targetingKey, emptyMap()); + } + + private static Flag staticFlag(final ValueType type, final Object value) { + return flag(type, Map.of("on", new Variant("on", value)), List.of(split("on", emptyList()))); + } + + private static Flag targetedFlag(final Condition condition) { + return new Flag( + "flag", + true, + ValueType.STRING, + Map.of("on", new Variant("on", "value")), + List.of( + new Allocation( + "allocation", + List.of(new Rule(List.of(condition))), + null, + null, + List.of(split("on", emptyList())), + false))); + } + + private static Flag flag( + final ValueType type, final Map variants, final List splits) { + return new Flag( + "flag", + true, + type, + variants, + List.of(new Allocation("allocation", emptyList(), null, null, splits, true))); + } + + private static Split split(final String variationKey, final List shards) { + return new Split(shards, variationKey, emptyMap(), 7); + } + + private static Condition condition( + final ConditionOperator operator, final String attribute, final Object value) { + return new Condition(operator, attribute, value); + } +} diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java new file mode 100644 index 00000000000..219a658b2b5 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java @@ -0,0 +1,141 @@ +package datadog.openfeature.internal.core; + +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class UfcParserTest { + + private final UfcParser parser = new UfcParser(); + + @Test + void parsesRawUfc() throws Exception { + final ConfigurationSnapshot snapshot = parser.parse(Fixtures.UFC.getBytes(UTF_8)); + + assertEquals("test", snapshot.environmentName); + assertEquals(1, snapshot.flags.size()); + assertNotNull(snapshot.flags.get("message")); + } + + @Test + void parsesJsonApiResponse() throws Exception { + final String response = + "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + Fixtures.UFC + + "}}"; + + assertEquals(1, parser.parse(response.getBytes(UTF_8)).flags.size()); + } + + @Test + void parsesCompleteUfcModelAndFreezesNestedValues() throws Exception { + final String content = + "{" + + "\"createdAt\":\"2026-01-01T00:00:00Z\"," + + "\"format\":\"SERVER\"," + + "\"environment\":{\"name\":\"production\"}," + + "\"flags\":{\"targeted\":{" + + "\"enabled\":true," + + "\"variationType\":\"JSON\"," + + "\"variations\":{\"on\":{\"value\":{\"nested\":[1,true]}}}," + + "\"allocations\":[{" + + "\"key\":\"allocation\"," + + "\"startAt\":\"2025-01-01T00:00:00Z\"," + + "\"endAt\":\"invalid-date\"," + + "\"doLog\":true," + + "\"rules\":[{\"conditions\":[{" + + "\"operator\":\"ONE_OF\",\"attribute\":\"country\",\"value\":[\"US\"]" + + "}]}]," + + "\"splits\":[{" + + "\"variationKey\":\"on\"," + + "\"serialId\":12," + + "\"extraLogging\":{\"reason\":\"test\"}," + + "\"shards\":[{\"salt\":\"salt\",\"totalShards\":100," + + "\"ranges\":[{\"start\":0,\"end\":50}]}]" + + "}]" + + "}]" + + "}}}"; + + final ConfigurationSnapshot snapshot = parser.parse(content.getBytes(UTF_8)); + final ConfigurationSnapshot.Flag flag = snapshot.flags.get("targeted"); + final ConfigurationSnapshot.Allocation allocation = flag.allocations.get(0); + final ConfigurationSnapshot.Split split = allocation.splits.get(0); + + assertEquals("targeted", flag.key); + assertEquals(ConfigurationSnapshot.ValueType.JSON, flag.variationType); + assertNotNull(allocation.startAtMillis); + assertEquals(null, allocation.endAtMillis); + assertEquals( + ConfigurationSnapshot.ConditionOperator.ONE_OF, + allocation.rules.get(0).conditions.get(0).operator); + assertEquals(12, split.serialId); + assertEquals("test", split.extraLogging.get("reason")); + assertEquals(100, split.shards.get(0).totalShards); + assertEquals(50, split.shards.get(0).ranges.get(0).end); + assertThrows( + UnsupportedOperationException.class, + () -> ((Map) flag.variations.get("on").value).put("new", true)); + assertThrows( + UnsupportedOperationException.class, + () -> + ((List) ((Map) flag.variations.get("on").value).get("nested")) + .add("new")); + } + + @Test + void skipsMalformedFlagAndKeepsValidFlag() throws Exception { + final String content = + Fixtures.UFC.replace("\"flags\":{", "\"flags\":{\"broken\":{\"enabled\":\"yes\"},"); + + final ConfigurationSnapshot snapshot = parser.parse(content.getBytes(UTF_8)); + + assertFalse(snapshot.flags.containsKey("broken")); + assertNotNull(snapshot.flags.get("message")); + } + + @Test + void rejectsWrongJsonApiType() { + assertThrows( + IOException.class, + () -> parser.parse("{\"data\":{\"type\":\"other\",\"attributes\":{}}}".getBytes(UTF_8))); + } + + @Test + void rejectsEmptyMalformedAndStructurallyInvalidDocuments() { + assertThrows(IOException.class, () -> parser.parse(null)); + assertThrows(IOException.class, () -> parser.parse(new byte[0])); + assertThrows(IOException.class, () -> parser.parse("{".getBytes(UTF_8))); + assertThrows(IOException.class, () -> parser.parse("[]".getBytes(UTF_8))); + assertThrows(IOException.class, () -> parser.parse("{}".getBytes(UTF_8))); + assertThrows( + IOException.class, + () -> + parser.parse("{\"data\":{\"type\":\"universal-flag-configuration\"}}".getBytes(UTF_8))); + } + + @Test + void skipsFlagsWithInvalidRequiredFields() throws Exception { + final String content = + "{\"flags\":{" + + "\"enabled\":{\"enabled\":\"yes\",\"variationType\":\"STRING\",\"variations\":{}}," + + "\"type\":{\"enabled\":true,\"variationType\":\"UNKNOWN\",\"variations\":{}}," + + "\"variants\":{\"enabled\":true,\"variationType\":\"STRING\",\"variations\":[]}," + + "\"allocations\":{\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{},\"allocations\":\"bad\"}," + + "\"operator\":{\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"value\":\"on\"}},\"allocations\":[{" + + "\"key\":\"a\",\"rules\":[{\"conditions\":[{" + + "\"operator\":\"UNKNOWN\",\"attribute\":\"id\"}]}],\"splits\":[]}]}" + + "}}"; + + assertTrue(parser.parse(content.getBytes(UTF_8)).flags.isEmpty()); + } +} diff --git a/products/feature-flagging/feature-flagging-http/build.gradle.kts b/products/feature-flagging/feature-flagging-http/build.gradle.kts new file mode 100644 index 00000000000..5be2acc81bc --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/build.gradle.kts @@ -0,0 +1,48 @@ +import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension +import groovy.lang.Closure + +plugins { + `java-library` +} + +apply(from = "$rootDir/gradle/java.gradle") + +description = "Provider-owned Feature Flagging CDN transport and polling" + +// Transport failures and concurrent lifecycle races add defensive branches. +// Integration tests cover success, timeout, cancellation, retry, and shutdown behavior. +extra["minimumBranchCoverage"] = 0.7 +extra["minimumInstructionCoverage"] = 0.8 + +configure { + minJavaVersion.set(JavaVersion.VERSION_11) +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(11) + } +} + +dependencies { + api(project(":products:feature-flagging:feature-flagging-core")) + + testImplementation(libs.bundles.junit5) +} + +fun AbstractCompile.configureCompiler( + javaVersionInteger: Int, + compatibilityVersion: JavaVersion? = null, + unsetReleaseFlagReason: String? = null +) { + (project.extra["configureCompiler"] as Closure<*>).call( + this, + javaVersionInteger, + compatibilityVersion, + unsetReleaseFlagReason + ) +} + +tasks.withType().configureEach { + configureCompiler(11, JavaVersion.VERSION_11) +} diff --git a/products/feature-flagging/feature-flagging-http/gradle.lockfile b/products/feature-flagging/feature-flagging-http/gradle.lockfile new file mode 100644 index 00000000000..f60721da879 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/gradle.lockfile @@ -0,0 +1,79 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :products:feature-flagging:feature-flagging-http:dependencies --write-locks +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,spotbugs +com.github.spotbugs:spotbugs:4.9.8=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.squareup.moshi:moshi:1.11.0=runtimeClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=runtimeClasspath,testRuntimeClasspath +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-io:commons-io:2.20.0=spotbugs +de.thetaphi:forbiddenapis:3.10=compileClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.12.8=testRuntimeClasspath +net.bytebuddy:byte-buddy:1.12.8=testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.11.0=spotbugs +org.apache.commons:commons-lang3:3.19.0=spotbugs +org.apache.commons:commons-text:1.14.0=spotbugs +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.codehaus.groovy:groovy-ant:3.0.23=codenarc +org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc +org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt +org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt +org.jacoco:org.jacoco.core:0.8.15=jacocoAnt +org.jacoco:org.jacoco.report:0.8.15=jacocoAnt +org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.9=spotbugs +org.ow2.asm:asm-commons:9.10.1=jacocoAnt +org.ow2.asm:asm-commons:9.9=spotbugs +org.ow2.asm:asm-tree:9.10.1=jacocoAnt +org.ow2.asm:asm-tree:9.9=spotbugs +org.ow2.asm:asm-util:9.9=spotbugs +org.ow2.asm:asm:9.10.1=jacocoAnt +org.ow2.asm:asm:9.9=spotbugs +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.32=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java new file mode 100644 index 00000000000..270b9104447 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java @@ -0,0 +1,329 @@ +package datadog.openfeature.internal.http; + +import datadog.openfeature.internal.core.ApplyResult; +import datadog.openfeature.internal.core.ConfigurationSink; +import datadog.openfeature.internal.core.ConfigurationSource; +import datadog.openfeature.internal.core.SourceStatus; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.DoubleSupplier; +import java.util.zip.GZIPInputStream; + +/** Java 11 HTTP client source for CDN-backed UFC delivery. */ +public final class CdnConfigurationSource implements ConfigurationSource { + + static final int MAX_ATTEMPTS = 3; + private static final double RETRY_JITTER = 0.2; + + private final HttpConfigurationOptions options; + private final ConfigurationSink sink; + private final Transport transport; + private final ScheduledExecutorService executor; + private final Sleeper sleeper; + private final DoubleSupplier jitter; + private final AtomicBoolean polling = new AtomicBoolean(); + private final Object lifecycleLock = new Object(); + private volatile SourceStatus status = SourceStatus.NEW; + private volatile boolean closed; + private volatile boolean started; + private volatile ScheduledFuture scheduledPoll; + private volatile String etag; + + public CdnConfigurationSource( + final HttpConfigurationOptions options, final ConfigurationSink sink) { + this( + options, + sink, + new Java11Transport( + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()), + Executors.newSingleThreadScheduledExecutor( + runnable -> { + final Thread thread = new Thread(runnable, "dd-openfeature-cdn-poller"); + thread.setDaemon(true); + return thread; + }), + TimeUnit.MILLISECONDS::sleep, + () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)); + } + + CdnConfigurationSource( + final HttpConfigurationOptions options, + final ConfigurationSink sink, + final Transport transport, + final ScheduledExecutorService executor, + final Sleeper sleeper, + final DoubleSupplier jitter) { + this.options = options; + this.sink = sink; + this.transport = transport; + this.executor = executor; + this.sleeper = sleeper; + this.jitter = jitter; + } + + @Override + public void start() { + synchronized (lifecycleLock) { + if (closed || started) { + return; + } + started = true; + status = SourceStatus.STARTING; + } + + pollOnce(); + + synchronized (lifecycleLock) { + if (!closed) { + scheduledPoll = + executor.scheduleWithFixedDelay( + this::pollOnceSafely, + options.pollInterval.toMillis(), + options.pollInterval.toMillis(), + TimeUnit.MILLISECONDS); + } + } + } + + public boolean pollOnce() { + if (closed || !polling.compareAndSet(false, true)) { + return false; + } + try { + final boolean success = fetchWithRetry(); + if (!closed) { + status = success ? SourceStatus.READY : SourceStatus.ERROR; + } + return success; + } finally { + polling.set(false); + } + } + + @Override + public SourceStatus status() { + return status; + } + + @Override + public void close() { + final ScheduledFuture poll; + synchronized (lifecycleLock) { + if (closed) { + return; + } + closed = true; + started = false; + status = SourceStatus.CLOSED; + poll = scheduledPoll; + scheduledPoll = null; + } + if (poll != null) { + poll.cancel(true); + } + transport.cancel(); + executor.shutdownNow(); + } + + private void pollOnceSafely() { + try { + pollOnce(); + } catch (final RuntimeException ignored) { + if (!closed) { + status = SourceStatus.ERROR; + } + } + } + + private boolean fetchWithRetry() { + for (int attempt = 1; attempt <= MAX_ATTEMPTS && !closed; attempt++) { + try { + final TransportResponse response = + transport.fetch(options, etag == null ? Collections.emptyMap() : etagHeader(etag)); + synchronized (lifecycleLock) { + if (closed) { + return false; + } + if (response.status == 304) { + return true; + } + if (response.status == 200 && response.body != null) { + final ApplyResult result = sink.apply(response.body); + if (result == ApplyResult.ACCEPTED) { + etag = blankToNull(response.etag); + return true; + } + return false; + } + } + if (!retryable(response.status) || attempt == MAX_ATTEMPTS) { + return false; + } + } catch (final IOException e) { + if (attempt == MAX_ATTEMPTS || closed) { + return false; + } + } + + try { + sleeper.sleep( + retryDelayMillis(options.pollInterval.toMillis(), attempt, jitter.getAsDouble())); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return false; + } + + static long retryDelayMillis( + final long pollIntervalMillis, final int attempt, final double jitter) { + final long minimum = attempt == 1 ? 2_000 : 5_000; + final long maximum = attempt == 1 ? 10_000 : 30_000; + final long fraction = pollIntervalMillis / (attempt == 1 ? 6 : 3); + return Math.max(1, Math.round(Math.max(minimum, Math.min(maximum, fraction)) * jitter)); + } + + private static boolean retryable(final int status) { + return status == 408 || status == 429 || status >= 500 && status <= 599; + } + + private static Map etagHeader(final String etag) { + final Map headers = new LinkedHashMap<>(); + headers.put("If-None-Match", etag); + return headers; + } + + private static String blankToNull(final String value) { + return value == null || value.trim().isEmpty() ? null : value; + } + + interface Sleeper { + void sleep(long millis) throws InterruptedException; + } + + interface Transport { + TransportResponse fetch(HttpConfigurationOptions options, Map headers) + throws IOException; + + void cancel(); + } + + static final class TransportResponse { + final int status; + final String etag; + final byte[] body; + + TransportResponse(final int status, final String etag, final byte[] body) { + this.status = status; + this.etag = etag; + this.body = body; + } + } + + static final class Java11Transport implements Transport { + private final HttpClient client; + private final AtomicReference> active = new AtomicReference<>(); + private final AtomicBoolean cancelled = new AtomicBoolean(); + + Java11Transport(final HttpClient client) { + this.client = client; + } + + @Override + public TransportResponse fetch( + final HttpConfigurationOptions options, final Map headers) + throws IOException { + if (cancelled.get()) { + throw new InterruptedIOException("Feature Flagging HTTP source is closed"); + } + final HttpRequest.Builder request = + HttpRequest.newBuilder(options.endpoint) + .timeout(options.requestTimeout) + .GET() + .header("Datadog-Meta-Lang", "java") + .header("Accept", "application/json") + .header("Accept-Encoding", "gzip"); + if (options.managedEndpoint && options.apiKey != null && !options.apiKey.isEmpty()) { + request.header("DD-API-KEY", options.apiKey); + } + headers.forEach(request::header); + + final CompletableFuture> future = + client.sendAsync(request.build(), BodyHandlers.ofByteArray()); + active.set(future); + if (cancelled.get()) { + future.cancel(true); + } + try { + final java.net.http.HttpResponse response = future.get(); + return new TransportResponse( + response.statusCode(), + response.headers().firstValue("ETag").orElse(null), + decodeBody( + response.body(), response.headers().firstValue("Content-Encoding").orElse(null))); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("Feature Flagging HTTP request interrupted"); + } catch (final CancellationException e) { + throw new InterruptedIOException("Feature Flagging HTTP request cancelled"); + } catch (final ExecutionException e) { + final Throwable cause = e.getCause(); + if (cause instanceof CancellationException) { + throw new InterruptedIOException("Feature Flagging HTTP request cancelled"); + } + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new IOException("Feature Flagging HTTP request failed", cause); + } finally { + active.compareAndSet(future, null); + } + } + + private static byte[] decodeBody(final byte[] body, final String contentEncoding) + throws IOException { + if (body == null + || contentEncoding == null + || !"gzip".equalsIgnoreCase(contentEncoding.trim())) { + return body; + } + try (GZIPInputStream input = new GZIPInputStream(new ByteArrayInputStream(body)); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + final byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } + } + + @Override + public void cancel() { + cancelled.set(true); + final CompletableFuture future = active.get(); + if (future != null) { + future.cancel(true); + } + } + } +} diff --git a/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnEndpointResolver.java b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnEndpointResolver.java new file mode 100644 index 00000000000..dcb9ee76459 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnEndpointResolver.java @@ -0,0 +1,41 @@ +package datadog.openfeature.internal.http; + +import java.net.URI; +import java.net.URISyntaxException; + +/** Resolves the managed CDN endpoint or a test-controlled endpoint. */ +public final class CdnEndpointResolver { + + public static final String UFC_PATH = "/api/v2/feature-flagging/config/rules-based/server"; + + private CdnEndpointResolver() {} + + public static URI resolve(final String configuredBaseUrl, final String site, final String env) { + if (configuredBaseUrl != null && !configuredBaseUrl.trim().isEmpty()) { + final URI configured = URI.create(configuredBaseUrl.trim()); + if (!configured.isAbsolute() || configured.getHost() == null) { + throw new IllegalArgumentException( + "Invalid Feature Flagging HTTP configuration source URL: " + configuredBaseUrl); + } + final String path = configured.getPath(); + if (path == null || path.isEmpty() || "/".equals(path)) { + final String query = configured.getRawQuery(); + return configured.resolve(URI.create(UFC_PATH + (query == null ? "" : "?" + query))); + } + return configured; + } + + try { + return new URI( + "https", + null, + "ufc-server.ff-cdn." + (site == null || site.isEmpty() ? "datadoghq.com" : site), + -1, + UFC_PATH, + env == null || env.isEmpty() ? null : "dd_env=" + env, + null); + } catch (final URISyntaxException e) { + throw new IllegalArgumentException("Invalid Feature Flagging CDN endpoint", e); + } + } +} diff --git a/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/HttpConfigurationOptions.java b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/HttpConfigurationOptions.java new file mode 100644 index 00000000000..e10fb12dcec --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/HttpConfigurationOptions.java @@ -0,0 +1,72 @@ +package datadog.openfeature.internal.http; + +import java.net.URI; +import java.time.Duration; +import java.util.Objects; + +/** Immutable options for the provider-owned CDN configuration source. */ +public final class HttpConfigurationOptions { + + public final URI endpoint; + public final Duration pollInterval; + public final Duration requestTimeout; + public final String apiKey; + public final boolean managedEndpoint; + + private HttpConfigurationOptions(final Builder builder) { + endpoint = Objects.requireNonNull(builder.endpoint, "endpoint"); + pollInterval = positive(builder.pollInterval, "pollInterval"); + requestTimeout = positive(builder.requestTimeout, "requestTimeout"); + apiKey = builder.apiKey; + managedEndpoint = builder.managedEndpoint; + } + + public static Builder builder() { + return new Builder(); + } + + private static Duration positive(final Duration value, final String name) { + Objects.requireNonNull(value, name); + if (value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + + public static final class Builder { + private URI endpoint; + private Duration pollInterval = Duration.ofSeconds(30); + private Duration requestTimeout = Duration.ofSeconds(5); + private String apiKey; + private boolean managedEndpoint; + + public Builder endpoint(final URI endpoint) { + this.endpoint = endpoint; + return this; + } + + public Builder pollInterval(final Duration pollInterval) { + this.pollInterval = pollInterval; + return this; + } + + public Builder requestTimeout(final Duration requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + + public Builder apiKey(final String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder managedEndpoint(final boolean managedEndpoint) { + this.managedEndpoint = managedEndpoint; + return this; + } + + public HttpConfigurationOptions build() { + return new HttpConfigurationOptions(this); + } + } +} diff --git a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java new file mode 100644 index 00000000000..5491d6894bc --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java @@ -0,0 +1,188 @@ +package datadog.openfeature.internal.http; + +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.assertTrue; + +import datadog.openfeature.internal.core.ConfigurationStore; +import datadog.openfeature.internal.core.SourceStatus; +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; + +class CdnConfigurationSourceTest { + + @Test + void keepsEtagAndLastKnownGoodAfterMalformedPayload() { + final QueueTransport transport = new QueueTransport(); + transport.responses.add(response(200, "\"one\"", UFC)); + transport.responses.add(response(200, "\"two\"", "{")); + transport.responses.add(response(304, null, null)); + transport.responses.add(response(200, "\"three\"", UFC.replace("hello", "recovered"))); + final ConfigurationStore store = new ConfigurationStore(); + final CdnConfigurationSource source = source(store, transport, millis -> {}); + + assertTrue(source.pollOnce()); + final Object accepted = store.current(); + assertFalse(source.pollOnce()); + assertEquals(accepted, store.current()); + assertTrue(source.pollOnce()); + assertEquals("\"one\"", transport.requestHeaders.get(2).get("If-None-Match")); + assertTrue(source.pollOnce()); + assertEquals("\"one\"", transport.requestHeaders.get(3).get("If-None-Match")); + assertEquals("recovered", store.current().flags.get("message").variations.get("on").value); + } + + @Test + void retriesTimeoutAndServerError() { + final QueueTransport transport = new QueueTransport(); + transport.failures.add(new IOException("timeout")); + transport.responses.add(response(503, null, null)); + transport.responses.add(response(200, null, UFC)); + final List delays = new ArrayList<>(); + final CdnConfigurationSource source = source(new ConfigurationStore(), transport, delays::add); + + assertTrue(source.pollOnce()); + assertEquals(2, delays.size()); + assertEquals(3, transport.requestHeaders.size()); + } + + @Test + void preventsOverlappingPollsAndCancelsOnClose() throws Exception { + final BlockingTransport transport = new BlockingTransport(); + final ConfigurationStore store = new ConfigurationStore(); + final CdnConfigurationSource source = source(store, transport, millis -> {}); + final Thread first = new Thread(source::pollOnce); + first.start(); + assertTrue(transport.entered.await(1, TimeUnit.SECONDS)); + + assertFalse(source.pollOnce()); + source.close(); + transport.release.countDown(); + first.join(1_000); + + assertTrue(transport.cancelled.get()); + assertEquals(SourceStatus.CLOSED, source.status()); + assertFalse(store.hasConfiguration()); + } + + @Test + void startPollsImmediatelyAndCloseCancelsSchedule() { + final QueueTransport transport = new QueueTransport(); + transport.responses.add(response(200, null, UFC)); + final CdnConfigurationSource source = source(new ConfigurationStore(), transport, millis -> {}); + + source.start(); + source.start(); + assertEquals(SourceStatus.READY, source.status()); + assertEquals(1, transport.requestHeaders.size()); + + source.close(); + source.close(); + assertTrue(transport.cancelled.get()); + assertFalse(source.pollOnce()); + source.start(); + assertEquals(1, transport.requestHeaders.size()); + } + + @Test + void doesNotRetryPermanentFailures() { + final QueueTransport transport = new QueueTransport(); + transport.responses.add(response(401, null, null)); + final CdnConfigurationSource source = source(new ConfigurationStore(), transport, millis -> {}); + + assertFalse(source.pollOnce()); + assertEquals(1, transport.requestHeaders.size()); + } + + @Test + void calculatesBoundedRetryDelays() { + assertEquals(5_000, CdnConfigurationSource.retryDelayMillis(30_000, 1, 1)); + assertEquals(10_000, CdnConfigurationSource.retryDelayMillis(600_000, 1, 1)); + assertEquals(5_000, CdnConfigurationSource.retryDelayMillis(3_000, 2, 1)); + assertEquals(30_000, CdnConfigurationSource.retryDelayMillis(600_000, 2, 1)); + } + + private static CdnConfigurationSource source( + final ConfigurationStore store, + final CdnConfigurationSource.Transport transport, + final CdnConfigurationSource.Sleeper sleeper) { + final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + final HttpConfigurationOptions options = + HttpConfigurationOptions.builder() + .endpoint(URI.create("https://example.test/config")) + .pollInterval(Duration.ofHours(1)) + .requestTimeout(Duration.ofSeconds(1)) + .build(); + return new CdnConfigurationSource(options, store, transport, executor, sleeper, () -> 1); + } + + private static CdnConfigurationSource.TransportResponse response( + final int status, final String etag, final String body) { + return new CdnConfigurationSource.TransportResponse( + status, etag, body == null ? null : body.getBytes(UTF_8)); + } + + private static class QueueTransport implements CdnConfigurationSource.Transport { + final Queue responses = new ArrayDeque<>(); + final Queue failures = new ArrayDeque<>(); + final List> requestHeaders = new ArrayList<>(); + final AtomicBoolean cancelled = new AtomicBoolean(); + + @Override + public CdnConfigurationSource.TransportResponse fetch( + final HttpConfigurationOptions options, final Map headers) + throws IOException { + requestHeaders.add(headers); + final IOException failure = failures.poll(); + if (failure != null) { + throw failure; + } + return responses.remove(); + } + + @Override + public void cancel() { + cancelled.set(true); + } + } + + private static final class BlockingTransport extends QueueTransport { + final CountDownLatch entered = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + + @Override + public CdnConfigurationSource.TransportResponse fetch( + final HttpConfigurationOptions options, final Map headers) + throws IOException { + requestHeaders.add(headers); + entered.countDown(); + try { + release.await(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + return response(200, null, UFC); + } + } + + private static final String UFC = + "{\"format\":\"SERVER\",\"environment\":{\"name\":\"test\"},\"flags\":{" + + "\"message\":{\"key\":\"message\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"hello\"}}," + + "\"allocations\":[{\"key\":\"a\",\"rules\":[],\"splits\":[" + + "{\"variationKey\":\"on\",\"shards\":[]}]}]}}}"; +} diff --git a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnEndpointResolverTest.java b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnEndpointResolverTest.java new file mode 100644 index 00000000000..2403e060fbd --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnEndpointResolverTest.java @@ -0,0 +1,47 @@ +package datadog.openfeature.internal.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; +import org.junit.jupiter.api.Test; + +class CdnEndpointResolverTest { + + @Test + void resolvesManagedEndpointWithEnvironment() { + assertEquals( + URI.create( + "https://ufc-server.ff-cdn.datadoghq.eu/api/v2/feature-flagging/config/rules-based/server?dd_env=production"), + CdnEndpointResolver.resolve(null, "datadoghq.eu", "production")); + assertEquals( + URI.create( + "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server"), + CdnEndpointResolver.resolve(null, null, null)); + } + + @Test + void resolvesCustomBaseUrlAndKeepsExplicitPath() { + assertEquals( + URI.create("http://localhost:8080/api/v2/feature-flagging/config/rules-based/server"), + CdnEndpointResolver.resolve("http://localhost:8080/", "ignored", "ignored")); + assertEquals( + URI.create( + "https://example.test/api/v2/feature-flagging/config/rules-based/server?token=a%2Fb"), + CdnEndpointResolver.resolve("https://example.test/?token=a%2Fb", "ignored", "ignored")); + assertEquals( + URI.create("https://example.test/custom?query=true"), + CdnEndpointResolver.resolve( + " https://example.test/custom?query=true ", "ignored", "ignored")); + } + + @Test + void rejectsRelativeCustomUrlAndInvalidManagedSite() { + assertThrows( + IllegalArgumentException.class, + () -> CdnEndpointResolver.resolve("/relative", "datadoghq.com", null)); + assertThrows( + IllegalArgumentException.class, + () -> CdnEndpointResolver.resolve(null, "invalid site", null)); + } +} diff --git a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/HttpConfigurationOptionsTest.java b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/HttpConfigurationOptionsTest.java new file mode 100644 index 00000000000..fdd1d8be71d --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/HttpConfigurationOptionsTest.java @@ -0,0 +1,53 @@ +package datadog.openfeature.internal.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class HttpConfigurationOptionsTest { + + @Test + void buildsImmutableOptionsAndDefaults() { + final HttpConfigurationOptions options = + HttpConfigurationOptions.builder() + .endpoint(URI.create("https://example.test/config")) + .apiKey("key") + .managedEndpoint(true) + .build(); + + assertEquals(Duration.ofSeconds(30), options.pollInterval); + assertEquals(Duration.ofSeconds(5), options.requestTimeout); + assertEquals("key", options.apiKey); + assertTrue(options.managedEndpoint); + } + + @Test + void validatesRequiredAndPositiveOptions() { + assertThrows(NullPointerException.class, () -> HttpConfigurationOptions.builder().build()); + assertThrows( + NullPointerException.class, + () -> + HttpConfigurationOptions.builder() + .endpoint(URI.create("https://example.test")) + .pollInterval(null) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> + HttpConfigurationOptions.builder() + .endpoint(URI.create("https://example.test")) + .pollInterval(Duration.ZERO) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> + HttpConfigurationOptions.builder() + .endpoint(URI.create("https://example.test")) + .requestTimeout(Duration.ofSeconds(-1)) + .build()); + } +} diff --git a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java new file mode 100644 index 00000000000..800f6fd032e --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java @@ -0,0 +1,180 @@ +package datadog.openfeature.internal.http; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.GZIPOutputStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class Java11TransportTest { + + private HttpServer server; + + @AfterEach + void closeServer() { + if (server != null) { + server.stop(0); + } + } + + @Test + void sendsHeadersAndReadsResponse() throws Exception { + final AtomicReference apiKey = new AtomicReference<>(); + final AtomicReference etag = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + apiKey.set(exchange.getRequestHeaders().getFirst("DD-API-KEY")); + etag.set(exchange.getRequestHeaders().getFirst("If-None-Match")); + final byte[] body = "response".getBytes(UTF_8); + exchange.getResponseHeaders().add("ETag", "\"next\""); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + final CdnConfigurationSource.Java11Transport transport = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + + final CdnConfigurationSource.TransportResponse response = + transport.fetch(options(Duration.ofSeconds(1), true), Map.of("If-None-Match", "\"old\"")); + + assertEquals(200, response.status); + assertEquals("\"next\"", response.etag); + assertArrayEquals("response".getBytes(UTF_8), response.body); + assertEquals("secret", apiKey.get()); + assertEquals("\"old\"", etag.get()); + } + + @Test + void enforcesRequestTimeout() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + try { + Thread.sleep(250); + exchange.sendResponseHeaders(304, -1); + } catch (final InterruptedException error) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + server.start(); + final CdnConfigurationSource.Java11Transport transport = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + + assertThrows( + IOException.class, () -> transport.fetch(options(Duration.ofMillis(25), false), Map.of())); + } + + @Test + void requestsAndDecodesGzipResponses() throws Exception { + final AtomicReference acceptEncoding = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + acceptEncoding.set(exchange.getRequestHeaders().getFirst("Accept-Encoding")); + final byte[] body = gzip("compressed".getBytes(UTF_8)); + exchange.getResponseHeaders().add("Content-Encoding", "gzip"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + final CdnConfigurationSource.Java11Transport transport = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + + final CdnConfigurationSource.TransportResponse response = + transport.fetch(options(Duration.ofSeconds(1), false), Map.of()); + + assertEquals("gzip", acceptEncoding.get()); + assertArrayEquals("compressed".getBytes(UTF_8), response.body); + } + + @Test + void cancelsRequestsBeforeAndDuringFetch() throws Exception { + final CdnConfigurationSource.Java11Transport closed = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + closed.cancel(); + assertThrows( + InterruptedIOException.class, + () -> closed.fetch(options(Duration.ofSeconds(1), false), Map.of())); + + final CountDownLatch entered = new CountDownLatch(1); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + entered.countDown(); + try { + Thread.sleep(5_000); + } catch (final InterruptedException error) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + server.start(); + final CdnConfigurationSource.Java11Transport active = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + final AtomicReference failure = new AtomicReference<>(); + final Thread thread = + new Thread( + () -> { + try { + active.fetch(options(Duration.ofSeconds(10), false), Map.of()); + } catch (final Throwable error) { + failure.set(error); + } + }); + thread.start(); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + active.cancel(); + thread.join(1_000); + + assertTrue(failure.get() instanceof InterruptedIOException, String.valueOf(failure.get())); + } + + private HttpConfigurationOptions options( + final Duration requestTimeout, final boolean managedEndpoint) { + final URI endpoint = + server == null + ? URI.create("https://example.test/config") + : URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + return HttpConfigurationOptions.builder() + .endpoint(endpoint) + .requestTimeout(requestTimeout) + .pollInterval(Duration.ofSeconds(30)) + .apiKey("secret") + .managedEndpoint(managedEndpoint) + .build(); + } + + private static byte[] gzip(final byte[] input) throws IOException { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { + gzip.write(input); + } + return output.toByteArray(); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c2ef7c17958..cc9ca3d886d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -162,6 +162,8 @@ include( ":products:feature-flagging:feature-flagging-api", ":products:feature-flagging:feature-flagging-bootstrap", ":products:feature-flagging:feature-flagging-config", + ":products:feature-flagging:feature-flagging-core", + ":products:feature-flagging:feature-flagging-http", ":products:feature-flagging:feature-flagging-lib" ) From 2af86eb9414df6071eff81bd6976cbc683e24cce Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 29 Jul 2026 21:37:17 -0600 Subject: [PATCH 02/16] Delegate agent-backed flag evaluation to shared core --- .../trace/api/openfeature/DDEvaluator.java | 459 +----------------- .../OpenFeatureEvaluationAdapter.java | 229 +++++++++ .../ServerConfigurationAdapter.java | 165 +++++++ 3 files changed, 408 insertions(+), 445 deletions(-) create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ServerConfigurationAdapter.java 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 6d55c54a5b3..32310755ed7 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 @@ -1,64 +1,38 @@ package datadog.trace.api.openfeature; -import static java.util.Arrays.asList; - +import datadog.openfeature.internal.core.ConfigurationSnapshot; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; -import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.Rule; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.AbstractMap; -import java.util.Date; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; -import java.util.Objects; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; +/** Agent-backed entrypoint over the shared Feature Flagging evaluator. */ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { - private static final Set> SUPPORTED_RESOLUTION_TYPES = - new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); - - // Evaluation-metadata keys consumed by the span-enrichment capture hook (see - // SpanEnrichmentHook). Emitted only when the span-enrichment gate is on. static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; static final String METADATA_DO_LOG = "__dd_do_log"; - // Read once: when off, the __dd_* span-enrichment metadata is not attached to evaluations, so an - // enabled provider pays nothing extra unless span enrichment is also enabled. The gate does not - // change at runtime, and this class is loaded lazily (well after startup) so config is ready. private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled(); private final Runnable configCallback; - private final AtomicReference configuration = new AtomicReference<>(); + private final AtomicReference configuration = new AtomicReference<>(); private final CountDownLatch initializationLatch = new CountDownLatch(1); + private final OpenFeatureEvaluationAdapter evaluator = + new OpenFeatureEvaluationAdapter(DDEvaluator::dispatchExposure, SPAN_ENRICHMENT_ENABLED); public DDEvaluator(final Runnable configCallback) { this.configCallback = configCallback; @@ -84,7 +58,7 @@ public void shutdown() { @Override public void accept(final ServerConfiguration config) { - configuration.set(config); + configuration.set(ServerConfigurationAdapter.adapt(config)); if (config != null) { initializationLatch.countDown(); configCallback.run(); @@ -99,412 +73,18 @@ public ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { - try { - final ServerConfiguration config = configuration.get(); - if (config == null) { - return error(defaultValue, ErrorCode.PROVIDER_NOT_READY); - } - - if (context == null) { - return error(defaultValue, ErrorCode.INVALID_CONTEXT); - } - - final Flag flag = config.flags.get(key); - if (flag == null) { - return error(defaultValue, ErrorCode.FLAG_NOT_FOUND); - } - - if (!flag.enabled) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DISABLED.name()) - .build(); - } - - if (flag.allocations == null) { - return error(defaultValue, ErrorCode.GENERAL, "Missing allocations for flag " + key); - } - - final Date now = new Date(); - final String targetingKey = context.getTargetingKey(); - - for (final Allocation allocation : flag.allocations) { - if (!isAllocationActive(allocation, now)) { - continue; - } - - if (!isEmpty(allocation.rules)) { - if (!evaluateRules(allocation.rules, context)) { - continue; - } - } - - if (!isEmpty(allocation.splits)) { - for (final Split split : allocation.splits) { - if (isEmpty(split.shards)) { - return resolveVariant( - target, key, defaultValue, flag, split.variationKey, allocation, split, context); - } else { - if (targetingKey == null) { - return error(defaultValue, ErrorCode.TARGETING_KEY_MISSING); - } - // To match a split, subject must match ALL underlying shards - boolean allShardsMatch = true; - for (final Shard shard : split.shards) { - if (!matchesShard(shard, targetingKey)) { - allShardsMatch = false; - break; - } - } - if (allShardsMatch) { - return resolveVariant( - target, - key, - defaultValue, - flag, - split.variationKey, - allocation, - split, - context); - } - } - } - } - } - - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DEFAULT.name()) - .build(); - } catch (final PatternSyntaxException e) { - return error(defaultValue, ErrorCode.PARSE_ERROR, e); - } catch (final NumberFormatException e) { - return error(defaultValue, ErrorCode.TYPE_MISMATCH, e); - } catch (final Exception e) { - return error(defaultValue, ErrorCode.GENERAL, e); - } - } - - private static ProviderEvaluation error(final T defaultValue, final ErrorCode code) { - return error(defaultValue, code, (String) null); - } - - private static ProviderEvaluation error( - final T defaultValue, final ErrorCode code, final Throwable cause) { - return error(defaultValue, code, cause == null ? null : cause.getMessage()); - } - - private static ProviderEvaluation error( - final T defaultValue, final ErrorCode code, final String errorMessage) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.ERROR.name()) - .errorCode(code) - .errorMessage(errorMessage) - .build(); - } - - private static boolean isEmpty(final List list) { - return list == null || list.isEmpty(); - } - - private static boolean isAllocationActive(final Allocation allocation, final Date now) { - final Date startDate = allocation.startAt; - if (startDate != null && now.before(startDate)) { - return false; - } - - final Date endDate = allocation.endAt; - if (endDate != null && now.after(endDate)) { - return false; - } - - return true; - } - - private static boolean evaluateRules(final List rules, final EvaluationContext context) { - for (final Rule rule : rules) { - if (isEmpty(rule.conditions)) { - continue; - } - - boolean allConditionsMatch = true; - for (final ConditionConfiguration condition : rule.conditions) { - if (!evaluateCondition(condition, context)) { - allConditionsMatch = false; - break; - } - } - - if (allConditionsMatch) { - return true; - } - } - return false; - } - - private static boolean evaluateCondition( - final ConditionConfiguration condition, final EvaluationContext context) { - if (condition.operator == ConditionOperator.IS_NULL) { - final Object value = resolveAttribute(condition.attribute, context); - boolean isNull = value == null; - // condition.value determines if we're checking for null (true) or not null (false) - boolean expectedNull = condition.value instanceof Boolean ? (Boolean) condition.value : true; - return isNull == expectedNull; - } - - final Object attributeValue = resolveAttribute(condition.attribute, context); - if (attributeValue == null) { - return false; - } - - switch (condition.operator) { - case MATCHES: - return matchesRegex(attributeValue, condition.value); - case NOT_MATCHES: - return !matchesRegex(attributeValue, condition.value); - case ONE_OF: - return isOneOf(attributeValue, condition.value); - case NOT_ONE_OF: - return !isOneOf(attributeValue, condition.value); - case GTE: - return compareNumber(attributeValue, condition.value, (a, b) -> a >= b); - case GT: - return compareNumber(attributeValue, condition.value, (a, b) -> a > b); - case LTE: - return compareNumber(attributeValue, condition.value, (a, b) -> a <= b); - case LT: - return compareNumber(attributeValue, condition.value, (a, b) -> a < b); - default: - return false; - } - } - - private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { - // PatternSyntaxException is intentionally not caught here so it propagates to evaluate(), - // which maps it to ErrorCode.PARSE_ERROR. - final Pattern pattern = Pattern.compile(String.valueOf(conditionValue)); - return pattern.matcher(String.valueOf(attributeValue)).find(); - } - - private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { - if (!(conditionValue instanceof Iterable)) { - return false; - } - for (final Object value : (Iterable) conditionValue) { - if (valuesEqual(attributeValue, value)) { - return true; - } - } - return false; + return evaluator.evaluate(configuration.get(), target, key, defaultValue, context); } - private static boolean valuesEqual(final Object a, final Object b) { - if (Objects.equals(a, b)) { - return true; - } - - if (a instanceof Number || b instanceof Number) { - return compareNumber(a, b, (first, second) -> first == second); - } - - return String.valueOf(a).equals(String.valueOf(b)); - } - - private static boolean compareNumber( - final Object attributeValue, final Object conditionValue, NumberComparator comparator) { - final double a = mapValue(Double.class, attributeValue); - final double b = mapValue(Double.class, conditionValue); - return comparator.compare(a, b); - } - - private static boolean matchesShard(final Shard shard, final String targetingKey) { - final int assignedShard = getShard(shard.salt, targetingKey, shard.totalShards); - for (final ShardRange range : shard.ranges) { - if (assignedShard >= range.start && assignedShard < range.end) { - return true; - } - } - return false; - } - - private static int getShard(final String salt, final String targetingKey, final int totalShards) { - final String hashKey = salt + "-" + targetingKey; - final String md5Hash = getMD5Hash(hashKey); - final String first8Chars = md5Hash.substring(0, Math.min(8, md5Hash.length())); - final long intFromHash = Long.parseLong(first8Chars, 16); - return (int) (intFromHash % totalShards); - } - - private static String getMD5Hash(final String input) { - try { - final MessageDigest md = MessageDigest.getInstance("MD5"); - final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); - final StringBuilder hexString = new StringBuilder(); - for (byte b : hashBytes) { - final String hex = Integer.toHexString(0xff & b); - if (hex.length() == 1) { - hexString.append('0'); - } - hexString.append(hex); - } - return hexString.toString(); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("MD5 algorithm not available", e); - } - } - - private static ProviderEvaluation resolveVariant( - final Class target, - final String key, - final T defaultValue, - final Flag flag, - final String variationKey, - final Allocation allocation, - final Split split, - final EvaluationContext context) { - final Variant variant = flag.variations.get(variationKey); - if (variant == null) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("Variant not found for: " + variationKey) - .build(); - } - - if (!isTypeCompatible(target, flag.variationType)) { - return error( - defaultValue, - ErrorCode.TYPE_MISMATCH, - "Requested type " - + target.getSimpleName() - + " does not match flag variationType " - + flag.variationType.name()); - } - - final T mappedValue; - try { - mappedValue = mapValue(target, variant.value); - } catch (final NumberFormatException e) { - return error( - defaultValue, - ErrorCode.PARSE_ERROR, - "Variant '" - + variant.key - + "' value does not match declared type " - + flag.variationType.name() - + ": " - + e.getMessage()); - } - - final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = - ImmutableMetadata.builder() - .addString("flagKey", flag.key) - .addString("variationType", flag.variationType.name()) - .addString("allocationKey", allocation.key); - // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment — - // only when span enrichment is on, so a provider without enrichment pays nothing extra. - // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always - // present (when enrichment is on) so the span-enrichment hook can decide whether to record the - // subject. - if (SPAN_ENRICHMENT_ENABLED) { - if (split.serialId != null) { - metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); - } - metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); - } - final ProviderEvaluation result = - ProviderEvaluation.builder() - .value(mappedValue) - .reason( - !isEmpty(allocation.rules) - ? Reason.TARGETING_MATCH.name() - : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) - .variant(variant.key) - .flagMetadata(metadataBuilder.build()) - .build(); - final boolean doLog = allocation.doLog != null && allocation.doLog; - if (doLog) { - dispatchExposure(key, result, context); - } - return result; - } - - private static Object resolveAttribute(final String name, final EvaluationContext context) { - // Special handling for "id" attribute: if not explicitly provided, use targeting key - if ("id".equals(name) && !context.keySet().contains(name)) { - return context.getTargetingKey(); - } - final Value resolved = context.getValue(name); - return context.convertValue(resolved); - } - - private static boolean isTypeCompatible(final Class target, final ValueType variationType) { - if (variationType == null) { - return true; // No type info — allow any - } - switch (variationType) { - case BOOLEAN: - return target == Boolean.class; - case STRING: - return target == String.class; - case INTEGER: - return target == Integer.class; - case NUMERIC: - return target == Double.class; - case JSON: - return target == Value.class; - default: - return true; // Unknown types pass through — mapValue errors caught as GENERAL - } - } - - @SuppressWarnings("unchecked") static T mapValue(final Class target, final Object value) { - if (value == null) { - return null; - } - if (!SUPPORTED_RESOLUTION_TYPES.contains(target)) { - throw new IllegalArgumentException("Type not supported: " + target); - } - if (target.isInstance(value)) { - return target.cast(value); - } - if (target == String.class) { - return (T) String.valueOf(value); - } - if (target == Boolean.class) { - if (value instanceof Number) { - return (T) (Boolean) (parseDouble(value) != 0); - } - return (T) Boolean.valueOf(value.toString()); - } - if (target == Integer.class) { - final Double number = parseDouble(value); - return (T) (Integer) number.intValue(); - } - if (target == Double.class) { - final Double number = parseDouble(value); - return (T) number; - } - return (T) Value.objectToValue(value); - } - - private static Double parseDouble(final Object value) { - if (value instanceof Number) { - return ((Number) value).doubleValue(); - } - return Double.parseDouble(String.valueOf(value)); + return OpenFeatureEvaluationAdapter.mapValue(target, value); } - private static void dispatchExposure( - final String flag, final ProviderEvaluation evaluation, final EvaluationContext context) { - final String allocationKey = allocationKey(evaluation); - final String variantKey = evaluation.getVariant(); - if (allocationKey == null || variantKey == null) { - return; - } + private static void dispatchExposure( + final String flag, + final String allocationKey, + final String variantKey, + final EvaluationContext context) { final ExposureEvent event = new ExposureEvent( System.currentTimeMillis(), @@ -512,15 +92,9 @@ private static void dispatchExposure( new datadog.trace.api.featureflag.exposure.Flag(flag), new datadog.trace.api.featureflag.exposure.Variant(variantKey), new Subject(context.getTargetingKey(), flattenContext(context))); - FeatureFlaggingGateway.dispatch(event); } - private static String allocationKey(final ProviderEvaluation resolution) { - final ImmutableMetadata meta = resolution.getFlagMetadata(); - return meta == null ? null : meta.getString("allocationKey"); - } - static AbstractMap flattenContext(final EvaluationContext context) { final Set keys = context.keySet(); final HashMap result = new HashMap<>(); @@ -554,12 +128,7 @@ static AbstractMap flattenContext(final EvaluationContext contex return result; } - @FunctionalInterface - private interface NumberComparator { - boolean compare(double a, double b); - } - - private static class FlattenEntry { + private static final class FlattenEntry { private final String key; private final Value value; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java new file mode 100644 index 00000000000..99fb00759ae --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java @@ -0,0 +1,229 @@ +package datadog.trace.api.openfeature; + +import datadog.openfeature.internal.core.ConfigurationSnapshot; +import datadog.openfeature.internal.core.EvaluationResult; +import datadog.openfeature.internal.core.EvaluationResult.Reason; +import datadog.openfeature.internal.core.FlagEvaluator; +import datadog.openfeature.internal.core.FlagEvaluator.ValueKind; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Maps OpenFeature values to the shared evaluator model. */ +final class OpenFeatureEvaluationAdapter { + + interface ExposureHandler { + void accept( + String flagKey, + String allocationKey, + String variantKey, + EvaluationContext evaluationContext); + } + + private static final ExposureHandler NO_EXPOSURES = + (flagKey, allocationKey, variantKey, evaluationContext) -> {}; + + private final FlagEvaluator evaluator = new FlagEvaluator(); + private final ExposureHandler exposureHandler; + private final boolean spanEnrichmentEnabled; + + OpenFeatureEvaluationAdapter( + final ExposureHandler exposureHandler, final boolean spanEnrichmentEnabled) { + this.exposureHandler = exposureHandler == null ? NO_EXPOSURES : exposureHandler; + this.spanEnrichmentEnabled = spanEnrichmentEnabled; + } + + ProviderEvaluation evaluate( + final ConfigurationSnapshot snapshot, + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + final EvaluationResult result = + evaluator.evaluate( + snapshot, + valueKind(target), + key, + unwrapDefaultValue(defaultValue), + toCoreContext(context)); + return toProviderEvaluation(target, key, defaultValue, context, result); + } + + private ProviderEvaluation toProviderEvaluation( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context, + final EvaluationResult result) { + if (result.error != null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(dev.openfeature.sdk.Reason.ERROR.name()) + .errorCode(errorCode(result.error)) + .errorMessage(result.errorMessage) + .build(); + } + + final ImmutableMetadata.ImmutableMetadataBuilder metadata = ImmutableMetadata.builder(); + if (result.flagKey != null) { + metadata.addString("flagKey", result.flagKey); + } + if (result.variationType != null) { + metadata.addString("variationType", result.variationType); + } + if (result.allocationKey != null) { + metadata.addString("allocationKey", result.allocationKey); + } + if (spanEnrichmentEnabled) { + if (result.splitSerialId != null) { + metadata.addInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID, result.splitSerialId); + } + metadata.addBoolean(DDEvaluator.METADATA_DO_LOG, result.doLog); + } + + final T value = + target == Value.class + && (result.reason == Reason.DISABLED || result.reason == Reason.DEFAULT) + ? defaultValue + : mapResultValue(target, result.value); + final ProviderEvaluation evaluation = + ProviderEvaluation.builder() + .value(value) + .reason(result.reason.name()) + .variant(result.variant) + .flagMetadata(metadata.build()) + .build(); + if (result.doLog && context != null && result.allocationKey != null && result.variant != null) { + exposureHandler.accept(key, result.allocationKey, result.variant, context); + } + return evaluation; + } + + static ValueKind valueKind(final Class target) { + if (target == Boolean.class) { + return ValueKind.BOOLEAN; + } + if (target == String.class) { + return ValueKind.STRING; + } + if (target == Integer.class) { + return ValueKind.INTEGER; + } + if (target == Double.class) { + return ValueKind.DOUBLE; + } + if (target == Value.class) { + return ValueKind.OBJECT; + } + throw new IllegalArgumentException("Type not supported: " + target); + } + + private static datadog.openfeature.internal.core.EvaluationContext toCoreContext( + final EvaluationContext context) { + if (context == null) { + return null; + } + return datadog.openfeature.internal.core.EvaluationContext.lazy( + context.getTargetingKey(), + new datadog.openfeature.internal.core.EvaluationContext.AttributeProvider() { + @Override + public boolean contains(final String name) { + return context.keySet().contains(name); + } + + @Override + public Object get(final String name) { + return unwrapValue(context.getValue(name)); + } + }); + } + + private static ErrorCode errorCode(final EvaluationResult.Error error) { + switch (error) { + case PROVIDER_NOT_READY: + return ErrorCode.PROVIDER_NOT_READY; + case INVALID_CONTEXT: + return ErrorCode.INVALID_CONTEXT; + case FLAG_NOT_FOUND: + return ErrorCode.FLAG_NOT_FOUND; + case TARGETING_KEY_MISSING: + return ErrorCode.TARGETING_KEY_MISSING; + case TYPE_MISMATCH: + return ErrorCode.TYPE_MISMATCH; + case PARSE_ERROR: + return ErrorCode.PARSE_ERROR; + default: + return ErrorCode.GENERAL; + } + } + + @SuppressWarnings("unchecked") + private static T mapResultValue(final Class target, final Object value) { + if (target == Value.class) { + return (T) Value.objectToValue(value); + } + return target.cast(value); + } + + @SuppressWarnings("unchecked") + static T mapValue(final Class target, final Object value) { + final Object mapped = FlagEvaluator.mapValue(valueKind(target), value); + return mapped == null + ? null + : target == Value.class ? (T) Value.objectToValue(mapped) : target.cast(mapped); + } + + static Object unwrapDefaultValue(final Object value) { + return value instanceof Value ? unwrapValue((Value) value) : value; + } + + private static Object unwrapValue(final Value value) { + if (value == null || value.isNull()) { + return null; + } + if (value.isStructure()) { + final Map map = new LinkedHashMap<>(); + if (value.asStructure() != null) { + for (final String key : value.asStructure().keySet()) { + map.put(key, unwrapValue(value.asStructure().getValue(key))); + } + } + return map; + } + if (value.isList()) { + final List list = value.asList(); + final List output = new ArrayList<>(list == null ? 0 : list.size()); + if (list != null) { + for (final Value element : list) { + output.add(unwrapValue(element)); + } + } + return output; + } + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isString()) { + return value.asString(); + } + if (value.isNumber()) { + final Double number = value.asDouble(); + if (number != null && number == Math.rint(number) && !Double.isInfinite(number)) { + final Integer integer = value.asInteger(); + if (integer != null) { + return integer; + } + } + return number; + } + final Instant instant = value.asInstant(); + return instant == null ? value.asObject() : instant.toString(); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ServerConfigurationAdapter.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ServerConfigurationAdapter.java new file mode 100644 index 00000000000..1def0c09413 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ServerConfigurationAdapter.java @@ -0,0 +1,165 @@ +package datadog.trace.api.openfeature; + +import datadog.openfeature.internal.core.ConfigurationSnapshot; +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.Rule; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Shard; +import datadog.trace.api.featureflag.ufc.v1.ShardRange; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.Variant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Converts the agent-delivered UFC model to the shared evaluator model. */ +final class ServerConfigurationAdapter { + + private ServerConfigurationAdapter() {} + + static ConfigurationSnapshot adapt(final ServerConfiguration source) { + if (source == null) { + return null; + } + final Map flags = new LinkedHashMap<>(); + if (source.flags != null) { + for (final Map.Entry entry : source.flags.entrySet()) { + flags.put(entry.getKey(), adapt(entry.getValue())); + } + } + return new ConfigurationSnapshot( + source.createdAt, + source.format, + source.environment == null ? null : source.environment.name, + flags); + } + + private static ConfigurationSnapshot.Flag adapt(final Flag source) { + if (source == null) { + return null; + } + final Map variants = new LinkedHashMap<>(); + if (source.variations != null) { + for (final Map.Entry entry : source.variations.entrySet()) { + final Variant variant = entry.getValue(); + variants.put( + entry.getKey(), + variant == null ? null : new ConfigurationSnapshot.Variant(variant.key, variant.value)); + } + } + return new ConfigurationSnapshot.Flag( + source.key, + source.enabled, + source.variationType == null + ? null + : ConfigurationSnapshot.ValueType.valueOf(source.variationType.name()), + variants, + adaptAllocations(source.allocations)); + } + + private static List adaptAllocations( + final List sources) { + if (sources == null) { + return null; + } + final List result = new ArrayList<>(sources.size()); + for (final Allocation source : sources) { + result.add( + source == null + ? null + : new ConfigurationSnapshot.Allocation( + source.key, + adaptRules(source.rules), + source.startAt == null ? null : source.startAt.getTime(), + source.endAt == null ? null : source.endAt.getTime(), + adaptSplits(source.splits), + Boolean.TRUE.equals(source.doLog))); + } + return result; + } + + private static List adaptRules(final List sources) { + if (sources == null) { + return null; + } + final List result = new ArrayList<>(sources.size()); + for (final Rule source : sources) { + result.add( + source == null + ? null + : new ConfigurationSnapshot.Rule(adaptConditions(source.conditions))); + } + return result; + } + + private static List adaptConditions( + final List sources) { + if (sources == null) { + return null; + } + final List result = new ArrayList<>(sources.size()); + for (final ConditionConfiguration source : sources) { + result.add( + source == null + ? null + : new ConfigurationSnapshot.Condition( + source.operator == null + ? null + : ConfigurationSnapshot.ConditionOperator.valueOf(source.operator.name()), + source.attribute, + source.value)); + } + return result; + } + + private static List adaptSplits(final List sources) { + if (sources == null) { + return null; + } + final List result = new ArrayList<>(sources.size()); + for (final Split source : sources) { + result.add( + source == null + ? null + : new ConfigurationSnapshot.Split( + adaptShards(source.shards), + source.variationKey, + source.extraLogging == null ? null : new LinkedHashMap<>(source.extraLogging), + source.serialId)); + } + return result; + } + + private static List adaptShards(final List sources) { + if (sources == null) { + return null; + } + final List result = new ArrayList<>(sources.size()); + for (final Shard source : sources) { + result.add( + source == null + ? null + : new ConfigurationSnapshot.Shard( + source.salt, adaptRanges(source.ranges), source.totalShards)); + } + return result; + } + + private static List adaptRanges( + final List sources) { + if (sources == null) { + return Collections.emptyList(); + } + final List result = new ArrayList<>(sources.size()); + for (final ShardRange source : sources) { + if (source != null) { + result.add(new ConfigurationSnapshot.ShardRange(source.start, source.end)); + } + } + return result; + } +} From 0c792890309c86e13943777f0ac33e74110bff41 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 29 Jul 2026 21:37:30 -0600 Subject: [PATCH 03/16] Support CDN polling without the Java agent --- .../trace/api/openfeature/Provider.java | 17 +- .../openfeature/StandaloneDDEvaluator.java | 63 +++++++ .../StandaloneProviderRuntime.java | 118 +++++++++++++ .../StandaloneRuntimeConfiguration.java | 164 ++++++++++++++++++ .../openfeature/ProviderOnlyChildJvmTest.java | 55 ++++++ .../openfeature/ProviderOnlyChildMain.java | 82 +++++++++ .../RemoteConfigWithoutAgentChildJvmTest.java | 53 ++++++ .../RemoteConfigWithoutAgentChildMain.java | 23 +++ .../StandaloneRuntimeConfigurationTest.java | 88 ++++++++++ 9 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildJvmTest.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildMain.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildJvmTest.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildMain.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java 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 38fa735d4e9..7f9bc2eddd0 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 @@ -29,6 +29,8 @@ public class Provider extends EventProvider implements Metadata { private static final Logger log = LoggerFactory.getLogger(Provider.class); static final String METADATA = "datadog-openfeature-provider"; private static final String EVALUATOR_IMPL = "datadog.trace.api.openfeature.DDEvaluator"; + private static final String AGENT_GATEWAY = + "datadog.trace.api.featureflag.FeatureFlaggingGateway"; private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); private volatile Evaluator evaluator; @@ -129,7 +131,7 @@ public void initialize(final EvaluationContext context) throws Exception { throw e; } catch (final Throwable e) { markInitializationError(); - throw new FatalError("Failed to initialize provider, is the tracer configured?", e); + throw new FatalError("Failed to initialize provider: " + e.getMessage(), e); } } @@ -206,6 +208,9 @@ private Evaluator buildEvaluator() throws Exception { if (evaluator != null) { return evaluator; } + if (!isAgentGatewayAvailable()) { + return new StandaloneDDEvaluator(this::onConfigurationChange); + } final Class evaluatorClass = loadEvaluatorClass(); final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); @@ -279,6 +284,16 @@ protected Class loadEvaluatorClass() throws ClassNotFoundException { return Class.forName(EVALUATOR_IMPL); } + @SuppressForbidden // Class#forName is required because the agent classes are optional + protected boolean isAgentGatewayAvailable() { + try { + Class.forName(AGENT_GATEWAY, false, Provider.class.getClassLoader()); + return true; + } catch (final ClassNotFoundException | LinkageError ignored) { + return false; + } + } + private enum InitializationState { NOT_STARTED, INITIALIZING, diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java new file mode 100644 index 00000000000..5c66869da97 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java @@ -0,0 +1,63 @@ +package datadog.trace.api.openfeature; + +import datadog.openfeature.internal.core.ConfigurationSnapshot; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.util.concurrent.TimeUnit; + +/** No-agent entrypoint that owns CDN polling and delegates evaluation to the shared core. */ +final class StandaloneDDEvaluator implements Evaluator { + + private final Runnable configCallback; + private final OpenFeatureEvaluationAdapter evaluator = + new OpenFeatureEvaluationAdapter(null, false); + private volatile StandaloneProviderRuntime.Handle runtime; + + StandaloneDDEvaluator(final Runnable configCallback) { + this.configCallback = configCallback; + } + + @Override + public boolean initialize( + final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { + StandaloneProviderRuntime.Handle current = runtime; + if (current == null) { + synchronized (this) { + current = runtime; + if (current == null) { + current = + StandaloneProviderRuntime.acquire( + StandaloneRuntimeConfiguration.resolve(), ignored -> configCallback.run()); + runtime = current; + } + } + } + return current.awaitConfiguration(timeout, unit) || hasConfiguration(); + } + + @Override + public boolean hasConfiguration() { + final StandaloneProviderRuntime.Handle current = runtime; + return current != null && current.configuration() != null; + } + + @Override + public void shutdown() { + final StandaloneProviderRuntime.Handle current = runtime; + runtime = null; + if (current != null) { + current.close(); + } + } + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + final StandaloneProviderRuntime.Handle current = runtime; + final ConfigurationSnapshot snapshot = current == null ? null : current.configuration(); + return evaluator.evaluate(snapshot, target, key, defaultValue, context); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java new file mode 100644 index 00000000000..67786192cd1 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java @@ -0,0 +1,118 @@ +package datadog.trace.api.openfeature; + +import datadog.openfeature.internal.core.ConfigurationSnapshot; +import datadog.openfeature.internal.core.ConfigurationSource; +import datadog.openfeature.internal.core.ConfigurationStore; +import datadog.openfeature.internal.http.CdnConfigurationSource; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** One reference-counted CDN runtime per provider classloader. */ +final class StandaloneProviderRuntime { + + private static final Object LOCK = new Object(); + private static SharedRuntime shared; + + private StandaloneProviderRuntime() {} + + static Handle acquire( + final StandaloneRuntimeConfiguration configuration, + final Consumer listener) { + if (configuration.source == StandaloneRuntimeConfiguration.Source.DISABLED) { + throw new IllegalStateException("Datadog OpenFeature provider is disabled by configuration"); + } + if (configuration.source == StandaloneRuntimeConfiguration.Source.REMOTE_CONFIG) { + throw new IllegalStateException( + "The remote_config source requires dd-java-agent.jar. " + + "Use the agentless source when the Java agent is not installed."); + } + synchronized (LOCK) { + if (shared == null) { + shared = new SharedRuntime(configuration); + } else if (!shared.configuration.equals(configuration)) { + throw new IllegalStateException( + "All Datadog OpenFeature providers in one application classloader must use " + + "the same configuration source and options"); + } + shared.references++; + shared.store.addListener(listener); + try { + shared.start(); + } catch (final RuntimeException | Error e) { + shared.store.removeListener(listener); + shared.references--; + if (shared.references == 0) { + shared.close(); + shared = null; + } + throw e; + } + return new Handle(shared, listener); + } + } + + static final class Handle implements AutoCloseable { + private SharedRuntime runtime; + private Consumer listener; + + private Handle(final SharedRuntime runtime, final Consumer listener) { + this.runtime = runtime; + this.listener = listener; + } + + ConfigurationSnapshot configuration() { + return runtime == null ? null : runtime.store.current(); + } + + boolean awaitConfiguration(final long timeout, final TimeUnit unit) + throws InterruptedException { + return runtime != null && runtime.store.awaitConfiguration(timeout, unit); + } + + @Override + public void close() { + synchronized (LOCK) { + if (runtime == null) { + return; + } + runtime.store.removeListener(listener); + runtime.references--; + if (runtime.references == 0) { + runtime.close(); + if (shared == runtime) { + shared = null; + } + } + runtime = null; + listener = null; + } + } + } + + private static final class SharedRuntime { + private final StandaloneRuntimeConfiguration configuration; + private final ConfigurationStore store = new ConfigurationStore(); + private final ConfigurationSource source; + private int references; + private boolean started; + + private SharedRuntime(final StandaloneRuntimeConfiguration configuration) { + this.configuration = configuration; + source = new CdnConfigurationSource(configuration.http, store); + } + + private void start() { + if (started) { + return; + } + started = true; + source.start(); + } + + private void close() { + source.close(); + store.clear(); + started = false; + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java new file mode 100644 index 00000000000..1ff1e975847 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java @@ -0,0 +1,164 @@ +package datadog.trace.api.openfeature; + +import datadog.openfeature.internal.http.CdnEndpointResolver; +import datadog.openfeature.internal.http.HttpConfigurationOptions; +import de.thetaphi.forbiddenapis.SuppressForbidden; +import java.net.URI; +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; + +/** Immutable configuration for the provider-owned CDN runtime. */ +final class StandaloneRuntimeConfiguration { + + enum Source { + CDN, + REMOTE_CONFIG, + DISABLED + } + + final Source source; + final HttpConfigurationOptions http; + + private StandaloneRuntimeConfiguration(final Source source, final HttpConfigurationOptions http) { + this.source = source; + this.http = http; + } + + static StandaloneRuntimeConfiguration resolve() { + final Boolean providerEnabled = + booleanSetting("dd.feature.flags.enabled", "DD_FEATURE_FLAGS_ENABLED"); + final String sourceValue = + setting("dd.feature.flags.configuration.source", "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"); + final Boolean legacyProviderEnabled = + booleanSetting( + "dd.experimental.flagging.provider.enabled", + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"); + final Source source = resolveSource(providerEnabled, sourceValue, legacyProviderEnabled); + if (source != Source.CDN) { + return new StandaloneRuntimeConfiguration(source, null); + } + + final String configuredBaseUrl = + setting( + "dd.feature.flags.configuration.source.agentless.base.url", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL"); + final String site = first(setting("dd.site", "DD_SITE"), "datadoghq.com"); + final String environment = setting("dd.env", "DD_ENV"); + final URI endpoint = CdnEndpointResolver.resolve(configuredBaseUrl, site, environment); + final Duration pollInterval = + seconds( + setting( + "dd.feature.flags.configuration.source.agentless.poll.interval.seconds", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS"), + 30); + final Duration requestTimeout = + seconds( + setting( + "dd.feature.flags.configuration.source.agentless.request.timeout.seconds", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS"), + 5); + final String apiKey = setting("dd.api.key", "DD_API_KEY"); + return new StandaloneRuntimeConfiguration( + source, + HttpConfigurationOptions.builder() + .endpoint(endpoint) + .pollInterval(pollInterval) + .requestTimeout(requestTimeout) + .apiKey(apiKey) + .managedEndpoint(configuredBaseUrl == null) + .build()); + } + + static Source resolveSource( + final Boolean providerEnabled, + final String sourceValue, + final Boolean legacyProviderEnabled) { + if (Boolean.FALSE.equals(providerEnabled)) { + return Source.DISABLED; + } + if (sourceValue == null || sourceValue.trim().isEmpty()) { + if (legacyProviderEnabled != null) { + return legacyProviderEnabled ? Source.REMOTE_CONFIG : Source.DISABLED; + } + return Source.CDN; + } + final String normalized = sourceValue.trim().toLowerCase(Locale.ROOT); + if ("agentless".equals(normalized)) { + return Source.CDN; + } + if ("remote_config".equals(normalized)) { + return Source.REMOTE_CONFIG; + } + throw new IllegalArgumentException( + "Unsupported Feature Flagging configuration source: " + sourceValue); + } + + private static Duration seconds(final String value, final long defaultValue) { + if (value == null) { + return Duration.ofSeconds(defaultValue); + } + try { + final long seconds = Long.parseLong(value); + return seconds > 0 ? Duration.ofSeconds(seconds) : Duration.ofSeconds(defaultValue); + } catch (final NumberFormatException ignored) { + return Duration.ofSeconds(defaultValue); + } + } + + @SuppressForbidden + private static String setting(final String property, final String environment) { + final String propertyValue = System.getProperty(property); + return propertyValue != null ? propertyValue : System.getenv(environment); + } + + private static Boolean booleanSetting(final String property, final String environment) { + final String value = setting(property, environment); + return value == null ? null : Boolean.valueOf(value); + } + + private static String first(final String... values) { + for (final String value : values) { + if (value != null && !value.isEmpty()) { + return value; + } + } + return null; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (!(other instanceof StandaloneRuntimeConfiguration)) { + return false; + } + final StandaloneRuntimeConfiguration that = (StandaloneRuntimeConfiguration) other; + return source == that.source + && Objects.equals( + http == null ? null : http.endpoint, that.http == null ? null : that.http.endpoint) + && Objects.equals( + http == null ? null : http.pollInterval, + that.http == null ? null : that.http.pollInterval) + && Objects.equals( + http == null ? null : http.requestTimeout, + that.http == null ? null : that.http.requestTimeout) + && Objects.equals( + http == null ? null : http.apiKey, that.http == null ? null : that.http.apiKey) + && (http == null + ? that.http == null + : that.http != null && http.managedEndpoint == that.http.managedEndpoint); + } + + @Override + public int hashCode() { + return Objects.hash( + source, + http == null ? null : http.endpoint, + http == null ? null : http.pollInterval, + http == null ? null : http.requestTimeout, + http == null ? null : http.apiKey, + http != null && http.managedEndpoint); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildJvmTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildJvmTest.java new file mode 100644 index 00000000000..56ea6052157 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildJvmTest.java @@ -0,0 +1,55 @@ +package datadog.trace.api.openfeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class ProviderOnlyChildJvmTest { + + @Test + void evaluatesCdnFlagWithoutJavaAgent() throws Exception { + final String classpath = + Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator)) + .filter(path -> !path.contains("feature-flagging-bootstrap")) + .filter(path -> !path.contains("feature-flagging-core")) + .filter(path -> !path.contains("feature-flagging-http")) + .filter( + path -> + !path.contains( + "feature-flagging-api" + + File.separator + + "build" + + File.separator + + "classes" + + File.separator + + "java" + + File.separator + + "main")) + .collect(Collectors.joining(File.pathSeparator)) + + File.pathSeparator + + System.getProperty("dd.openfeature.test.jar"); + final Process process = + new ProcessBuilder( + new File(System.getProperty("java.home"), "bin/java").getAbsolutePath(), + "-cp", + classpath, + ProviderOnlyChildMain.class.getName()) + .redirectErrorStream(true) + .start(); + + assertTrue(process.waitFor(20, TimeUnit.SECONDS)); + final String output = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.exitValue(), output); + assertTrue(output.contains("AGENT_ATTACHED=false"), output); + assertTrue(output.contains("AGENT_GATEWAY_AVAILABLE=false"), output); + assertTrue(output.contains("REQUESTS_BEFORE_ACTIVATION=0"), output); + assertTrue(output.contains("VALUE=hello"), output); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildMain.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildMain.java new file mode 100644 index 00000000000..ba7ece1070d --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildMain.java @@ -0,0 +1,82 @@ +package datadog.trace.api.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.sun.net.httpserver.HttpServer; +import dev.openfeature.sdk.MutableContext; +import java.lang.management.ManagementFactory; +import java.net.InetSocketAddress; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** Child JVM entry point for the provider-only CDN smoke test. */ +public final class ProviderOnlyChildMain { + + private ProviderOnlyChildMain() {} + + public static void main(final String[] args) throws Exception { + final boolean agentAttached = + ManagementFactory.getRuntimeMXBean().getInputArguments().stream() + .anyMatch(argument -> argument.startsWith("-javaagent:")); + final AtomicInteger requests = new AtomicInteger(); + final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + requests.incrementAndGet(); + final byte[] response = UFC.getBytes(UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + try { + boolean agentGatewayAvailable = false; + try { + Class.forName("datadog.trace.api.featureflag.FeatureFlaggingGateway"); + agentGatewayAvailable = true; + } catch (final ClassNotFoundException expected) { + } + System.setProperty( + "dd.feature.flags.configuration.source.agentless.base.url", + "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + System.setProperty("dd.feature.flags.configuration.source", "agentless"); + final Provider provider = + new Provider(new Provider.Options().initTimeout(1, TimeUnit.SECONDS)); + final int beforeActivation = requests.get(); + provider.initialize(new MutableContext("child")); + final String value = + provider + .getStringEvaluation("message", "default", new MutableContext("child")) + .getValue(); + final int afterActivation = requests.get(); + provider.shutdown(); + final int afterShutdown = requests.get(); + Thread.sleep(150); + + System.out.println("AGENT_ATTACHED=" + agentAttached); + System.out.println("AGENT_GATEWAY_AVAILABLE=" + agentGatewayAvailable); + System.out.println("REQUESTS_BEFORE_ACTIVATION=" + beforeActivation); + System.out.println("REQUESTS_AFTER_ACTIVATION=" + afterActivation); + System.out.println("REQUESTS_AFTER_SHUTDOWN=" + requests.get()); + System.out.println("VALUE=" + value); + if (agentAttached + || agentGatewayAvailable + || beforeActivation != 0 + || afterActivation == 0 + || requests.get() != afterShutdown + || !"hello".equals(value)) { + System.exit(2); + } + } finally { + server.stop(0); + } + } + + private static final String UFC = + "{\"format\":\"SERVER\",\"environment\":{\"name\":\"test\"},\"flags\":{" + + "\"message\":{\"key\":\"message\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"hello\"}}," + + "\"allocations\":[{\"key\":\"allocation\",\"rules\":[],\"splits\":[" + + "{\"variationKey\":\"on\",\"shards\":[]}],\"doLog\":true}]}}}"; +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildJvmTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildJvmTest.java new file mode 100644 index 00000000000..1a547fff62e --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildJvmTest.java @@ -0,0 +1,53 @@ +package datadog.trace.api.openfeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class RemoteConfigWithoutAgentChildJvmTest { + + @Test + void reportsClearErrorWhenRemoteConfigurationRequiresAgent() throws Exception { + final String classpath = + Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator)) + .filter(path -> !path.contains("feature-flagging-bootstrap")) + .filter(path -> !path.contains("feature-flagging-core")) + .filter(path -> !path.contains("feature-flagging-http")) + .filter( + path -> + !path.contains( + "feature-flagging-api" + + File.separator + + "build" + + File.separator + + "classes" + + File.separator + + "java" + + File.separator + + "main")) + .collect(Collectors.joining(File.pathSeparator)) + + File.pathSeparator + + System.getProperty("dd.openfeature.test.jar"); + final Process process = + new ProcessBuilder( + new File(System.getProperty("java.home"), "bin/java").getAbsolutePath(), + "-cp", + classpath, + RemoteConfigWithoutAgentChildMain.class.getName()) + .redirectErrorStream(true) + .start(); + + assertTrue(process.waitFor(30, TimeUnit.SECONDS)); + final String output = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.exitValue(), output); + assertTrue(output.contains("REMOTE_CONFIGURATION_ERROR="), output); + assertTrue(output.contains("requires dd-java-agent.jar"), output); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildMain.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildMain.java new file mode 100644 index 00000000000..6a15c61ab62 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/RemoteConfigWithoutAgentChildMain.java @@ -0,0 +1,23 @@ +package datadog.trace.api.openfeature; + +import dev.openfeature.sdk.exceptions.FatalError; + +public final class RemoteConfigWithoutAgentChildMain { + private RemoteConfigWithoutAgentChildMain() {} + + public static void main(String[] args) { + System.setProperty("dd.feature.flags.configuration.source", "remote_config"); + final Provider provider = new Provider(); + try { + provider.initialize(null); + throw new AssertionError("Remote Configuration initialization unexpectedly succeeded"); + } catch (final FatalError error) { + if (!error.getMessage().contains("requires dd-java-agent.jar")) { + throw new AssertionError("Unexpected initialization error: " + error.getMessage(), error); + } + System.out.println("REMOTE_CONFIGURATION_ERROR=" + error.getMessage()); + } catch (final Exception error) { + throw new AssertionError("Unexpected checked exception", error); + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java new file mode 100644 index 00000000000..aadbe112dd1 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java @@ -0,0 +1,88 @@ +package datadog.trace.api.openfeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class StandaloneRuntimeConfigurationTest { + + private static final String ENABLED = "dd.feature.flags.enabled"; + private static final String SOURCE = "dd.feature.flags.configuration.source"; + private static final String LEGACY_ENABLED = "dd.experimental.flagging.provider.enabled"; + private static final String BASE_URL = "dd.feature.flags.configuration.source.agentless.base.url"; + private static final String POLL_INTERVAL = + "dd.feature.flags.configuration.source.agentless.poll.interval.seconds"; + private static final String REQUEST_TIMEOUT = + "dd.feature.flags.configuration.source.agentless.request.timeout.seconds"; + + @AfterEach + void clearProperties() { + System.clearProperty(ENABLED); + System.clearProperty(SOURCE); + System.clearProperty(LEGACY_ENABLED); + System.clearProperty(BASE_URL); + System.clearProperty(POLL_INTERVAL); + System.clearProperty(REQUEST_TIMEOUT); + } + + @Test + void defaultsToAgentless() { + assertEquals( + StandaloneRuntimeConfiguration.Source.CDN, + StandaloneRuntimeConfiguration.resolveSource(null, null, null)); + } + + @Test + void explicitAgentlessOverridesLegacyRemoteConfiguration() { + System.setProperty(SOURCE, "agentless"); + System.setProperty(LEGACY_ENABLED, "true"); + + final StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(StandaloneRuntimeConfiguration.Source.CDN, configuration.source); + } + + @Test + void preservesLegacyRemoteConfigurationDefault() { + System.setProperty(LEGACY_ENABLED, "true"); + + final StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(StandaloneRuntimeConfiguration.Source.REMOTE_CONFIG, configuration.source); + } + + @Test + void stableKillSwitchDisablesExplicitSource() { + System.setProperty(ENABLED, "false"); + System.setProperty(SOURCE, "agentless"); + + final StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(StandaloneRuntimeConfiguration.Source.DISABLED, configuration.source); + } + + @Test + void rejectsUnsupportedSource() { + System.setProperty(SOURCE, "offline"); + + assertThrows(IllegalArgumentException.class, StandaloneRuntimeConfiguration::resolve); + } + + @Test + void resolvesCustomEndpointAndPollingOptions() { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:8080/config?tenant=test"); + System.setProperty(POLL_INTERVAL, "7"); + System.setProperty(REQUEST_TIMEOUT, "2"); + + final StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals( + "http://127.0.0.1:8080/config?tenant=test", configuration.http.endpoint.toString()); + assertEquals(Duration.ofSeconds(7), configuration.http.pollInterval); + assertEquals(Duration.ofSeconds(2), configuration.http.requestTimeout); + } +} From 7346a72a47f4d014dc713bb6a3895b8acc2da2d4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 29 Jul 2026 21:38:56 -0600 Subject: [PATCH 04/16] Cover shared evaluation context behavior --- .../internal/core/EvaluationContextTest.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java new file mode 100644 index 00000000000..de9133b32ed --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java @@ -0,0 +1,58 @@ +package datadog.openfeature.internal.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class EvaluationContextTest { + + @Test + void copiesAttributesAndUsesTargetingKeyAsDefaultId() { + final Map attributes = new LinkedHashMap<>(); + attributes.put("role", "admin"); + final EvaluationContext context = new EvaluationContext("subject", attributes); + attributes.put("role", "changed"); + + assertEquals("subject", context.targetingKey()); + assertEquals("subject", context.attribute("id")); + assertEquals("admin", context.attribute("role")); + } + + @Test + void preservesExplicitId() { + final EvaluationContext context = + new EvaluationContext("subject", Map.of("id", "explicit-subject")); + + assertEquals("explicit-subject", context.attribute("id")); + } + + @Test + void delegatesLazyAttributeLookup() { + final EvaluationContext context = + EvaluationContext.lazy( + "subject", + new EvaluationContext.AttributeProvider() { + @Override + public boolean contains(final String name) { + return "id".equals(name); + } + + @Override + public Object get(final String name) { + return "id".equals(name) ? "lazy-subject" : null; + } + }); + + assertEquals("lazy-subject", context.attribute("id")); + assertNull(context.attribute("missing")); + } + + @Test + void rejectsMissingLazyProvider() { + assertThrows(IllegalArgumentException.class, () -> EvaluationContext.lazy("subject", null)); + } +} From 264a0ca4e1ab4a8cf74b37fded7caa7cc4d0ecf1 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 29 Jul 2026 22:40:38 -0600 Subject: [PATCH 05/16] Remove duplicate Feature Flagging models and parsers --- .../feature-flagging-api/build.gradle.kts | 7 +- .../trace/api/openfeature/DDEvaluator.java | 5 +- .../OpenFeatureEvaluationAdapter.java | 4 +- .../ServerConfigurationAdapter.java | 165 -------- .../openfeature/StandaloneDDEvaluator.java | 4 +- .../StandaloneProviderRuntime.java | 10 +- .../feature-flagging-core/build.gradle.kts | 30 +- .../internal/core/ConfigurationSnapshot.java | 160 ------- .../internal/core/ConfigurationStore.java | 19 +- .../internal/core/EvaluationContext.java | 3 +- .../internal/core/FlagEvaluator.java | 35 +- .../openfeature/internal/core/UfcParser.java | 395 +++++++----------- .../internal/core/ConfigurationStoreTest.java | 7 +- .../internal/core/EvaluationContextTest.java | 3 +- .../internal/core/FlagEvaluatorTest.java | 134 +++--- .../internal/core/UfcParserTest.java | 77 ++-- .../feature-flagging-http/build.gradle.kts | 1 + .../feature-flagging-lib/build.gradle.kts | 1 + .../AgentlessConfigurationSource.java | 4 +- .../featureflag/JsonApiUfcResponseParser.java | 92 ---- .../UniversalFlagConfigParser.java | 126 +----- .../JsonApiUfcResponseParserTest.java | 87 ---- .../RemoteConfigServiceImplTest.java | 92 ---- 23 files changed, 340 insertions(+), 1121 deletions(-) delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ServerConfigurationAdapter.java delete mode 100644 products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSnapshot.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index 277aa5a3ce7..98620a65b19 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -88,10 +88,14 @@ tasks.withType().configureEach { val coreProject = project(":products:feature-flagging:feature-flagging-core") val httpProject = project(":products:feature-flagging:feature-flagging-http") +val bootstrapProject = project(":products:feature-flagging:feature-flagging-bootstrap") tasks.named("jar") { from(coreProject.extensions.getByType().named("main").map { it.output }) from(httpProject.extensions.getByType().named("main").map { it.output }) + from(bootstrapProject.extensions.getByType().named("main").map { it.output }) { + include("datadog/trace/api/featureflag/ufc/v1/**") + } } tasks.withType().configureEach { @@ -116,7 +120,8 @@ tasks.register("verifyDdOpenfeatureArtifact") { "datadog/openfeature/internal/core/ConfigurationStore.class", "datadog/openfeature/internal/core/FlagEvaluator.class", "datadog/openfeature/internal/http/CdnConfigurationSource.class", - "datadog/openfeature/internal/http/HttpConfigurationOptions.class" + "datadog/openfeature/internal/http/HttpConfigurationOptions.class", + "datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.class" ) ZipFile(providerJar).use { zip -> val entryNames = zip.entries().asSequence().map { it.name }.toSet() 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 32310755ed7..979b9702153 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 @@ -1,6 +1,5 @@ package datadog.trace.api.openfeature; -import datadog.openfeature.internal.core.ConfigurationSnapshot; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.exposure.Subject; @@ -29,7 +28,7 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled(); private final Runnable configCallback; - private final AtomicReference configuration = new AtomicReference<>(); + private final AtomicReference configuration = new AtomicReference<>(); private final CountDownLatch initializationLatch = new CountDownLatch(1); private final OpenFeatureEvaluationAdapter evaluator = new OpenFeatureEvaluationAdapter(DDEvaluator::dispatchExposure, SPAN_ENRICHMENT_ENABLED); @@ -58,7 +57,7 @@ public void shutdown() { @Override public void accept(final ServerConfiguration config) { - configuration.set(ServerConfigurationAdapter.adapt(config)); + configuration.set(config); if (config != null) { initializationLatch.countDown(); configCallback.run(); diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java index 99fb00759ae..b3c5b6f681d 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java @@ -1,10 +1,10 @@ package datadog.trace.api.openfeature; -import datadog.openfeature.internal.core.ConfigurationSnapshot; import datadog.openfeature.internal.core.EvaluationResult; import datadog.openfeature.internal.core.EvaluationResult.Reason; import datadog.openfeature.internal.core.FlagEvaluator; import datadog.openfeature.internal.core.FlagEvaluator.ValueKind; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ImmutableMetadata; @@ -41,7 +41,7 @@ void accept( } ProviderEvaluation evaluate( - final ConfigurationSnapshot snapshot, + final ServerConfiguration snapshot, final Class target, final String key, final T defaultValue, diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ServerConfigurationAdapter.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ServerConfigurationAdapter.java deleted file mode 100644 index 1def0c09413..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ServerConfigurationAdapter.java +++ /dev/null @@ -1,165 +0,0 @@ -package datadog.trace.api.openfeature; - -import datadog.openfeature.internal.core.ConfigurationSnapshot; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.Rule; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** Converts the agent-delivered UFC model to the shared evaluator model. */ -final class ServerConfigurationAdapter { - - private ServerConfigurationAdapter() {} - - static ConfigurationSnapshot adapt(final ServerConfiguration source) { - if (source == null) { - return null; - } - final Map flags = new LinkedHashMap<>(); - if (source.flags != null) { - for (final Map.Entry entry : source.flags.entrySet()) { - flags.put(entry.getKey(), adapt(entry.getValue())); - } - } - return new ConfigurationSnapshot( - source.createdAt, - source.format, - source.environment == null ? null : source.environment.name, - flags); - } - - private static ConfigurationSnapshot.Flag adapt(final Flag source) { - if (source == null) { - return null; - } - final Map variants = new LinkedHashMap<>(); - if (source.variations != null) { - for (final Map.Entry entry : source.variations.entrySet()) { - final Variant variant = entry.getValue(); - variants.put( - entry.getKey(), - variant == null ? null : new ConfigurationSnapshot.Variant(variant.key, variant.value)); - } - } - return new ConfigurationSnapshot.Flag( - source.key, - source.enabled, - source.variationType == null - ? null - : ConfigurationSnapshot.ValueType.valueOf(source.variationType.name()), - variants, - adaptAllocations(source.allocations)); - } - - private static List adaptAllocations( - final List sources) { - if (sources == null) { - return null; - } - final List result = new ArrayList<>(sources.size()); - for (final Allocation source : sources) { - result.add( - source == null - ? null - : new ConfigurationSnapshot.Allocation( - source.key, - adaptRules(source.rules), - source.startAt == null ? null : source.startAt.getTime(), - source.endAt == null ? null : source.endAt.getTime(), - adaptSplits(source.splits), - Boolean.TRUE.equals(source.doLog))); - } - return result; - } - - private static List adaptRules(final List sources) { - if (sources == null) { - return null; - } - final List result = new ArrayList<>(sources.size()); - for (final Rule source : sources) { - result.add( - source == null - ? null - : new ConfigurationSnapshot.Rule(adaptConditions(source.conditions))); - } - return result; - } - - private static List adaptConditions( - final List sources) { - if (sources == null) { - return null; - } - final List result = new ArrayList<>(sources.size()); - for (final ConditionConfiguration source : sources) { - result.add( - source == null - ? null - : new ConfigurationSnapshot.Condition( - source.operator == null - ? null - : ConfigurationSnapshot.ConditionOperator.valueOf(source.operator.name()), - source.attribute, - source.value)); - } - return result; - } - - private static List adaptSplits(final List sources) { - if (sources == null) { - return null; - } - final List result = new ArrayList<>(sources.size()); - for (final Split source : sources) { - result.add( - source == null - ? null - : new ConfigurationSnapshot.Split( - adaptShards(source.shards), - source.variationKey, - source.extraLogging == null ? null : new LinkedHashMap<>(source.extraLogging), - source.serialId)); - } - return result; - } - - private static List adaptShards(final List sources) { - if (sources == null) { - return null; - } - final List result = new ArrayList<>(sources.size()); - for (final Shard source : sources) { - result.add( - source == null - ? null - : new ConfigurationSnapshot.Shard( - source.salt, adaptRanges(source.ranges), source.totalShards)); - } - return result; - } - - private static List adaptRanges( - final List sources) { - if (sources == null) { - return Collections.emptyList(); - } - final List result = new ArrayList<>(sources.size()); - for (final ShardRange source : sources) { - if (source != null) { - result.add(new ConfigurationSnapshot.ShardRange(source.start, source.end)); - } - } - return result; - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java index 5c66869da97..411a2c8ebfd 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneDDEvaluator.java @@ -1,6 +1,6 @@ package datadog.trace.api.openfeature; -import datadog.openfeature.internal.core.ConfigurationSnapshot; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ProviderEvaluation; import java.util.concurrent.TimeUnit; @@ -57,7 +57,7 @@ public ProviderEvaluation evaluate( final T defaultValue, final EvaluationContext context) { final StandaloneProviderRuntime.Handle current = runtime; - final ConfigurationSnapshot snapshot = current == null ? null : current.configuration(); + final ServerConfiguration snapshot = current == null ? null : current.configuration(); return evaluator.evaluate(snapshot, target, key, defaultValue, context); } } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java index 67786192cd1..21af19e69d7 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneProviderRuntime.java @@ -1,9 +1,9 @@ package datadog.trace.api.openfeature; -import datadog.openfeature.internal.core.ConfigurationSnapshot; import datadog.openfeature.internal.core.ConfigurationSource; import datadog.openfeature.internal.core.ConfigurationStore; import datadog.openfeature.internal.http.CdnConfigurationSource; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -17,7 +17,7 @@ private StandaloneProviderRuntime() {} static Handle acquire( final StandaloneRuntimeConfiguration configuration, - final Consumer listener) { + final Consumer listener) { if (configuration.source == StandaloneRuntimeConfiguration.Source.DISABLED) { throw new IllegalStateException("Datadog OpenFeature provider is disabled by configuration"); } @@ -53,14 +53,14 @@ static Handle acquire( static final class Handle implements AutoCloseable { private SharedRuntime runtime; - private Consumer listener; + private Consumer listener; - private Handle(final SharedRuntime runtime, final Consumer listener) { + private Handle(final SharedRuntime runtime, final Consumer listener) { this.runtime = runtime; this.listener = listener; } - ConfigurationSnapshot configuration() { + ServerConfiguration configuration() { return runtime == null ? null : runtime.store.current(); } diff --git a/products/feature-flagging/feature-flagging-core/build.gradle.kts b/products/feature-flagging/feature-flagging-core/build.gradle.kts index 5161ecf3a82..45f8b2f1e06 100644 --- a/products/feature-flagging/feature-flagging-core/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-core/build.gradle.kts @@ -1,5 +1,4 @@ import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension -import groovy.lang.Closure plugins { `java-library` @@ -15,8 +14,6 @@ extra["minimumBranchCoverage"] = 0.7 extra["excludedClassesCoverage"] = listOf( // Immutable data transfer types - "datadog.openfeature.internal.core.ConfigurationSnapshot", - "datadog.openfeature.internal.core.ConfigurationSnapshot.*", "datadog.openfeature.internal.core.EvaluationResult", "datadog.openfeature.internal.core.EvaluationResult.*", "datadog.openfeature.internal.core.ApplyResult", @@ -24,34 +21,13 @@ extra["excludedClassesCoverage"] = listOf( ) configure { - minJavaVersion.set(JavaVersion.VERSION_11) -} - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(11) - } -} - -fun AbstractCompile.configureCompiler( - javaVersionInteger: Int, - compatibilityVersion: JavaVersion? = null, - unsetReleaseFlagReason: String? = null -) { - (project.extra["configureCompiler"] as Closure<*>).call( - this, - javaVersionInteger, - compatibilityVersion, - unsetReleaseFlagReason - ) -} - -tasks.withType().configureEach { - configureCompiler(11, JavaVersion.VERSION_11) + minJavaVersion.set(JavaVersion.VERSION_1_8) } dependencies { implementation(libs.moshi) + compileOnlyApi(project(":products:feature-flagging:feature-flagging-bootstrap")) testImplementation(libs.bundles.junit5) + testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) } diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSnapshot.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSnapshot.java deleted file mode 100644 index d8a3fb01658..00000000000 --- a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationSnapshot.java +++ /dev/null @@ -1,160 +0,0 @@ -package datadog.openfeature.internal.core; - -import java.util.Collections; -import java.util.List; -import java.util.Map; - -/** Immutable UFC configuration used by the provider-owned evaluator. */ -public final class ConfigurationSnapshot { - - public final String createdAt; - public final String format; - public final String environmentName; - public final Map flags; - - public ConfigurationSnapshot( - final String createdAt, - final String format, - final String environmentName, - final Map flags) { - this.createdAt = createdAt; - this.format = format; - this.environmentName = environmentName; - this.flags = Collections.unmodifiableMap(flags); - } - - public enum ValueType { - BOOLEAN, - INTEGER, - NUMERIC, - STRING, - JSON - } - - public enum ConditionOperator { - LT, - LTE, - GT, - GTE, - MATCHES, - NOT_MATCHES, - ONE_OF, - NOT_ONE_OF, - IS_NULL - } - - public static final class Flag { - public final String key; - public final boolean enabled; - public final ValueType variationType; - public final Map variations; - public final List allocations; - - public Flag( - final String key, - final boolean enabled, - final ValueType variationType, - final Map variations, - final List allocations) { - this.key = key; - this.enabled = enabled; - this.variationType = variationType; - this.variations = Collections.unmodifiableMap(variations); - this.allocations = allocations == null ? null : Collections.unmodifiableList(allocations); - } - } - - public static final class Variant { - public final String key; - public final Object value; - - public Variant(final String key, final Object value) { - this.key = key; - this.value = value; - } - } - - public static final class Allocation { - public final String key; - public final List rules; - public final Long startAtMillis; - public final Long endAtMillis; - public final List splits; - public final boolean doLog; - - public Allocation( - final String key, - final List rules, - final Long startAtMillis, - final Long endAtMillis, - final List splits, - final boolean doLog) { - this.key = key; - this.rules = rules == null ? null : Collections.unmodifiableList(rules); - this.startAtMillis = startAtMillis; - this.endAtMillis = endAtMillis; - this.splits = splits == null ? null : Collections.unmodifiableList(splits); - this.doLog = doLog; - } - } - - public static final class Rule { - public final List conditions; - - public Rule(final List conditions) { - this.conditions = conditions == null ? null : Collections.unmodifiableList(conditions); - } - } - - public static final class Condition { - public final ConditionOperator operator; - public final String attribute; - public final Object value; - - public Condition(final ConditionOperator operator, final String attribute, final Object value) { - this.operator = operator; - this.attribute = attribute; - this.value = value; - } - } - - public static final class Split { - public final List shards; - public final String variationKey; - public final Map extraLogging; - public final Integer serialId; - - public Split( - final List shards, - final String variationKey, - final Map extraLogging, - final Integer serialId) { - this.shards = shards == null ? null : Collections.unmodifiableList(shards); - this.variationKey = variationKey; - this.extraLogging = extraLogging == null ? null : Collections.unmodifiableMap(extraLogging); - this.serialId = serialId; - } - } - - public static final class Shard { - public final String salt; - public final List ranges; - public final int totalShards; - - public Shard(final String salt, final List ranges, final int totalShards) { - this.salt = salt; - this.ranges = Collections.unmodifiableList(ranges); - this.totalShards = totalShards; - } - } - - public static final class ShardRange { - public final int start; - public final int end; - - public ShardRange(final int start, final int end) { - this.start = start; - this.end = end; - } - } -} diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java index 23233fd192a..3710f2590ed 100644 --- a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java @@ -1,5 +1,6 @@ package datadog.openfeature.internal.core; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.List; @@ -12,8 +13,8 @@ public final class ConfigurationStore implements ConfigurationSink { private final UfcParser parser; - private final AtomicReference current = new AtomicReference<>(); - private final List> listeners = new CopyOnWriteArrayList<>(); + private final AtomicReference current = new AtomicReference<>(); + private final List> listeners = new CopyOnWriteArrayList<>(); private final Object changeMonitor = new Object(); public ConfigurationStore() { @@ -26,7 +27,7 @@ public ConfigurationStore() { @Override public ApplyResult apply(final byte[] content) { - final ConfigurationSnapshot next; + final ServerConfiguration next; try { next = parser.parse(content); } catch (final IOException | RuntimeException ignored) { @@ -44,7 +45,7 @@ public ApplyResult clear() { return ApplyResult.CLEARED; } - public ConfigurationSnapshot current() { + public ServerConfiguration current() { return current.get(); } @@ -52,15 +53,15 @@ public boolean hasConfiguration() { return current.get() != null; } - public void addListener(final Consumer listener) { + public void addListener(final Consumer listener) { listeners.add(listener); - final ConfigurationSnapshot snapshot = current.get(); + final ServerConfiguration snapshot = current.get(); if (snapshot != null) { listener.accept(snapshot); } } - public void removeListener(final Consumer listener) { + public void removeListener(final Consumer listener) { listeners.remove(listener); } @@ -85,11 +86,11 @@ public boolean awaitConfiguration(final long timeout, final TimeUnit unit) @SuppressFBWarnings( value = "NN_NAKED_NOTIFY", justification = "The caller updates the atomic snapshot before it signals waiting readers") - private void signalChange(final ConfigurationSnapshot snapshot) { + private void signalChange(final ServerConfiguration snapshot) { synchronized (changeMonitor) { changeMonitor.notifyAll(); } - for (final Consumer listener : listeners) { + for (final Consumer listener : listeners) { listener.accept(snapshot); } } diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java index 7f57a947fe9..271c40207ca 100644 --- a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java @@ -1,5 +1,6 @@ package datadog.openfeature.internal.core; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -11,7 +12,7 @@ public final class EvaluationContext { public EvaluationContext(final String targetingKey, final Map attributes) { final Map retained = - attributes == null ? Map.of() : new LinkedHashMap<>(attributes); + attributes == null ? Collections.emptyMap() : new LinkedHashMap<>(attributes); this.targetingKey = targetingKey; this.attributes = new AttributeProvider() { diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java index 6a447aefe43..f9f4742974c 100644 --- a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/FlagEvaluator.java @@ -1,17 +1,18 @@ package datadog.openfeature.internal.core; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Allocation; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Condition; -import datadog.openfeature.internal.core.ConfigurationSnapshot.ConditionOperator; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Flag; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Rule; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Shard; -import datadog.openfeature.internal.core.ConfigurationSnapshot.ShardRange; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Split; -import datadog.openfeature.internal.core.ConfigurationSnapshot.ValueType; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Variant; import datadog.openfeature.internal.core.EvaluationResult.Error; import datadog.openfeature.internal.core.EvaluationResult.Reason; +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; +import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.Rule; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Shard; +import datadog.trace.api.featureflag.ufc.v1.ShardRange; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.ValueType; +import datadog.trace.api.featureflag.ufc.v1.Variant; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -20,7 +21,7 @@ import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; -/** Evaluates immutable UFC snapshots without OpenFeature or agent classes. */ +/** Evaluates the existing UFC model without OpenFeature SDK types. */ public final class FlagEvaluator { public enum ValueKind { @@ -32,7 +33,7 @@ public enum ValueKind { } public EvaluationResult evaluate( - final ConfigurationSnapshot snapshot, + final ServerConfiguration snapshot, final ValueKind target, final String key, final Object defaultValue, @@ -137,7 +138,7 @@ private static EvaluationResult resolve( typeName(flag), allocation.key, split.serialId, - allocation.doLog); + Boolean.TRUE.equals(allocation.doLog)); } public static Object mapValue(final ValueKind target, final Object value) { @@ -165,8 +166,8 @@ public static Object mapValue(final ValueKind target, final Object value) { } private static boolean isActive(final Allocation allocation, final long now) { - return (allocation.startAtMillis == null || now >= allocation.startAtMillis) - && (allocation.endAtMillis == null || now <= allocation.endAtMillis); + return (allocation.startAt == null || now >= allocation.startAt.getTime()) + && (allocation.endAt == null || now <= allocation.endAt.getTime()); } private static boolean evaluateRules(final List rules, final EvaluationContext context) { @@ -175,7 +176,7 @@ private static boolean evaluateRules(final List rules, final EvaluationCon continue; } boolean matches = true; - for (final Condition condition : rule.conditions) { + for (final ConditionConfiguration condition : rule.conditions) { if (!evaluateCondition(condition, context)) { matches = false; break; @@ -189,7 +190,7 @@ private static boolean evaluateRules(final List rules, final EvaluationCon } private static boolean evaluateCondition( - final Condition condition, final EvaluationContext context) { + final ConditionConfiguration condition, final EvaluationContext context) { final Object attribute = context.attribute(condition.attribute); if (condition.operator == ConditionOperator.IS_NULL) { final boolean expectedNull = diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java index ffec1125399..5ba96bee4b8 100644 --- a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java @@ -2,306 +2,203 @@ import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import java.io.ByteArrayInputStream; import java.io.IOException; -import java.nio.charset.StandardCharsets; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.HashMap; import java.util.Map; +import java.util.Set; +import okio.BufferedSource; +import okio.Okio; -/** Parses raw UFC documents and JSON API UFC responses without agent-owned model classes. */ +/** Parses raw UFC documents and JSON API UFC responses into the existing UFC model. */ public final class UfcParser { private static final String UFC_TYPE = "universal-flag-configuration"; - private static final JsonAdapter JSON_ADAPTER = - new Moshi.Builder().build().adapter(Object.class); + private static final JsonReader.Options RESPONSE_FIELDS = JsonReader.Options.of("data"); + private static final JsonReader.Options DATA_FIELDS = JsonReader.Options.of("type", "attributes"); + private static final Moshi MOSHI = + new Moshi.Builder().add(Date.class, new DateAdapter()).add(FlagMapAdapter.FACTORY).build(); + private static final JsonAdapter V1_ADAPTER = + MOSHI.adapter(ServerConfiguration.class); - public ConfigurationSnapshot parse(final byte[] content) throws IOException { - if (content == null || content.length == 0) { - throw new IOException("UFC payload is empty"); - } - - final Object decoded; - try { - decoded = JSON_ADAPTER.fromJson(new String(content, StandardCharsets.UTF_8)); - } catch (final JsonDataException | IllegalArgumentException e) { - throw new IOException("UFC payload is malformed", e); - } - - final Map root = map(decoded, "UFC document"); - final Map configuration = unwrapJsonApi(root); - final Map rawFlags = map(configuration.get("flags"), "flags"); - final Map flags = new LinkedHashMap<>(); - for (final Map.Entry entry : rawFlags.entrySet()) { - try { - flags.put(entry.getKey(), parseFlag(entry.getKey(), map(entry.getValue(), "flag"))); - } catch (final IOException | RuntimeException ignored) { - // A malformed flag must not prevent other flags in the same UFC document from loading. - } - } - - final Map environment = optionalMap(configuration.get("environment")); - return new ConfigurationSnapshot( - optionalString(configuration.get("createdAt")), - optionalString(configuration.get("format")), - environment == null ? null : optionalString(environment.get("name")), - flags); + public ServerConfiguration parse(final byte[] content) throws IOException { + return parse(content, false); } - private static Map unwrapJsonApi(final Map root) - throws IOException { - if (!root.containsKey("data")) { - return root; - } - final Map data = map(root.get("data"), "data"); - if (!UFC_TYPE.equals(data.get("type"))) { - throw new IOException("JSON API response does not contain UFC data"); - } - return map(data.get("attributes"), "attributes"); + public ServerConfiguration parseJsonApi(final byte[] content) throws IOException { + return parse(content, true); } - private static ConfigurationSnapshot.Flag parseFlag( - final String mapKey, final Map value) throws IOException { - final String flagKey = defaultString(value.get("key"), mapKey); - final boolean enabled = bool(value.get("enabled"), "enabled"); - final ConfigurationSnapshot.ValueType variationType = - enumValue( - ConfigurationSnapshot.ValueType.class, - string(value.get("variationType"), "variationType")); - - final Map rawVariants = map(value.get("variations"), "variations"); - final Map variants = new LinkedHashMap<>(); - for (final Map.Entry entry : rawVariants.entrySet()) { - final Map variant = map(entry.getValue(), "variant"); - variants.put( - entry.getKey(), - new ConfigurationSnapshot.Variant( - defaultString(variant.get("key"), entry.getKey()), freeze(variant.get("value")))); + private static ServerConfiguration parse( + final byte[] content, final boolean requireJsonApiEnvelope) throws IOException { + if (content == null || content.length == 0) { + throw new IOException("UFC payload is empty"); } - - final List rawAllocations = optionalList(value.get("allocations")); - final List allocations; - if (rawAllocations == null) { - allocations = null; - } else { - allocations = new ArrayList<>(rawAllocations.size()); - for (final Object rawAllocation : rawAllocations) { - allocations.add(parseAllocation(map(rawAllocation, "allocation"))); + try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { + final JsonReader reader = JsonReader.of(source); + final boolean jsonApi = requireJsonApiEnvelope || isJsonApi(reader.peekJson()); + final ServerConfiguration configuration = + jsonApi ? parseJsonApi(reader) : V1_ADAPTER.fromJson(reader); + requireEndOfDocument(reader); + if (jsonApi && (configuration == null || configuration.flags == null)) { + throw new IOException("JSON API response does not contain UFC data"); } + return configuration; + } catch (final JsonDataException | IllegalArgumentException e) { + throw new IOException("UFC payload is malformed", e); } - return new ConfigurationSnapshot.Flag(flagKey, enabled, variationType, variants, allocations); } - private static ConfigurationSnapshot.Allocation parseAllocation(final Map value) - throws IOException { - return new ConfigurationSnapshot.Allocation( - string(value.get("key"), "allocation key"), - parseRules(optionalList(value.get("rules"))), - parseDate(optionalString(value.get("startAt"))), - parseDate(optionalString(value.get("endAt"))), - parseSplits(optionalList(value.get("splits"))), - Boolean.TRUE.equals(value.get("doLog"))); - } - - private static List parseRules(final List values) - throws IOException { - if (values == null) { - return null; + private static boolean isJsonApi(final JsonReader reader) throws IOException { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + return false; } - final List rules = new ArrayList<>(values.size()); - for (final Object rawRule : values) { - final Map rule = map(rawRule, "rule"); - final List rawConditions = optionalList(rule.get("conditions")); - final List conditions; - if (rawConditions == null) { - conditions = null; - } else { - conditions = new ArrayList<>(rawConditions.size()); - for (final Object rawCondition : rawConditions) { - final Map condition = map(rawCondition, "condition"); - conditions.add( - new ConfigurationSnapshot.Condition( - enumValue( - ConfigurationSnapshot.ConditionOperator.class, - string(condition.get("operator"), "condition operator")), - string(condition.get("attribute"), "condition attribute"), - freeze(condition.get("value")))); - } + reader.beginObject(); + while (reader.hasNext()) { + if (reader.selectName(RESPONSE_FIELDS) == 0) { + return true; } - rules.add(new ConfigurationSnapshot.Rule(conditions)); - } - return rules; - } - - private static List parseSplits(final List values) - throws IOException { - if (values == null) { - return null; - } - final List splits = new ArrayList<>(values.size()); - for (final Object rawSplit : values) { - final Map split = map(rawSplit, "split"); - splits.add( - new ConfigurationSnapshot.Split( - parseShards(optionalList(split.get("shards"))), - string(split.get("variationKey"), "variationKey"), - stringMap(optionalMap(split.get("extraLogging"))), - optionalInteger(split.get("serialId")))); + reader.skipName(); + reader.skipValue(); } - return splits; + return false; } - private static List parseShards(final List values) - throws IOException { - if (values == null) { + private static ServerConfiguration parseJsonApi(final JsonReader reader) throws IOException { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue(); return null; } - final List shards = new ArrayList<>(values.size()); - for (final Object rawShard : values) { - final Map shard = map(rawShard, "shard"); - final List rawRanges = list(shard.get("ranges"), "ranges"); - final List ranges = new ArrayList<>(rawRanges.size()); - for (final Object rawRange : rawRanges) { - final Map range = map(rawRange, "range"); - ranges.add( - new ConfigurationSnapshot.ShardRange( - integer(range.get("start"), "range start"), - integer(range.get("end"), "range end"))); + ServerConfiguration configuration = null; + reader.beginObject(); + while (reader.hasNext()) { + if (reader.selectName(RESPONSE_FIELDS) == 0) { + configuration = parseData(reader); + } else { + reader.skipName(); + reader.skipValue(); } - shards.add( - new ConfigurationSnapshot.Shard( - string(shard.get("salt"), "shard salt"), - ranges, - integer(shard.get("totalShards"), "totalShards"))); } - return shards; + reader.endObject(); + return configuration; } - private static Long parseDate(final String value) { - if (value == null) { - return null; - } - try { - return Instant.parse(value).toEpochMilli(); - } catch (final DateTimeParseException ignored) { + private static ServerConfiguration parseData(final JsonReader reader) throws IOException { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue(); return null; } - } - - private static Object freeze(final Object value) throws IOException { - if (value instanceof Map) { - final Map frozen = new LinkedHashMap<>(); - for (final Map.Entry entry : map(value, "object").entrySet()) { - frozen.put(entry.getKey(), freeze(entry.getValue())); - } - return Collections.unmodifiableMap(frozen); - } - if (value instanceof List) { - final List frozen = new ArrayList<>(); - for (final Object element : (List) value) { - frozen.add(freeze(element)); + String type = null; + ServerConfiguration configuration = null; + reader.beginObject(); + while (reader.hasNext()) { + switch (reader.selectName(DATA_FIELDS)) { + case 0: + if (reader.peek() == JsonReader.Token.STRING) { + type = reader.nextString(); + } else { + reader.skipValue(); + } + break; + case 1: + configuration = V1_ADAPTER.fromJson(reader); + break; + default: + reader.skipName(); + reader.skipValue(); } - return Collections.unmodifiableList(frozen); } - return value; + reader.endObject(); + return UFC_TYPE.equals(type) ? configuration : null; } - private static Map stringMap(final Map value) { - if (value == null) { - return null; - } - final Map strings = new LinkedHashMap<>(); - for (final Map.Entry entry : value.entrySet()) { - strings.put(entry.getKey(), String.valueOf(entry.getValue())); - } - return strings; + private static void requireEndOfDocument(final JsonReader reader) throws IOException { + // A strict JsonReader throws if another top-level value follows the parsed document. + reader.peek(); } - private static > T enumValue(final Class type, final String value) { - return Enum.valueOf(type, value.toUpperCase(Locale.ROOT)); - } + static final class FlagMapAdapter extends JsonAdapter> { - private static String defaultString(final Object value, final String defaultValue) { - final String string = optionalString(value); - return string == null ? defaultValue : string; - } + private static final Type FLAGS_TYPE = + Types.newParameterizedType(Map.class, String.class, Flag.class); - private static String string(final Object value, final String field) throws IOException { - final String string = optionalString(value); - if (string == null) { - throw new IOException("Missing UFC " + field); - } - return string; - } + static final Factory FACTORY = + new Factory() { + @Override + public JsonAdapter create( + final Type type, final Set annotations, final Moshi moshi) { + if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { + return null; + } + return new FlagMapAdapter(moshi.adapter(Flag.class)); + } + }; - private static String optionalString(final Object value) { - return value instanceof String && !((String) value).isEmpty() ? (String) value : null; - } + private final JsonAdapter flagAdapter; - private static boolean bool(final Object value, final String field) throws IOException { - if (!(value instanceof Boolean)) { - throw new IOException("Invalid UFC " + field); + FlagMapAdapter(final JsonAdapter flagAdapter) { + this.flagAdapter = flagAdapter; } - return (Boolean) value; - } - private static int integer(final Object value, final String field) throws IOException { - if (!(value instanceof Number)) { - throw new IOException("Invalid UFC " + field); + @Override + public Map fromJson(final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } + final Map flags = new HashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + final String flagKey = reader.nextName(); + final Object rawFlag = reader.readJsonValue(); + try { + final Flag flag = flagAdapter.fromJsonValue(rawFlag); + if (flag != null) { + flags.put(flagKey, flag); + } + } catch (final JsonDataException | IllegalArgumentException ignored) { + // A malformed flag must not prevent other flags in the same config from evaluating. + } + } + reader.endObject(); + return flags; } - return ((Number) value).intValue(); - } - - private static Integer optionalInteger(final Object value) throws IOException { - return value == null ? null : integer(value, "integer"); - } - private static List list(final Object value, final String field) throws IOException { - final List list = optionalList(value); - if (list == null) { - throw new IOException("Invalid UFC " + field); + @Override + public void toJson(final JsonWriter writer, final Map value) { + throw new UnsupportedOperationException("Reading only adapter"); } - return list; } - @SuppressWarnings("unchecked") - private static List optionalList(final Object value) throws IOException { - if (value == null) { - return null; - } - if (!(value instanceof List)) { - throw new IOException("Expected UFC array"); - } - return (List) value; - } + static final class DateAdapter extends JsonAdapter { - private static Map map(final Object value, final String field) - throws IOException { - final Map map = optionalMap(value); - if (map == null) { - throw new IOException("Invalid UFC " + field); + @Override + public Date fromJson(final JsonReader reader) throws IOException { + final String date = reader.nextString(); + if (date == null) { + return null; + } + try { + final Instant instant = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); + return Date.from(instant); + } catch (final RuntimeException ignored) { + return null; + } } - return map; - } - @SuppressWarnings("unchecked") - private static Map optionalMap(final Object value) throws IOException { - if (value == null) { - return null; - } - if (!(value instanceof Map)) { - throw new IOException("Expected UFC object"); - } - for (final Object key : ((Map) value).keySet()) { - if (!(key instanceof String)) { - throw new IOException("UFC object key is not a string"); - } + @Override + public void toJson(final JsonWriter writer, final Date value) { + throw new UnsupportedOperationException("Reading only adapter"); } - return (Map) value; } } diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java index 9b1e5d89814..10f776a1769 100644 --- a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -21,7 +22,7 @@ void preservesLastKnownGoodAfterRejectedPayload() { final AtomicInteger changes = new AtomicInteger(); store.addListener(ignored -> changes.incrementAndGet()); assertEquals(ApplyResult.ACCEPTED, store.apply(Fixtures.UFC.getBytes(UTF_8))); - final ConfigurationSnapshot accepted = store.current(); + final ServerConfiguration accepted = store.current(); assertEquals(ApplyResult.REJECTED, store.apply("{".getBytes(UTF_8))); assertSame(accepted, store.current()); @@ -48,8 +49,8 @@ void clearsConfigurationExplicitly() { void sendsCurrentAndDeletedSnapshotsToListeners() { final ConfigurationStore store = new ConfigurationStore(); store.apply(Fixtures.UFC.getBytes(UTF_8)); - final AtomicReference current = new AtomicReference<>(); - final Consumer listener = current::set; + final AtomicReference current = new AtomicReference<>(); + final Consumer listener = current::set; store.addListener(listener); assertSame(store.current(), current.get()); diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java index de9133b32ed..3ce6fe5f3f0 100644 --- a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/EvaluationContextTest.java @@ -1,5 +1,6 @@ package datadog.openfeature.internal.core; +import static java.util.Collections.singletonMap; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -25,7 +26,7 @@ void copiesAttributesAndUsesTargetingKeyAsDefaultId() { @Test void preservesExplicitId() { final EvaluationContext context = - new EvaluationContext("subject", Map.of("id", "explicit-subject")); + new EvaluationContext("subject", singletonMap("id", "explicit-subject")); assertEquals("explicit-subject", context.attribute("id")); } diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java index a8ba419ecb1..0b60857209b 100644 --- a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java @@ -5,19 +5,24 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Allocation; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Condition; -import datadog.openfeature.internal.core.ConfigurationSnapshot.ConditionOperator; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Flag; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Rule; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Shard; -import datadog.openfeature.internal.core.ConfigurationSnapshot.ShardRange; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Split; -import datadog.openfeature.internal.core.ConfigurationSnapshot.ValueType; -import datadog.openfeature.internal.core.ConfigurationSnapshot.Variant; import datadog.openfeature.internal.core.EvaluationResult.Error; import datadog.openfeature.internal.core.EvaluationResult.Reason; import datadog.openfeature.internal.core.FlagEvaluator.ValueKind; +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; +import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; +import datadog.trace.api.featureflag.ufc.v1.Environment; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.Rule; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Shard; +import datadog.trace.api.featureflag.ufc.v1.ShardRange; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.ValueType; +import datadog.trace.api.featureflag.ufc.v1.Variant; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -51,9 +56,8 @@ void mapsAllSupportedValueKinds() { assertEquals( "42", evaluate(staticFlag(ValueType.STRING, 42), ValueKind.STRING, context("id")).value); assertEquals( - Map.of("nested", true), - evaluate( - staticFlag(ValueType.JSON, Map.of("nested", true)), ValueKind.OBJECT, context("id")) + map("nested", true), + evaluate(staticFlag(ValueType.JSON, map("nested", true)), ValueKind.OBJECT, context("id")) .value); assertNull(FlagEvaluator.mapValue(ValueKind.STRING, null)); } @@ -78,8 +82,7 @@ void reportsInputAndConfigurationErrors() { assertError( snapshot( - new Flag( - "flag", true, ValueType.STRING, Map.of("on", new Variant("on", "value")), null)), + new Flag("flag", true, ValueType.STRING, map("on", new Variant("on", "value")), null)), ValueKind.STRING, context("id"), Error.GENERAL); @@ -92,8 +95,8 @@ void reportsInputAndConfigurationErrors() { snapshot( flag( ValueType.STRING, - Map.of("on", new Variant("on", "value")), - List.of(split("missing", emptyList())))), + map("on", new Variant("on", "value")), + list(split("missing", emptyList())))), ValueKind.STRING, context("id"), Error.GENERAL); @@ -108,11 +111,11 @@ void reportsInputAndConfigurationErrors() { void returnsDisabledAndDefaultResults() { final Flag disabled = new Flag( - "flag", false, ValueType.STRING, Map.of("on", new Variant("on", "value")), emptyList()); + "flag", false, ValueType.STRING, map("on", new Variant("on", "value")), emptyList()); assertEquals(Reason.DISABLED, evaluate(disabled, ValueKind.STRING, context("id")).reason); final Flag noSplits = - flag(ValueType.STRING, Map.of("on", new Variant("on", "value")), emptyList()); + flag(ValueType.STRING, map("on", new Variant("on", "value")), emptyList()); assertEquals(Reason.DEFAULT, evaluate(noSplits, ValueKind.STRING, context("id")).reason); final long now = System.currentTimeMillis(); @@ -121,21 +124,21 @@ void returnsDisabledAndDefaultResults() { "flag", true, ValueType.STRING, - Map.of("on", new Variant("on", "value")), - List.of( + map("on", new Variant("on", "value")), + list( new Allocation( "future", emptyList(), - now + 60_000, + new Date(now + 60_000), null, - List.of(split("on", emptyList())), + list(split("on", emptyList())), false), new Allocation( "past", emptyList(), null, - now - 60_000, - List.of(split("on", emptyList())), + new Date(now - 60_000), + list(split("on", emptyList())), false))); assertEquals(Reason.DEFAULT, evaluate(inactive, ValueKind.STRING, context("id")).reason); } @@ -144,35 +147,35 @@ void returnsDisabledAndDefaultResults() { void evaluatesAllRuleOperators() { final Rule matchingRule = new Rule( - List.of( + list( condition(ConditionOperator.MATCHES, "country", "^U"), condition(ConditionOperator.NOT_MATCHES, "country", "^C"), - condition(ConditionOperator.ONE_OF, "tier", List.of("free", "paid")), - condition(ConditionOperator.NOT_ONE_OF, "tier", List.of("blocked")), + condition(ConditionOperator.ONE_OF, "tier", list("free", "paid")), + condition(ConditionOperator.NOT_ONE_OF, "tier", list("blocked")), condition(ConditionOperator.GTE, "age", 18), condition(ConditionOperator.GT, "age", 17), condition(ConditionOperator.LTE, "age", 18), condition(ConditionOperator.LT, "age", 19), condition(ConditionOperator.IS_NULL, "missing", true), condition(ConditionOperator.IS_NULL, "country", false), - condition(ConditionOperator.ONE_OF, "score", List.of("18")))); + condition(ConditionOperator.ONE_OF, "score", list("18")))); final Flag flag = new Flag( "flag", true, ValueType.STRING, - Map.of("on", new Variant("on", "matched")), - List.of( + map("on", new Variant("on", "matched")), + list( new Allocation( "targeted", - List.of(new Rule(emptyList()), matchingRule), + list(new Rule(emptyList()), matchingRule), null, null, - List.of(split("on", emptyList())), + list(split("on", emptyList())), false))); final EvaluationContext matching = new EvaluationContext( - "subject", Map.of("country", "US", "tier", "paid", "age", 18, "score", 18)); + "subject", map("country", "US", "tier", "paid", "age", 18, "score", 18)); assertEquals(Reason.TARGETING_MATCH, evaluate(flag, ValueKind.STRING, matching).reason); assertEquals( @@ -181,7 +184,7 @@ void evaluatesAllRuleOperators() { flag, ValueKind.STRING, new EvaluationContext( - "subject", Map.of("country", "CA", "tier", "paid", "age", 18, "score", 18))) + "subject", map("country", "CA", "tier", "paid", "age", 18, "score", 18))) .reason); } @@ -190,27 +193,24 @@ void mapsInvalidRulesToEvaluationErrors() { final Flag invalidRegex = targetedFlag(condition(ConditionOperator.MATCHES, "value", "[")); assertEquals( Error.PARSE_ERROR, - evaluate( - invalidRegex, - ValueKind.STRING, - new EvaluationContext("id", Map.of("value", "text"))) + evaluate(invalidRegex, ValueKind.STRING, new EvaluationContext("id", map("value", "text"))) .error); final Flag invalidNumber = targetedFlag(condition(ConditionOperator.GT, "value", "number")); assertEquals( Error.TYPE_MISMATCH, - evaluate(invalidNumber, ValueKind.STRING, new EvaluationContext("id", Map.of("value", 2))) + evaluate(invalidNumber, ValueKind.STRING, new EvaluationContext("id", map("value", 2))) .error); } @Test void evaluatesShardsAndRequiresTargetingKey() { - final Shard matchingShard = new Shard("salt", List.of(new ShardRange(0, 1)), 1); + final Shard matchingShard = new Shard("salt", list(new ShardRange(0, 1)), 1); final Flag sharded = flag( ValueType.STRING, - Map.of("on", new Variant("on", "value")), - List.of(split("on", List.of(matchingShard)))); + map("on", new Variant("on", "value")), + list(split("on", list(matchingShard)))); assertEquals(Reason.SPLIT, evaluate(sharded, ValueKind.STRING, context("id")).reason); assertEquals( @@ -220,8 +220,8 @@ void evaluatesShardsAndRequiresTargetingKey() { final Flag unmatched = flag( ValueType.STRING, - Map.of("on", new Variant("on", "value")), - List.of(split("on", List.of(invalidShard)))); + map("on", new Variant("on", "value")), + list(split("on", list(invalidShard)))); assertEquals(Reason.DEFAULT, evaluate(unmatched, ValueKind.STRING, context("id")).reason); } @@ -235,7 +235,7 @@ void contextUsesTargetingKeyAsDefaultIdAndCopiesAttributes() { assertEquals("subject", context.attribute("id")); assertEquals("value", context.attribute("name")); assertEquals( - "explicit", new EvaluationContext("subject", Map.of("id", "explicit")).attribute("id")); + "explicit", new EvaluationContext("subject", map("id", "explicit")).attribute("id")); assertNull(new EvaluationContext("subject", null).attribute("missing")); } @@ -245,15 +245,15 @@ private EvaluationResult evaluate( } private void assertError( - final ConfigurationSnapshot snapshot, + final ServerConfiguration snapshot, final ValueKind kind, final EvaluationContext context, final Error error) { assertEquals(error, evaluator.evaluate(snapshot, kind, "flag", "default", context).error); } - private static ConfigurationSnapshot snapshot(final Flag flag) { - return new ConfigurationSnapshot(null, "SERVER", "test", Map.of("flag", flag)); + private static ServerConfiguration snapshot(final Flag flag) { + return new ServerConfiguration(null, "SERVER", new Environment("test"), map("flag", flag)); } private static EvaluationContext context(final String targetingKey) { @@ -261,22 +261,22 @@ private static EvaluationContext context(final String targetingKey) { } private static Flag staticFlag(final ValueType type, final Object value) { - return flag(type, Map.of("on", new Variant("on", value)), List.of(split("on", emptyList()))); + return flag(type, map("on", new Variant("on", value)), list(split("on", emptyList()))); } - private static Flag targetedFlag(final Condition condition) { + private static Flag targetedFlag(final ConditionConfiguration condition) { return new Flag( "flag", true, ValueType.STRING, - Map.of("on", new Variant("on", "value")), - List.of( + map("on", new Variant("on", "value")), + list( new Allocation( "allocation", - List.of(new Rule(List.of(condition))), + list(new Rule(list(condition))), null, null, - List.of(split("on", emptyList())), + list(split("on", emptyList())), false))); } @@ -287,15 +287,33 @@ private static Flag flag( true, type, variants, - List.of(new Allocation("allocation", emptyList(), null, null, splits, true))); + list(new Allocation("allocation", emptyList(), null, null, splits, true))); } private static Split split(final String variationKey, final List shards) { return new Split(shards, variationKey, emptyMap(), 7); } - private static Condition condition( + private static ConditionConfiguration condition( final ConditionOperator operator, final String attribute, final Object value) { - return new Condition(operator, attribute, value); + return new ConditionConfiguration(operator, attribute, value); + } + + @SafeVarargs + private static List list(final T... values) { + final List result = new ArrayList<>(values.length); + for (final T value : values) { + result.add(value); + } + return result; + } + + @SuppressWarnings("unchecked") + private static Map map(final Object... keyValues) { + final Map result = new LinkedHashMap<>(); + for (int index = 0; index < keyValues.length; index += 2) { + result.put((K) keyValues[index], (V) keyValues[index + 1]); + } + return result; } } diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java index 219a658b2b5..55f26b17fbf 100644 --- a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java @@ -7,9 +7,15 @@ 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 datadog.trace.api.featureflag.ufc.v1.ConditionOperator; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.ValueType; import java.io.IOException; +import java.time.Instant; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.Test; class UfcParserTest { @@ -18,9 +24,9 @@ class UfcParserTest { @Test void parsesRawUfc() throws Exception { - final ConfigurationSnapshot snapshot = parser.parse(Fixtures.UFC.getBytes(UTF_8)); + final ServerConfiguration snapshot = parser.parse(Fixtures.UFC.getBytes(UTF_8)); - assertEquals("test", snapshot.environmentName); + assertEquals("test", snapshot.environment.name); assertEquals(1, snapshot.flags.size()); assertNotNull(snapshot.flags.get("message")); } @@ -33,16 +39,19 @@ void parsesJsonApiResponse() throws Exception { + "}}"; assertEquals(1, parser.parse(response.getBytes(UTF_8)).flags.size()); + assertEquals(1, parser.parseJsonApi(response.getBytes(UTF_8)).flags.size()); + assertThrows(IOException.class, () -> parser.parseJsonApi(Fixtures.UFC.getBytes(UTF_8))); } @Test - void parsesCompleteUfcModelAndFreezesNestedValues() throws Exception { + void parsesCompleteUfcModel() throws Exception { final String content = "{" + "\"createdAt\":\"2026-01-01T00:00:00Z\"," + "\"format\":\"SERVER\"," + "\"environment\":{\"name\":\"production\"}," + "\"flags\":{\"targeted\":{" + + "\"key\":\"targeted\"," + "\"enabled\":true," + "\"variationType\":\"JSON\"," + "\"variations\":{\"on\":{\"value\":{\"nested\":[1,true]}}}," @@ -64,30 +73,45 @@ void parsesCompleteUfcModelAndFreezesNestedValues() throws Exception { + "}]" + "}}}"; - final ConfigurationSnapshot snapshot = parser.parse(content.getBytes(UTF_8)); - final ConfigurationSnapshot.Flag flag = snapshot.flags.get("targeted"); - final ConfigurationSnapshot.Allocation allocation = flag.allocations.get(0); - final ConfigurationSnapshot.Split split = allocation.splits.get(0); + final ServerConfiguration snapshot = parser.parse(content.getBytes(UTF_8)); + final Flag flag = snapshot.flags.get("targeted"); + final Allocation allocation = flag.allocations.get(0); + final Split split = allocation.splits.get(0); assertEquals("targeted", flag.key); - assertEquals(ConfigurationSnapshot.ValueType.JSON, flag.variationType); - assertNotNull(allocation.startAtMillis); - assertEquals(null, allocation.endAtMillis); - assertEquals( - ConfigurationSnapshot.ConditionOperator.ONE_OF, - allocation.rules.get(0).conditions.get(0).operator); + assertEquals(ValueType.JSON, flag.variationType); + assertNotNull(allocation.startAt); + assertEquals(null, allocation.endAt); + assertEquals(ConditionOperator.ONE_OF, allocation.rules.get(0).conditions.get(0).operator); assertEquals(12, split.serialId); assertEquals("test", split.extraLogging.get("reason")); assertEquals(100, split.shards.get(0).totalShards); assertEquals(50, split.shards.get(0).ranges.get(0).end); - assertThrows( - UnsupportedOperationException.class, - () -> ((Map) flag.variations.get("on").value).put("new", true)); - assertThrows( - UnsupportedOperationException.class, - () -> - ((List) ((Map) flag.variations.get("on").value).get("nested")) - .add("new")); + } + + @Test + void parsesOffsetDatesAndMapsInvalidDatesToNull() throws Exception { + final String content = + "{" + + "\"flags\":{\"flag\":{" + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"value\":\"on\"}}," + + "\"allocations\":[" + + "{\"key\":\"positive\",\"startAt\":\"2023-01-01T01:00:00+01:00\"," + + "\"splits\":[]}," + + "{\"key\":\"negative\",\"startAt\":\"2023-01-01T00:00:00-05:00\"," + + "\"splits\":[]}," + + "{\"key\":\"invalid\",\"startAt\":\"not-a-date\",\"splits\":[]}" + + "]" + + "}}}"; + + final List allocations = + parser.parse(content.getBytes(UTF_8)).flags.get("flag").allocations; + + assertEquals(Instant.parse("2023-01-01T00:00:00Z"), allocations.get(0).startAt.toInstant()); + assertEquals(Instant.parse("2023-01-01T05:00:00Z"), allocations.get(1).startAt.toInstant()); + assertEquals(null, allocations.get(2).startAt); } @Test @@ -95,7 +119,7 @@ void skipsMalformedFlagAndKeepsValidFlag() throws Exception { final String content = Fixtures.UFC.replace("\"flags\":{", "\"flags\":{\"broken\":{\"enabled\":\"yes\"},"); - final ConfigurationSnapshot snapshot = parser.parse(content.getBytes(UTF_8)); + final ServerConfiguration snapshot = parser.parse(content.getBytes(UTF_8)); assertFalse(snapshot.flags.containsKey("broken")); assertNotNull(snapshot.flags.get("message")); @@ -114,13 +138,18 @@ void rejectsEmptyMalformedAndStructurallyInvalidDocuments() { assertThrows(IOException.class, () -> parser.parse(new byte[0])); assertThrows(IOException.class, () -> parser.parse("{".getBytes(UTF_8))); assertThrows(IOException.class, () -> parser.parse("[]".getBytes(UTF_8))); - assertThrows(IOException.class, () -> parser.parse("{}".getBytes(UTF_8))); assertThrows( IOException.class, () -> parser.parse("{\"data\":{\"type\":\"universal-flag-configuration\"}}".getBytes(UTF_8))); } + @Test + void allowsMissingAndNullFlagMaps() throws Exception { + assertEquals(null, parser.parse("{}".getBytes(UTF_8)).flags); + assertEquals(null, parser.parse("{\"flags\":null}".getBytes(UTF_8)).flags); + } + @Test void skipsFlagsWithInvalidRequiredFields() throws Exception { final String content = diff --git a/products/feature-flagging/feature-flagging-http/build.gradle.kts b/products/feature-flagging/feature-flagging-http/build.gradle.kts index 5be2acc81bc..7b93ee4ad71 100644 --- a/products/feature-flagging/feature-flagging-http/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-http/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { api(project(":products:feature-flagging:feature-flagging-core")) testImplementation(libs.bundles.junit5) + testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) } fun AbstractCompile.configureCompiler( diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 425e217e822..5ba5fa18003 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { api(project(":communication")) implementation(project(":internal-api")) api(project(":products:feature-flagging:feature-flagging-bootstrap")) + implementation(project(":products:feature-flagging:feature-flagging-core")) implementation(project(":utils:logging-utils")) api(project(":utils:queue-utils")) diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java index f4fc35cce30..3495714b406 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java @@ -8,6 +8,7 @@ import datadog.communication.http.HttpRetryPolicy; import datadog.communication.http.OkHttpUtils; import datadog.logging.RatelimitedLogger; +import datadog.openfeature.internal.core.UfcParser; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; @@ -38,6 +39,7 @@ final class AgentlessConfigurationSource implements ConfigurationSourceService { private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessConfigurationSource.class); + private static final UfcParser UFC_PARSER = new UfcParser(); private static final String DATADOG_UFC_RULES_BASED_SERVER_PATH = "/api/v2/feature-flagging/config/rules-based/server"; @@ -227,7 +229,7 @@ private boolean apply(final UfcHttpResponse response) { } final ServerConfiguration configuration; try { - configuration = JsonApiUfcResponseParser.INSTANCE.parse(response.body); + configuration = UFC_PARSER.parseJsonApi(response.body); } catch (final IOException | RuntimeException e) { LOGGER.debug("Feature Flagging HTTP configuration source returned malformed UFC payload", e); return false; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java deleted file mode 100644 index 0a47d316075..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonReader; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import javax.annotation.Nullable; -import okio.BufferedSource; -import okio.Okio; - -final class JsonApiUfcResponseParser { - - private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; - private static final JsonReader.Options RESPONSE_FIELDS = JsonReader.Options.of("data"); - private static final JsonReader.Options DATA_FIELDS = JsonReader.Options.of("type", "attributes"); - - static final JsonApiUfcResponseParser INSTANCE = - new JsonApiUfcResponseParser(UniversalFlagConfigParser.INSTANCE); - - private final UniversalFlagConfigParser ufcParser; - - JsonApiUfcResponseParser(final UniversalFlagConfigParser ufcParser) { - this.ufcParser = ufcParser; - } - - @Nullable - ServerConfiguration parse(final byte[] content) throws IOException { - try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { - final JsonReader reader = JsonReader.of(source); - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - ServerConfiguration configuration = null; - reader.beginObject(); - while (reader.hasNext()) { - if (reader.selectName(RESPONSE_FIELDS) == 0) { - configuration = parseData(reader); - } else { - reader.skipName(); - reader.skipValue(); - } - } - reader.endObject(); - requireEndOfDocument(reader); - return configuration; - } - } - - @Nullable - private ServerConfiguration parseData(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - String type = null; - ServerConfiguration configuration = null; - reader.beginObject(); - while (reader.hasNext()) { - switch (reader.selectName(DATA_FIELDS)) { - case 0: - if (reader.peek() == JsonReader.Token.STRING) { - type = reader.nextString(); - } else { - reader.skipValue(); - } - break; - case 1: - configuration = ufcParser.parse(reader); - break; - default: - reader.skipName(); - reader.skipValue(); - } - } - reader.endObject(); - return UNIVERSAL_FLAG_CONFIGURATION_TYPE.equals(type) - ? validConfiguration(configuration) - : null; - } - - @Nullable - private static ServerConfiguration validConfiguration( - @Nullable final ServerConfiguration configuration) { - return configuration != null && configuration.flags != null ? configuration : null; - } - - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } -} 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 ba5536601f7..238d53a9857 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 @@ -1,139 +1,21 @@ package com.datadog.featureflag; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; +import datadog.openfeature.internal.core.UfcParser; import datadog.remoteconfig.ConfigurationDeserializer; -import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.time.Instant; -import java.time.format.DateTimeFormatter; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import okio.BufferedSource; -import okio.Okio; +/** Adapts the shared UFC parser to the Remote Configuration callback contract. */ final class UniversalFlagConfigParser implements ConfigurationDeserializer { static final UniversalFlagConfigParser INSTANCE = new UniversalFlagConfigParser(); - private static final Moshi MOSHI = - new Moshi.Builder().add(Date.class, new DateAdapter()).add(FlagMapAdapter.FACTORY).build(); - private static final JsonAdapter V1_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); + private final UfcParser parser = new UfcParser(); private UniversalFlagConfigParser() {} @Override public ServerConfiguration deserialize(final byte[] content) throws IOException { - try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { - final JsonReader reader = JsonReader.of(source); - final ServerConfiguration configuration = parse(reader); - requireEndOfDocument(reader); - return configuration; - } - } - - @Nullable - ServerConfiguration parse(final JsonReader reader) throws IOException { - return V1_ADAPTER.fromJson(reader); - } - - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } - - static final class FlagMapAdapter extends JsonAdapter> { - - private static final Type FLAGS_TYPE = - Types.newParameterizedType(Map.class, String.class, Flag.class); - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { - return null; - } - return new FlagMapAdapter(moshi.adapter(Flag.class)); - } - }; - - private final JsonAdapter flagAdapter; - - FlagMapAdapter(final JsonAdapter flagAdapter) { - this.flagAdapter = flagAdapter; - } - - @Nullable - @Override - public Map fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - final Map flags = new HashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String flagKey = reader.nextName(); - final Object rawFlag = reader.readJsonValue(); - try { - final Flag flag = flagAdapter.fromJsonValue(rawFlag); - if (flag != null) { - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException ignored) { - // A malformed flag must not prevent other flags in the same config from evaluating. - } - } - reader.endObject(); - return flags; - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class DateAdapter extends JsonAdapter { - - @Nullable - @Override - public Date fromJson(@Nonnull final JsonReader reader) throws IOException { - final String date = reader.nextString(); - if (date == null) { - return null; - } - try { - final Instant instant = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); - return Date.from(instant); - } catch (Exception e) { - // ignore wrongly set dates - return null; - } - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Date value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } + return parser.parse(content); } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java deleted file mode 100644 index a31d47889ba..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java +++ /dev/null @@ -1,87 +0,0 @@ -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.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.ServerConfiguration; -import java.io.IOException; -import org.junit.jupiter.api.Test; - -class JsonApiUfcResponseParserTest { - - @Test - void parsesJsonApiMembersInAnyOrder() throws Exception { - final ServerConfiguration configuration = - parse( - "{" - + "\"meta\":{\"ignored\":true}," - + "\"data\":{" - + "\"attributes\":" - + emptyConfig() - + ",\"ignored\":true," - + "\"type\":\"universal-flag-configuration\"" - + "}" - + "}"); - - assertNotNull(configuration); - assertEquals("Test", configuration.environment.name); - assertTrue(configuration.flags.isEmpty()); - } - - @Test - void rejectsRawUfc() throws Exception { - assertNull(parse(emptyConfig())); - } - - @Test - void rejectsUnexpectedJsonApiType() throws Exception { - assertNull(parse("{\"data\":{\"type\":\"other-type\",\"attributes\":" + emptyConfig() + "}}")); - } - - @Test - void rejectsNonStringJsonApiType() throws Exception { - assertNull(parse("{\"data\":{\"type\":null,\"attributes\":" + emptyConfig() + "}}")); - } - - @Test - void rejectsConfigurationWithoutFlags() throws Exception { - assertNull( - parse( - "{\"data\":{" - + "\"type\":\"universal-flag-configuration\"," - + "\"attributes\":{\"environment\":{\"name\":\"Test\"}}" - + "}}")); - } - - @Test - void rejectsNonObjectData() throws Exception { - assertNull(parse("{\"data\":[]}")); - } - - @Test - void rejectsTrailingJson() { - assertThrows( - IOException.class, - () -> - parse( - "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" - + emptyConfig() - + "}}{}")); - } - - private static ServerConfiguration parse(final String json) throws Exception { - return JsonApiUfcResponseParser.INSTANCE.parse(json.getBytes(UTF_8)); - } - - private static String emptyConfig() { - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{}" - + "}"; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java index c1f5ef17b87..0f18a665971 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java @@ -1,9 +1,6 @@ package com.datadog.featureflag; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.Collections.emptyMap; -import static java.util.Collections.emptySet; -import static java.util.Collections.singleton; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -16,11 +13,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.remoteconfig.Capabilities; import datadog.remoteconfig.ConfigurationDeserializer; @@ -29,14 +21,8 @@ import datadog.remoteconfig.Product; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.time.Instant; -import java.util.Date; -import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -44,7 +30,6 @@ import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.tabletest.junit.TableTest; @ExtendWith(MockitoExtension.class) class RemoteConfigServiceImplTest { @@ -189,23 +174,6 @@ void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); } - @Test - void flagMapAdapterFactoryOnlyCreatesFlagMapAdapterForFlagMapType() { - final Moshi moshi = moshi(); - final Type flagsType = Types.newParameterizedType(Map.class, String.class, Flag.class); - - final JsonAdapter adapter = - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(flagsType, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.FlagMapAdapter); - assertNull( - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(String.class, emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create( - flagsType, singleton(mock(Annotation.class)), moshi)); - } - @Test void allowsNullFlagMap() throws Exception { final ServerConfiguration config = @@ -252,62 +220,6 @@ void skipsNullFlagAndKeepsValidFlag() throws Exception { assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); } - @Test - void flagMapAdapterIsReadOnly() { - final UniversalFlagConfigParser.FlagMapAdapter adapter = - new UniversalFlagConfigParser.FlagMapAdapter(moshi().adapter(Flag.class)); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), emptyMap())); - } - - @TableTest({ - "scenario | value | expectedEpochMilli", - "utc second | '2023-01-01T00:00:00Z' | 1672531200000 ", - "utc end of year | '2023-12-31T23:59:59Z' | 1704067199000 ", - "leap day | '2024-02-29T12:00:00Z' | 1709208000000 ", - "millisecond precision | '2023-01-01T00:00:00.000Z' | 1672531200000 ", - "three fractional digits | '2023-06-15T14:30:45.123Z' | 1686839445123 ", - "six fractional digits truncate to millis | '2023-06-15T14:30:45.123456Z' | 1686839445123 ", - "six fractional digits preserve millis | '2023-06-15T14:30:45.235982Z' | 1686839445235 ", - "nine fractional digits truncate to millis | '2023-06-15T14:30:45.123456789Z' | 1686839445123 ", - "one fractional digit | '2023-06-15T14:30:45.1Z' | 1686839445100 ", - "two fractional digits | '2023-06-15T14:30:45.12Z' | 1686839445120 ", - "positive offset | '2023-01-01T01:00:00+01:00' | 1672531200000 ", - "negative offset | '2023-01-01T00:00:00-05:00' | 1672549200000 ", - "date only | '2023-01-01' | ", - "invalid | 'invalid-date' | ", - "empty string | '' | ", - "not a date | 'not-a-date' | ", - "slash date | '2023/01/01T00:00:00Z' | ", - "null | | " - }) - void testDateParsing(final String value, final Long expectedEpochMilli) throws Exception { - final JsonReader reader = mock(JsonReader.class); - when(reader.nextString()).thenReturn(value); - final UniversalFlagConfigParser.DateAdapter adapter = - new UniversalFlagConfigParser.DateAdapter(); - - final Date parsed = adapter.fromJson(reader); - if (expectedEpochMilli == null) { - assertNull(parsed); - } else { - assertNotNull(parsed); - assertEquals(Instant.ofEpochMilli(expectedEpochMilli), parsed.toInstant()); - } - } - - @Test - void testParsingOnlyAdapter() { - final UniversalFlagConfigParser.DateAdapter adapter = - new UniversalFlagConfigParser.DateAdapter(); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), new Date())); - } - @SuppressWarnings("unchecked") private ConfigurationDeserializer deserializer() { return deserializerCaptor.getValue(); @@ -317,10 +229,6 @@ private static ServerConfiguration deserialize(final String json) throws Excepti return UniversalFlagConfigParser.INSTANCE.deserialize(json.getBytes(UTF_8)); } - private static Moshi moshi() { - return new Moshi.Builder().add(Date.class, new UniversalFlagConfigParser.DateAdapter()).build(); - } - private static String emptyConfig() { return "{" + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," From 143330237dcc98f9ba322c81093a62fe54d95920 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 29 Jul 2026 23:15:19 -0600 Subject: [PATCH 06/16] Test UFC forward-compatible fields --- .../internal/core/UfcParserTest.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java index 55f26b17fbf..66f1e72c7f6 100644 --- a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java @@ -89,6 +89,66 @@ void parsesCompleteUfcModel() throws Exception { assertEquals(50, split.shards.get(0).ranges.get(0).end); } + @Test + void ignoresUnknownAdditiveFieldsAtEveryUfcLevel() throws Exception { + final String content = + "{" + + "\"futureEnvelope\":{\"version\":2}," + + "\"data\":{" + + "\"type\":\"universal-flag-configuration\"," + + "\"futureResource\":true," + + "\"attributes\":{" + + "\"futureConfiguration\":{\"key\":\"value\"}," + + "\"environment\":{\"name\":\"test\",\"futureEnvironment\":true}," + + "\"flags\":{\"message\":{" + + "\"key\":\"message\"," + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"futureFlag\":1," + + "\"variations\":{\"on\":{" + + "\"key\":\"on\",\"value\":\"hello\",\"futureVariant\":\"ignored\"" + + "}}," + + "\"allocations\":[{" + + "\"key\":\"allocation\"," + + "\"futureAllocation\":{}," + + "\"rules\":[{" + + "\"futureRule\":true," + + "\"conditions\":[{" + + "\"operator\":\"IS_NULL\"," + + "\"attribute\":\"missing\"," + + "\"value\":true," + + "\"futureCondition\":[]" + + "}]" + + "}]," + + "\"splits\":[{" + + "\"variationKey\":\"on\"," + + "\"serialId\":7," + + "\"futureSplit\":false," + + "\"shards\":[{" + + "\"salt\":\"salt\"," + + "\"totalShards\":1," + + "\"futureShard\":null," + + "\"ranges\":[{\"start\":0,\"end\":1,\"futureRange\":9}]" + + "}]" + + "}]" + + "}]" + + "}}" + + "}" + + "}" + + "}"; + + final ServerConfiguration snapshot = parser.parseJsonApi(content.getBytes(UTF_8)); + final Flag flag = snapshot.flags.get("message"); + final Allocation allocation = flag.allocations.get(0); + final Split split = allocation.splits.get(0); + + assertEquals("test", snapshot.environment.name); + assertEquals("hello", flag.variations.get("on").value); + assertEquals(ConditionOperator.IS_NULL, allocation.rules.get(0).conditions.get(0).operator); + assertEquals(7, split.serialId); + assertEquals(1, split.shards.get(0).ranges.get(0).end); + } + @Test void parsesOffsetDatesAndMapsInvalidDatesToNull() throws Exception { final String content = From 3bcfb76cf3245a7f3e5909e4c0e3757aa42d7254 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 29 Jul 2026 23:15:26 -0600 Subject: [PATCH 07/16] Run core evaluator against canonical fixtures --- .../api/openfeature/DDEvaluatorTest.java | 197 ------------ .../internal/core/FlagEvaluatorTest.java | 300 +++++++++--------- 2 files changed, 144 insertions(+), 353 deletions(-) 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 7b6cc01e021..8532b099f5e 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 @@ -10,19 +10,12 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.hasEntry; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; @@ -31,13 +24,6 @@ import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; -import java.io.IOException; -import java.lang.reflect.Type; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; @@ -46,8 +32,6 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.stream.Collectors; -import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -55,16 +39,6 @@ public class DDEvaluatorTest { - private static final String CANONICAL_FIXTURE_PATH = - "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; - private static final Moshi MOSHI = new Moshi.Builder().add(Date.class, new DateAdapter()).build(); - private static final JsonAdapter CONFIG_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); - private static final Type FIXTURE_LIST_TYPE = - Types.newParameterizedType(List.class, FixtureCase.class); - private static final JsonAdapter> FIXTURE_LIST_ADAPTER = - MOSHI.adapter(FIXTURE_LIST_TYPE); - @Test public void testInitializeSignalsApplicationProviderActivation() throws Exception { final FeatureFlaggingGateway.ActivationListener listener = @@ -244,130 +218,6 @@ public void testFlattening( } } - @Test - public void testCanonicalFixturesArePresent() throws IOException { - assertThat(canonicalTestCases().size(), greaterThan(0)); - } - - @MethodSource("canonicalTestCases") - @ParameterizedTest(name = "{0}") - public void testEvaluateCanonicalFixture(final FixtureCase testCase) throws IOException { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(loadCanonicalConfiguration()); - - final Class targetType = targetType(testCase.variationType); - final Object defaultValue = mapFixtureValue(targetType, testCase.defaultValue); - final Object expectedValue = mapFixtureValue(targetType, testCase.result.value); - final ProviderEvaluation details = - evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); - - assertThat(details.getValue(), equalTo(expectedValue)); - assertThat(details.getReason(), equalTo(testCase.result.reason)); - if (testCase.result.variant != null) { - assertThat(details.getVariant(), equalTo(testCase.result.variant)); - } - if (testCase.result.errorCode != null) { - assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.result.errorCode))); - } - if (testCase.result.flagMetadata != null - && testCase.result.flagMetadata.get("allocationKey") != null) { - assertThat( - details.getFlagMetadata().getString("allocationKey"), - equalTo(String.valueOf(testCase.result.flagMetadata.get("allocationKey")))); - } - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private static ProviderEvaluation evaluate( - final DDEvaluator evaluator, - final Class targetType, - final String flag, - final Object defaultValue, - final EvaluationContext context) { - return evaluator.evaluate((Class) targetType, flag, defaultValue, context); - } - - private static ServerConfiguration loadCanonicalConfiguration() throws IOException { - return CONFIG_ADAPTER.fromJson(read(fixtureRoot().resolve("ufc-config.json"))); - } - - private static List canonicalTestCases() throws IOException { - final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); - final List result = new ArrayList<>(); - - try (final Stream paths = Files.list(evaluationCases)) { - final List files = - paths - .filter(path -> path.getFileName().toString().endsWith(".json")) - .sorted((left, right) -> left.getFileName().compareTo(right.getFileName())) - .collect(Collectors.toList()); - for (final Path file : files) { - final List testCases = FIXTURE_LIST_ADAPTER.fromJson(read(file)); - if (testCases == null) { - throw new JsonDataException("Fixture file did not contain an array: " + file); - } - for (int index = 0; index < testCases.size(); index++) { - final FixtureCase testCase = testCases.get(index); - testCase.fileName = file.getFileName().toString(); - testCase.index = index; - result.add(testCase); - } - } - } - - assertThat(result.size(), greaterThan(0)); - return result; - } - - private static Path fixtureRoot() { - Path directory = Paths.get("").toAbsolutePath(); - while (directory != null) { - final Path candidate = directory.resolve(CANONICAL_FIXTURE_PATH); - if (Files.exists(candidate.resolve("ufc-config.json")) - && Files.isDirectory(candidate.resolve("evaluation-cases"))) { - return candidate; - } - directory = directory.getParent(); - } - throw new IllegalStateException("Unable to find canonical FFE fixtures"); - } - - private static String read(final Path path) throws IOException { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static EvaluationContext context(final FixtureCase testCase) { - final Map attributes = - testCase.attributes == null ? emptyMap() : testCase.attributes; - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - if (testCase.targetingKey != null) { - context.setTargetingKey(testCase.targetingKey); - } - return context; - } - - private static Class targetType(final String variationType) { - switch (variationType) { - case "BOOLEAN": - return Boolean.class; - case "INTEGER": - return Integer.class; - case "NUMERIC": - return Double.class; - case "STRING": - return String.class; - case "JSON": - return Value.class; - default: - throw new IllegalArgumentException("Unsupported variationType: " + variationType); - } - } - - private static Object mapFixtureValue(final Class targetType, final Object value) { - return DDEvaluator.mapValue(targetType, value); - } - private static Map mapOf(final Object... props) { final Map result = new HashMap<>(props.length << 1); int index = 0; @@ -378,51 +228,4 @@ private static Map mapOf(final Object... props) { } return result; } - - private static final class FixtureCase { - Map attributes = emptyMap(); - Object defaultValue; - String flag; - FixtureResult result; - String targetingKey; - String variationType; - transient String fileName; - transient int index; - - @Override - public String toString() { - return fileName + "[" + index + "] flag=" + flag; - } - } - - private static final class FixtureResult { - Object value; - String reason; - String errorCode; - String variant; - Map flagMetadata = emptyMap(); - } - - private static final class DateAdapter extends JsonAdapter { - @Override - public Date fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - try { - return Date.from(OffsetDateTime.parse(reader.nextString()).toInstant()); - } catch (final Exception ignored) { - return null; - } - } - - @Override - public void toJson(final JsonWriter writer, final Date value) throws IOException { - if (value == null) { - writer.nullValue(); - return; - } - writer.value(value.toInstant().toString()); - } - } } diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java index 0b60857209b..bbf99edb275 100644 --- a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java @@ -3,8 +3,12 @@ import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertFalse; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; import datadog.openfeature.internal.core.EvaluationResult.Error; import datadog.openfeature.internal.core.EvaluationResult.Reason; import datadog.openfeature.internal.core.FlagEvaluator.ValueKind; @@ -15,24 +19,64 @@ import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.Rule; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; import datadog.trace.api.featureflag.ufc.v1.Split; import datadog.trace.api.featureflag.ufc.v1.ValueType; import datadog.trace.api.featureflag.ufc.v1.Variant; +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; class FlagEvaluatorTest { + private static final String CANONICAL_FIXTURE_PATH = + "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; + private static final Type FIXTURE_LIST_TYPE = + Types.newParameterizedType(List.class, FixtureCase.class); + private static final JsonAdapter> FIXTURE_LIST_ADAPTER = + new Moshi.Builder().build().adapter(FIXTURE_LIST_TYPE); + private static final UfcParser UFC_PARSER = new UfcParser(); + private final FlagEvaluator evaluator = new FlagEvaluator(); + @ParameterizedTest(name = "{0}") + @MethodSource("canonicalTestCases") + void evaluatesCanonicalFixture(final FixtureCase testCase) throws IOException { + final ValueKind valueKind = valueKind(testCase.variationType); + final Object defaultValue = FlagEvaluator.mapValue(valueKind, testCase.defaultValue); + final EvaluationResult result = + evaluator.evaluate( + loadCanonicalConfiguration(), + valueKind, + testCase.flag, + defaultValue, + context(testCase)); + + assertEquals(FlagEvaluator.mapValue(valueKind, testCase.result.value), result.value); + assertEquals(Reason.valueOf(testCase.result.reason), result.reason); + if (testCase.result.errorCode != null) { + assertEquals(Error.valueOf(testCase.result.errorCode), result.error); + } + } + @Test - void evaluatesStaticValuesAndMetadata() { + void canonicalFixturesArePresent() throws IOException { + assertFalse(canonicalTestCases().isEmpty()); + } + + @Test + void evaluatesMetadataExcludedFromCanonicalFixtures() { final EvaluationResult result = evaluate(staticFlag(ValueType.STRING, "hello"), ValueKind.STRING, context("subject")); @@ -45,41 +89,13 @@ void evaluatesStaticValuesAndMetadata() { } @Test - void mapsAllSupportedValueKinds() { - assertEquals( - true, evaluate(staticFlag(ValueType.BOOLEAN, 1), ValueKind.BOOLEAN, context("id")).value); - assertEquals( - 42, - evaluate(staticFlag(ValueType.INTEGER, "42.9"), ValueKind.INTEGER, context("id")).value); - assertEquals( - 42D, evaluate(staticFlag(ValueType.NUMERIC, "42"), ValueKind.DOUBLE, context("id")).value); - assertEquals( - "42", evaluate(staticFlag(ValueType.STRING, 42), ValueKind.STRING, context("id")).value); - assertEquals( - map("nested", true), - evaluate(staticFlag(ValueType.JSON, map("nested", true)), ValueKind.OBJECT, context("id")) - .value); - assertNull(FlagEvaluator.mapValue(ValueKind.STRING, null)); - } - - @Test - void reportsInputAndConfigurationErrors() { + void reportsErrorsOutsideCanonicalFixtureContract() { assertError(null, ValueKind.STRING, context("id"), Error.PROVIDER_NOT_READY); assertError( snapshot(staticFlag(ValueType.STRING, "value")), ValueKind.STRING, null, Error.INVALID_CONTEXT); - - final EvaluationResult missing = - evaluator.evaluate( - snapshot(staticFlag(ValueType.STRING, "value")), - ValueKind.STRING, - "missing", - "default", - context("id")); - assertEquals(Error.FLAG_NOT_FOUND, missing.error); - assertError( snapshot( new Flag("flag", true, ValueType.STRING, map("on", new Variant("on", "value")), null)), @@ -93,10 +109,7 @@ void reportsInputAndConfigurationErrors() { Error.TYPE_MISMATCH); assertError( snapshot( - flag( - ValueType.STRING, - map("on", new Variant("on", "value")), - list(split("missing", emptyList())))), + flag(ValueType.STRING, map("on", new Variant("on", "value")), list(split("missing")))), ValueKind.STRING, context("id"), Error.GENERAL); @@ -108,88 +121,7 @@ void reportsInputAndConfigurationErrors() { } @Test - void returnsDisabledAndDefaultResults() { - final Flag disabled = - new Flag( - "flag", false, ValueType.STRING, map("on", new Variant("on", "value")), emptyList()); - assertEquals(Reason.DISABLED, evaluate(disabled, ValueKind.STRING, context("id")).reason); - - final Flag noSplits = - flag(ValueType.STRING, map("on", new Variant("on", "value")), emptyList()); - assertEquals(Reason.DEFAULT, evaluate(noSplits, ValueKind.STRING, context("id")).reason); - - final long now = System.currentTimeMillis(); - final Flag inactive = - new Flag( - "flag", - true, - ValueType.STRING, - map("on", new Variant("on", "value")), - list( - new Allocation( - "future", - emptyList(), - new Date(now + 60_000), - null, - list(split("on", emptyList())), - false), - new Allocation( - "past", - emptyList(), - null, - new Date(now - 60_000), - list(split("on", emptyList())), - false))); - assertEquals(Reason.DEFAULT, evaluate(inactive, ValueKind.STRING, context("id")).reason); - } - - @Test - void evaluatesAllRuleOperators() { - final Rule matchingRule = - new Rule( - list( - condition(ConditionOperator.MATCHES, "country", "^U"), - condition(ConditionOperator.NOT_MATCHES, "country", "^C"), - condition(ConditionOperator.ONE_OF, "tier", list("free", "paid")), - condition(ConditionOperator.NOT_ONE_OF, "tier", list("blocked")), - condition(ConditionOperator.GTE, "age", 18), - condition(ConditionOperator.GT, "age", 17), - condition(ConditionOperator.LTE, "age", 18), - condition(ConditionOperator.LT, "age", 19), - condition(ConditionOperator.IS_NULL, "missing", true), - condition(ConditionOperator.IS_NULL, "country", false), - condition(ConditionOperator.ONE_OF, "score", list("18")))); - final Flag flag = - new Flag( - "flag", - true, - ValueType.STRING, - map("on", new Variant("on", "matched")), - list( - new Allocation( - "targeted", - list(new Rule(emptyList()), matchingRule), - null, - null, - list(split("on", emptyList())), - false))); - final EvaluationContext matching = - new EvaluationContext( - "subject", map("country", "US", "tier", "paid", "age", 18, "score", 18)); - - assertEquals(Reason.TARGETING_MATCH, evaluate(flag, ValueKind.STRING, matching).reason); - assertEquals( - Reason.DEFAULT, - evaluate( - flag, - ValueKind.STRING, - new EvaluationContext( - "subject", map("country", "CA", "tier", "paid", "age", 18, "score", 18))) - .reason); - } - - @Test - void mapsInvalidRulesToEvaluationErrors() { + void reportsMalformedRuleErrorsOutsideCanonicalFixtureContract() { final Flag invalidRegex = targetedFlag(condition(ConditionOperator.MATCHES, "value", "[")); assertEquals( Error.PARSE_ERROR, @@ -203,40 +135,74 @@ void mapsInvalidRulesToEvaluationErrors() { .error); } - @Test - void evaluatesShardsAndRequiresTargetingKey() { - final Shard matchingShard = new Shard("salt", list(new ShardRange(0, 1)), 1); - final Flag sharded = - flag( - ValueType.STRING, - map("on", new Variant("on", "value")), - list(split("on", list(matchingShard)))); - - assertEquals(Reason.SPLIT, evaluate(sharded, ValueKind.STRING, context("id")).reason); - assertEquals( - Error.TARGETING_KEY_MISSING, evaluate(sharded, ValueKind.STRING, context(null)).error); - - final Shard invalidShard = new Shard("salt", emptyList(), 0); - final Flag unmatched = - flag( - ValueType.STRING, - map("on", new Variant("on", "value")), - list(split("on", list(invalidShard)))); - assertEquals(Reason.DEFAULT, evaluate(unmatched, ValueKind.STRING, context("id")).reason); + private static ServerConfiguration loadCanonicalConfiguration() throws IOException { + return UFC_PARSER.parse(Files.readAllBytes(fixtureRoot().resolve("ufc-config.json"))); } - @Test - void contextUsesTargetingKeyAsDefaultIdAndCopiesAttributes() { - final java.util.Map mutable = new java.util.LinkedHashMap<>(); - mutable.put("name", "value"); - final EvaluationContext context = new EvaluationContext("subject", mutable); - mutable.put("name", "changed"); - - assertEquals("subject", context.attribute("id")); - assertEquals("value", context.attribute("name")); - assertEquals( - "explicit", new EvaluationContext("subject", map("id", "explicit")).attribute("id")); - assertNull(new EvaluationContext("subject", null).attribute("missing")); + private static List canonicalTestCases() throws IOException { + final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); + final List result = new ArrayList<>(); + + try (final Stream paths = Files.list(evaluationCases)) { + final List files = + paths + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted((left, right) -> left.getFileName().compareTo(right.getFileName())) + .collect(Collectors.toList()); + for (final Path file : files) { + final List testCases = FIXTURE_LIST_ADAPTER.fromJson(read(file)); + if (testCases == null) { + throw new JsonDataException("Fixture file did not contain an array: " + file); + } + for (int index = 0; index < testCases.size(); index++) { + final FixtureCase testCase = testCases.get(index); + testCase.fileName = file.getFileName().toString(); + testCase.index = index; + result.add(testCase); + } + } + } + + return result; + } + + private static Path fixtureRoot() { + Path directory = Paths.get("").toAbsolutePath(); + while (directory != null) { + final Path candidate = directory.resolve(CANONICAL_FIXTURE_PATH); + if (Files.exists(candidate.resolve("ufc-config.json")) + && Files.isDirectory(candidate.resolve("evaluation-cases"))) { + return candidate; + } + directory = directory.getParent(); + } + throw new IllegalStateException("Unable to find canonical FFE fixtures"); + } + + private static String read(final Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + private static ValueKind valueKind(final String variationType) { + switch (variationType) { + case "BOOLEAN": + return ValueKind.BOOLEAN; + case "INTEGER": + return ValueKind.INTEGER; + case "NUMERIC": + return ValueKind.DOUBLE; + case "STRING": + return ValueKind.STRING; + case "JSON": + return ValueKind.OBJECT; + default: + throw new IllegalArgumentException("Unsupported variationType: " + variationType); + } + } + + private static EvaluationContext context(final FixtureCase testCase) { + return new EvaluationContext( + testCase.targetingKey, testCase.attributes == null ? emptyMap() : testCase.attributes); } private EvaluationResult evaluate( @@ -261,7 +227,7 @@ private static EvaluationContext context(final String targetingKey) { } private static Flag staticFlag(final ValueType type, final Object value) { - return flag(type, map("on", new Variant("on", value)), list(split("on", emptyList()))); + return flag(type, map("on", new Variant("on", value)), list(split("on"))); } private static Flag targetedFlag(final ConditionConfiguration condition) { @@ -276,7 +242,7 @@ private static Flag targetedFlag(final ConditionConfiguration condition) { list(new Rule(list(condition))), null, null, - list(split("on", emptyList())), + list(split("on")), false))); } @@ -290,8 +256,8 @@ private static Flag flag( list(new Allocation("allocation", emptyList(), null, null, splits, true))); } - private static Split split(final String variationKey, final List shards) { - return new Split(shards, variationKey, emptyMap(), 7); + private static Split split(final String variationKey) { + return new Split(emptyList(), variationKey, emptyMap(), 7); } private static ConditionConfiguration condition( @@ -316,4 +282,26 @@ private static Map map(final Object... keyValues) { } return result; } + + private static final class FixtureCase { + Map attributes = emptyMap(); + Object defaultValue; + String flag; + FixtureResult result; + String targetingKey; + String variationType; + transient String fileName; + transient int index; + + @Override + public String toString() { + return fileName + "[" + index + "] flag=" + flag; + } + } + + private static final class FixtureResult { + Object value; + String reason; + String errorCode; + } } From 6df1d71d183faa1d22f88c4b700ad62f407c2e1e Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 29 Jul 2026 23:54:10 -0600 Subject: [PATCH 08/16] Fix explicit null UFC dates --- .../openfeature/internal/core/UfcParser.java | 6 +++--- .../openfeature/internal/core/UfcParserTest.java | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java index 5ba96bee4b8..058cf4487d7 100644 --- a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/UfcParser.java @@ -184,10 +184,10 @@ static final class DateAdapter extends JsonAdapter { @Override public Date fromJson(final JsonReader reader) throws IOException { - final String date = reader.nextString(); - if (date == null) { - return null; + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); } + final String date = reader.nextString(); try { final Instant instant = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); return Date.from(instant); diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java index 66f1e72c7f6..d9645bd176f 100644 --- a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/UfcParserTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.squareup.moshi.JsonWriter; import datadog.trace.api.featureflag.ufc.v1.Allocation; import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; import datadog.trace.api.featureflag.ufc.v1.Flag; @@ -163,6 +164,7 @@ void parsesOffsetDatesAndMapsInvalidDatesToNull() throws Exception { + "{\"key\":\"negative\",\"startAt\":\"2023-01-01T00:00:00-05:00\"," + "\"splits\":[]}," + "{\"key\":\"invalid\",\"startAt\":\"not-a-date\",\"splits\":[]}" + + ",{\"key\":\"null\",\"startAt\":null,\"splits\":[]}" + "]" + "}}}"; @@ -172,6 +174,17 @@ void parsesOffsetDatesAndMapsInvalidDatesToNull() throws Exception { assertEquals(Instant.parse("2023-01-01T00:00:00Z"), allocations.get(0).startAt.toInstant()); assertEquals(Instant.parse("2023-01-01T05:00:00Z"), allocations.get(1).startAt.toInstant()); assertEquals(null, allocations.get(2).startAt); + assertEquals(null, allocations.get(3).startAt); + } + + @Test + void rejectsSerializationThroughReadOnlyAdapters() { + assertThrows( + UnsupportedOperationException.class, + () -> new UfcParser.DateAdapter().toJson((JsonWriter) null, null)); + assertThrows( + UnsupportedOperationException.class, + () -> new UfcParser.FlagMapAdapter(null).toJson((JsonWriter) null, null)); } @Test From b8ce2a8bdcf61cfeb708f85c4a6ad7cc3ff5ef6f Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 29 Jul 2026 23:54:17 -0600 Subject: [PATCH 09/16] Cover standalone OpenFeature runtime paths --- .../api/openfeature/DDEvaluatorTest.java | 43 +++++ .../OpenFeatureEvaluationAdapterTest.java | 180 ++++++++++++++++++ .../StandaloneDDEvaluatorTest.java | 122 ++++++++++++ .../StandaloneRuntimeConfigurationTest.java | 50 +++++ 4 files changed, 395 insertions(+) create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java 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 8532b099f5e..30f6a610d8b 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 @@ -5,6 +5,7 @@ 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.MILLISECONDS; import static java.util.concurrent.TimeUnit.SECONDS; import static org.hamcrest.CoreMatchers.equalTo; @@ -17,8 +18,13 @@ import static org.mockito.Mockito.verify; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import datadog.trace.api.featureflag.ufc.v1.Allocation; import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.ValueType; +import datadog.trace.api.featureflag.ufc.v1.Variant; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.MutableContext; @@ -32,6 +38,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -186,6 +193,42 @@ public void testNoAllocations() { assertThat(details.getErrorCode(), nullValue()); } + @Test + public void testDispatchesExposureForSuccessfulEvaluation() { + final AtomicReference exposure = new AtomicReference<>(); + final FeatureFlaggingGateway.ExposureListener listener = exposure::set; + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final Split split = new Split(emptyList(), "on", emptyMap(), 7); + final Allocation allocation = + new Allocation("allocation", emptyList(), null, null, singletonList(split), true); + final Flag flag = + new Flag( + "flag", + true, + ValueType.STRING, + singletonMap("on", new Variant("on", "hello")), + singletonList(allocation)); + final Map attributes = new HashMap<>(); + attributes.put("nullable", new Value()); + final EvaluationContext context = new MutableContext("subject", attributes); + FeatureFlaggingGateway.addExposureListener(listener); + try { + evaluator.accept(new ServerConfiguration(null, "SERVER", null, singletonMap("flag", flag))); + + assertThat( + evaluator.evaluate(String.class, "flag", "default", context).getValue(), + equalTo("hello")); + assertThat(exposure.get().flag.key, equalTo("flag")); + assertThat(exposure.get().allocation.key, equalTo("allocation")); + assertThat(exposure.get().variant.key, equalTo("on")); + assertThat(exposure.get().subject.id, equalTo("subject")); + assertThat(exposure.get().subject.attributes, hasEntry("nullable", null)); + } finally { + FeatureFlaggingGateway.removeExposureListener(listener); + evaluator.shutdown(); + } + } + private static Arguments[] flatteningTestCases() { final List arguments = new ArrayList<>(); arguments.add(Arguments.of(emptyMap(), emptyMap())); diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java new file mode 100644 index 00000000000..bb08aa85f2c --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java @@ -0,0 +1,180 @@ +package datadog.trace.api.openfeature; + +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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; +import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; +import datadog.trace.api.featureflag.ufc.v1.Environment; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.Rule; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Shard; +import datadog.trace.api.featureflag.ufc.v1.ShardRange; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.ValueType; +import datadog.trace.api.featureflag.ufc.v1.Variant; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class OpenFeatureEvaluationAdapterTest { + + @Test + void mapsEvaluationMetadataContextAndExposure() { + final AtomicInteger exposures = new AtomicInteger(); + final OpenFeatureEvaluationAdapter adapter = + new OpenFeatureEvaluationAdapter( + (flagKey, allocationKey, variantKey, context) -> exposures.incrementAndGet(), true); + final Rule rule = + new Rule( + singletonList( + new ConditionConfiguration(ConditionOperator.ONE_OF, "id", singletonList("US")))); + final ProviderEvaluation result = + adapter.evaluate( + configuration(ValueType.STRING, "hello", true, singletonList(rule), emptyList()), + String.class, + "flag", + "default", + new MutableContext("subject").add("id", "US")); + + assertEquals("hello", result.getValue()); + assertEquals("TARGETING_MATCH", result.getReason()); + assertEquals("on", result.getVariant()); + assertEquals("flag", result.getFlagMetadata().getString("flagKey")); + assertEquals("STRING", result.getFlagMetadata().getString("variationType")); + assertEquals("allocation", result.getFlagMetadata().getString("allocationKey")); + assertEquals(7, result.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + assertEquals(true, result.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG)); + assertEquals(1, exposures.get()); + } + + @Test + void mapsCoreErrorsToOpenFeatureErrors() { + final OpenFeatureEvaluationAdapter adapter = new OpenFeatureEvaluationAdapter(null, false); + final MutableContext context = new MutableContext("subject"); + + assertError( + ErrorCode.FLAG_NOT_FOUND, + adapter.evaluate( + new ServerConfiguration(null, "SERVER", new Environment("test"), emptyMap()), + String.class, + "flag", + "default", + context)); + assertError( + ErrorCode.TYPE_MISMATCH, + adapter.evaluate( + configuration(ValueType.STRING, "hello", true, emptyList(), emptyList()), + Boolean.class, + "flag", + false, + context)); + assertError( + ErrorCode.PARSE_ERROR, + adapter.evaluate( + configuration(ValueType.INTEGER, "not-an-integer", true, emptyList(), emptyList()), + Integer.class, + "flag", + 1, + context)); + assertError( + ErrorCode.TARGETING_KEY_MISSING, + adapter.evaluate( + configuration( + ValueType.STRING, + "hello", + true, + emptyList(), + singletonList(new Shard("salt", singletonList(new ShardRange(0, 1)), 1))), + String.class, + "flag", + "default", + new MutableContext())); + } + + @Test + void preservesObjectDefaultsAndUnwrapsOpenFeatureValues() { + final OpenFeatureEvaluationAdapter adapter = new OpenFeatureEvaluationAdapter(null, false); + final Value defaultValue = Value.objectToValue(singletonMap("default", true)); + final ProviderEvaluation disabled = + adapter.evaluate( + configuration( + ValueType.JSON, singletonMap("enabled", true), false, emptyList(), emptyList()), + Value.class, + "flag", + defaultValue, + new MutableContext("subject")); + assertSame(defaultValue, disabled.getValue()); + + final ProviderEvaluation enabled = + adapter.evaluate( + configuration( + ValueType.JSON, singletonMap("enabled", true), true, emptyList(), emptyList()), + Value.class, + "flag", + new Value(), + new MutableContext("subject")); + assertEquals(Value.objectToValue(singletonMap("enabled", true)), enabled.getValue()); + + final Instant instant = Instant.parse("2026-01-01T00:00:00Z"); + final Map object = new LinkedHashMap<>(); + object.put("null", null); + object.put("boolean", true); + object.put("string", "value"); + object.put("integer", 1); + object.put("double", 1.5); + object.put("list", Arrays.asList("value", 2)); + object.put("instant", instant); + + final Map unwrapped = + (Map) OpenFeatureEvaluationAdapter.unwrapDefaultValue(Value.objectToValue(object)); + assertNull(unwrapped.get("null")); + assertEquals(true, unwrapped.get("boolean")); + assertEquals("value", unwrapped.get("string")); + assertEquals(1, unwrapped.get("integer")); + assertEquals(1.5, unwrapped.get("double")); + assertEquals(Arrays.asList("value", 2), unwrapped.get("list")); + assertEquals(instant.toString(), unwrapped.get("instant")); + assertNull(OpenFeatureEvaluationAdapter.unwrapDefaultValue(new Value())); + assertEquals("raw", OpenFeatureEvaluationAdapter.unwrapDefaultValue("raw")); + } + + private static void assertError( + final ErrorCode expected, final ProviderEvaluation evaluation) { + assertEquals(expected, evaluation.getErrorCode()); + } + + private static ServerConfiguration configuration( + final ValueType type, + final Object value, + final boolean enabled, + final java.util.List rules, + final java.util.List shards) { + final Split split = new Split(shards, "on", emptyMap(), 7); + final Allocation allocation = + new Allocation("allocation", rules, null, null, singletonList(split), true); + final Flag flag = + new Flag( + "flag", + enabled, + type, + singletonMap("on", new Variant("on", value)), + singletonList(allocation)); + return new ServerConfiguration( + null, "SERVER", new Environment("test"), singletonMap("flag", flag)); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java new file mode 100644 index 00000000000..2e0ed50a5e8 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java @@ -0,0 +1,122 @@ +package datadog.trace.api.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class StandaloneDDEvaluatorTest { + + private static final String ENABLED = "dd.feature.flags.enabled"; + private static final String SOURCE = "dd.feature.flags.configuration.source"; + private static final String BASE_URL = "dd.feature.flags.configuration.source.agentless.base.url"; + + @AfterEach + void clearProperties() { + System.clearProperty(ENABLED); + System.clearProperty(SOURCE); + System.clearProperty(BASE_URL); + } + + @Test + void ownsAndSharesCdnRuntimeLifecycle() throws Exception { + final AtomicInteger requests = new AtomicInteger(); + final HttpServer server = server(requests); + final StandaloneDDEvaluator first = new StandaloneDDEvaluator(() -> {}); + final AtomicInteger callbacks = new AtomicInteger(); + final StandaloneDDEvaluator second = new StandaloneDDEvaluator(callbacks::incrementAndGet); + try { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + final MutableContext context = new MutableContext("subject"); + + assertFalse(first.hasConfiguration()); + final ProviderEvaluation before = + first.evaluate(String.class, "message", "default", context); + assertEquals("default", before.getValue()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, before.getErrorCode()); + + assertTrue(first.initialize(1, SECONDS, context)); + assertTrue(first.initialize(1, SECONDS, context)); + assertEquals("hello", first.evaluate(String.class, "message", "default", context).getValue()); + + assertTrue(second.initialize(1, SECONDS, context)); + assertTrue(callbacks.get() > 0); + assertEquals(1, requests.get()); + + first.shutdown(); + first.shutdown(); + assertFalse(first.hasConfiguration()); + assertEquals( + "hello", second.evaluate(String.class, "message", "default", context).getValue()); + } finally { + first.shutdown(); + second.shutdown(); + server.stop(0); + } + } + + @Test + void rejectsDisabledRemoteConfigAndMismatchedSharedConfiguration() throws Exception { + final MutableContext context = new MutableContext("subject"); + System.setProperty(ENABLED, "false"); + assertThrows( + IllegalStateException.class, + () -> new StandaloneDDEvaluator(() -> {}).initialize(1, SECONDS, context)); + + System.clearProperty(ENABLED); + System.setProperty(SOURCE, "remote_config"); + assertThrows( + IllegalStateException.class, + () -> new StandaloneDDEvaluator(() -> {}).initialize(1, SECONDS, context)); + + final HttpServer server = server(new AtomicInteger()); + final StandaloneDDEvaluator first = new StandaloneDDEvaluator(() -> {}); + final StandaloneDDEvaluator second = new StandaloneDDEvaluator(() -> {}); + try { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + assertTrue(first.initialize(1, SECONDS, context)); + + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/other"); + assertThrows(IllegalStateException.class, () -> second.initialize(1, SECONDS, context)); + } finally { + first.shutdown(); + second.shutdown(); + server.stop(0); + } + } + + private static HttpServer server(final AtomicInteger requests) throws Exception { + final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + requests.incrementAndGet(); + final byte[] response = UFC.getBytes(UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + return server; + } + + private static final String UFC = + "{\"format\":\"SERVER\",\"environment\":{\"name\":\"test\"},\"flags\":{" + + "\"message\":{\"key\":\"message\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"hello\"}}," + + "\"allocations\":[{\"key\":\"allocation\",\"rules\":[],\"splits\":[" + + "{\"variationKey\":\"on\",\"shards\":[]}],\"doLog\":true}]}}}"; +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java index aadbe112dd1..fd976db4494 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java @@ -1,6 +1,7 @@ package datadog.trace.api.openfeature; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import java.time.Duration; @@ -17,6 +18,7 @@ class StandaloneRuntimeConfigurationTest { "dd.feature.flags.configuration.source.agentless.poll.interval.seconds"; private static final String REQUEST_TIMEOUT = "dd.feature.flags.configuration.source.agentless.request.timeout.seconds"; + private static final String API_KEY = "dd.api.key"; @AfterEach void clearProperties() { @@ -26,6 +28,7 @@ void clearProperties() { System.clearProperty(BASE_URL); System.clearProperty(POLL_INTERVAL); System.clearProperty(REQUEST_TIMEOUT); + System.clearProperty(API_KEY); } @Test @@ -85,4 +88,51 @@ void resolvesCustomEndpointAndPollingOptions() { assertEquals(Duration.ofSeconds(7), configuration.http.pollInterval); assertEquals(Duration.ofSeconds(2), configuration.http.requestTimeout); } + + @Test + void usesDefaultsForInvalidPollingOptions() { + System.setProperty(POLL_INTERVAL, "invalid"); + System.setProperty(REQUEST_TIMEOUT, "0"); + + StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(Duration.ofSeconds(30), configuration.http.pollInterval); + assertEquals(Duration.ofSeconds(5), configuration.http.requestTimeout); + + System.setProperty(POLL_INTERVAL, "-1"); + System.setProperty(REQUEST_TIMEOUT, "invalid"); + + configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(Duration.ofSeconds(30), configuration.http.pollInterval); + assertEquals(Duration.ofSeconds(5), configuration.http.requestTimeout); + } + + @Test + void comparesResolvedConfigurationsByEffectiveOptions() { + System.setProperty(BASE_URL, "https://example.test/config"); + System.setProperty(API_KEY, "key"); + final StandaloneRuntimeConfiguration first = StandaloneRuntimeConfiguration.resolve(); + final StandaloneRuntimeConfiguration equivalent = StandaloneRuntimeConfiguration.resolve(); + + assertEquals(first, first); + assertEquals(first, equivalent); + assertEquals(first.hashCode(), equivalent.hashCode()); + assertNotEquals(first, null); + assertNotEquals(first, "configuration"); + + System.setProperty(API_KEY, "other"); + assertNotEquals(first, StandaloneRuntimeConfiguration.resolve()); + + System.clearProperty(API_KEY); + System.clearProperty(BASE_URL); + System.setProperty(ENABLED, "false"); + final StandaloneRuntimeConfiguration disabled = StandaloneRuntimeConfiguration.resolve(); + assertEquals(disabled, StandaloneRuntimeConfiguration.resolve()); + assertEquals(disabled.hashCode(), StandaloneRuntimeConfiguration.resolve().hashCode()); + + System.clearProperty(ENABLED); + System.setProperty(SOURCE, "remote_config"); + assertNotEquals(disabled, StandaloneRuntimeConfiguration.resolve()); + } } From 8def9ceb4278dfc2e1e169fa6c7968c9d31ad0cf Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 30 Jul 2026 00:05:08 -0600 Subject: [PATCH 10/16] Cover standalone runtime failure paths --- .../StandaloneDDEvaluatorTest.java | 28 ++++- .../StandaloneProviderRuntimeTest.java | 110 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java index 2e0ed50a5e8..46199fb4d2e 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java @@ -1,6 +1,7 @@ package datadog.trace.api.openfeature; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -98,13 +99,38 @@ void rejectsDisabledRemoteConfigAndMismatchedSharedConfiguration() throws Except } } + @Test + void remainsNotReadyWhenCdnReturnsInvalidConfiguration() throws Exception { + final HttpServer server = server(new AtomicInteger(), "{"); + final StandaloneDDEvaluator evaluator = new StandaloneDDEvaluator(() -> {}); + try { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + + assertFalse(evaluator.initialize(1, MILLISECONDS, new MutableContext("subject"))); + assertFalse(evaluator.hasConfiguration()); + final ProviderEvaluation result = + evaluator.evaluate(String.class, "message", "default", new MutableContext("subject")); + assertEquals("default", result.getValue()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, result.getErrorCode()); + } finally { + evaluator.shutdown(); + server.stop(0); + } + } + private static HttpServer server(final AtomicInteger requests) throws Exception { + return server(requests, UFC); + } + + private static HttpServer server(final AtomicInteger requests, final String body) + throws Exception { final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext( "/config", exchange -> { requests.incrementAndGet(); - final byte[] response = UFC.getBytes(UTF_8); + final byte[] response = body.getBytes(UTF_8); exchange.sendResponseHeaders(200, response.length); exchange.getResponseBody().write(response); exchange.close(); diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java new file mode 100644 index 00000000000..db048b06d2c --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java @@ -0,0 +1,110 @@ +package datadog.trace.api.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +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.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class StandaloneProviderRuntimeTest { + + private static final String SOURCE = "dd.feature.flags.configuration.source"; + private static final String BASE_URL = "dd.feature.flags.configuration.source.agentless.base.url"; + private static final String POLL_INTERVAL = + "dd.feature.flags.configuration.source.agentless.poll.interval.seconds"; + + @AfterEach + void clearProperties() { + System.clearProperty(SOURCE); + System.clearProperty(BASE_URL); + System.clearProperty(POLL_INTERVAL); + } + + @Test + void closedHandleIsSafeAndIdempotent() throws Exception { + final HttpServer server = server(); + final AtomicInteger callbacks = new AtomicInteger(); + StandaloneProviderRuntime.Handle handle = null; + try { + configure(server, "30"); + handle = + StandaloneProviderRuntime.acquire( + StandaloneRuntimeConfiguration.resolve(), ignored -> callbacks.incrementAndGet()); + + assertNotNull(handle.configuration()); + assertTrue(handle.awaitConfiguration(1, MILLISECONDS)); + assertTrue(callbacks.get() > 0); + + handle.close(); + assertNull(handle.configuration()); + assertFalse(handle.awaitConfiguration(1, MILLISECONDS)); + handle.close(); + } finally { + if (handle != null) { + handle.close(); + } + server.stop(0); + } + } + + @Test + void failedStartReleasesSharedRuntime() throws Exception { + final HttpServer server = server(); + StandaloneProviderRuntime.Handle recovered = null; + try { + configure(server, String.valueOf(Long.MAX_VALUE)); + + assertThrows( + ArithmeticException.class, + () -> + StandaloneProviderRuntime.acquire( + StandaloneRuntimeConfiguration.resolve(), ignored -> {})); + + System.setProperty(POLL_INTERVAL, "30"); + recovered = + StandaloneProviderRuntime.acquire( + StandaloneRuntimeConfiguration.resolve(), ignored -> {}); + assertNotNull(recovered.configuration()); + } finally { + if (recovered != null) { + recovered.close(); + } + server.stop(0); + } + } + + private static void configure(final HttpServer server, final String pollInterval) { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + System.setProperty(POLL_INTERVAL, pollInterval); + } + + private static HttpServer server() throws Exception { + final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + final byte[] response = UFC.getBytes(UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + return server; + } + + private static final String UFC = + "{\"format\":\"SERVER\",\"environment\":{\"name\":\"test\"},\"flags\":{" + + "\"message\":{\"key\":\"message\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"hello\"}}," + + "\"allocations\":[{\"key\":\"allocation\",\"rules\":[],\"splits\":[" + + "{\"variationKey\":\"on\",\"shards\":[]}],\"doLog\":true}]}}}"; +} From 3e2adf8d24ca52d11b843ba0cc84279d44fdfd98 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 30 Jul 2026 01:04:22 -0600 Subject: [PATCH 11/16] Reject UFC configurations without flags --- .../internal/core/ConfigurationStore.java | 3 +++ .../internal/core/ConfigurationStoreTest.java | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java index 3710f2590ed..5340086b1b8 100644 --- a/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/ConfigurationStore.java @@ -33,6 +33,9 @@ public ApplyResult apply(final byte[] content) { } catch (final IOException | RuntimeException ignored) { return ApplyResult.REJECTED; } + if (next == null || next.flags == null) { + return ApplyResult.REJECTED; + } current.set(next); signalChange(next); return ApplyResult.ACCEPTED; diff --git a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java index 10f776a1769..9f3b7adc7b5 100644 --- a/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/ConfigurationStoreTest.java @@ -35,6 +35,23 @@ void preservesLastKnownGoodAfterRejectedPayload() { assertTrue(store.hasConfiguration()); } + @Test + void rejectsMissingAndNullFlagMapsButAcceptsAnEmptyMap() { + final ConfigurationStore store = new ConfigurationStore(); + assertEquals(ApplyResult.ACCEPTED, store.apply(Fixtures.UFC.getBytes(UTF_8))); + final ServerConfiguration accepted = store.current(); + + assertEquals(ApplyResult.REJECTED, store.apply("{}".getBytes(UTF_8))); + assertSame(accepted, store.current()); + assertEquals(ApplyResult.REJECTED, store.apply("{\"flags\":null}".getBytes(UTF_8))); + assertSame(accepted, store.current()); + assertEquals(ApplyResult.REJECTED, store.apply("null".getBytes(UTF_8))); + assertSame(accepted, store.current()); + + assertEquals(ApplyResult.ACCEPTED, store.apply("{\"flags\":{}}".getBytes(UTF_8))); + assertTrue(store.current().flags.isEmpty()); + } + @Test void clearsConfigurationExplicitly() { final ConfigurationStore store = new ConfigurationStore(); From 094c6fcff4fe9ed2036cd58fd83d643242c7abf8 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 30 Jul 2026 01:06:14 -0600 Subject: [PATCH 12/16] Honor provider timeout during initial CDN poll --- .../StandaloneDDEvaluatorTest.java | 37 +++++++++++++++++++ .../internal/http/CdnConfigurationSource.java | 7 +--- .../http/CdnConfigurationSourceTest.java | 30 ++++++++++++++- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java index 46199fb4d2e..32c50a10352 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneDDEvaluatorTest.java @@ -6,6 +6,7 @@ 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.assertTimeout; import static org.junit.jupiter.api.Assertions.assertTrue; import com.sun.net.httpserver.HttpServer; @@ -13,6 +14,7 @@ import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.ProviderEvaluation; import java.net.InetSocketAddress; +import java.time.Duration; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -119,6 +121,23 @@ void remainsNotReadyWhenCdnReturnsInvalidConfiguration() throws Exception { } } + @Test + void initializationTimeoutIncludesTheInitialCdnRequest() throws Exception { + final HttpServer server = delayedServer(500); + final StandaloneDDEvaluator evaluator = new StandaloneDDEvaluator(() -> {}); + try { + System.setProperty(SOURCE, "agentless"); + System.setProperty(BASE_URL, "http://127.0.0.1:" + server.getAddress().getPort() + "/config"); + + assertTimeout( + Duration.ofMillis(250), + () -> assertFalse(evaluator.initialize(10, MILLISECONDS, new MutableContext("subject")))); + } finally { + evaluator.shutdown(); + server.stop(0); + } + } + private static HttpServer server(final AtomicInteger requests) throws Exception { return server(requests, UFC); } @@ -139,6 +158,24 @@ private static HttpServer server(final AtomicInteger requests, final String body return server; } + private static HttpServer delayedServer(final long delayMillis) throws Exception { + final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + try { + Thread.sleep(delayMillis); + exchange.sendResponseHeaders(304, -1); + } catch (final InterruptedException error) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + server.start(); + return server; + } + private static final String UFC = "{\"format\":\"SERVER\",\"environment\":{\"name\":\"test\"},\"flags\":{" + "\"message\":{\"key\":\"message\",\"enabled\":true,\"variationType\":\"STRING\"," diff --git a/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java index 270b9104447..ce2ed64adaf 100644 --- a/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java +++ b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java @@ -89,16 +89,11 @@ public void start() { status = SourceStatus.STARTING; } - pollOnce(); - synchronized (lifecycleLock) { if (!closed) { scheduledPoll = executor.scheduleWithFixedDelay( - this::pollOnceSafely, - options.pollInterval.toMillis(), - options.pollInterval.toMillis(), - TimeUnit.MILLISECONDS); + this::pollOnceSafely, 0, options.pollInterval.toMillis(), TimeUnit.MILLISECONDS); } } } diff --git a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java index 5491d6894bc..d10e2aa886c 100644 --- a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java @@ -79,13 +79,17 @@ void preventsOverlappingPollsAndCancelsOnClose() throws Exception { } @Test - void startPollsImmediatelyAndCloseCancelsSchedule() { + void startPollsImmediatelyAndCloseCancelsSchedule() throws Exception { final QueueTransport transport = new QueueTransport(); transport.responses.add(response(200, null, UFC)); - final CdnConfigurationSource source = source(new ConfigurationStore(), transport, millis -> {}); + final ConfigurationStore store = new ConfigurationStore(); + final CountDownLatch configured = new CountDownLatch(1); + store.addListener(ignored -> configured.countDown()); + final CdnConfigurationSource source = source(store, transport, millis -> {}); source.start(); source.start(); + assertTrue(configured.await(1, TimeUnit.SECONDS)); assertEquals(SourceStatus.READY, source.status()); assertEquals(1, transport.requestHeaders.size()); @@ -97,6 +101,28 @@ void startPollsImmediatelyAndCloseCancelsSchedule() { assertEquals(1, transport.requestHeaders.size()); } + @Test + void startDoesNotBlockOnInitialPoll() throws Exception { + final BlockingTransport transport = new BlockingTransport(); + final CdnConfigurationSource source = source(new ConfigurationStore(), transport, millis -> {}); + final CountDownLatch returned = new CountDownLatch(1); + final Thread start = + new Thread( + () -> { + source.start(); + returned.countDown(); + }); + + start.start(); + assertTrue(transport.entered.await(1, TimeUnit.SECONDS)); + assertTrue(returned.await(1, TimeUnit.SECONDS)); + assertEquals(SourceStatus.STARTING, source.status()); + + source.close(); + transport.release.countDown(); + start.join(1_000); + } + @Test void doesNotRetryPermanentFailures() { final QueueTransport transport = new QueueTransport(); From d3c23f56317715d4d7b1f2e753be675650fec2aa Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 30 Jul 2026 01:07:12 -0600 Subject: [PATCH 13/16] Contain exposure failures during evaluation --- .../OpenFeatureEvaluationAdapter.java | 25 +++++++++++++------ .../OpenFeatureEvaluationAdapterTest.java | 23 +++++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java index b3c5b6f681d..180a35d9c0d 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapter.java @@ -46,14 +46,23 @@ ProviderEvaluation evaluate( final String key, final T defaultValue, final EvaluationContext context) { - final EvaluationResult result = - evaluator.evaluate( - snapshot, - valueKind(target), - key, - unwrapDefaultValue(defaultValue), - toCoreContext(context)); - return toProviderEvaluation(target, key, defaultValue, context, result); + try { + final EvaluationResult result = + evaluator.evaluate( + snapshot, + valueKind(target), + key, + unwrapDefaultValue(defaultValue), + toCoreContext(context)); + return toProviderEvaluation(target, key, defaultValue, context, result); + } catch (final RuntimeException error) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(dev.openfeature.sdk.Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage(error.getMessage()) + .build(); + } } private ProviderEvaluation toProviderEvaluation( diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java index bb08aa85f2c..45217a0aff3 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/OpenFeatureEvaluationAdapterTest.java @@ -62,6 +62,29 @@ void mapsEvaluationMetadataContextAndExposure() { assertEquals(1, exposures.get()); } + @Test + void convertsExposureFailuresToGeneralErrors() { + final OpenFeatureEvaluationAdapter adapter = + new OpenFeatureEvaluationAdapter( + (flagKey, allocationKey, variantKey, context) -> { + throw new IllegalStateException("exposure failed"); + }, + false); + + final ProviderEvaluation result = + adapter.evaluate( + configuration(ValueType.STRING, "hello", true, emptyList(), emptyList()), + String.class, + "flag", + "default", + new MutableContext("subject")); + + assertEquals("default", result.getValue()); + assertEquals(dev.openfeature.sdk.Reason.ERROR.name(), result.getReason()); + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals("exposure failed", result.getErrorMessage()); + } + @Test void mapsCoreErrorsToOpenFeatureErrors() { final OpenFeatureEvaluationAdapter adapter = new OpenFeatureEvaluationAdapter(null, false); From 30da6b6f02a252d21b7975130e19410c0d4de083 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 30 Jul 2026 01:07:41 -0600 Subject: [PATCH 14/16] Treat blank CDN URLs as managed endpoints --- .../StandaloneRuntimeConfiguration.java | 2 +- .../StandaloneRuntimeConfigurationTest.java | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java index 1ff1e975847..29fac06b82d 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/StandaloneRuntimeConfiguration.java @@ -66,7 +66,7 @@ static StandaloneRuntimeConfiguration resolve() { .pollInterval(pollInterval) .requestTimeout(requestTimeout) .apiKey(apiKey) - .managedEndpoint(configuredBaseUrl == null) + .managedEndpoint(configuredBaseUrl == null || configuredBaseUrl.trim().isEmpty()) .build()); } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java index fd976db4494..7160d99d887 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneRuntimeConfigurationTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; import org.junit.jupiter.api.AfterEach; @@ -89,6 +90,22 @@ void resolvesCustomEndpointAndPollingOptions() { assertEquals(Duration.ofSeconds(2), configuration.http.requestTimeout); } + @Test + void treatsBlankBaseUrlsAsTheManagedEndpoint() { + System.setProperty(API_KEY, "secret"); + for (final String baseUrl : new String[] {"", " "}) { + System.setProperty(BASE_URL, baseUrl); + + final StandaloneRuntimeConfiguration configuration = StandaloneRuntimeConfiguration.resolve(); + + assertEquals( + "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", + configuration.http.endpoint.toString()); + assertTrue(configuration.http.managedEndpoint); + assertEquals("secret", configuration.http.apiKey); + } + } + @Test void usesDefaultsForInvalidPollingOptions() { System.setProperty(POLL_INTERVAL, "invalid"); From cf86df81dda8a62ab98b33f942ba8bba60b0ef28 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 30 Jul 2026 01:08:15 -0600 Subject: [PATCH 15/16] Accept empty gzip not-modified responses --- .../internal/http/CdnConfigurationSource.java | 1 + .../internal/http/Java11TransportTest.java | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java index ce2ed64adaf..8d6713987ce 100644 --- a/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java +++ b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java @@ -297,6 +297,7 @@ public TransportResponse fetch( private static byte[] decodeBody(final byte[] body, final String contentEncoding) throws IOException { if (body == null + || body.length == 0 || contentEncoding == null || !"gzip".equalsIgnoreCase(contentEncoding.trim())) { return body; diff --git a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java index 800f6fd032e..f705f009afd 100644 --- a/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java @@ -111,6 +111,27 @@ void requestsAndDecodesGzipResponses() throws Exception { assertArrayEquals("compressed".getBytes(UTF_8), response.body); } + @Test + void acceptsEmptyGzipNotModifiedResponses() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + exchange.getResponseHeaders().add("Content-Encoding", "gzip"); + exchange.sendResponseHeaders(304, -1); + exchange.close(); + }); + server.start(); + final CdnConfigurationSource.Java11Transport transport = + new CdnConfigurationSource.Java11Transport(HttpClient.newHttpClient()); + + final CdnConfigurationSource.TransportResponse response = + transport.fetch(options(Duration.ofSeconds(1), false), Map.of()); + + assertEquals(304, response.status); + assertArrayEquals(new byte[0], response.body); + } + @Test void cancelsRequestsBeforeAndDuringFetch() throws Exception { final CdnConfigurationSource.Java11Transport closed = From 7faa22ee17681ebc87dc97f9c5f77252adb97d3c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 30 Jul 2026 01:09:40 -0600 Subject: [PATCH 16/16] Wait for asynchronous standalone configuration --- .../trace/api/openfeature/StandaloneProviderRuntimeTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java index db048b06d2c..cb3d55211c5 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandaloneProviderRuntimeTest.java @@ -2,6 +2,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -39,8 +40,8 @@ void closedHandleIsSafeAndIdempotent() throws Exception { StandaloneProviderRuntime.acquire( StandaloneRuntimeConfiguration.resolve(), ignored -> callbacks.incrementAndGet()); + assertTrue(handle.awaitConfiguration(1, SECONDS)); assertNotNull(handle.configuration()); - assertTrue(handle.awaitConfiguration(1, MILLISECONDS)); assertTrue(callbacks.get() > 0); handle.close(); @@ -72,6 +73,7 @@ void failedStartReleasesSharedRuntime() throws Exception { recovered = StandaloneProviderRuntime.acquire( StandaloneRuntimeConfiguration.resolve(), ignored -> {}); + assertTrue(recovered.awaitConfiguration(1, SECONDS)); assertNotNull(recovered.configuration()); } finally { if (recovered != null) {