From 289d4a0b31d7205e97cee89e40032b4fcc04c6fb Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 28 Jul 2026 22:07:30 -0600 Subject: [PATCH 1/4] Move feature flagging evaluation into provider runtime --- .../openfeature/application/build.gradle | 5 +- .../featureflag/FeatureFlaggingSystem.java | 7 +- .../FeatureFlaggingSystemTest.java | 9 +- .../feature-flagging-api/README.md | 36 +- .../feature-flagging-api/build.gradle.kts | 70 +- .../feature-flagging-api/gradle.lockfile | 6 +- .../trace/api/openfeature/DDEvaluator.java | 609 ++------ .../trace/api/openfeature/Provider.java | 64 +- .../api/openfeature/ProviderRuntime.java | 113 ++ .../api/openfeature/RawBridgeAccess.java | 170 ++ .../api/openfeature/RuntimeConfiguration.java | 152 ++ .../api/openfeature/SpanEnrichmentGate.java | 22 +- .../api/openfeature/SpanEnrichmentHook.java | 17 +- .../api/openfeature/DDEvaluatorTest.java | 443 +----- .../MissingBridgeChildJvmTest.java | 37 + .../openfeature/MissingBridgeChildMain.java | 24 + .../openfeature/ProviderOnlyChildJvmTest.java | 32 + .../openfeature/ProviderOnlyChildMain.java | 76 + .../trace/api/openfeature/ProviderTest.java | 470 ++---- .../featureflag/FeatureFlaggingRawBridge.java | 86 ++ .../FeatureFlaggingRawBridgeTest.java | 109 ++ .../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 | 34 + .../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 | 302 ++++ .../internal/core/UfcParserTest.java | 141 ++ .../feature-flagging-http/build.gradle.kts | 48 + .../feature-flagging-http/gradle.lockfile | 79 + .../internal/http/CdnConfigurationSource.java | 298 ++++ .../internal/http/CdnEndpointResolver.java | 40 + .../http/HttpConfigurationOptions.java | 72 + .../http/CdnConfigurationSourceTest.java | 186 +++ .../http/CdnEndpointResolverTest.java | 43 + .../http/HttpConfigurationOptionsTest.java | 53 + .../internal/http/Java11TransportTest.java | 145 ++ .../AgentlessConfigurationSource.java | 489 ------ .../featureflag/JsonApiUfcResponseParser.java | 92 -- .../featureflag/RemoteConfigServiceImpl.java | 16 +- .../AgentlessConfigurationSourceTest.java | 1363 ----------------- .../JsonApiUfcResponseParserTest.java | 87 -- .../RemoteConfigServiceImplTest.java | 25 +- settings.gradle.kts | 2 + 53 files changed, 4024 insertions(+), 3296 deletions(-) create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ProviderRuntime.java create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RawBridgeAccess.java create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RuntimeConfiguration.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/MissingBridgeChildJvmTest.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/MissingBridgeChildMain.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-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingRawBridge.java create mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingRawBridgeTest.java 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 delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.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/AgentlessConfigurationSourceTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java diff --git a/dd-smoke-tests/openfeature/application/build.gradle b/dd-smoke-tests/openfeature/application/build.gradle index 9f7f9558e9b..2357591ff08 100644 --- a/dd-smoke-tests/openfeature/application/build.gradle +++ b/dd-smoke-tests/openfeature/application/build.gradle @@ -31,8 +31,9 @@ if (hasProperty('featureFlaggingApiJar')) { } dependencies { - // OpenFeature SDK is an API dependency of feature-flagging-api but is not - // transitively resolved when the jar is passed as a files() dependency. + // Maven resolves these provider dependencies from the published POM. This smoke test uses the + // provider as a files() dependency, so it must declare them explicitly. implementation 'dev.openfeature:sdk:1.20.1' + implementation 'com.squareup.moshi:moshi:1.11.0' implementation 'org.springframework.boot:spring-boot-starter-web' } diff --git a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java index adbae0a6e44..f6f896358d6 100644 --- a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java +++ b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java @@ -70,10 +70,6 @@ private static synchronized void activateAgentless( private static void initializeSystem(final SharedCommunicationObjects sco, final Config config) { final ConfigurationSourceService configService = createConfigurationSourceService(sco, config); - if (configService == null) { - LOGGER.debug("Feature Flagging system disabled by unsupported configuration source"); - return; - } final ExposureWriter exposureWriter = new ExposureWriterImpl(sco, config); initialize(configService, exposureWriter); @@ -119,7 +115,8 @@ static ConfigurationSourceService createConfigurationSourceService( return new RemoteConfigServiceImpl(sco, config); } if (CONFIGURATION_SOURCE_AGENTLESS.equals(configurationSource)) { - return new AgentlessConfigurationSource(config); + // CDN delivery belongs to the application provider. The agent owns telemetry only. + return null; } return null; } diff --git a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java index d408d91da4e..8dd66527a52 100644 --- a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java +++ b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java @@ -21,7 +21,7 @@ import datadog.communication.ddagent.DDAgentFeaturesDiscovery; import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.remoteconfig.Capabilities; -import datadog.remoteconfig.ConfigurationDeserializer; +import datadog.remoteconfig.ConfigurationChangesListener; import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.Product; import datadog.trace.api.Config; @@ -99,7 +99,7 @@ void testFeatureFlagSystemInitialization() { FeatureFlaggingSystem.start(sharedCommunicationObjects); verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).addListener(eq(Product.FFE_FLAGS), any(ConfigurationDeserializer.class), any()); + verify(poller).addListener(eq(Product.FFE_FLAGS), any(ConfigurationChangesListener.class)); verify(poller).start(); FeatureFlaggingSystem.stop(); @@ -128,9 +128,8 @@ void testThatRemoteConfigIsRequired() { @Test @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - void agentlessConfigurationSourceUsesHttpServiceWithoutRemoteConfig() { - assertInstanceOf( - AgentlessConfigurationSource.class, + void agentlessConfigurationSourceIsProviderOwned() { + assertNull( FeatureFlaggingSystem.createConfigurationSourceService( sharedCommunicationObjects(), Config.get())); } diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 74bbccca2e2..fb62e245a3f 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -85,19 +85,23 @@ OTEL_EXPORTER_OTLP_PROTOCOL=grpc ## Requirements - Java 11+ -- `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless` uses the Datadog agentless - backend. Set `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL` to a - different HTTP backend while keeping agentless delivery semantics. A bare - host uses the standard rules-based server path; a URL with a path is used as - the exact UFC endpoint. Configured URLs are opaque: the SDK does not add the - Datadog-managed `dd_env` query parameter, so custom backends must include any - required tenant or environment scope in the configured URL. The derived - Datadog-managed endpoint is - `https://ufc-server.ff-cdn./api/v2/feature-flagging/config/rules-based/server` - and expects UFC under the JSON:API `data.attributes` response member. It is - intended for supported commercial sites; use an explicit base URL elsewhere. - Agentless responses do not have an SDK-imposed payload-size limit. - `remote_config` uses the existing Agent Remote - Configuration path. `offline` is reserved for startup-provided UFC bytes; - until those bytes are implemented, no network source starts and evaluations - use defaults. + +CDN delivery is the default configuration source. It requires `dd-openfeature`. It does not +require the Datadog Java agent. + +`DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless` selects the same CDN delivery mode. Set +`DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL` to use a different HTTP endpoint. +A root URL uses the standard rules-based server path. A URL with a path is the exact UFC endpoint. + +The managed endpoint is: + +`https://ufc-server.ff-cdn./api/v2/feature-flagging/config/rules-based/server` + +The managed endpoint returns UFC under the JSON:API `data.attributes` member. + +`DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=remote_config` selects Remote Configuration. This mode +requires a compatible Datadog Java agent. Java agent version 1.65.0 adds the raw UFC bridge for this +provider version. An older agent produces a clear provider initialization error. + +Offline startup bytes are a future configuration source. This version does not support offline +delivery. diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index 88531ceaeed..d41a3fdebbd 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` @@ -16,6 +18,11 @@ configure { description = "Implementation of the OpenFeature Provider interface." +// This module is an adapter and lifecycle boundary. Core evaluation and HTTP behavior have +// stronger module-local gates. Child JVM tests cover classloader and no-agent behavior here. +extra["minimumBranchCoverage"] = 0.4 +extra["minimumInstructionCoverage"] = 0.6 + // Set both JAR and Maven artifact name val openFeatureArtifactId = "dd-openfeature" base { @@ -42,14 +49,16 @@ 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(":utils:config-utils")) + compileOnly(project(":products:feature-flagging:feature-flagging-core")) + compileOnly(project(":products:feature-flagging:feature-flagging-http")) compileOnly("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) - testImplementation(project(":utils:config-utils")) + testImplementation(project(":products:feature-flagging:feature-flagging-core")) + testImplementation(project(":products:feature-flagging:feature-flagging-http")) testImplementation("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) @@ -78,8 +87,61 @@ 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 }) +} + // 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" + ) + val forbiddenReferences = setOf( + "datadog/trace/api/featureflag/ufc", + "datadog/trace/api/featureflag/FeatureFlaggingGateway", + "datadog/trace/api/Config", + "datadog/trace/util/AgentThread", + "datadog/communication/http" + ) + 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 provider classes: $missing" + } + zip.entries().asSequence() + .filter { it.name.endsWith(".class") } + .forEach { entry -> + val classBytes = zip.getInputStream(entry).readBytes().toString(Charsets.ISO_8859_1) + forbiddenReferences.forEach { reference -> + check(!classBytes.contains(reference)) { + "dd-openfeature class ${entry.name} references forbidden agent class $reference" + } + } + } + } + + 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-api/gradle.lockfile b/products/feature-flagging/feature-flagging-api/gradle.lockfile index d947d858465..a8b4b97140f 100644 --- a/products/feature-flagging/feature-flagging-api/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-api/gradle.lockfile @@ -11,8 +11,8 @@ 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=testCompileClasspath,testRuntimeClasspath -com.squareup.okio:okio:1.17.5=testCompileClasspath,testRuntimeClasspath +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 @@ -57,7 +57,6 @@ 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.0=spotbugs org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-core:4.4.0=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:4.4.0=testCompileClasspath,testRuntimeClasspath @@ -76,7 +75,6 @@ org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j -org.snakeyaml:snakeyaml-engine:2.9=testRuntimeClasspath 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 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..f1633f8cca3 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,95 +1,76 @@ package datadog.trace.api.openfeature; -import static java.util.Arrays.asList; - -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 datadog.openfeature.internal.core.ConfigurationSnapshot; +import datadog.openfeature.internal.core.EvaluationResult; +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.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.time.Instant; import java.util.AbstractMap; -import java.util.Date; +import java.util.ArrayList; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; -import java.util.Objects; +import java.util.Map; 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; - -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)); +/** Thin OpenFeature adapter over the provider-owned core runtime. */ +class DDEvaluator implements Evaluator { - // 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 CountDownLatch initializationLatch = new CountDownLatch(1); + private final Provider.Options options; + private final FlagEvaluator evaluator = new FlagEvaluator(); + private volatile ProviderRuntime.Handle runtime; - public DDEvaluator(final Runnable configCallback) { + DDEvaluator(final Runnable configCallback, final Provider.Options options) { this.configCallback = configCallback; + this.options = options; } @Override public boolean initialize( final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { - FeatureFlaggingGateway.activate(); - FeatureFlaggingGateway.addConfigListener(this); - return initializationLatch.await(timeout, unit) || hasConfiguration(); + ProviderRuntime.Handle current = runtime; + if (current == null) { + synchronized (this) { + current = runtime; + if (current == null) { + current = + ProviderRuntime.acquire( + RuntimeConfiguration.resolve(options), ignored -> configCallback.run()); + runtime = current; + } + } + } + return current.awaitConfiguration(timeout, unit) || hasConfiguration(); } @Override public boolean hasConfiguration() { - return configuration.get() != null; + final ProviderRuntime.Handle current = runtime; + return current != null && current.configuration() != null; } @Override public void shutdown() { - FeatureFlaggingGateway.removeConfigListener(this); - } - - @Override - public void accept(final ServerConfiguration config) { - configuration.set(config); - if (config != null) { - initializationLatch.countDown(); - configCallback.run(); - } else if (initializationLatch.getCount() == 0) { - configCallback.run(); + final ProviderRuntime.Handle current = runtime; + runtime = null; + if (current != null) { + current.close(); } } @@ -99,426 +80,133 @@ 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); - } + final ProviderRuntime.Handle current = runtime; + final ConfigurationSnapshot snapshot = current == null ? null : current.configuration(); + final EvaluationResult result = + evaluator.evaluate( + snapshot, + valueKind(target), + key, + unwrapDefaultValue(defaultValue), + toCoreContext(context)); + return toProviderEvaluation(target, key, defaultValue, context, result); } - 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 ValueKind valueKind(final Class target) { + if (target == Boolean.class) { + return ValueKind.BOOLEAN; } - } - - 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; + if (target == String.class) { + return ValueKind.STRING; } - for (final Object value : (Iterable) conditionValue) { - if (valuesEqual(attributeValue, value)) { - return true; - } + if (target == Integer.class) { + return ValueKind.INTEGER; } - return false; - } - - private static boolean valuesEqual(final Object a, final Object b) { - if (Objects.equals(a, b)) { - return true; + if (target == Double.class) { + return ValueKind.DOUBLE; } - - if (a instanceof Number || b instanceof Number) { - return compareNumber(a, b, (first, second) -> first == second); + if (target == Value.class) { + return ValueKind.OBJECT; } - - return String.valueOf(a).equals(String.valueOf(b)); + throw new IllegalArgumentException("Type not supported: " + target); } - 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; - } + private static datadog.openfeature.internal.core.EvaluationContext toCoreContext( + final EvaluationContext context) { + if (context == null) { + return null; } - 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); + final Map attributes = new LinkedHashMap<>(); + for (final String key : context.keySet()) { + attributes.put(key, unwrapValue(context.getValue(key))); } + return new datadog.openfeature.internal.core.EvaluationContext( + context.getTargetingKey(), attributes); } - private static ProviderEvaluation resolveVariant( + private static ProviderEvaluation toProviderEvaluation( 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) { + final EvaluationContext context, + final EvaluationResult result) { + if (result.error != null) { return ProviderEvaluation.builder() .value(defaultValue) - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.GENERAL) - .errorMessage("Variant not found for: " + variationKey) + .reason(dev.openfeature.sdk.Reason.ERROR.name()) + .errorCode(errorCode(result.error)) + .errorMessage(result.errorMessage) .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 ImmutableMetadata.ImmutableMetadataBuilder metadata = ImmutableMetadata.builder(); + if (result.flagKey != null) { + metadata.addString("flagKey", result.flagKey); } - - 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()); + if (result.variationType != null) { + metadata.addString("variationType", result.variationType); + } + if (result.allocationKey != null) { + metadata.addString("allocationKey", result.allocationKey); } - - 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); + if (result.splitSerialId != null) { + metadata.addInteger(METADATA_SPLIT_SERIAL_ID, result.splitSerialId); } - metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); + metadata.addBoolean(METADATA_DO_LOG, result.doLog); } - final ProviderEvaluation result = + + final T value = mapResultValue(target, result.value); + final ProviderEvaluation evaluation = 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()) + .value(value) + .reason(result.reason.name()) + .variant(result.variant) + .flagMetadata(metadata.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); + if (result.doLog && context != null && result.allocationKey != null && result.variant != null) { + RawBridgeAccess.dispatchExposure( + System.currentTimeMillis(), + result.allocationKey, + key, + result.variant, + context.getTargetingKey(), + flattenContext(context)); + } + return evaluation; } - 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; + 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 true; // Unknown types pass through — mapValue errors caught as GENERAL + return ErrorCode.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); + private static T mapResultValue(final Class target, final Object value) { + if (target == Value.class) { + return (T) Value.objectToValue(value); } - 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)); - } - - 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; - } - final ExposureEvent event = - new ExposureEvent( - System.currentTimeMillis(), - new datadog.trace.api.featureflag.exposure.Allocation(allocationKey), - 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); + return target.cast(value); } - private static String allocationKey(final ProviderEvaluation resolution) { - final ImmutableMetadata meta = resolution.getFlagMetadata(); - return meta == null ? null : meta.getString("allocationKey"); + @SuppressWarnings("unchecked") + static T mapValue(final Class target, final Object value) { + final Object mapped = FlagEvaluator.mapValue(valueKind(target), value); + return target == Value.class ? (T) Value.objectToValue(mapped) : target.cast(mapped); } static AbstractMap flattenContext(final EvaluationContext context) { @@ -554,12 +242,55 @@ static AbstractMap flattenContext(final EvaluationContext contex return result; } - @FunctionalInterface - private interface NumberComparator { - boolean compare(double a, double b); + 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 Structure structure = value.asStructure(); + final Map map = new LinkedHashMap<>(); + if (structure != null) { + for (final String key : structure.keySet()) { + map.put(key, unwrapValue(structure.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(); } - 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/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index 38fa735d4e9..d5cdc8b8b12 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 @@ -2,7 +2,6 @@ import static java.util.concurrent.TimeUnit.SECONDS; -import de.thetaphi.forbiddenapis.SuppressForbidden; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.EventProvider; @@ -15,7 +14,7 @@ import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.OpenFeatureError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; -import java.lang.reflect.Constructor; +import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -28,8 +27,6 @@ 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 Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); private volatile Evaluator evaluator; private final Options options; @@ -129,7 +126,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); } } @@ -202,13 +199,11 @@ private void markInitializationError() { } } - private Evaluator buildEvaluator() throws Exception { + private Evaluator buildEvaluator() { if (evaluator != null) { return evaluator; } - final Class evaluatorClass = loadEvaluatorClass(); - final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); - return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); + return new DDEvaluator(this::onConfigurationChange, options); } @Override @@ -274,11 +269,6 @@ public ProviderEvaluation getObjectEvaluation( return evaluator.evaluate(Value.class, key, defaultValue, ctx); } - @SuppressForbidden // Class#forName(String) used to lazy-load the evaluator implementation - protected Class loadEvaluatorClass() throws ClassNotFoundException { - return Class.forName(EVALUATOR_IMPL); - } - private enum InitializationState { NOT_STARTED, INITIALIZING, @@ -289,8 +279,15 @@ private enum InitializationState { public static class Options { - private long timeout; - private TimeUnit unit; + private long timeout = 30; + private TimeUnit unit = SECONDS; + String configurationSource; + String cdnBaseUrl; + String apiKey; + String site; + String environment; + Duration pollInterval; + Duration requestTimeout; public Options initTimeout(final long timeout, final TimeUnit unit) { this.timeout = timeout; @@ -298,6 +295,41 @@ public Options initTimeout(final long timeout, final TimeUnit unit) { return this; } + public Options configurationSource(final String configurationSource) { + this.configurationSource = configurationSource; + return this; + } + + public Options cdnBaseUrl(final String cdnBaseUrl) { + this.cdnBaseUrl = cdnBaseUrl; + return this; + } + + public Options apiKey(final String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Options site(final String site) { + this.site = site; + return this; + } + + public Options environment(final String environment) { + this.environment = environment; + return this; + } + + public Options pollInterval(final Duration pollInterval) { + this.pollInterval = pollInterval; + return this; + } + + public Options requestTimeout(final Duration requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + public long getTimeout() { return timeout; } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ProviderRuntime.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ProviderRuntime.java new file mode 100644 index 00000000000..2f0e4d64497 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ProviderRuntime.java @@ -0,0 +1,113 @@ +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 configuration runtime per provider classloader. */ +final class ProviderRuntime { + + private static final Object LOCK = new Object(); + private static SharedRuntime shared; + + private ProviderRuntime() {} + + static Handle acquire( + final RuntimeConfiguration configuration, final Consumer listener) { + 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 RuntimeConfiguration configuration; + private final ConfigurationStore store = new ConfigurationStore(); + private final ConfigurationSource source; + private int references; + private boolean started; + + private SharedRuntime(final RuntimeConfiguration configuration) { + this.configuration = configuration; + source = + configuration.source == RuntimeConfiguration.Source.CDN + ? new CdnConfigurationSource(configuration.http, store) + : RawBridgeAccess.remoteConfigurationSource(store); + } + + private void start() { + if (started) { + return; + } + started = true; + RawBridgeAccess.activateIfPresent(); + 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/RawBridgeAccess.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RawBridgeAccess.java new file mode 100644 index 00000000000..cd7d7666a37 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RawBridgeAccess.java @@ -0,0 +1,170 @@ +package datadog.trace.api.openfeature; + +import datadog.openfeature.internal.core.ConfigurationSink; +import datadog.openfeature.internal.core.ConfigurationSource; +import datadog.openfeature.internal.core.SourceStatus; +import de.thetaphi.forbiddenapis.SuppressForbidden; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Reflective access to the optional agent bridge. */ +final class RawBridgeAccess { + + private static final Logger log = LoggerFactory.getLogger(RawBridgeAccess.class); + private static final String BRIDGE_CLASS = + "datadog.trace.api.featureflag.FeatureFlaggingRawBridge"; + private static final String CONFIG_LISTENER_CLASS = BRIDGE_CLASS + "$ConfigurationListener"; + + private RawBridgeAccess() {} + + static ConfigurationSource remoteConfigurationSource(final ConfigurationSink sink) { + return new RemoteConfigurationSource(sink); + } + + static void activateIfPresent() { + invokeOptional("activate", new Class[0]); + } + + static void dispatchExposure( + final long timestamp, + final String allocationKey, + final String flagKey, + final String variantKey, + final String targetingKey, + final Map attributes) { + invokeOptional( + "dispatchExposure", + new Class[] { + long.class, String.class, String.class, String.class, String.class, Map.class + }, + timestamp, + allocationKey, + flagKey, + variantKey, + targetingKey, + attributes); + } + + static void dispatchSpanSerialId( + final int serialId, final boolean doLog, final String targetingKey) { + invokeOptional( + "dispatchSpanSerialId", + new Class[] {int.class, boolean.class, String.class}, + serialId, + doLog, + targetingKey); + } + + static void dispatchSpanRuntimeDefault(final String flagKey, final Object value) { + invokeOptional( + "dispatchSpanRuntimeDefault", new Class[] {String.class, Object.class}, flagKey, value); + } + + @SuppressForbidden + private static void invokeOptional( + final String methodName, final Class[] parameterTypes, final Object... arguments) { + try { + final Class bridge = Class.forName(BRIDGE_CLASS); + bridge.getMethod(methodName, parameterTypes).invoke(null, arguments); + } catch (final ClassNotFoundException | NoSuchMethodException ignored) { + // CDN evaluation works without an agent and with agents released before the raw bridge. + } catch (final ReflectiveOperationException | LinkageError e) { + log.debug("Feature Flagging agent bridge call failed: {}", methodName, e); + } + } + + private static final class RemoteConfigurationSource implements ConfigurationSource { + private final ConfigurationSink sink; + private Class bridge; + private Method removeListener; + private Object listener; + private volatile SourceStatus status = SourceStatus.NEW; + + private RemoteConfigurationSource(final ConfigurationSink sink) { + this.sink = sink; + } + + @Override + @SuppressForbidden + public synchronized void start() { + if (status != SourceStatus.NEW) { + return; + } + status = SourceStatus.STARTING; + try { + bridge = Class.forName(BRIDGE_CLASS); + final Class listenerType = Class.forName(CONFIG_LISTENER_CLASS); + final InvocationHandler handler = + (proxy, method, arguments) -> { + if (method.getDeclaringClass() == Object.class) { + switch (method.getName()) { + case "equals": + return proxy == arguments[0]; + case "hashCode": + return System.identityHashCode(proxy); + case "toString": + return "DatadogFeatureFlaggingConfigurationListener"; + default: + throw new UnsupportedOperationException(method.getName()); + } + } + if ("accept".equals(method.getName())) { + final byte[] content = + arguments == null || arguments.length == 0 ? null : (byte[]) arguments[0]; + if (content == null) { + sink.clear(); + } else { + sink.apply(content); + } + status = SourceStatus.READY; + } + return null; + }; + listener = + Proxy.newProxyInstance( + RawBridgeAccess.class.getClassLoader(), new Class[] {listenerType}, handler); + bridge.getMethod("addConfigurationListener", listenerType).invoke(null, listener); + removeListener = bridge.getMethod("removeConfigurationListener", listenerType); + status = SourceStatus.READY; + } catch (final ClassNotFoundException | NoSuchMethodException e) { + status = SourceStatus.ERROR; + throw new IllegalStateException( + "Remote Configuration requires a Java agent with the Feature Flagging raw bridge " + + "(version 1.65.0 or later)", + e); + } catch (final ReflectiveOperationException | LinkageError e) { + status = SourceStatus.ERROR; + throw new IllegalStateException( + "Remote Configuration could not initialize the Java agent Feature Flagging bridge", e); + } + } + + @Override + public SourceStatus status() { + return status; + } + + @Override + public synchronized void close() { + if (status == SourceStatus.CLOSED) { + return; + } + try { + if (removeListener != null && listener != null) { + removeListener.invoke(null, listener); + } + } catch (final ReflectiveOperationException | LinkageError e) { + log.debug("Feature Flagging agent bridge listener removal failed", e); + } finally { + bridge = null; + removeListener = null; + listener = null; + status = SourceStatus.CLOSED; + } + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RuntimeConfiguration.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RuntimeConfiguration.java new file mode 100644 index 00000000000..fd5475d0749 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RuntimeConfiguration.java @@ -0,0 +1,152 @@ +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 provider runtime configuration resolved from explicit options or process settings. */ +final class RuntimeConfiguration { + + enum Source { + CDN, + REMOTE_CONFIG + } + + final Source source; + final HttpConfigurationOptions http; + + private RuntimeConfiguration(final Source source, final HttpConfigurationOptions http) { + this.source = source; + this.http = http; + } + + static RuntimeConfiguration resolve(final Provider.Options options) { + final String sourceValue = + first( + options.configurationSource, + setting( + "dd.feature.flags.configuration.source", "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE")); + final Source source = parseSource(sourceValue); + if (source == Source.REMOTE_CONFIG) { + return new RuntimeConfiguration(source, null); + } + + final String configuredBaseUrl = + first( + options.cdnBaseUrl, + setting( + "dd.feature.flags.configuration.source.agentless.base.url", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL")); + final String site = first(options.site, setting("dd.site", "DD_SITE"), "datadoghq.com"); + final String env = first(options.environment, setting("dd.env", "DD_ENV")); + final URI endpoint = CdnEndpointResolver.resolve(configuredBaseUrl, site, env); + final Duration pollInterval = + options.pollInterval != null + ? options.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 = + options.requestTimeout != null + ? options.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 = first(options.apiKey, setting("dd.api.key", "DD_API_KEY")); + return new RuntimeConfiguration( + source, + HttpConfigurationOptions.builder() + .endpoint(endpoint) + .pollInterval(pollInterval) + .requestTimeout(requestTimeout) + .apiKey(apiKey) + .managedEndpoint(configuredBaseUrl == null) + .build()); + } + + private static Source parseSource(final String value) { + if (value == null || value.trim().isEmpty()) { + return Source.CDN; + } + final String normalized = value.trim().toLowerCase(Locale.ROOT); + if ("agentless".equals(normalized) || "cdn".equals(normalized)) { + return Source.CDN; + } + if ("remote_config".equals(normalized)) { + return Source.REMOTE_CONFIG; + } + throw new IllegalArgumentException( + "Unsupported Feature Flagging configuration source: " + value); + } + + 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 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 RuntimeConfiguration)) { + return false; + } + final RuntimeConfiguration that = (RuntimeConfiguration) 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/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java index 64739f0ad39..0b3cf9edb67 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java @@ -1,23 +1,27 @@ package datadog.trace.api.openfeature; -import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; -import datadog.trace.bootstrap.config.provider.ConfigProvider; +import de.thetaphi.forbiddenapis.SuppressForbidden; /** - * Single source for reading the experimental span-enrichment gate, with full {@link ConfigProvider} - * precedence (system property > stable config > env var {@code - * DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED}). OFF by default; distinct from the - * provider-enabled gate. Shared so {@link Provider} (per construction) and {@link DDEvaluator} - * (once at class load) read it the same way. + * Single source for reading the experimental span-enrichment gate. A system property takes + * precedence over the {@code DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED}. OFF by + * default; distinct from the provider-enabled gate. Shared so {@link Provider} (per construction) + * and {@link DDEvaluator} (once at class load) read it the same way. */ final class SpanEnrichmentGate { private SpanEnrichmentGate() {} + @SuppressForbidden static boolean isEnabled() { try { - return ConfigProvider.getInstance() - .getBoolean(FeatureFlaggingConfig.EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED, false); + final String property = + System.getProperty("dd.experimental.flagging.provider.span.enrichment.enabled"); + final String value = + property != null + ? property + : System.getenv("DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED"); + return Boolean.parseBoolean(value); } catch (final Throwable t) { return false; // never let config reading break construction } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java index 40e998a6546..c6d2dcbd17a 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java @@ -1,7 +1,5 @@ package datadog.trace.api.openfeature; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.SpanEnrichmentEvent; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; @@ -19,10 +17,9 @@ /** * OpenFeature {@code finally} hook that captures feature-flag evaluation metadata for APM span - * enrichment. Registered from {@link Provider#getProviderHooks()} only when the gate is on. For - * each relevant evaluation it dispatches a {@link SpanEnrichmentEvent} onto {@link - * FeatureFlaggingGateway}; the agent-side write tier resolves the active local-root span and - * accumulates the per-trace state that is flushed onto the root when the trace completes. + * enrichment. Registered from {@link Provider#getProviderHooks()} only when the gate is on. The + * hook sends JDK values through the optional reflective agent bridge. CDN evaluation does not + * require that bridge. * *

Capture branch (frozen Node reference): * @@ -56,14 +53,12 @@ public void finallyAfter( final Integer serialId = metadata != null ? metadata.getInteger(METADATA_SERIAL_ID) : null; if (serialId != null) { final boolean doLog = Boolean.TRUE.equals(metadata.getBoolean(METADATA_DO_LOG)); - FeatureFlaggingGateway.dispatch( - SpanEnrichmentEvent.serialId(serialId, doLog, targetingKey(ctx))); + RawBridgeAccess.dispatchSpanSerialId(serialId, doLog, targetingKey(ctx)); } else if (details.getVariant() == null) { // Runtime-default detection = MISSING VARIANT (never a reason enum). Unwrap any OpenFeature // Value to a native Java type here so the seam carries only JDK types. - FeatureFlaggingGateway.dispatch( - SpanEnrichmentEvent.runtimeDefault( - details.getFlagKey(), unwrapDefaultValue(details.getValue()))); + RawBridgeAccess.dispatchSpanRuntimeDefault( + details.getFlagKey(), unwrapDefaultValue(details.getValue())); } } catch (final Throwable t) { // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. 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..ba703f9beb9 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 @@ -1,428 +1,97 @@ package datadog.trace.api.openfeature; -import static dev.openfeature.sdk.Reason.ERROR; -import static java.util.Arrays.asList; -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonList; -import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.concurrent.TimeUnit.SECONDS; -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.assertEquals; 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 static org.junit.jupiter.api.Assertions.assertTrue; -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; +import datadog.trace.api.featureflag.FeatureFlaggingRawBridge; import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; 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; -import java.util.List; import java.util.Map; -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 java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -public class DDEvaluatorTest { +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); + private DDEvaluator evaluator; - @Test - public void testInitializeSignalsApplicationProviderActivation() throws Exception { - final FeatureFlaggingGateway.ActivationListener listener = - mock(FeatureFlaggingGateway.ActivationListener.class); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - FeatureFlaggingGateway.addActivationListener(listener); - try { - evaluator.initialize(1, MILLISECONDS, mock(EvaluationContext.class)); - - verify(listener).activate(); - } finally { + @AfterEach + void close() { + if (evaluator != null) { evaluator.shutdown(); - FeatureFlaggingGateway.removeActivationListener(listener); - } - } - - private static Arguments[] valueMappingTestCases() { - return new Arguments[] { - // String mappings - Arguments.of(String.class, "hello", "hello"), - Arguments.of(String.class, 123, "123"), - Arguments.of(String.class, true, "true"), - Arguments.of(String.class, 3.14, "3.14"), - Arguments.of(String.class, null, null), - - // Boolean mappings - Arguments.of(Boolean.class, true, true), - Arguments.of(Boolean.class, false, false), - Arguments.of(Boolean.class, "true", true), - Arguments.of(Boolean.class, "false", false), - Arguments.of(Boolean.class, "TRUE", true), - Arguments.of(Boolean.class, "FALSE", false), - Arguments.of(Boolean.class, 1, true), - Arguments.of(Boolean.class, 0, false), - Arguments.of(Boolean.class, null, null), - - // Integer mappings - Arguments.of(Integer.class, 42, 42), - Arguments.of(Integer.class, "42", 42), - Arguments.of(Integer.class, 3.14, 3), - Arguments.of(Integer.class, "3.14", 3), - Arguments.of(Integer.class, null, null), - - // Double mappings - Arguments.of(Double.class, 3.14, 3.14), - Arguments.of(Double.class, "3.14", 3.14), - Arguments.of(Double.class, 42, 42.0), - Arguments.of(Double.class, "42", 42.0), - Arguments.of(Double.class, null, null), - - // Value mappings (OpenFeature Value objects) - Arguments.of(Value.class, "hello", Value.objectToValue("hello")), - Arguments.of(Value.class, 42, Value.objectToValue(42)), - Arguments.of(Value.class, 3.14, Value.objectToValue(3.14)), - Arguments.of(Value.class, true, Value.objectToValue(true)), - Arguments.of(Value.class, null, null), - - // Unsupported - Arguments.of(Date.class, "21-12-2023", IllegalArgumentException.class), - }; - } - - @ParameterizedTest - @MethodSource("valueMappingTestCases") - public void testValueMapping(final Class target, final Object value, final Object expected) { - if (expected == IllegalArgumentException.class) { - assertThrows(IllegalArgumentException.class, () -> DDEvaluator.mapValue(target, value)); - } else { - final Object result = DDEvaluator.mapValue(target, value); - assertThat(result, equalTo(expected)); } + FeatureFlaggingRawBridge.dispatchConfiguration(null); } @Test - public void testEvaluateNoConfig() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - final ProviderEvaluation details = - evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); - } + void initializesFromLateRemoteConfigurationListener() throws Exception { + FeatureFlaggingRawBridge.dispatchConfiguration(UFC.getBytes(UTF_8)); + final AtomicBoolean changed = new AtomicBoolean(); + evaluator = + new DDEvaluator( + () -> changed.set(true), new Provider.Options().configurationSource("remote_config")); - @Test - public void testInitializeTimesOutWithoutConfig() throws Exception { - final Runnable configCallback = mock(Runnable.class); - final DDEvaluator evaluator = new DDEvaluator(configCallback); - evaluator.accept(null); - try { - assertThat( - evaluator.initialize(10, MILLISECONDS, mock(EvaluationContext.class)), equalTo(false)); - verify(configCallback, times(0)).run(); - } finally { - evaluator.shutdown(); - } + assertTrue(evaluator.initialize(1, SECONDS, new MutableContext("subject"))); + assertTrue(evaluator.hasConfiguration()); + assertTrue(changed.get()); } @Test - public void testInitializeWaitsForNonNullConfig() throws Exception { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - final ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - final Future initialized = - executor.submit(() -> evaluator.initialize(1, SECONDS, mock(EvaluationContext.class))); - - evaluator.accept(null); - assertThat(initialized.isDone(), equalTo(false)); + void evaluatesProviderOwnedConfiguration() throws Exception { + FeatureFlaggingRawBridge.dispatchConfiguration(UFC.getBytes(UTF_8)); + evaluator = + new DDEvaluator(() -> {}, new Provider.Options().configurationSource("remote_config")); + evaluator.initialize(1, SECONDS, new MutableContext("subject")); - evaluator.accept(mock(ServerConfiguration.class)); - assertThat(initialized.get(1, SECONDS), equalTo(true)); - } finally { - executor.shutdownNow(); - evaluator.shutdown(); - } - } + final ProviderEvaluation result = + evaluator.evaluate(String.class, "message", "default", new MutableContext("subject")); - @Test - public void testEvaluateNoContext() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(mock(ServerConfiguration.class)); - final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, null); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.INVALID_CONTEXT)); + assertEquals("hello", result.getValue()); + assertEquals("STATIC", result.getReason()); + assertEquals("on", result.getVariant()); + assertEquals("allocation", result.getFlagMetadata().getString("allocationKey")); } @Test - public void testNoAllocations() { - final Map flags = new HashMap<>(); - flags.put("null-allocation", new Flag("target", true, null, null, null)); - flags.put("empty-allocation", new Flag("target", true, null, null, emptyList())); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); + void reportsProviderNotReadyWithoutInitialization() { + evaluator = + new DDEvaluator(() -> {}, new Provider.Options().configurationSource("remote_config")); - ProviderEvaluation details = evaluator.evaluate(Integer.class, "null-allocation", 23, ctx); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.GENERAL)); + final ProviderEvaluation result = + evaluator.evaluate(Integer.class, "message", 23, new MutableContext("subject")); - details = evaluator.evaluate(Integer.class, "empty-allocation", 23, ctx); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat(details.getErrorCode(), nullValue()); - } - - private static Arguments[] flatteningTestCases() { - final List arguments = new ArrayList<>(); - arguments.add(Arguments.of(emptyMap(), emptyMap())); - arguments.add( - Arguments.of( - mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null), - mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null))); - arguments.add( - Arguments.of( - mapOf("list", asList(1, 2, singletonList(4))), - mapOf("list[0]", 1, "list[1]", 2, "list[2][0]", 4))); - arguments.add( - Arguments.of( - mapOf("map", mapOf("key1", 1, "key2", 2, "key3", mapOf("key4", 4))), - mapOf("map.key1", 1, "map.key2", 2, "map.key3.key4", 4))); - return arguments.toArray(new Arguments[0]); - } - - @MethodSource("flatteningTestCases") - @ParameterizedTest - public void testFlattening( - final Map attributes, final Map expected) { - final EvaluationContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - final Map result = DDEvaluator.flattenContext(context); - - assertThat(result.size(), equalTo(expected.size())); - for (final Map.Entry entry : expected.entrySet()) { - assertThat(result, hasEntry(entry.getKey(), entry.getValue())); - } + assertEquals(23, result.getValue()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, result.getErrorCode()); } @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"))); + void mapsSupportedValues() { + assertEquals("42", DDEvaluator.mapValue(String.class, 42)); + assertEquals(42, DDEvaluator.mapValue(Integer.class, "42")); + assertEquals(42D, DDEvaluator.mapValue(Double.class, 42)); + assertEquals(Value.objectToValue("value"), DDEvaluator.mapValue(Value.class, "value")); + assertThrows(IllegalArgumentException.class, () -> DDEvaluator.mapValue(Date.class, "date")); } - 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; + @Test + void flattensNestedContextForPrimitiveTelemetryBridge() { final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - if (testCase.targetingKey != null) { - context.setTargetingKey(testCase.targetingKey); - } - return context; - } + new MutableContext( + Value.objectToValue(Map.of("nested", Map.of("value", 7))).asStructure().asMap()); - 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); - } + assertEquals(7, DDEvaluator.flattenContext(context).get("nested.value")); } - 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; - while (index < props.length) { - final String key = String.valueOf(props[index++]); - final Object value = props[index++]; - result.put(key, value); - } - 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()); - } - } + 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\":[],\"serialId\":7}],\"doLog\":true}]}}}"; } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/MissingBridgeChildJvmTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/MissingBridgeChildJvmTest.java new file mode 100644 index 00000000000..e8d375bbf20 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/MissingBridgeChildJvmTest.java @@ -0,0 +1,37 @@ +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 MissingBridgeChildJvmTest { + + @Test + void reportsClearErrorWhenRemoteConfigurationBridgeIsMissing() throws Exception { + final String classpath = + Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator)) + .filter(path -> !path.contains("feature-flagging-bootstrap")) + .collect(Collectors.joining(File.pathSeparator)); + final Process process = + new ProcessBuilder( + new File(System.getProperty("java.home"), "bin/java").getAbsolutePath(), + "-cp", + classpath, + MissingBridgeChildMain.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("version 1.65.0 or later"), output); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/MissingBridgeChildMain.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/MissingBridgeChildMain.java new file mode 100644 index 00000000000..8798ed06f74 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/MissingBridgeChildMain.java @@ -0,0 +1,24 @@ +package datadog.trace.api.openfeature; + +import dev.openfeature.sdk.exceptions.FatalError; + +public final class MissingBridgeChildMain { + private MissingBridgeChildMain() {} + + public static void main(String[] args) { + final Provider provider = + new Provider( + new Provider.Options().configurationSource("remote_config").environment("production")); + try { + provider.initialize(null); + throw new AssertionError("Remote Configuration initialization unexpectedly succeeded"); + } catch (final FatalError error) { + if (!error.getMessage().contains("version 1.65.0 or later")) { + 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/ProviderOnlyChildJvmTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildJvmTest.java new file mode 100644 index 00000000000..64236a41419 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildJvmTest.java @@ -0,0 +1,32 @@ +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.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class ProviderOnlyChildJvmTest { + + @Test + void evaluatesCdnFlagWithoutJavaAgent() throws Exception { + final Process process = + new ProcessBuilder( + new File(System.getProperty("java.home"), "bin/java").getAbsolutePath(), + "-cp", + System.getProperty("java.class.path"), + 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("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..7a7c0048e62 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderOnlyChildMain.java @@ -0,0 +1,76 @@ +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.time.Duration; +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 { + final Provider provider = + new Provider( + new Provider.Options() + .cdnBaseUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/config") + .pollInterval(Duration.ofMillis(50)) + .requestTimeout(Duration.ofSeconds(1)) + .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("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 + || 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\":false}]}}}"; +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java index 27d4dd5d2b5..61eeda44890 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -1,397 +1,167 @@ package datadog.trace.api.openfeature; -import static datadog.trace.api.openfeature.Provider.METADATA; -import static java.time.Duration.ofSeconds; +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.awaitility.Awaitility.await; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.MatcherAssert.assertThat; +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 static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.openfeature.Provider.Options; -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.EventDetails; -import dev.openfeature.sdk.Features; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.OpenFeatureAPI; +import com.sun.net.httpserver.HttpServer; +import datadog.trace.api.featureflag.FeatureFlaggingRawBridge; +import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.ProviderEvent; -import dev.openfeature.sdk.ProviderState; -import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; -import java.lang.reflect.Field; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; +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.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) -public class ProviderTest { +class ProviderTest { - @Captor private ArgumentCaptor eventDetailsCaptor; - - private ExecutorService executor; - - @BeforeEach - public void setup() { - executor = Executors.newSingleThreadExecutor(); - } + private Provider first; + private Provider second; @AfterEach - public void tearDown() { - executor.shutdownNow(); - OpenFeatureAPI.getInstance().shutdown(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + void close() { + if (first != null) { + first.shutdown(); + } + if (second != null) { + second.shutdown(); + } + FeatureFlaggingRawBridge.dispatchConfiguration(null); } @Test - public void testSetProvider() { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new Provider()); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - await().atMost(ofSeconds(1)).until(() -> client.getProviderState() == ProviderState.READY); + void selectsCdnByDefaultAndRemoteConfigExplicitly() { + assertEquals( + RuntimeConfiguration.Source.CDN, + RuntimeConfiguration.resolve(new Provider.Options()).source); + assertEquals( + RuntimeConfiguration.Source.REMOTE_CONFIG, + RuntimeConfiguration.resolve(new Provider.Options().configurationSource("remote_config")) + .source); } @Test - public void testSetProviderAndWait() throws Exception { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final Future provider = executor.submit(() -> api.setProviderAndWait(new Provider())); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - await().atMost(ofSeconds(1)).until(() -> client.getProviderState() == ProviderState.READY); - provider.get(1, SECONDS); + void providerOwnsCdnLifecycleWithoutAgent() throws Exception { + 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.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + try { + first = + new Provider( + new Provider.Options() + .cdnBaseUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/config") + .pollInterval(Duration.ofMillis(50)) + .requestTimeout(Duration.ofSeconds(1)) + .initTimeout(1, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals(0, requests.get()); + + first.initialize(new MutableContext("subject")); + + assertTrue(requests.get() > 0); + assertEquals( + "hello", + first + .getStringEvaluation("message", "default", new MutableContext("subject")) + .getValue()); + first.shutdown(); + first = null; + final int stoppedAt = requests.get(); + Thread.sleep(150); + assertEquals(stoppedAt, requests.get()); + } finally { + server.stop(0); + } } @Test - public void testSetProviderAndWaitTimeoutRecoversWhenConfigurationArrives() { - final Consumer readyEvent = mock(Consumer.class); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final Client client = api.getClient(); - client.on(ProviderEvent.PROVIDER_READY, readyEvent); - - assertThrows( - ProviderNotReadyError.class, - () -> api.setProviderAndWait(new Provider(new Options().initTimeout(10, MILLISECONDS)))); - - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); - verify(readyEvent, times(0)).accept(any()); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - - await() - .atMost(ofSeconds(1)) - .untilAsserted( - () -> { - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - verify(readyEvent, times(1)).accept(eventDetailsCaptor.capture()); - final EventDetails eventDetails = eventDetailsCaptor.getValue(); - assertThat(eventDetails.getProviderName(), equalTo(METADATA)); - }); - } - - @Test - public void testSetProviderAndWaitCompletesWhenConfigurationArrivesAtTimeoutBoundary() - throws Exception { - final Provider[] providerRef = new Provider[1]; - final Evaluator evaluator = - new Evaluator() { - private boolean hasConfiguration; - - @Override - public boolean initialize( - final long timeout, - final java.util.concurrent.TimeUnit timeUnit, - final EvaluationContext context) { - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - return false; - } + void sharesOneReferenceCountedRuntimePerClassloader() throws Exception { + FeatureFlaggingRawBridge.dispatchConfiguration(UFC.getBytes(UTF_8)); + final Provider.Options options = + new Provider.Options().configurationSource("remote_config").initTimeout(100, MILLISECONDS); + first = new Provider(options); + second = new Provider(options); - @Override - public boolean hasConfiguration() { - return hasConfiguration; - } + first.initialize(new MutableContext("first")); + second.initialize(new MutableContext("second")); + first.shutdown(); + first = null; + FeatureFlaggingRawBridge.dispatchConfiguration(UFC.replace("hello", "updated").getBytes(UTF_8)); - @Override - public void shutdown() {} - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - return ProviderEvaluation.builder().value(defaultValue).build(); - } - }; - - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); - api.setProviderAndWait(providerRef[0]); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + assertEquals( + "updated", + second.getStringEvaluation("message", "default", new MutableContext("subject")).getValue()); } @Test - public void testSetProviderAndWaitFailsWhenConfigurationIsRemovedBeforeInitializationCompletes() { - final Provider[] providerRef = new Provider[1]; - final Evaluator evaluator = - new Evaluator() { - private boolean hasConfiguration; - - @Override - public boolean initialize( - final long timeout, - final java.util.concurrent.TimeUnit timeUnit, - final EvaluationContext context) { - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - hasConfiguration = false; - providerRef[0].onConfigurationChange(); - return true; - } - - @Override - public boolean hasConfiguration() { - return hasConfiguration; - } - - @Override - public void shutdown() {} - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - return ProviderEvaluation.builder().value(defaultValue).build(); - } - }; - - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); - - assertThrows(ProviderNotReadyError.class, () -> api.setProviderAndWait(providerRef[0])); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); + void rejectsDifferentOptionsWithinOneClassloader() throws Exception { + FeatureFlaggingRawBridge.dispatchConfiguration(UFC.getBytes(UTF_8)); + first = + new Provider( + new Provider.Options() + .configurationSource("remote_config") + .initTimeout(100, MILLISECONDS)); + first.initialize(new MutableContext("subject")); + second = + new Provider( + new Provider.Options() + .cdnBaseUrl("http://127.0.0.1:1/config") + .initTimeout(10, MILLISECONDS)); + + final FatalError error = + assertThrows(FatalError.class, () -> second.initialize(new MutableContext())); + assertTrue(error.getMessage().contains("same configuration source and options")); } @Test - public void testInitializationErrorDoesNotOverwriteRecoveredReadyState() throws Exception { - final Provider[] providerRef = new Provider[1]; - final Evaluator evaluator = - new Evaluator() { - private boolean hasConfiguration; - - @Override - public boolean initialize( - final long timeout, - final java.util.concurrent.TimeUnit timeUnit, - final EvaluationContext context) { - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - hasConfiguration = false; - providerRef[0].onConfigurationChange(); - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - throw new ProviderNotReadyError( - "Provider timed-out while waiting for initial configuration"); - } - - @Override - public boolean hasConfiguration() { - return hasConfiguration; - } - - @Override - public void shutdown() {} - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - return ProviderEvaluation.builder().value(defaultValue).build(); - } - }; - - providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); - - assertThrows(ProviderNotReadyError.class, () -> providerRef[0].initialize(null)); - - assertThat(initializationState(providerRef[0]), equalTo("READY")); - } - - @Test - public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new Provider()); - final Client client = api.getClient(); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - await().atMost(ofSeconds(1)).until(() -> client.getProviderState() == ProviderState.READY); - - final Consumer errorEvent = mock(Consumer.class); - final Consumer readyEvent = mock(Consumer.class); - final Consumer configChangedEvent = mock(Consumer.class); - client.on(ProviderEvent.PROVIDER_ERROR, errorEvent); - client.on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, configChangedEvent); - - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - await() - .atMost(ofSeconds(1)) - .untilAsserted( - () -> { - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); - verify(errorEvent, times(1)).accept(eventDetailsCaptor.capture()); - final EventDetails eventDetails = eventDetailsCaptor.getValue(); - assertThat(eventDetails.getProviderName(), equalTo(METADATA)); - }); - - final FlagEvaluationDetails evalDetails = client.getStringDetails("missing", "default"); - assertThat(evalDetails.getValue(), equalTo("default")); - assertThat(evalDetails.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); - - client.on(ProviderEvent.PROVIDER_READY, readyEvent); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - await() - .atMost(ofSeconds(1)) - .untilAsserted( - () -> { - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - verify(readyEvent, times(1)).accept(any()); - }); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - await() - .atMost(ofSeconds(1)) - .untilAsserted(() -> verify(configChangedEvent, times(1)).accept(any())); - } - - @Test - public void testFailureToLoadInternalApi() { - @SuppressWarnings("unchecked") - final Consumer consumer = mock(Consumer.class); - - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.onProviderError(consumer); - - assertThrows( - FatalError.class, - () -> - api.setProviderAndWait( - new Provider() { - @Override - protected Class loadEvaluatorClass() throws ClassNotFoundException { - throw new ClassNotFoundException( - "Class " + FeatureFlaggingGateway.class.getName() + " not found"); - } - })); - } + void reportsInitializationTimeout() { + final Evaluator evaluator = mock(Evaluator.class); + first = new Provider(new Provider.Options().initTimeout(10, MILLISECONDS), evaluator); - @Test - public void testGetProviderHooksReturnsFlagEvalHook() { - Provider provider = - new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); - List hooks = provider.getProviderHooks(); - assertThat(hooks.size(), equalTo(1)); - assertThat(hooks.get(0) instanceof FlagEvalHook, equalTo(true)); + assertThrows(ProviderNotReadyError.class, () -> first.initialize(new MutableContext())); } @Test - public void testShutdownCleansUpMetrics() throws Exception { - Evaluator evaluator = mock(Evaluator.class); + void mapsProviderCallsAndClosesInjectedEvaluator() throws Exception { + final Evaluator evaluator = mock(Evaluator.class); when(evaluator.initialize(eq(10L), eq(MILLISECONDS), any())).thenReturn(true); when(evaluator.hasConfiguration()).thenReturn(true); - Provider provider = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); - provider.initialize(null); - provider.shutdown(); - verify(evaluator).shutdown(); - // After shutdown, getProviderHooks still returns a list (hook is still present but metrics is - // shut down) - assertThat(provider.getProviderHooks().size(), equalTo(1)); - } + when(evaluator.evaluate(eq(String.class), eq("key"), eq("default"), any())) + .thenReturn(ProviderEvaluation.builder().value("value").build()); + first = new Provider(new Provider.Options().initTimeout(10, MILLISECONDS), evaluator); - public interface EvaluateMethod { - FlagEvaluationDetails evaluate(Features client, String flag, E defaultValue); - } - - private static Arguments[] providerMethods() { - return new Arguments[] { - Arguments.of("bool", false, (EvaluateMethod) Features::getBooleanDetails), - Arguments.of("string", "Hello!", (EvaluateMethod) Features::getStringDetails), - Arguments.of("int", 23, (EvaluateMethod) Features::getIntegerDetails), - Arguments.of("double", 3.14D, (EvaluateMethod) Features::getDoubleDetails), - Arguments.of("object", new Value(), (EvaluateMethod) Features::getObjectDetails) - }; - } + first.initialize(new MutableContext()); + assertEquals( + "value", first.getStringEvaluation("key", "default", new MutableContext()).getValue()); + first.shutdown(); + first = null; - @MethodSource("providerMethods") - @ParameterizedTest - public void testProviderEvaluation( - final String flag, final E defaultValue, final EvaluateMethod method) throws Exception { - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - final Evaluator evaluator = mock(Evaluator.class); - when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); - when(evaluator.hasConfiguration()).thenReturn(true); - when(evaluator.evaluate(any(), any(), any(), any())) - .thenAnswer( - invocation -> - ProviderEvaluation.builder() - .value(invocation.getArgument(2)) - .reason("MOCK") - .build()); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); - final Client client = api.getClient(); - final FlagEvaluationDetails result = method.evaluate(client, flag, defaultValue); - assertThat(result.getValue(), equalTo(defaultValue)); - assertThat(result.getReason(), equalTo("MOCK")); - verify(evaluator, times(1)).initialize(eq(10L), eq(SECONDS), any()); - verify(evaluator, times(1)) - .evaluate(any(), eq(flag), eq(defaultValue), any(EvaluationContext.class)); + verify(evaluator).shutdown(); } - private static String initializationState(final Provider provider) throws Exception { - final Field stateField = Provider.class.getDeclaredField("initializationState"); - stateField.setAccessible(true); - final AtomicReference state = (AtomicReference) stateField.get(provider); - return state.get().toString(); - } + 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\":false}]}}}"; } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingRawBridge.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingRawBridge.java new file mode 100644 index 00000000000..c4b0bf33989 --- /dev/null +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingRawBridge.java @@ -0,0 +1,86 @@ +package datadog.trace.api.featureflag; + +import datadog.trace.api.featureflag.exposure.Allocation; +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import datadog.trace.api.featureflag.exposure.Flag; +import datadog.trace.api.featureflag.exposure.Subject; +import datadog.trace.api.featureflag.exposure.Variant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Stable bootstrap bridge for provider-owned Feature Flagging implementations. + * + *

The bridge accepts raw UFC bytes and JDK values only. Provider-owned UFC model and evaluator + * objects must not cross the application and agent classloader boundary. + */ +public final class FeatureFlaggingRawBridge { + + public interface ConfigurationListener { + void accept(byte[] content); + } + + private static final List CONFIGURATION_LISTENERS = + new CopyOnWriteArrayList<>(); + private static final AtomicReference CURRENT_CONFIGURATION = new AtomicReference<>(); + + private FeatureFlaggingRawBridge() {} + + public static void addConfigurationListener(final ConfigurationListener listener) { + CONFIGURATION_LISTENERS.add(listener); + final byte[] current = CURRENT_CONFIGURATION.get(); + if (current != null) { + listener.accept(current.clone()); + } + } + + public static void removeConfigurationListener(final ConfigurationListener listener) { + CONFIGURATION_LISTENERS.remove(listener); + } + + public static void dispatchConfiguration(final byte[] content) { + final byte[] retained = content == null ? null : content.clone(); + CURRENT_CONFIGURATION.set(retained); + for (final ConfigurationListener listener : CONFIGURATION_LISTENERS) { + listener.accept(retained == null ? null : retained.clone()); + } + } + + /** Signals that application code initialized a Feature Flagging provider. */ + public static void activate() { + FeatureFlaggingGateway.activate(); + } + + public static void dispatchExposure( + final long timestamp, + final String allocationKey, + final String flagKey, + final String variantKey, + final String targetingKey, + final Map attributes) { + final Map copiedAttributes = + attributes == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); + FeatureFlaggingGateway.dispatch( + new ExposureEvent( + timestamp, + new Allocation(allocationKey), + new Flag(flagKey), + new Variant(variantKey), + new Subject(targetingKey, copiedAttributes))); + } + + public static void dispatchSpanSerialId( + final int serialId, final boolean doLog, final String targetingKey) { + FeatureFlaggingGateway.dispatch(SpanEnrichmentEvent.serialId(serialId, doLog, targetingKey)); + } + + public static void dispatchSpanRuntimeDefault(final String flagKey, final Object value) { + FeatureFlaggingGateway.dispatch(SpanEnrichmentEvent.runtimeDefault(flagKey, value)); + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingRawBridgeTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingRawBridgeTest.java new file mode 100644 index 00000000000..3ef14fb08fa --- /dev/null +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingRawBridgeTest.java @@ -0,0 +1,109 @@ +package datadog.trace.api.featureflag; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class FeatureFlaggingRawBridgeTest { + + @AfterEach + void clear() { + FeatureFlaggingRawBridge.dispatchConfiguration(null); + } + + @Test + void dispatchesUpdatesAndDeletion() { + final AtomicReference received = new AtomicReference<>(); + final FeatureFlaggingRawBridge.ConfigurationListener listener = received::set; + FeatureFlaggingRawBridge.addConfigurationListener(listener); + try { + FeatureFlaggingRawBridge.dispatchConfiguration("one".getBytes(UTF_8)); + assertArrayEquals("one".getBytes(UTF_8), received.get()); + + FeatureFlaggingRawBridge.dispatchConfiguration(null); + assertNull(received.get()); + } finally { + FeatureFlaggingRawBridge.removeConfigurationListener(listener); + } + } + + @Test + void sendsRetainedConfigurationToLateListener() { + FeatureFlaggingRawBridge.dispatchConfiguration("retained".getBytes(UTF_8)); + final AtomicReference received = new AtomicReference<>(); + final FeatureFlaggingRawBridge.ConfigurationListener listener = received::set; + + FeatureFlaggingRawBridge.addConfigurationListener(listener); + try { + assertEquals("retained", new String(received.get(), UTF_8)); + } finally { + FeatureFlaggingRawBridge.removeConfigurationListener(listener); + } + } + + @Test + void adaptsPrimitiveTelemetryToLegacyBridgeTypes() { + final AtomicBoolean activated = new AtomicBoolean(); + final AtomicReference exposure = new AtomicReference<>(); + final AtomicReference span = new AtomicReference<>(); + final FeatureFlaggingGateway.ActivationListener activationListener = () -> activated.set(true); + final FeatureFlaggingGateway.ExposureListener exposureListener = exposure::set; + final FeatureFlaggingGateway.SpanEnrichmentListener spanListener = span::set; + FeatureFlaggingGateway.addActivationListener(activationListener); + FeatureFlaggingGateway.addExposureListener(exposureListener); + FeatureFlaggingGateway.addSpanEnrichmentListener(spanListener); + try { + FeatureFlaggingRawBridge.activate(); + assertTrue(activated.get()); + + final Map attributes = new LinkedHashMap<>(); + attributes.put("country", "US"); + FeatureFlaggingRawBridge.dispatchExposure( + 123, "allocation", "flag", "variant", "subject", attributes); + attributes.put("country", "changed"); + assertEquals(123, exposure.get().timestamp); + assertEquals("allocation", exposure.get().allocation.key); + assertEquals("flag", exposure.get().flag.key); + assertEquals("variant", exposure.get().variant.key); + assertEquals("subject", exposure.get().subject.id); + assertEquals("US", exposure.get().subject.attributes.get("country")); + assertThrowsUnsupportedMutation(exposure.get().subject.attributes); + + FeatureFlaggingRawBridge.dispatchSpanSerialId(7, true, "subject"); + assertTrue(span.get().hasSerialId()); + assertEquals(7, span.get().serialId()); + assertTrue(span.get().doLog()); + assertEquals("subject", span.get().targetingKey()); + + FeatureFlaggingRawBridge.dispatchSpanRuntimeDefault("flag", 42); + assertEquals("flag", span.get().flagKey()); + assertEquals(42, span.get().defaultValue()); + + FeatureFlaggingRawBridge.dispatchExposure(124, "allocation", "flag", "variant", null, null); + assertTrue(exposure.get().subject.attributes.isEmpty()); + } finally { + FeatureFlaggingGateway.removeActivationListener(activationListener); + FeatureFlaggingGateway.removeExposureListener(exposureListener); + FeatureFlaggingGateway.removeSpanEnrichmentListener(spanListener); + } + } + + private static void assertThrowsUnsupportedMutation(final Map attributes) { + try { + attributes.put("new", true); + } catch (final UnsupportedOperationException expected) { + return; + } + throw new AssertionError("Expected immutable exposure attributes"); + } +} 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..29374eb7fb6 --- /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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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..e975616572e --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/main/java/datadog/openfeature/internal/core/EvaluationContext.java @@ -0,0 +1,34 @@ +package datadog.openfeature.internal.core; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** OpenFeature-independent evaluation context. */ +public final class EvaluationContext { + + private final String targetingKey; + private final Map attributes; + + public EvaluationContext(final String targetingKey, final Map attributes) { + this.targetingKey = targetingKey; + this.attributes = + Collections.unmodifiableMap( + attributes == null ? Collections.emptyMap() : new LinkedHashMap<>(attributes)); + } + + public String targetingKey() { + return targetingKey; + } + + public Map attributes() { + return attributes; + } + + public Object attribute(final String name) { + if ("id".equals(name) && !attributes.containsKey(name)) { + return targetingKey; + } + return attributes.get(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..27cb6e333e0 --- /dev/null +++ b/products/feature-flagging/feature-flagging-core/src/test/java/datadog/openfeature/internal/core/FlagEvaluatorTest.java @@ -0,0 +1,302 @@ +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("value", context.attributes().get("name")); + assertEquals( + "explicit", new EvaluationContext("subject", Map.of("id", "explicit")).attribute("id")); + assertEquals(emptyMap(), new EvaluationContext("subject", null).attributes()); + } + + 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..adf16d6d992 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnConfigurationSource.java @@ -0,0 +1,298 @@ +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.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; + +/** 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)); + 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"); + 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), + response.body()); + } 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 IOException) { + throw (IOException) cause; + } + throw new IOException("Feature Flagging HTTP request failed", cause); + } finally { + active.compareAndSet(future, null); + } + } + + @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..237329af9a2 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/main/java/datadog/openfeature/internal/http/CdnEndpointResolver.java @@ -0,0 +1,40 @@ +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)) { + return configured.resolve(UFC_PATH.substring(1)); + } + 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..4133b425658 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnConfigurationSourceTest.java @@ -0,0 +1,186 @@ +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 CdnConfigurationSource source = source(new ConfigurationStore(), 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()); + } + + @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..4d8a377e498 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/CdnEndpointResolverTest.java @@ -0,0 +1,43 @@ +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/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..4924a556013 --- /dev/null +++ b/products/feature-flagging/feature-flagging-http/src/test/java/datadog/openfeature/internal/http/Java11TransportTest.java @@ -0,0 +1,145 @@ +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.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 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 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(); + } +} 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 deleted file mode 100644 index f4fc35cce30..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java +++ /dev/null @@ -1,489 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.communication.http.OkHttpUtils.prepareRequest; -import static datadog.communication.http.OkHttpUtils.sendWithRetries; -import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_CONFIGURATION_POLLER; -import static datadog.trace.util.Strings.isBlank; - -import datadog.communication.http.HttpRetryPolicy; -import datadog.communication.http.OkHttpUtils; -import datadog.logging.RatelimitedLogger; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.util.AgentThreadFactory; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.io.IOException; -import java.io.InterruptedIOException; -import java.net.HttpURLConnection; -import java.util.HashMap; -import java.util.Map; -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 javax.annotation.Nullable; -import okhttp3.Call; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -final class AgentlessConfigurationSource implements ConfigurationSourceService { - private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessConfigurationSource.class); - - private static final String DATADOG_UFC_RULES_BASED_SERVER_PATH = - "/api/v2/feature-flagging/config/rules-based/server"; - private static final int MAX_ATTEMPTS = 3; - private static final int MINUTES_BETWEEN_WARNINGS = 5; - private static final long FIRST_RETRY_MIN_MILLIS = 2_000; - private static final long FIRST_RETRY_MAX_MILLIS = 10_000; - private static final long SECOND_RETRY_MIN_MILLIS = 5_000; - private static final long SECOND_RETRY_MAX_MILLIS = 30_000; - private static final double RETRY_JITTER = 0.2; - - private final HttpUrl endpoint; - private final Config config; - private final long pollIntervalMillis; - private final UfcHttpClient client; - private final ScheduledExecutorService executor; - private final RatelimitedLogger ratelimitedLogger; - private final Object lifecycleLock = new Object(); - private final AtomicBoolean polling = new AtomicBoolean(); - private final AtomicReference pollingThread = new AtomicReference<>(); - private volatile boolean closed; - private volatile boolean started; - private volatile ScheduledFuture scheduledPoll; - private volatile String etag; - - AgentlessConfigurationSource(final Config config) { - this(config, endpoint(config)); - } - - private AgentlessConfigurationSource(final Config config, final HttpUrl endpoint) { - this( - endpoint, - config, - millis(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()), - new OkHttpUfcHttpClient( - OkHttpUtils.buildHttpClient( - endpoint, - millis(config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds())), - millis(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()), - TimeUnit.MILLISECONDS::sleep, - () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)), - Executors.newSingleThreadScheduledExecutor( - new AgentThreadFactory(FEATURE_FLAG_CONFIGURATION_POLLER)), - new RatelimitedLogger(LOGGER, MINUTES_BETWEEN_WARNINGS, TimeUnit.MINUTES)); - } - - AgentlessConfigurationSource( - final HttpUrl endpoint, - final Config config, - final long pollIntervalMillis, - final UfcHttpClient client, - final ScheduledExecutorService executor) { - this( - endpoint, - config, - pollIntervalMillis, - client, - executor, - new RatelimitedLogger(LOGGER, MINUTES_BETWEEN_WARNINGS, TimeUnit.MINUTES)); - } - - AgentlessConfigurationSource( - final HttpUrl endpoint, - final Config config, - final long pollIntervalMillis, - final UfcHttpClient client, - final ScheduledExecutorService executor, - final RatelimitedLogger ratelimitedLogger) { - this.endpoint = endpoint; - this.config = config; - this.pollIntervalMillis = pollIntervalMillis; - this.client = client; - this.executor = executor; - this.ratelimitedLogger = ratelimitedLogger; - } - - @Override - public void init() { - synchronized (lifecycleLock) { - if (closed || started) { - return; - } - started = true; - } - - // Complete the first poll cycle on the activation thread. This lets OpenFeature provider - // initialization observe a successful retry before it checks whether configuration is ready. - // No request occurs before application code activates the provider. - pollOnceSafely(); - - synchronized (lifecycleLock) { - if (!closed) { - scheduledPoll = - executor.scheduleWithFixedDelay( - this::pollOnceSafely, - pollIntervalMillis, - pollIntervalMillis, - TimeUnit.MILLISECONDS); - } - } - } - - boolean pollOnce() { - if (closed || !polling.compareAndSet(false, true)) { - return false; - } - pollingThread.set(Thread.currentThread()); - try { - return fetchAndApply(); - } finally { - pollingThread.compareAndSet(Thread.currentThread(), null); - polling.set(false); - } - } - - @Override - public void close() { - final ScheduledFuture poll; - synchronized (lifecycleLock) { - if (closed) { - return; - } - closed = true; - started = false; - poll = scheduledPoll; - scheduledPoll = null; - } - if (poll != null) { - poll.cancel(true); - } - client.cancel(); - final Thread activePollingThread = pollingThread.get(); - if (activePollingThread != null) { - activePollingThread.interrupt(); - } - executor.shutdownNow(); - } - - private void pollOnceSafely() { - try { - pollOnce(); - } catch (final RuntimeException e) { - LOGGER.debug("Unexpected error while polling Feature Flagging HTTP configuration source", e); - } - } - - private boolean fetchAndApply() { - try { - final UfcHttpResponse response = client.fetch(endpoint, config, etag); - if (closed) { - return false; - } - if (isRetryableStatus(response.status)) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint failed after {} attempts with HTTP {}", - MAX_ATTEMPTS, - response.status); - return false; - } - synchronized (lifecycleLock) { - return !closed && apply(response); - } - } catch (final IOException e) { - if (!closed) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint request failed after {} attempts", - MAX_ATTEMPTS, - e); - } - return false; - } - } - - private boolean apply(final UfcHttpResponse response) { - if (response.status == HttpURLConnection.HTTP_NOT_MODIFIED) { - return true; - } - if (response.status == HttpURLConnection.HTTP_UNAUTHORIZED - || response.status == HttpURLConnection.HTTP_FORBIDDEN) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - response.status); - return false; - } - if (response.status != HttpURLConnection.HTTP_OK || response.body == null) { - return false; - } - final ServerConfiguration configuration; - try { - configuration = JsonApiUfcResponseParser.INSTANCE.parse(response.body); - } catch (final IOException | RuntimeException e) { - LOGGER.debug("Feature Flagging HTTP configuration source returned malformed UFC payload", e); - return false; - } - if (configuration == null) { - return false; - } - FeatureFlaggingGateway.dispatch(configuration); - updateEtag(response.etag); - return true; - } - - private static boolean isRetryableStatus(final int status) { - return status == HttpURLConnection.HTTP_CLIENT_TIMEOUT - || status == 429 - || (status >= 500 && status <= 599); - } - - private void updateEtag(final String nextEtag) { - etag = isBlank(nextEtag) ? null : nextEtag; - } - - static HttpUrl endpoint(final Config config) { - final String configuredBaseUrl = config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl(); - return configuredBaseUrl == null - ? datadogApiServerDistributionEndpoint(config) - : endpointFromConfiguredBaseUrl(configuredBaseUrl); - } - - private static HttpUrl endpointFromConfiguredBaseUrl(final String configuredBaseUrl) { - final HttpUrl parsed = HttpUrl.parse(configuredBaseUrl.trim()); - if (parsed == null) { - throw new IllegalArgumentException( - "Invalid Feature Flagging HTTP configuration source URL: " + configuredBaseUrl); - } - if ("/".equals(parsed.encodedPath()) || parsed.encodedPath().isEmpty()) { - return parsed - .newBuilder() - .addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1)) - .build(); - } - return parsed; - } - - private static HttpUrl datadogApiServerDistributionEndpoint(final Config config) { - final HttpUrl.Builder endpoint = - new HttpUrl.Builder() - .scheme("https") - .host("ufc-server.ff-cdn." + config.getSite()) - .addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1)); - final String env = config.getEnv(); - if (env != null && !env.isEmpty()) { - endpoint.addQueryParameter("dd_env", env); - } - return endpoint.build(); - } - - static long millis(final int seconds) { - return TimeUnit.SECONDS.toMillis(seconds); - } - - static long retryDelayMillis( - final long pollIntervalMillis, final int attempt, final double jitter) { - final long baseDelay; - if (attempt == 1) { - baseDelay = clamp(pollIntervalMillis / 6, FIRST_RETRY_MIN_MILLIS, FIRST_RETRY_MAX_MILLIS); - } else if (attempt == 2) { - baseDelay = clamp(pollIntervalMillis / 3, SECOND_RETRY_MIN_MILLIS, SECOND_RETRY_MAX_MILLIS); - } else { - throw new IllegalArgumentException("Unsupported Feature Flagging retry attempt: " + attempt); - } - return Math.max(1, Math.round(baseDelay * jitter)); - } - - private static long clamp(final long value, final long minimum, final long maximum) { - return Math.max(minimum, Math.min(maximum, value)); - } - - interface UfcHttpClient { - UfcHttpResponse fetch(HttpUrl endpoint, Config config, String etag) throws IOException; - - void cancel(); - } - - interface RetrySleeper { - void sleep(long delayMillis) throws InterruptedException; - } - - static final class UfcHttpResponse { - final int status; - @Nullable final String etag; - @Nullable final byte[] body; - - UfcHttpResponse(final int status, @Nullable final String etag, @Nullable final byte[] body) { - this.status = status; - this.etag = etag; - this.body = body; - } - } - - static final class OkHttpUfcHttpClient implements UfcHttpClient { - private final OkHttpClient httpClient; - private final long pollIntervalMillis; - private final RetrySleeper retrySleeper; - private final DoubleSupplier jitter; - private final AtomicBoolean fetching = new AtomicBoolean(); - private final AtomicBoolean cancelled = new AtomicBoolean(); - private final AtomicReference activeCall = new AtomicReference<>(); - - OkHttpUfcHttpClient(final OkHttpClient httpClient) { - this( - httpClient, - TimeUnit.SECONDS.toMillis(30), - TimeUnit.MILLISECONDS::sleep, - () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)); - } - - OkHttpUfcHttpClient( - final OkHttpClient httpClient, - final long pollIntervalMillis, - final RetrySleeper retrySleeper, - final DoubleSupplier jitter) { - this.httpClient = httpClient; - this.pollIntervalMillis = pollIntervalMillis; - this.retrySleeper = retrySleeper; - this.jitter = jitter; - } - - @Override - public UfcHttpResponse fetch(final HttpUrl endpoint, final Config config, final String etag) - throws IOException { - final Map headers = new HashMap<>(); - if (etag != null) { - headers.put("If-None-Match", etag); - } - // Leave Accept-Encoding unset so OkHttp negotiates gzip and transparently decompresses it. - final Request request = - prepareRequest(endpoint, headers, config, isDatadogManagedEndpoint(endpoint, config)) - .get() - .build(); - if (!fetching.compareAndSet(false, true)) { - throw new IllegalStateException("Feature Flagging HTTP request already in flight"); - } - if (cancelled.get()) { - fetching.set(false); - throw new InterruptedIOException("Feature Flagging HTTP client is closed"); - } - try { - final HttpRetryPolicy.Factory retryPolicyFactory = - new HttpRetryPolicy.Factory(0, 0, 0) { - @Override - public HttpRetryPolicy create() { - return new AgentlessRetryPolicy( - cancelled, pollIntervalMillis, retrySleeper, jitter); - } - }; - final Call.Factory callFactory = - retryRequest -> { - final Call call = httpClient.newCall(retryRequest); - activeCall.set(call); - if (cancelled.get()) { - call.cancel(); - } - return call; - }; - return sendWithRetries( - callFactory, - retryPolicyFactory, - request, - response -> { - final int status = response.code(); - final String responseEtag = response.header("ETag"); - try (ResponseBody responseBody = response.body()) { - final byte[] body = responseBody != null ? responseBody.bytes() : null; - return new UfcHttpResponse(status, responseEtag, body); - } - }); - } finally { - activeCall.set(null); - fetching.set(false); - } - } - - @Override - public void cancel() { - cancelled.set(true); - final Call call = activeCall.get(); - if (call != null) { - call.cancel(); - } - } - - private static boolean isDatadogManagedEndpoint(final HttpUrl endpoint, final Config config) { - return config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl() == null - && endpoint.isHttps() - && endpoint.host().equalsIgnoreCase("ufc-server.ff-cdn." + config.getSite()); - } - } - - @SuppressFBWarnings( - value = "AT_NONATOMIC_OPERATIONS_ON_SHARED_VARIABLE", - justification = - "Each retry policy belongs to one synchronous HTTP request and is confined to one thread") - static final class AgentlessRetryPolicy extends HttpRetryPolicy { - private final AtomicBoolean cancelled; - private final long pollIntervalMillis; - private final RetrySleeper retrySleeper; - private final DoubleSupplier jitter; - private int retriesLeft = MAX_ATTEMPTS - 1; - private int retryAttempt; - - AgentlessRetryPolicy( - final AtomicBoolean cancelled, - final long pollIntervalMillis, - final RetrySleeper retrySleeper, - final DoubleSupplier jitter) { - super(0, 0, 0, false); - this.cancelled = cancelled; - this.pollIntervalMillis = pollIntervalMillis; - this.retrySleeper = retrySleeper; - this.jitter = jitter; - } - - @Override - public boolean shouldRetry(final Exception exception) { - return exception instanceof IOException - && !Thread.currentThread().isInterrupted() - && reserveRetry(); - } - - @Override - public boolean shouldRetry(@Nullable final Response response) { - return response != null && isRetryableStatus(response.code()) && reserveRetry(); - } - - private boolean reserveRetry() { - if (cancelled.get() || retriesLeft == 0) { - return false; - } - retriesLeft--; - retryAttempt++; - return true; - } - - @Override - public void backoff() throws IOException { - if (cancelled.get()) { - throw new InterruptedIOException("Feature Flagging HTTP client is closed"); - } - try { - retrySleeper.sleep( - retryDelayMillis(pollIntervalMillis, retryAttempt, jitter.getAsDouble())); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new InterruptedIOException("Feature Flagging retry interrupted"); - } - } - } -} 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/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index e66a6231990..08a1e92aca2 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -2,17 +2,19 @@ import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.remoteconfig.Capabilities; -import datadog.remoteconfig.ConfigurationChangesTypedListener; +import datadog.remoteconfig.ConfigurationChangesListener; import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.PollingRateHinter; import datadog.remoteconfig.Product; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.FeatureFlaggingRawBridge; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import java.io.IOException; import javax.annotation.Nullable; public class RemoteConfigServiceImpl - implements ConfigurationSourceService, ConfigurationChangesTypedListener { + implements ConfigurationSourceService, ConfigurationChangesListener { private final ConfigurationPoller configurationPoller; @@ -23,7 +25,7 @@ public RemoteConfigServiceImpl(final SharedCommunicationObjects sco, final Confi @Override public void init() { configurationPoller.addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - configurationPoller.addListener(Product.FFE_FLAGS, UniversalFlagConfigParser.INSTANCE, this); + configurationPoller.addListener(Product.FFE_FLAGS, this); configurationPoller.start(); } @@ -37,8 +39,12 @@ public void close() { @Override public void accept( final String configKey, - @Nullable final ServerConfiguration configuration, - final PollingRateHinter pollingRateHinter) { + @Nullable final byte[] content, + final PollingRateHinter pollingRateHinter) + throws IOException { + final ServerConfiguration configuration = + content == null ? null : UniversalFlagConfigParser.INSTANCE.deserialize(content); FeatureFlaggingGateway.dispatch(configuration); + FeatureFlaggingRawBridge.dispatchConfiguration(content); } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java deleted file mode 100644 index b02bd05642b..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java +++ /dev/null @@ -1,1363 +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.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -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 static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import datadog.communication.http.OkHttpUtils; -import datadog.logging.RatelimitedLogger; -import datadog.trace.agent.test.server.http.JavaTestHttpServer; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InterruptedIOException; -import java.net.HttpURLConnection; -import java.net.SocketTimeoutException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.zip.GZIPOutputStream; -import okhttp3.Call; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Protocol; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class AgentlessConfigurationSourceTest { - private static final String CONFIG_PATH = "/api/v2/feature-flagging/config/rules-based/server"; - - @Mock private FeatureFlaggingGateway.ConfigListener listener; - - @AfterEach - void cleanup() { - FeatureFlaggingGateway.removeConfigListener(listener); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - - @Test - void derivesDatadogUfcCdnEndpointFromSiteAndEnv() { - final Config config = config("datad0g.com", "staging env"); - - assertEquals( - "https://ufc-server.ff-cdn.datad0g.com/api/v2/feature-flagging/config/rules-based/server?dd_env=staging%20env", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void derivesDatadogUfcCdnEndpointWithoutEnv() { - assertEquals( - "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config("datadoghq.com", "")).toString()); - assertEquals( - "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config("datadoghq.com", null)).toString()); - } - - @Test - void appendsRulesBasedServerPathToConfiguredAgentlessBaseUrl() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://mock-backend:8080"); - - assertEquals( - "http://mock-backend:8080/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void usesConfiguredAgentlessEndpointWithPathUnchanged() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://mock-backend:8080/custom/ufc?tenant=test"); - - assertEquals( - "http://mock-backend:8080/custom/ufc?tenant=test", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void rejectsInvalidConfiguredAgentlessBaseUrl() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("not a URL"); - - assertThrows( - IllegalArgumentException.class, () -> AgentlessConfigurationSource.endpoint(config)); - } - - @Test - void rejectsInvalidDatadogUfcCdnEndpoint() { - assertThrows( - IllegalArgumentException.class, - () -> AgentlessConfigurationSource.endpoint(config("datadoghq.com:bad", ""))); - } - - @Test - void defaultConstructorBuildsHttpClientFromConfig() { - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource(config("datad0g.com", "staging")); - - service.close(); - } - - @Test - void realHttpClientDoesNotSendApiKeyOverHttp() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> - api.getResponse() - .addHeader("ETag", "etag-b") - .send(emptyConfig()))))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), "etag-a"); - - assertEquals(HttpURLConnection.HTTP_OK, response.status); - assertEquals("etag-b", response.etag); - assertEquals(emptyConfig(), new String(response.body, UTF_8)); - assertNull(server.getLastRequest().getHeader("DD-API-KEY")); - assertEquals("etag-a", server.getLastRequest().getHeader("If-None-Match")); - assertEquals("java", server.getLastRequest().getHeader("Datadog-Meta-Lang")); - assertEquals("gzip", server.getLastRequest().getHeader("Accept-Encoding")); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void sendsApiKeyToDefaultDatadogHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final HttpUrl endpoint = HttpUrl.get("https://ufc-server.ff-cdn.datadoghq.com" + CONFIG_PATH); - - client.fetch(endpoint, config(), null); - - assertEquals("test-api-key", requests.get(0).header("DD-API-KEY")); - } - - @Test - void stripsApiKeyFromCustomHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("https://flags.example.test/custom/ufc"); - final HttpUrl endpoint = HttpUrl.get("https://flags.example.test/custom/ufc"); - - client.fetch(endpoint, config, null); - - assertNull(requests.get(0).header("DD-API-KEY")); - } - - @Test - void stripsApiKeyFromUnexpectedHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final HttpUrl endpoint = HttpUrl.get("https://flags.example.test/custom/ufc"); - - client.fetch(endpoint, config(), null); - - assertNull(requests.get(0).header("DD-API-KEY")); - } - - @Test - void handlesGzipAndKeepsLastKnownGoodWhenNextResponseIsTruncated() throws Exception { - final byte[] compressedConfig = gzip(emptyConfig()); - final byte[] truncatedConfig = Arrays.copyOf(compressedConfig, compressedConfig.length - 8); - final AtomicInteger responses = new AtomicInteger(); - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - final boolean firstResponse = responses.getAndIncrement() == 0; - final byte[] body = - firstResponse ? compressedConfig : truncatedConfig; - api.getResponse() - .addHeader("Content-Encoding", "gzip") - .addHeader("ETag", firstResponse ? "etag-good" : "etag-bad") - .sendWithType("application/json", body); - })))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - endpoint, - config(), - 30_000, - new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, delay -> {}, () -> 1.0), - Executors.newSingleThreadScheduledExecutor()); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("Staging", configuration.getValue().environment.name); - assertEquals(4, responses.get()); - assertEquals("gzip", server.getLastRequest().getHeader("Accept-Encoding")); - assertEquals("etag-good", server.getLastRequest().getHeader("If-None-Match")); - } finally { - service.close(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void downloadsAndAppliesLargeUfcWithoutPayloadLimit() throws Exception { - final int flagCount = 5_000; - final String largeConfig = largeConfig(flagCount); - assertTrue(largeConfig.getBytes(UTF_8).length > 500_000); - - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> s.handlers(h -> h.get(CONFIG_PATH, api -> api.getResponse().send(largeConfig))))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - endpoint, - config(), - 30_000, - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient), - Executors.newSingleThreadScheduledExecutor()); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertTrue(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals(flagCount, configuration.getValue().flags.size()); - } finally { - service.close(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientAllowsMissingEtagAndEmptyResponseBody() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> - api.getResponse() - .status(HttpURLConnection.HTTP_NO_CONTENT) - .send())))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null); - - assertEquals(HttpURLConnection.HTTP_NO_CONTENT, response.status); - assertNull(response.etag); - assertEquals(0, response.body.length); - assertNull(server.getLastRequest().getHeader("If-None-Match")); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void httpClientAdapterPreservesMissingResponseBody() throws Exception { - final OkHttpClient httpClient = mock(OkHttpClient.class); - final Call call = mock(Call.class); - final HttpUrl endpoint = HttpUrl.get("http://localhost"); - final okhttp3.Request request = new okhttp3.Request.Builder().url(endpoint).build(); - final Response okHttpResponse = - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(HttpURLConnection.HTTP_OK) - .message("OK") - .build(); - when(httpClient.newCall(any())).thenReturn(call); - when(call.execute()).thenReturn(okHttpResponse); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(endpoint, config(), null); - - assertEquals(HttpURLConnection.HTTP_OK, response.status); - assertNull(response.etag); - assertNull(response.body); - } - - @Test - void realHttpClientCancellationInterruptsInFlightRequest() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - requestStarted.countDown(); - assertTrue(releaseRequest.await(1, TimeUnit.SECONDS)); - api.getResponse().send(emptyConfig()); - })))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future response = - runner.submit( - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - assertThrows( - IllegalStateException.class, - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - - client.cancel(); - - final ExecutionException failure = - assertThrows(ExecutionException.class, () -> response.get(1, TimeUnit.SECONDS)); - assertInstanceOf(IOException.class, failure.getCause()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientCancellationBeforeFetchPreventsRequest() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> h.get(CONFIG_PATH, api -> api.getResponse().send(emptyConfig()))))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - client.cancel(); - - assertThrows( - IOException.class, - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientTimesOutDelayedResponse() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - TimeUnit.MILLISECONDS.sleep(500); - api.getResponse().send(emptyConfig()); - })))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = OkHttpUtils.buildHttpClient(endpoint, 50); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, delay -> {}, () -> 1.0); - - try { - assertThrows(IOException.class, () -> client.fetch(endpoint, config(), null)); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void appliesAcceptedJsonApiUfcThroughGateway() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("2026-07-15T19:57:07.219869778Z", configuration.getValue().createdAt); - assertNull(configuration.getValue().format); - assertEquals("Staging", configuration.getValue().environment.name); - assertTrue(configuration.getValue().flags.isEmpty()); - assertNull(client.requests.get(0).etag); - } - - @Test - void customEndpointRequiresJsonApiAndKeepsLastKnownGoodOnRawUfc() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-good", emptyConfig()), - response(200, "etag-raw", emptyConfigAttributes())); - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://custom-backend/custom/ufc"); - final AgentlessConfigurationSource service = service(client, config); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("Staging", configuration.getValue().environment.name); - assertNull(client.requests.get(0).etag); - assertEquals("etag-good", client.requests.get(1).etag); - } - - @Test - void ignoresBlankEtag() throws Exception { - final FakeClient client = - new FakeClient(response(200, " ", emptyConfig()), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void successfulResponseWithoutEtagClearsPreviousEtag() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), - response(200, null, emptyConfig()), - response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertEquals("etag-a", client.requests.get(1).etag); - assertNull(client.requests.get(2).etag); - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - } - - @Test - void usesEtagAndSkipsDispatchOnUnchangedConfig() throws Exception { - final FakeClient client = - new FakeClient(response(200, "etag-a", emptyConfig()), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - verify(listener).accept(any(ServerConfiguration.class)); - assertEquals("etag-a", client.requests.get(1).etag); - } - - @Test - void coldNotModifiedDoesNotEstablishEtag() throws Exception { - final FakeClient client = - new FakeClient(response(304, "etag-cold", null), response(200, "etag-warm", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void failedGatewayDispatchDoesNotAdvanceEtag() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - final FeatureFlaggingGateway.ConfigListener failingListener = - configuration -> { - throw new IllegalStateException("listener rejected configuration"); - }; - FeatureFlaggingGateway.addConfigListener(failingListener); - - try { - assertThrows(IllegalStateException.class, service::pollOnce); - FeatureFlaggingGateway.removeConfigListener(failingListener); - - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - } finally { - FeatureFlaggingGateway.removeConfigListener(failingListener); - } - } - - @Test - void keepsLastKnownGoodOnAuthFailureAndMalformedPayload() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-good", emptyConfig()), - response(401, null, null), - response(200, null, "{not-json}"), - response(200, null, "{\"flags\":[]}")); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(any(ServerConfiguration.class)); - assertEquals("etag-good", client.requests.get(1).etag); - assertEquals("etag-good", client.requests.get(2).etag); - assertEquals("etag-good", client.requests.get(3).etag); - } - - @Test - void rejectsForbiddenNonOkMissingBodyAndNullConfiguration() throws Exception { - final FakeClient client = - new FakeClient( - response(403, null, null), - response(404, null, null), - response(600, null, null), - response(200, null, null), - response(200, null, "null"), - response(200, null, jsonApiResponse("other-configuration", emptyConfigAttributes())), - response(200, null, "{\"data\":null}"), - response( - 200, null, "{\"data\":{\"id\":\"1\",\"type\":\"universal-flag-configuration\"}}"), - response(200, null, emptyConfigAttributes())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verifyNoInteractions(listener); - } - - @Test - void warnsRateLimitedOnUnauthorizedAndForbidden() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final FakeClient client = new FakeClient(response(401, null, null), response(403, null, null)); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - - try { - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - HttpURLConnection.HTTP_UNAUTHORIZED); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - HttpURLConnection.HTTP_FORBIDDEN); - } finally { - service.close(); - } - } - - @Test - void retriesTimeoutBeforeApplyingConfig() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void retriesClientTimeoutAndRateLimitStatusBeforeApplyingConfig() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(408, null, null), - response(200, "etag-a", emptyConfig()), - response(429, null, null), - response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertEquals(4, requests.size()); - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - } - - @Test - void retriesServerErrorThenKeepsColdStateOnNotModified() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, delay -> {}, () -> 1.0, response(500, null, null), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(2, requests.size()); - verifyNoInteractions(listener); - } - - @Test - void warnsRateLimitedAfterRetryableFailuresAreExhausted() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(503, null, null), - response(503, null, null), - response(503, null, null)); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertFalse(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint failed after {} attempts with HTTP {}", 3, 503); - verifyNoInteractions(listener); - } finally { - service.close(); - } - } - - @Test - void warnsRateLimitedAfterIoFailuresAreExhausted() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final SocketTimeoutException finalFailure = - new SocketTimeoutException("slow HTTP configuration source"); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - new SocketTimeoutException("slow HTTP configuration source"), - finalFailure); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertFalse(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint request failed after {} attempts", - 3, - finalFailure); - verifyNoInteractions(listener); - } finally { - service.close(); - } - } - - @Test - void usesIntervalAwareRetryBackoff() throws Exception { - final List delays = new ArrayList<>(); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delays::add, - () -> 1.0, - response(503, null, null), - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(Arrays.asList(5_000L, 10_000L), delays); - assertEquals(3, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void retryPolicyRejectsNonIoAndInterruptedIoFailures() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean()); - - assertFalse(policy.shouldRetry(new IllegalStateException("not an I/O failure"))); - - Thread.currentThread().interrupt(); - try { - assertFalse(policy.shouldRetry(new IOException("interrupted"))); - } finally { - assertTrue(Thread.interrupted()); - } - } - - @Test - void retryPolicyRejectsMissingResponse() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean()); - - assertFalse(policy.shouldRetry((Response) null)); - } - - @Test - void cancelledRetryPolicyRejectsBackoff() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean(true)); - - assertThrows(InterruptedIOException.class, policy::backoff); - } - - @Test - void clampsAndJittersRetryBackoff() { - assertEquals(2_000, AgentlessConfigurationSource.retryDelayMillis(1_000, 1, 1.0)); - assertEquals(5_000, AgentlessConfigurationSource.retryDelayMillis(1_000, 2, 1.0)); - assertEquals(10_000, AgentlessConfigurationSource.retryDelayMillis(600_000, 1, 1.0)); - assertEquals(30_000, AgentlessConfigurationSource.retryDelayMillis(600_000, 2, 1.0)); - assertEquals(6_000, AgentlessConfigurationSource.retryDelayMillis(30_000, 1, 1.2)); - assertThrows( - IllegalArgumentException.class, - () -> AgentlessConfigurationSource.retryDelayMillis(30_000, 3, 1.0)); - } - - @Test - void rejectsOverlappingPolls() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newFixedThreadPool(2); - - try { - final Future first = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - final Future second = runner.submit(service::pollOnce); - - assertFalse(second.get(1, TimeUnit.SECONDS)); - releaseRequest.countDown(); - assertTrue(first.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.calls.get()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void initCompletesFirstPollAndCloseCancelsScheduledFuture() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 60_000, - client, - Executors.newSingleThreadScheduledExecutor()); - FeatureFlaggingGateway.addConfigListener(listener); - - service.init(); - assertEquals(1, client.calls.get()); - service.close(); - - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void initCompletesInitialRetryCycleBeforeReturning() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(500, null, null), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 60_000, - client, - Executors.newSingleThreadScheduledExecutor()); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - service.init(); - - assertEquals(2, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } finally { - service.close(); - } - } - - @Test - void repeatedInitStartsOnlyOnePoller() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.init(); - service.init(); - awaitCalls(client, 1); - service.close(); - - assertEquals(1, client.calls.get()); - } - - @Test - void scheduledPollContinuesAfterListenerRuntimeException() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 10, - client, - Executors.newSingleThreadScheduledExecutor()); - final AtomicInteger listenerCalls = new AtomicInteger(); - // Wait on the listener rather than on FakeClient.calls: the call counter is incremented when - // a request starts, so it reaches 2 before the second configuration has been applied. - final CountDownLatch listenerNotified = new CountDownLatch(2); - final FeatureFlaggingGateway.ConfigListener flakyListener = - configuration -> { - final int call = listenerCalls.incrementAndGet(); - listenerNotified.countDown(); - if (call == 1) { - throw new IllegalStateException("listener rejected first configuration"); - } - }; - FeatureFlaggingGateway.addConfigListener(flakyListener); - - try { - service.init(); - assertTrue(listenerNotified.await(5, TimeUnit.SECONDS)); - - assertEquals(2, listenerCalls.get()); - assertNull(client.requests.get(1).etag); - } finally { - service.close(); - FeatureFlaggingGateway.removeConfigListener(flakyListener); - } - } - - @Test - void closeCancelsInFlightRequestAndIgnoresLateSuccess() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - final Future poll = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - assertFalse(poll.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.cancelCalls.get()); - assertEquals(1, client.calls.get()); - verifyNoInteractions(listener); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void closeDuringIoFailurePreventsRetry() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = - new FakeClient(new SocketTimeoutException("slow HTTP configuration source")); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future poll = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - assertFalse(poll.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.calls.get()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void closeInterruptsRetryBackoff() throws Exception { - final CountDownLatch backoffStarted = new CountDownLatch(1); - final List requests = new ArrayList<>(); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> { - backoffStarted.countDown(); - TimeUnit.MINUTES.sleep(1); - }, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), config(), 30_000, client, executor); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future initialization = runner.submit(service::init); - assertTrue(backoffStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - initialization.get(1, TimeUnit.SECONDS); - assertTrue(executor.awaitTermination(1, TimeUnit.SECONDS)); - assertEquals(1, requests.size()); - } finally { - runner.shutdownNow(); - } - } - - @Test - void closePreventsFurtherPolls() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.close(); - - assertFalse(service.pollOnce()); - assertEquals(0, client.calls.get()); - } - - @Test - void initAfterCloseDoesNotSchedulePoll() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.close(); - service.init(); - - assertEquals(0, client.calls.get()); - } - - @Nested - class SystemTestParity { - @Test - void preservesSystemTestSourceTransitionsAndLastKnownGoodState() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(200, "etag-a", emptyConfig()), - response(304, "etag-must-not-replace-a", null), - response(509, null, null), - response(200, "etag-b", emptyConfig()), - response(200, "etag-c", "{not-json}"), - response(401, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - assertEquals("etag-a", requests.get(1).header("If-None-Match")); - assertEquals("etag-a", requests.get(2).header("If-None-Match")); - assertEquals("etag-a", requests.get(3).header("If-None-Match")); - assertEquals("etag-b", requests.get(4).header("If-None-Match")); - assertEquals("etag-b", requests.get(5).header("If-None-Match")); - } - } - - private static AgentlessConfigurationSource service( - final AgentlessConfigurationSource.UfcHttpClient client) { - return new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - Executors.newSingleThreadScheduledExecutor()); - } - - private static AgentlessConfigurationSource service( - final FakeClient client, final Config config) { - return new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config, - 30_000, - client, - Executors.newSingleThreadScheduledExecutor()); - } - - private static AgentlessConfigurationSource.OkHttpUfcHttpClient scriptedClient( - final List requests, - final AgentlessConfigurationSource.RetrySleeper retrySleeper, - final java.util.function.DoubleSupplier jitter, - final Object... outcomes) - throws IOException { - final BlockingQueue scriptedOutcomes = new LinkedBlockingQueue<>(); - scriptedOutcomes.addAll(Arrays.asList(outcomes)); - final OkHttpClient httpClient = mock(OkHttpClient.class); - when(httpClient.newCall(any())) - .thenAnswer( - invocation -> { - final okhttp3.Request request = invocation.getArgument(0); - requests.add(request); - final Object outcome = scriptedOutcomes.remove(); - final Call call = mock(Call.class); - when(call.execute()) - .thenAnswer( - ignored -> { - if (outcome instanceof IOException) { - throw (IOException) outcome; - } - return okHttpResponse( - request, (AgentlessConfigurationSource.UfcHttpResponse) outcome); - }); - return call; - }); - return new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, retrySleeper, jitter); - } - - private static AgentlessConfigurationSource.AgentlessRetryPolicy retryPolicy( - final AtomicBoolean cancelled) { - return new AgentlessConfigurationSource.AgentlessRetryPolicy( - cancelled, 30_000, delay -> {}, () -> 1.0); - } - - private static Response okHttpResponse( - final okhttp3.Request request, final AgentlessConfigurationSource.UfcHttpResponse response) { - final Response.Builder builder = - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(response.status) - .message(Integer.toString(response.status)); - if (response.etag != null) { - builder.header("ETag", response.etag); - } - if (response.body != null) { - builder.body(ResponseBody.create(MediaType.get("application/json"), response.body)); - } - return builder.build(); - } - - private static Config config() { - return config("datadoghq.com", ""); - } - - private static Config config(final String site, final String env) { - final Config config = mock(Config.class); - lenient() - .when(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()) - .thenReturn(30); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()) - .thenReturn(5); - lenient().when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()).thenReturn(null); - lenient().when(config.getApiKey()).thenReturn("test-api-key"); - lenient().when(config.getSite()).thenReturn(site); - lenient().when(config.getEnv()).thenReturn(env); - return config; - } - - private static AgentlessConfigurationSource.UfcHttpResponse response( - final int status, final String etag, final String body) { - return new AgentlessConfigurationSource.UfcHttpResponse( - status, etag, body == null ? null : body.getBytes(UTF_8)); - } - - private static String emptyConfig() { - return jsonApiResponse("universal-flag-configuration", emptyConfigAttributes()); - } - - private static String emptyConfigAttributes() { - return "{" - + "\"createdAt\":\"2026-07-15T19:57:07.219869778Z\"," - + "\"environment\":{\"name\":\"Staging\"}," - + "\"flags\":{}" - + "}"; - } - - private static String jsonApiResponse(final String type, final String attributes) { - return "{\"data\":{" - + "\"id\":\"1\"," - + "\"type\":\"" - + type - + "\"," - + "\"attributes\":" - + attributes - + "}}"; - } - - private static String largeConfig(final int flagCount) { - final StringBuilder json = - new StringBuilder( - "{\"createdAt\":\"2026-07-15T19:57:07.219869778Z\"," - + "\"environment\":{\"name\":\"Large Test\"}," - + "\"flags\":{"); - for (int index = 0; index < flagCount; index++) { - if (index > 0) { - json.append(','); - } - final String flagKey = "large-flag-" + index; - json.append('"') - .append(flagKey) - .append("\":{\"key\":\"") - .append(flagKey) - .append( - "\",\"enabled\":true,\"variationType\":\"STRING\"," - + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"on\"}}," - + "\"allocations\":[]}"); - } - return jsonApiResponse("universal-flag-configuration", json.append("}}").toString()); - } - - private static byte[] gzip(final String value) throws IOException { - final ByteArrayOutputStream output = new ByteArrayOutputStream(); - try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { - gzip.write(value.getBytes(UTF_8)); - } - return output.toByteArray(); - } - - private static void awaitCalls(final FakeClient client, final int count) throws Exception { - for (int i = 0; i < 100; i++) { - if (client.calls.get() >= count) { - return; - } - TimeUnit.MILLISECONDS.sleep(10); - } - assertEquals(count, client.calls.get()); - } - - private static final class FakeClient implements AgentlessConfigurationSource.UfcHttpClient { - private final AtomicInteger calls = new AtomicInteger(); - private final AtomicInteger cancelCalls = new AtomicInteger(); - private final List requests = new ArrayList<>(); - private final BlockingQueue responses = new LinkedBlockingQueue<>(); - private CountDownLatch requestStarted; - private CountDownLatch releaseRequest; - - private FakeClient(final Object... responses) { - for (final Object response : responses) { - this.responses.add(response); - } - } - - private void block(final CountDownLatch requestStarted, final CountDownLatch releaseRequest) { - this.requestStarted = requestStarted; - this.releaseRequest = releaseRequest; - } - - @Override - public AgentlessConfigurationSource.UfcHttpResponse fetch( - final HttpUrl endpoint, final Config config, final String etag) throws IOException { - calls.incrementAndGet(); - requests.add(new Request(etag)); - if (requestStarted != null) { - requestStarted.countDown(); - } - if (releaseRequest != null) { - await(releaseRequest); - } - final Object response = responses.remove(); - if (response instanceof IOException) { - throw (IOException) response; - } - if (response instanceof RuntimeException) { - throw (RuntimeException) response; - } - return (AgentlessConfigurationSource.UfcHttpResponse) response; - } - - @Override - public void cancel() { - cancelCalls.incrementAndGet(); - if (releaseRequest != null) { - releaseRequest.countDown(); - } - } - - private static void await(final CountDownLatch latch) throws IOException { - try { - if (!latch.await(1, TimeUnit.SECONDS)) { - throw new SocketTimeoutException("test request did not release"); - } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException(e); - } - } - } - - private static final class Request { - private final String etag; - - private Request(final String etag) { - this.etag = etag; - } - } -} 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..e781905b586 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 @@ -23,12 +23,12 @@ import com.squareup.moshi.Types; import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.remoteconfig.Capabilities; -import datadog.remoteconfig.ConfigurationDeserializer; import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.PollingRateHinter; import datadog.remoteconfig.Product; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.FeatureFlaggingRawBridge; import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import java.io.IOException; @@ -40,8 +40,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.tabletest.junit.TableTest; @@ -50,11 +48,13 @@ class RemoteConfigServiceImplTest { @Mock private FeatureFlaggingGateway.ConfigListener listener; - @Captor private ArgumentCaptor deserializerCaptor; + @Mock private FeatureFlaggingRawBridge.ConfigurationListener rawListener; @AfterEach void cleanup() { FeatureFlaggingGateway.removeConfigListener(listener); + FeatureFlaggingRawBridge.removeConfigurationListener(rawListener); + FeatureFlaggingRawBridge.dispatchConfiguration(null); } @Test @@ -63,17 +63,23 @@ void testNewConfigReceived() throws Exception { final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); when(sco.configurationPoller(any(Config.class))).thenReturn(poller); FeatureFlaggingGateway.addConfigListener(listener); + FeatureFlaggingRawBridge.addConfigurationListener(rawListener); final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(sco, Config.get()); service.init(); verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).addListener(eq(Product.FFE_FLAGS), deserializerCaptor.capture(), eq(service)); + verify(poller).addListener(Product.FFE_FLAGS, service); - final ServerConfiguration config = deserializer().deserialize(emptyConfig().getBytes(UTF_8)); - service.accept("test", config, mock(PollingRateHinter.class)); + final byte[] content = emptyConfig().getBytes(UTF_8); + service.accept("test", content, mock(PollingRateHinter.class)); verify(listener).accept(any(ServerConfiguration.class)); + verify(rawListener).accept(eq(content)); + + service.accept("test", null, mock(PollingRateHinter.class)); + verify(listener).accept(null); + verify(rawListener).accept(null); service.close(); @@ -308,11 +314,6 @@ void testParsingOnlyAdapter() { () -> adapter.toJson(mock(JsonWriter.class), new Date())); } - @SuppressWarnings("unchecked") - private ConfigurationDeserializer deserializer() { - return deserializerCaptor.getValue(); - } - private static ServerConfiguration deserialize(final String json) throws Exception { return UniversalFlagConfigParser.INSTANCE.deserialize(json.getBytes(UTF_8)); } 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 bca75fa7dee0718e7d18912d2990ebb610cba622 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 28 Jul 2026 22:51:16 -0600 Subject: [PATCH 2/4] fix(openfeature): preserve source compatibility settings --- .../trace/api/openfeature/Provider.java | 7 ++++ .../api/openfeature/ProviderRuntime.java | 3 ++ .../api/openfeature/RuntimeConfiguration.java | 39 +++++++++++++++---- .../trace/api/openfeature/ProviderTest.java | 30 ++++++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) 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 d5cdc8b8b12..cded6921ad7 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 @@ -281,6 +281,8 @@ public static class Options { private long timeout = 30; private TimeUnit unit = SECONDS; + Boolean providerEnabled; + Boolean legacyProviderEnabled; String configurationSource; String cdnBaseUrl; String apiKey; @@ -300,6 +302,11 @@ public Options configurationSource(final String configurationSource) { return this; } + public Options enabled(final boolean enabled) { + this.providerEnabled = enabled; + return this; + } + public Options cdnBaseUrl(final String cdnBaseUrl) { this.cdnBaseUrl = cdnBaseUrl; return this; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ProviderRuntime.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ProviderRuntime.java index 2f0e4d64497..91eb4c13e7b 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ProviderRuntime.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ProviderRuntime.java @@ -17,6 +17,9 @@ private ProviderRuntime() {} static Handle acquire( final RuntimeConfiguration configuration, final Consumer listener) { + if (configuration.source == RuntimeConfiguration.Source.DISABLED) { + throw new IllegalStateException("Datadog OpenFeature provider is disabled by configuration"); + } synchronized (LOCK) { if (shared == null) { shared = new SharedRuntime(configuration); diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RuntimeConfiguration.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RuntimeConfiguration.java index fd5475d0749..469e02123af 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RuntimeConfiguration.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/RuntimeConfiguration.java @@ -13,7 +13,8 @@ final class RuntimeConfiguration { enum Source { CDN, - REMOTE_CONFIG + REMOTE_CONFIG, + DISABLED } final Source source; @@ -25,13 +26,23 @@ private RuntimeConfiguration(final Source source, final HttpConfigurationOptions } static RuntimeConfiguration resolve(final Provider.Options options) { + final Boolean providerEnabled = + options.providerEnabled != null + ? options.providerEnabled + : booleanSetting("dd.feature.flags.enabled", "DD_FEATURE_FLAGS_ENABLED"); final String sourceValue = first( options.configurationSource, setting( "dd.feature.flags.configuration.source", "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE")); - final Source source = parseSource(sourceValue); - if (source == Source.REMOTE_CONFIG) { + final Boolean legacyProviderEnabled = + options.legacyProviderEnabled != null + ? options.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 RuntimeConfiguration(source, null); } @@ -72,11 +83,20 @@ static RuntimeConfiguration resolve(final Provider.Options options) { .build()); } - private static Source parseSource(final String value) { - if (value == null || value.trim().isEmpty()) { + private 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 = value.trim().toLowerCase(Locale.ROOT); + final String normalized = sourceValue.trim().toLowerCase(Locale.ROOT); if ("agentless".equals(normalized) || "cdn".equals(normalized)) { return Source.CDN; } @@ -84,7 +104,7 @@ private static Source parseSource(final String value) { return Source.REMOTE_CONFIG; } throw new IllegalArgumentException( - "Unsupported Feature Flagging configuration source: " + value); + "Unsupported Feature Flagging configuration source: " + sourceValue); } private static Duration seconds(final String value, final long defaultValue) { @@ -105,6 +125,11 @@ private static String setting(final String property, final String environment) { 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()) { diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java index 61eeda44890..1bbf65a64ee 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -50,6 +50,36 @@ void selectsCdnByDefaultAndRemoteConfigExplicitly() { .source); } + @Test + void preservesStableAndLegacySourceSelection() { + assertEquals( + RuntimeConfiguration.Source.DISABLED, + RuntimeConfiguration.resolve(new Provider.Options().enabled(false)).source); + + final Provider.Options legacyEnabled = new Provider.Options(); + legacyEnabled.legacyProviderEnabled = true; + assertEquals( + RuntimeConfiguration.Source.REMOTE_CONFIG, + RuntimeConfiguration.resolve(legacyEnabled).source); + + final Provider.Options legacyDisabled = new Provider.Options(); + legacyDisabled.legacyProviderEnabled = false; + assertEquals( + RuntimeConfiguration.Source.DISABLED, RuntimeConfiguration.resolve(legacyDisabled).source); + assertEquals( + RuntimeConfiguration.Source.CDN, + RuntimeConfiguration.resolve(legacyDisabled.configurationSource("agentless")).source); + } + + @Test + void disabledProviderDoesNotStartConfigurationSource() { + first = new Provider(new Provider.Options().enabled(false).initTimeout(10, MILLISECONDS)); + + final FatalError error = + assertThrows(FatalError.class, () -> first.initialize(new MutableContext())); + assertTrue(error.getMessage().contains("disabled by configuration")); + } + @Test void providerOwnsCdnLifecycleWithoutAgent() throws Exception { final AtomicInteger requests = new AtomicInteger(); From 8576219033788f228c94390f0f6908944c1680c8 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 28 Jul 2026 23:32:46 -0600 Subject: [PATCH 3/4] refactor(feature-flagging): separate agent runtime and telemetry --- dd-smoke-tests/openfeature/build.gradle | 2 +- .../build.gradle.kts | 13 +- .../gradle.lockfile | 3 +- .../ConfigurationSourceService.java | 0 .../datadog/featureflag/ExposureWriter.java | 0 .../featureflag/ExposureWriterImpl.java | 8 +- .../featureflag/RemoteConfigServiceImpl.java | 0 .../SpanEnrichmentInterceptor.java | 1 + .../featureflag/SpanEnrichmentStates.java | 1 + .../featureflag/SpanEnrichmentWriter.java | 1 + .../UniversalFlagConfigParser.java | 0 .../featureflag/ExposureWriterTests.java | 0 .../RemoteConfigServiceImplTest.java | 0 .../SpanEnrichmentInterceptorTest.java | 3 +- .../featureflag/SpanEnrichmentWriterTest.java | 21 +- .../feature-flagging-agent/build.gradle.kts | 2 +- .../openfeature/SpanEnrichmentHookTest.java | 2 +- .../datadog/featureflag/ExposureCache.java | 61 ----- .../datadog/featureflag/LRUExposureCache.java | 36 --- .../featureflag/LRUExposureCacheTest.java | 244 ------------------ .../build.gradle.kts | 17 ++ .../gradle.lockfile | 77 ++++++ .../telemetry/ExposureDeduplicationCache.java | 112 ++++++++ .../telemetry}/SpanEnrichmentAccumulator.java | 171 ++++++++---- .../internal/telemetry}/ULeb128Encoder.java | 2 +- .../ExposureDeduplicationCacheTest.java | 125 +++++++++ .../SpanEnrichmentAccumulatorTest.java | 48 +++- settings.gradle.kts | 3 +- 28 files changed, 522 insertions(+), 431 deletions(-) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/build.gradle.kts (60%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/gradle.lockfile (98%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java (100%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/main/java/com/datadog/featureflag/ExposureWriter.java (100%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java (95%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java (100%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java (98%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java (97%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java (99%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java (100%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/test/java/com/datadog/featureflag/ExposureWriterTests.java (100%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java (100%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java (98%) rename products/feature-flagging/{feature-flagging-lib => feature-flagging-agent-runtime}/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java (89%) delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java create mode 100644 products/feature-flagging/feature-flagging-telemetry/build.gradle.kts create mode 100644 products/feature-flagging/feature-flagging-telemetry/gradle.lockfile create mode 100644 products/feature-flagging/feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry/ExposureDeduplicationCache.java rename products/feature-flagging/{feature-flagging-lib/src/main/java/com/datadog/featureflag => feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry}/SpanEnrichmentAccumulator.java (62%) rename products/feature-flagging/{feature-flagging-lib/src/main/java/com/datadog/featureflag => feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry}/ULeb128Encoder.java (98%) create mode 100644 products/feature-flagging/feature-flagging-telemetry/src/test/java/datadog/openfeature/internal/telemetry/ExposureDeduplicationCacheTest.java rename products/feature-flagging/{feature-flagging-lib/src/test/java/com/datadog/featureflag => feature-flagging-telemetry/src/test/java/datadog/openfeature/internal/telemetry}/SpanEnrichmentAccumulatorTest.java (84%) diff --git a/dd-smoke-tests/openfeature/build.gradle b/dd-smoke-tests/openfeature/build.gradle index fb3a68a2638..58752d38c84 100644 --- a/dd-smoke-tests/openfeature/build.gradle +++ b/dd-smoke-tests/openfeature/build.gradle @@ -21,7 +21,7 @@ smokeTestApp { dependencies { testImplementation project(':dd-smoke-tests') - testImplementation project(':products:feature-flagging:feature-flagging-lib') + testImplementation project(':products:feature-flagging:feature-flagging-agent-runtime') } spotless { diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-agent-runtime/build.gradle.kts similarity index 60% rename from products/feature-flagging/feature-flagging-lib/build.gradle.kts rename to products/feature-flagging/feature-flagging-agent-runtime/build.gradle.kts index 425e217e822..c096ed11d44 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-agent-runtime/build.gradle.kts @@ -5,13 +5,7 @@ plugins { apply(from = "$rootDir/gradle/java.gradle") -description = "Feature flagging remote config and exposure handling" - -extra["excludedClassesCoverage"] = listOf( - // POJOs - "com.datadog.featureflag.ExposureCache.Key", - "com.datadog.featureflag.ExposureCache.Value" -) +description = "Java agent runtime for Feature Flagging configuration and telemetry" dependencies { api(libs.slf4j) @@ -20,13 +14,10 @@ dependencies { api(project(":communication")) implementation(project(":internal-api")) api(project(":products:feature-flagging:feature-flagging-bootstrap")) + implementation(project(":products:feature-flagging:feature-flagging-telemetry")) implementation(project(":utils:logging-utils")) api(project(":utils:queue-utils")) - compileOnly(project(":dd-trace-core")) // shading does not work with this one - // Platform JSON writer for the ffe_* tag values. - compileOnly(project(":components:json")) - testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) testImplementation(project(":utils:test-utils")) diff --git a/products/feature-flagging/feature-flagging-lib/gradle.lockfile b/products/feature-flagging/feature-flagging-agent-runtime/gradle.lockfile similarity index 98% rename from products/feature-flagging/feature-flagging-lib/gradle.lockfile rename to products/feature-flagging/feature-flagging-agent-runtime/gradle.lockfile index da561bffdff..dc46d334516 100644 --- a/products/feature-flagging/feature-flagging-lib/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-agent-runtime/gradle.lockfile @@ -1,7 +1,7 @@ # 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-lib:dependencies --write-locks +# To regenerate this file, run: ./gradlew :products:feature-flagging:feature-flagging-agent-runtime:dependencies --write-locks cafe.cryptography:curve25519-elisabeth:0.1.0=runtimeClasspath,testRuntimeClasspath cafe.cryptography:ed25519-elisabeth:0.1.0=runtimeClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath @@ -92,7 +92,6 @@ org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath org.junit.platform:junit-platform-runner:1.14.1=testRuntimeClasspath org.junit.platform:junit-platform-suite-api:1.14.1=testRuntimeClasspath org.junit.platform:junit-platform-suite-commons:1.14.1=testRuntimeClasspath -org.junit:junit-bom:5.14.0=spotbugs org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-core:4.4.0=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:4.4.0=testCompileClasspath,testRuntimeClasspath diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/ExposureWriter.java similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/ExposureWriter.java diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java similarity index 95% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java index eddcd520f27..5d24c5d1f48 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java @@ -11,6 +11,7 @@ import datadog.communication.BackendApi; import datadog.communication.BackendApiFactory; import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.openfeature.internal.telemetry.ExposureDeduplicationCache; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.exposure.ExposureEvent; @@ -105,7 +106,7 @@ private static class ExposureSerializingHandler implements Runnable { private BackendApi evp; private final Map context; - private final ExposureCache cache; + private final ExposureDeduplicationCache cache; private final List buffer = new ArrayList<>(); private final Runnable errorCallback; @@ -118,7 +119,7 @@ public ExposureSerializingHandler( final Map context, final Runnable errorCallback) { this.queue = queue; - this.cache = new LRUExposureCache(queue.capacity()); + this.cache = new ExposureDeduplicationCache(queue.capacity()); this.jsonAdapter = new Moshi.Builder().build().adapter(ExposuresRequest.class); this.backendApiFactory = backendApiFactory; this.context = context; @@ -166,7 +167,8 @@ private void consumeBatch() { /** Adds an element to the buffer taking care of duplicated exposures thanks to the LRU cache */ private boolean addToBuffer(final ExposureEvent event) { - if (cache.add(event)) { + if (cache.shouldEmit( + event.flag.key, event.subject.id, event.variant.key, event.allocation.key)) { buffer.add(event); return true; } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java similarity index 98% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java index a82e194e248..02de10e10c0 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java +++ b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java @@ -1,5 +1,6 @@ package com.datadog.featureflag; +import datadog.openfeature.internal.telemetry.SpanEnrichmentAccumulator; import datadog.trace.api.interceptor.MutableSpan; import datadog.trace.api.interceptor.TraceInterceptor; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java similarity index 97% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java index 7231466cd1b..ea6d76331b8 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java +++ b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java @@ -1,5 +1,6 @@ package com.datadog.featureflag; +import datadog.openfeature.internal.telemetry.SpanEnrichmentAccumulator; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import java.util.Map; import java.util.WeakHashMap; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java similarity index 99% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java index 428378b3055..55a3c0ebf82 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java +++ b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java @@ -1,5 +1,6 @@ package com.datadog.featureflag; +import datadog.openfeature.internal.telemetry.SpanEnrichmentAccumulator; import datadog.trace.api.GlobalTracer; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.SpanEnrichmentEvent; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java b/products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java b/products/feature-flagging/feature-flagging-agent-runtime/src/test/java/com/datadog/featureflag/ExposureWriterTests.java similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/test/java/com/datadog/featureflag/ExposureWriterTests.java diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-agent-runtime/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java b/products/feature-flagging/feature-flagging-agent-runtime/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java similarity index 98% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java index becbf4cd464..a380ac46de5 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java +++ b/products/feature-flagging/feature-flagging-agent-runtime/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import datadog.openfeature.internal.telemetry.SpanEnrichmentAccumulator; import datadog.trace.api.interceptor.MutableSpan; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import java.util.ArrayList; @@ -88,7 +89,7 @@ void partialFlushExcludingRootPreservesState() { verify(root, never()).setTag(anyString(), anyString()); final SpanEnrichmentAccumulator surviving = states.peek(root); assertNotNull(surviving, "partial flush must NOT remove the accumulator"); - assertTrue(surviving.serialIdsView().contains(100) && surviving.serialIdsView().contains(108)); + assertEquals("ZAg=", surviving.toSpanTags().get(SpanEnrichmentAccumulator.TAG_FLAGS_ENC)); // more evaluations, then the FINAL flush with the root present states.getOrCreate(root).addSerialId(128); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java b/products/feature-flagging/feature-flagging-agent-runtime/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java similarity index 89% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java rename to products/feature-flagging/feature-flagging-agent-runtime/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java index 419a9fef5b5..bef76fc5871 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java +++ b/products/feature-flagging/feature-flagging-agent-runtime/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import datadog.openfeature.internal.telemetry.SpanEnrichmentAccumulator; import datadog.trace.api.featureflag.SpanEnrichmentEvent; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import java.util.Collections; @@ -41,8 +42,10 @@ void serialIdWithDoLogAndTargetingKeyRecordsSerialAndSubject() { final SpanEnrichmentAccumulator state = writer.states().peek(root); assertNotNull(state); - assertTrue(state.serialIdsView().contains(42)); - assertEquals(1, state.subjectCount(), "doLog=true + targeting key => subject recorded"); + assertEquals("Kg==", state.toSpanTags().get(SpanEnrichmentAccumulator.TAG_FLAGS_ENC)); + assertNotNull( + state.toSpanTags().get(SpanEnrichmentAccumulator.TAG_SUBJECTS_ENC), + "doLog=true + targeting key => subject recorded"); } @Test @@ -52,8 +55,10 @@ void serialIdWithoutDoLogRecordsNoSubject() { writer.accept(SpanEnrichmentEvent.serialId(7, false, "user-1")); final SpanEnrichmentAccumulator state = writer.states().peek(root); - assertTrue(state.serialIdsView().contains(7)); - assertEquals(0, state.subjectCount(), "doLog=false must not record a subject"); + assertEquals("Bw==", state.toSpanTags().get(SpanEnrichmentAccumulator.TAG_FLAGS_ENC)); + assertNull( + state.toSpanTags().get(SpanEnrichmentAccumulator.TAG_SUBJECTS_ENC), + "doLog=false must not record a subject"); } @Test @@ -63,8 +68,10 @@ void serialIdWithNullTargetingKeyRecordsNoSubject() { writer.accept(SpanEnrichmentEvent.serialId(9, true, null)); final SpanEnrichmentAccumulator state = writer.states().peek(root); - assertTrue(state.serialIdsView().contains(9)); - assertEquals(0, state.subjectCount(), "no targeting key => no subject"); + assertEquals("CQ==", state.toSpanTags().get(SpanEnrichmentAccumulator.TAG_FLAGS_ENC)); + assertNull( + state.toSpanTags().get(SpanEnrichmentAccumulator.TAG_SUBJECTS_ENC), + "no targeting key => no subject"); } @Test @@ -75,7 +82,7 @@ void runtimeDefaultRecordsDefault() { final SpanEnrichmentAccumulator state = writer.states().peek(root); assertNotNull(state); - assertEquals(1, state.defaultCount()); + assertNotNull(state.toSpanTags().get(SpanEnrichmentAccumulator.TAG_RUNTIME_DEFAULTS)); } @Test diff --git a/products/feature-flagging/feature-flagging-agent/build.gradle.kts b/products/feature-flagging/feature-flagging-agent/build.gradle.kts index d2dc4baea2b..d2637fbbcc4 100644 --- a/products/feature-flagging/feature-flagging-agent/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-agent/build.gradle.kts @@ -15,7 +15,7 @@ description = "Feature flagging agent system" dependencies { api(libs.slf4j) - api(project(":products:feature-flagging:feature-flagging-lib")) + api(project(":products:feature-flagging:feature-flagging-agent-runtime")) api(project(":internal-api")) compileOnly(project(":products:feature-flagging:feature-flagging-config")) diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java index 2ce0931e116..01685425445 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java @@ -30,7 +30,7 @@ * Capture-side unit suite for APM feature-flag span enrichment. The hook dispatches a {@link * SpanEnrichmentEvent} onto {@link FeatureFlaggingGateway} and the agent-side write tier does the * accumulation, so these tests assert the dispatched events (not span tags — those are covered in - * {@code feature-flagging-lib}) plus the {@link Provider} gating. + * {@code feature-flagging-agent-runtime}) plus the {@link Provider} gating. */ class SpanEnrichmentHookTest { diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java deleted file mode 100644 index 6fdd06bece7..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.datadog.featureflag; - -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import java.util.Objects; - -public interface ExposureCache { - - boolean add(ExposureEvent event); - - Value get(Key key); - - int size(); - - final class Key { - public final String flag; - public final String subject; - - public Key(final ExposureEvent event) { - this.flag = event.flag == null ? null : event.flag.key; - this.subject = event.subject == null ? null : event.subject.id; - } - - @Override - public boolean equals(final Object o) { - if (o == null || getClass() != o.getClass()) { - return false; - } - final Key key = (Key) o; - return Objects.equals(flag, key.flag) && Objects.equals(subject, key.subject); - } - - @Override - public int hashCode() { - return Objects.hash(flag, subject); - } - } - - final class Value { - public final String variant; - public final String allocation; - - public Value(final ExposureEvent event) { - this.variant = event.variant == null ? null : event.variant.key; - this.allocation = event.allocation == null ? null : event.allocation.key; - } - - @Override - public boolean equals(final Object o) { - if (o == null || getClass() != o.getClass()) { - return false; - } - final Value value = (Value) o; - return Objects.equals(variant, value.variant) && Objects.equals(allocation, value.allocation); - } - - @Override - public int hashCode() { - return Objects.hash(variant, allocation); - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java deleted file mode 100644 index e21752cc529..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.datadog.featureflag; - -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.core.util.LRUCache; -import java.util.Map; - -/** - * This class is intentionally not thread-safe. Thread safety is ensured by the single-threaded - * access pattern managed by {@link ExposureWriterImpl.ExposureSerializingHandler}. - */ -public class LRUExposureCache implements ExposureCache { - - private final Map cache; - - public LRUExposureCache(final int capacity) { - cache = new LRUCache<>(capacity); - } - - @Override - public boolean add(final ExposureEvent event) { - final Key key = new Key(event); - final Value value = new Value(event); - final Value oldValue = cache.put(key, value); - return oldValue == null || !oldValue.equals(value); - } - - @Override - public Value get(final Key key) { - return cache.get(key); - } - - @Override - public int size() { - return cache.size(); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java deleted file mode 100644 index 43c4c2d40f0..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java +++ /dev/null @@ -1,244 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.Collections.emptyMap; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import datadog.trace.api.featureflag.exposure.Allocation; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.Flag; -import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.exposure.Variant; -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; - -class LRUExposureCacheTest { - - @Test - void testAddingElements() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - boolean added = cache.add(event); - - assertTrue(added); - assertEquals(1, cache.size()); - } - - @Test - void testAddingDuplicateEventsReturnsFalse() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - cache.add(event); - boolean duplicateAdded = cache.add(event); - - assertFalse(duplicateAdded); - assertEquals(1, cache.size()); - } - - @Test - void testAddingEventsWithSameKeyButDifferentDetailsUpdatesCache() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event1 = createEvent("flag", "subject", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag", "subject", "variant2", "allocation2"); - ExposureCache.Key key = new ExposureCache.Key(event1); - - boolean added1 = cache.add(event1); - boolean added2 = cache.add(event2); - ExposureCache.Value retrieved = cache.get(key); - - assertTrue(added1); - assertTrue(added2); - assertEquals(1, cache.size()); - assertEquals("variant2", retrieved.variant); - assertEquals("allocation2", retrieved.allocation); - } - - @Test - void testLruEvictionWhenCapacityExceeded() { - LRUExposureCache cache = new LRUExposureCache(2); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key3 = new ExposureCache.Key(event3); - - cache.add(event1); - cache.add(event2); - cache.add(event3); - - assertEquals(2, cache.size()); - assertNull(cache.get(key1)); // event1 should be evicted - assertNotNull(cache.get(key3)); // event3 should be present - assertEquals("variant3", cache.get(key3).variant); - assertEquals("allocation3", cache.get(key3).allocation); - } - - @Test - void testSingleCapacityCache() { - LRUExposureCache cache = new LRUExposureCache(1); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - - cache.add(event1); - cache.add(event2); - - assertEquals(1, cache.size()); - } - - @Test - void testZeroCapacityCache() { - LRUExposureCache cache = new LRUExposureCache(0); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - boolean added = cache.add(event); - - assertTrue(added); - assertEquals(0, cache.size()); - } - - @Test - void testEmptyCacheSize() { - LRUExposureCache cache = new LRUExposureCache(5); - - assertEquals(0, cache.size()); - } - - @Test - void testMultipleAdditionsWithSameFlagDifferentSubjects() { - LRUExposureCache cache = new LRUExposureCache(10); - List events = new ArrayList<>(); - for (int index = 0; index < 5; index++) { - events.add(createEvent("flag", "subject" + index, "variant", "allocation")); - } - - for (ExposureEvent event : events) { - assertTrue(cache.add(event)); - } - - assertEquals(5, cache.size()); - } - - @Test - void testMultipleAdditionsWithSameSubjectDifferentFlags() { - LRUExposureCache cache = new LRUExposureCache(10); - List events = new ArrayList<>(); - for (int index = 0; index < 5; index++) { - events.add(createEvent("flag" + index, "subject", "variant", "allocation")); - } - - for (ExposureEvent event : events) { - assertTrue(cache.add(event)); - } - - assertEquals(5, cache.size()); - } - - @Test - void testKeyEqualityWithNullValues() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event1 = - new ExposureEvent( - System.currentTimeMillis(), - new Allocation("allocation"), - new Flag(null), - new Variant("variant"), - new Subject(null, emptyMap())); - ExposureEvent event2 = - new ExposureEvent( - System.currentTimeMillis(), - new Allocation("allocation"), - new Flag(null), - new Variant("variant"), - new Subject(null, emptyMap())); - - cache.add(event1); - boolean duplicateAdded = cache.add(event2); - - assertFalse(duplicateAdded); - assertEquals(1, cache.size()); - } - - @Test - void testUpdatingExistingKeyMaintainsLruPosition() { - LRUExposureCache cache = new LRUExposureCache(3); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - ExposureEvent event1Updated = createEvent("flag1", "subject1", "variant2", "allocation2"); - ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key2 = new ExposureCache.Key(event2); - ExposureCache.Key key4 = new ExposureCache.Key(event4); - - cache.add(event1); - cache.add(event2); - cache.add(event3); - cache.add(event1Updated); // Updates event1, moves to most recent - cache.add(event4); // Should evict event2, not event1 - - assertEquals(3, cache.size()); - assertNotNull(cache.get(key1)); // event1 should be updated and present - assertEquals("variant2", cache.get(key1).variant); // verify it was updated - assertEquals("allocation2", cache.get(key1).allocation); - assertNull(cache.get(key2)); // event2 should be evicted - assertNotNull(cache.get(key4)); // event4 should be present - assertEquals("variant4", cache.get(key4).variant); - } - - @Test - void testDuplicateExposureKeepsSubjectHotInLruOrder() { - LRUExposureCache cache = new LRUExposureCache(3); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - // same key + same details as event1: will go through the "duplicate" path - ExposureEvent event1Duplicate = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); - - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key2 = new ExposureCache.Key(event2); - ExposureCache.Key key4 = new ExposureCache.Key(event4); - - // Fill cache - boolean added1 = cache.add(event1); - boolean added2 = cache.add(event2); - boolean added3 = cache.add(event3); - - // Duplicate exposure for subject1: should *not* change size, but *should* bump recency - boolean duplicateAdded = cache.add(event1Duplicate); - - // Now push over capacity: the least recently used *non-hot* entry (event2) should be evicted - boolean added4 = cache.add(event4); - - assertTrue(added1); - assertTrue(added2); - assertTrue(added3); - assertFalse(duplicateAdded); // dedup correctly - assertTrue(added4); - - assertEquals(3, cache.size()); - - assertNotNull(cache.get(key1)); // hot subject1 should still be present - assertNull(cache.get(key2)); // subject2 should be evicted - assertNotNull(cache.get(key4)); // newest subject4 should be present - - assertEquals("variant1", cache.get(key1).variant); - assertEquals("allocation1", cache.get(key1).allocation); - } - - private static ExposureEvent createEvent( - String flag, String subject, String variant, String allocation) { - return new ExposureEvent( - System.currentTimeMillis(), - new Allocation(allocation), - new Flag(flag), - new Variant(variant), - new Subject(subject, emptyMap())); - } -} diff --git a/products/feature-flagging/feature-flagging-telemetry/build.gradle.kts b/products/feature-flagging/feature-flagging-telemetry/build.gradle.kts new file mode 100644 index 00000000000..56eb7d149c7 --- /dev/null +++ b/products/feature-flagging/feature-flagging-telemetry/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + `java-library` +} + +apply(from = "$rootDir/gradle/java.gradle") + +description = "Agent-independent Feature Flagging telemetry state and encoding" + +extra["excludedClassesCoverage"] = listOf( + // Immutable cache key and value types + "datadog.openfeature.internal.telemetry.ExposureDeduplicationCache.Key", + "datadog.openfeature.internal.telemetry.ExposureDeduplicationCache.Value", +) + +dependencies { + testImplementation(libs.bundles.junit5) +} diff --git a/products/feature-flagging/feature-flagging-telemetry/gradle.lockfile b/products/feature-flagging/feature-flagging-telemetry/gradle.lockfile new file mode 100644 index 00000000000..1e49d32af89 --- /dev/null +++ b/products/feature-flagging/feature-flagging-telemetry/gradle.lockfile @@ -0,0 +1,77 @@ +# 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-telemetry: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.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,runtimeClasspath,spotbugsPlugins,testAnnotationProcessor diff --git a/products/feature-flagging/feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry/ExposureDeduplicationCache.java b/products/feature-flagging/feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry/ExposureDeduplicationCache.java new file mode 100644 index 00000000000..b6bf2098029 --- /dev/null +++ b/products/feature-flagging/feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry/ExposureDeduplicationCache.java @@ -0,0 +1,112 @@ +package datadog.openfeature.internal.telemetry; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Bounded least-recently-used cache for exposure deduplication. + * + *

The cache has no dependency on an exposure transport or Java agent model. Callers provide the + * four UFC identifiers that determine whether an exposure changed. + * + *

This class is intentionally not thread-safe. The owning telemetry writer must serialize + * access. + */ +public final class ExposureDeduplicationCache { + + private static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; + private static final float DEFAULT_LOAD_FACTOR = 0.75f; + + private final Map cache; + private final int capacity; + + public ExposureDeduplicationCache(final int capacity) { + this.capacity = capacity; + this.cache = new LinkedHashMap<>(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, true); + } + + /** + * Records an exposure. + * + * @return {@code true} when the flag and subject pair is new or its allocation changed. + */ + public boolean shouldEmit( + final String flagKey, + final String targetingKey, + final String variantKey, + final String allocationKey) { + final Key key = new Key(flagKey, targetingKey); + final Value value = new Value(variantKey, allocationKey); + final Value oldValue = cache.put(key, value); + if (cache.size() > capacity) { + final Iterator oldest = cache.keySet().iterator(); + oldest.next(); + oldest.remove(); + } + return oldValue == null || !oldValue.equals(value); + } + + Value getValue(final Key key) { + return cache.get(key); + } + + int size() { + return cache.size(); + } + + static final class Key { + final String flag; + final String subject; + + Key(final String flag, final String subject) { + this.flag = flag; + this.subject = subject; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Key)) { + return false; + } + final Key key = (Key) other; + return Objects.equals(flag, key.flag) && Objects.equals(subject, key.subject); + } + + @Override + public int hashCode() { + return Objects.hash(flag, subject); + } + } + + static final class Value { + final String variant; + final String allocation; + + Value(final String variant, final String allocation) { + this.variant = variant; + this.allocation = allocation; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Value)) { + return false; + } + final Value value = (Value) other; + return Objects.equals(variant, value.variant) && Objects.equals(allocation, value.allocation); + } + + @Override + public int hashCode() { + return Objects.hash(variant, allocation); + } + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentAccumulator.java b/products/feature-flagging/feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry/SpanEnrichmentAccumulator.java similarity index 62% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentAccumulator.java rename to products/feature-flagging/feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry/SpanEnrichmentAccumulator.java index a14274432b0..3f1add4da7e 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentAccumulator.java +++ b/products/feature-flagging/feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry/SpanEnrichmentAccumulator.java @@ -1,6 +1,5 @@ -package com.datadog.featureflag; +package datadog.openfeature.internal.telemetry; -import datadog.json.JsonWriter; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -14,11 +13,8 @@ * shapes are FROZEN against the Node reference ({@code dd-trace-js#8343}) — see {@link * ULeb128Encoder}. * - *

Instances are created lazily and held in a {@link SpanEnrichmentStates} store, keyed by the - * local-root span object. The agent-side {@link SpanEnrichmentWriter} writes (from the flag-eval - * seam); the write interceptor ({@link SpanEnrichmentInterceptor}) reads and clears. When the - * span-enrichment gate is off, no seam events are dispatched, so no store and no accumulator are - * ever created and there is no idle per-span overhead. + *

This class has no tracer or Java agent dependency. An attached-agent adapter can associate an + * instance with a local-root span. A future agentless adapter can use the same state and encoding. * *

Runtime-default values arrive already unwrapped to native Java types (the capture side unwraps * any OpenFeature {@code Value} before crossing the seam), so this class has no OpenFeature @@ -32,17 +28,17 @@ *

  • {@code ffe_runtime_defaults} — a JSON object string {@code {"": "", ...}} * */ -final class SpanEnrichmentAccumulator { +public final class SpanEnrichmentAccumulator { - static final int MAX_SERIAL_IDS = 200; - static final int MAX_SUBJECTS = 10; - static final int MAX_EXPERIMENTS_PER_SUBJECT = 20; - static final int MAX_DEFAULTS = 5; - static final int MAX_DEFAULT_VALUE_LENGTH = 64; + public static final int MAX_SERIAL_IDS = 200; + public static final int MAX_SUBJECTS = 10; + public static final int MAX_EXPERIMENTS_PER_SUBJECT = 20; + public static final int MAX_DEFAULTS = 5; + public static final int MAX_DEFAULT_VALUE_LENGTH = 64; - static final String TAG_FLAGS_ENC = "ffe_flags_enc"; - static final String TAG_SUBJECTS_ENC = "ffe_subjects_enc"; - static final String TAG_RUNTIME_DEFAULTS = "ffe_runtime_defaults"; + public static final String TAG_FLAGS_ENC = "ffe_flags_enc"; + public static final String TAG_SUBJECTS_ENC = "ffe_subjects_enc"; + public static final String TAG_RUNTIME_DEFAULTS = "ffe_runtime_defaults"; // dedupe is structural (a Set); sorted for deterministic encoding. private final TreeSet serialIds = new TreeSet<>(); @@ -52,7 +48,7 @@ final class SpanEnrichmentAccumulator { private final Map defaults = new LinkedHashMap<>(); /** Adds a serial id, dropping silently once {@link #MAX_SERIAL_IDS} is reached. */ - synchronized void addSerialId(final int id) { + public synchronized void addSerialId(final int id) { if (serialIds.size() >= MAX_SERIAL_IDS && !serialIds.contains(id)) { return; } @@ -64,7 +60,7 @@ synchronized void addSerialId(final int id) { * The targeting key is SHA-256-hashed before storage. Enforces both the subject cap ({@link * #MAX_SUBJECTS}) and the per-subject experiment cap ({@link #MAX_EXPERIMENTS_PER_SUBJECT}). */ - synchronized void addSubject(final String targetingKey, final int id) { + public synchronized void addSubject(final String targetingKey, final int id) { if (targetingKey == null) { return; } @@ -90,7 +86,7 @@ synchronized void addSubject(final String targetingKey, final int id) { * are serialized to JSON (NOT {@code toString()}); the result is truncated to {@link * #MAX_DEFAULT_VALUE_LENGTH}. */ - synchronized void addDefault(final String flagKey, final Object value) { + public synchronized void addDefault(final String flagKey, final Object value) { if (flagKey == null) { return; } @@ -111,7 +107,7 @@ synchronized void addDefault(final String flagKey, final Object value) { * @return true when there is at least one serial id or runtime default to write. Subjects are not * checked because a subject is never recorded without its serial id. */ - synchronized boolean hasData() { + public synchronized boolean hasData() { return !serialIds.isEmpty() || !defaults.isEmpty(); } @@ -121,13 +117,10 @@ synchronized boolean hasData() { * @return a map of tag name to tag value (a subset of {@code ffe_flags_enc}, {@code * ffe_subjects_enc}, {@code ffe_runtime_defaults}) */ - synchronized Map toSpanTags() { + public synchronized Map toSpanTags() { final Map tags = new LinkedHashMap<>(); if (!serialIds.isEmpty()) { - final String encoded = ULeb128Encoder.encodeDeltaVarint(serialIds); - if (!encoded.isEmpty()) { - tags.put(TAG_FLAGS_ENC, encoded); - } + tags.put(TAG_FLAGS_ENC, ULeb128Encoder.encodeDeltaVarint(serialIds)); } if (!subjects.isEmpty()) { final Map encodedSubjects = new LinkedHashMap<>(); @@ -181,66 +174,136 @@ static String utf8SafeTruncate(final String value, final int maxChars) { } /** - * Serializes a String->String map to a compact JSON object string using the platform {@link - * JsonWriter}. + * Serializes a String->String map to a compact JSON object string. * - *

    Consumers of these tags parse them as JSON (the backend enricher via Jackson, the parametric - * system-tests via {@code json.loads}), so the writer's escaping (e.g. {@code /} → {@code \/}, - * non-ASCII → {@code \\uXXXX}) is round-trip-equivalent and byte-parity with the JS reference is - * not required. + *

    The local encoder keeps this module independent from agent and provider JSON libraries. */ static String toJsonObject(final Map map) { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject(); - for (final Map.Entry entry : map.entrySet()) { - writer.name(entry.getKey()).value(entry.getValue()); + final StringBuilder json = new StringBuilder(); + json.append('{'); + boolean first = true; + for (final Map.Entry entry : map.entrySet()) { + if (!first) { + json.append(','); } - writer.endObject(); - return writer.toString(); + first = false; + appendJsonString(json, entry.getKey()); + json.append(':'); + appendJsonString(json, entry.getValue()); } + return json.append('}').toString(); } private static String toJsonValue(final Object value) { - try (JsonWriter writer = new JsonWriter()) { - writeJsonValue(writer, value); - return writer.toString(); - } + final StringBuilder json = new StringBuilder(); + appendJsonValue(json, value); + return json.toString(); } @SuppressWarnings("unchecked") - private static void writeJsonValue(final JsonWriter writer, final Object value) { + private static void appendJsonValue(final StringBuilder json, final Object value) { // Callers pass values already unwrapped to native form by the capture side, so no OpenFeature // Value ever reaches here. if (value == null) { - writer.nullValue(); + json.append("null"); } else if (value instanceof Map) { - writer.beginObject(); + json.append('{'); + boolean first = true; for (final Map.Entry entry : ((Map) value).entrySet()) { - writer.name(String.valueOf(entry.getKey())); - writeJsonValue(writer, entry.getValue()); + if (!first) { + json.append(','); + } + first = false; + appendJsonString(json, String.valueOf(entry.getKey())); + json.append(':'); + appendJsonValue(json, entry.getValue()); } - writer.endObject(); + json.append('}'); } else if (value instanceof Iterable) { - writer.beginArray(); + json.append('['); + boolean first = true; for (final Object element : (Iterable) value) { - writeJsonValue(writer, element); + if (!first) { + json.append(','); + } + first = false; + appendJsonValue(json, element); } - writer.endArray(); + json.append(']'); } else if (value instanceof Boolean) { - writer.value((Boolean) value); + json.append(value); } else if (value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte) { - writer.value(((Number) value).longValue()); + json.append(((Number) value).longValue()); } else if (value instanceof Number) { - writer.value(((Number) value).doubleValue()); + final double number = ((Number) value).doubleValue(); + if (Double.isNaN(number)) { + json.append("null"); + } else { + json.append(number); + } } else { // CharSequence / Character / anything else → string form. - writer.value(value.toString()); + appendJsonString(json, value.toString()); } } + private static void appendJsonString(final StringBuilder json, final String value) { + json.append('"'); + for (int index = 0; index < value.length(); index++) { + final char character = value.charAt(index); + if (character > 127) { + appendUnicodeEscape(json, character); + continue; + } + switch (character) { + case '"': + case '\\': + case '/': + json.append('\\').append(character); + break; + case '\b': + json.append("\\b"); + break; + case '\f': + json.append("\\f"); + break; + case '\n': + json.append("\\n"); + break; + case '\r': + json.append("\\r"); + break; + case '\t': + json.append("\\t"); + break; + default: + if (character < 0x20) { + appendUnicodeEscape(json, character); + } else { + json.append(character); + } + break; + } + } + json.append('"'); + } + + private static void appendUnicodeEscape(final StringBuilder json, final char character) { + json.append("\\u") + .append(HEX_DIGITS[(character >>> 12) & 0xF]) + .append(HEX_DIGITS[(character >>> 8) & 0xF]) + .append(HEX_DIGITS[(character >>> 4) & 0xF]) + .append(HEX_DIGITS[character & 0xF]); + } + + private static final char[] HEX_DIGITS = { + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' + }; + // ---- test-only accessors ---- synchronized Set serialIdsView() { diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ULeb128Encoder.java b/products/feature-flagging/feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry/ULeb128Encoder.java similarity index 98% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ULeb128Encoder.java rename to products/feature-flagging/feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry/ULeb128Encoder.java index 7566cf8698d..9bd95ea4d92 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ULeb128Encoder.java +++ b/products/feature-flagging/feature-flagging-telemetry/src/main/java/datadog/openfeature/internal/telemetry/ULeb128Encoder.java @@ -1,4 +1,4 @@ -package com.datadog.featureflag; +package datadog.openfeature.internal.telemetry; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; diff --git a/products/feature-flagging/feature-flagging-telemetry/src/test/java/datadog/openfeature/internal/telemetry/ExposureDeduplicationCacheTest.java b/products/feature-flagging/feature-flagging-telemetry/src/test/java/datadog/openfeature/internal/telemetry/ExposureDeduplicationCacheTest.java new file mode 100644 index 00000000000..b481bfa2e97 --- /dev/null +++ b/products/feature-flagging/feature-flagging-telemetry/src/test/java/datadog/openfeature/internal/telemetry/ExposureDeduplicationCacheTest.java @@ -0,0 +1,125 @@ +package datadog.openfeature.internal.telemetry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ExposureDeduplicationCacheTest { + + @Test + void emitsNewExposure() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + + assertTrue(cache.shouldEmit("flag", "subject", "variant", "allocation")); + assertEquals(1, cache.size()); + } + + @Test + void suppressesDuplicateExposure() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + + cache.shouldEmit("flag", "subject", "variant", "allocation"); + + assertFalse(cache.shouldEmit("flag", "subject", "variant", "allocation")); + assertEquals(1, cache.size()); + } + + @Test + void emitsChangedVariantOrAllocation() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + final ExposureDeduplicationCache.Key key = + new ExposureDeduplicationCache.Key("flag", "subject"); + + assertTrue(cache.shouldEmit("flag", "subject", "variant1", "allocation1")); + assertTrue(cache.shouldEmit("flag", "subject", "variant2", "allocation2")); + assertEquals(1, cache.size()); + assertEquals("variant2", cache.getValue(key).variant); + assertEquals("allocation2", cache.getValue(key).allocation); + } + + @Test + void evictsLeastRecentlyUsedExposure() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(2); + final ExposureDeduplicationCache.Key first = + new ExposureDeduplicationCache.Key("flag1", "subject1"); + final ExposureDeduplicationCache.Key third = + new ExposureDeduplicationCache.Key("flag3", "subject3"); + + cache.shouldEmit("flag1", "subject1", "variant1", "allocation1"); + cache.shouldEmit("flag2", "subject2", "variant2", "allocation2"); + cache.shouldEmit("flag3", "subject3", "variant3", "allocation3"); + + assertEquals(2, cache.size()); + assertNull(cache.getValue(first)); + assertNotNull(cache.getValue(third)); + } + + @Test + void supportsZeroCapacity() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(0); + + assertTrue(cache.shouldEmit("flag", "subject", "variant", "allocation")); + assertEquals(0, cache.size()); + } + + @Test + void distinguishesFlagsAndSubjects() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(10); + + for (int index = 0; index < 5; index++) { + assertTrue(cache.shouldEmit("flag", "subject" + index, "variant", "allocation")); + assertTrue(cache.shouldEmit("flag" + index, "subject", "variant", "allocation")); + } + + assertEquals(10, cache.size()); + } + + @Test + void supportsNullKeys() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + + assertTrue(cache.shouldEmit(null, null, "variant", "allocation")); + assertFalse(cache.shouldEmit(null, null, "variant", "allocation")); + assertEquals(1, cache.size()); + } + + @Test + void updateMovesExposureToMostRecentPosition() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(3); + final ExposureDeduplicationCache.Key first = + new ExposureDeduplicationCache.Key("flag1", "subject1"); + final ExposureDeduplicationCache.Key second = + new ExposureDeduplicationCache.Key("flag2", "subject2"); + + cache.shouldEmit("flag1", "subject1", "variant1", "allocation1"); + cache.shouldEmit("flag2", "subject2", "variant2", "allocation2"); + cache.shouldEmit("flag3", "subject3", "variant3", "allocation3"); + cache.shouldEmit("flag1", "subject1", "variant4", "allocation4"); + cache.shouldEmit("flag4", "subject4", "variant4", "allocation4"); + + assertNotNull(cache.getValue(first)); + assertNull(cache.getValue(second)); + } + + @Test + void duplicateMovesExposureToMostRecentPosition() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(3); + final ExposureDeduplicationCache.Key first = + new ExposureDeduplicationCache.Key("flag1", "subject1"); + final ExposureDeduplicationCache.Key second = + new ExposureDeduplicationCache.Key("flag2", "subject2"); + + cache.shouldEmit("flag1", "subject1", "variant1", "allocation1"); + cache.shouldEmit("flag2", "subject2", "variant2", "allocation2"); + cache.shouldEmit("flag3", "subject3", "variant3", "allocation3"); + assertFalse(cache.shouldEmit("flag1", "subject1", "variant1", "allocation1")); + cache.shouldEmit("flag4", "subject4", "variant4", "allocation4"); + + assertNotNull(cache.getValue(first)); + assertNull(cache.getValue(second)); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentAccumulatorTest.java b/products/feature-flagging/feature-flagging-telemetry/src/test/java/datadog/openfeature/internal/telemetry/SpanEnrichmentAccumulatorTest.java similarity index 84% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentAccumulatorTest.java rename to products/feature-flagging/feature-flagging-telemetry/src/test/java/datadog/openfeature/internal/telemetry/SpanEnrichmentAccumulatorTest.java index ce6df50cfc0..754e68d9559 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentAccumulatorTest.java +++ b/products/feature-flagging/feature-flagging-telemetry/src/test/java/datadog/openfeature/internal/telemetry/SpanEnrichmentAccumulatorTest.java @@ -1,4 +1,4 @@ -package com.datadog.featureflag; +package datadog.openfeature.internal.telemetry; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -59,6 +59,10 @@ void codecGoldenVectorAndRoundTrip() { withDup.add(100); assertEquals( encoded, ULeb128Encoder.encodeDeltaVarint(withDup), "duplicates do not change bytes"); + assertEquals( + "gAE=", + ULeb128Encoder.encodeDeltaVarint(Collections.singleton(128)), + "multi-byte varints must retain their continuation bit"); } @Test @@ -141,12 +145,10 @@ void runtimeDefaultJsonStringifyAndTruncation() { } @Test - void jsonUsesPlatformWriterEscaping() { - // The platform JsonWriter escapes '/' as backslash-slash and non-ASCII as a backslash-u escape. - // That differs from the JS reference bytes but is round-trip-equivalent: all consumers - // JSON-parse these tags (backend Jackson, system-tests json.loads), so byte-parity is not - // required. '/' matters in practice because ffe_subjects_enc values are base64 (may contain - // '/'). + void jsonPreservesPlatformWriterEscaping() { + // The local encoder preserves the platform JsonWriter output after removing that dependency. + // All consumers JSON-parse these tags, but byte parity prevents an extraction-only behavior + // change. '/' matters in practice because ffe_subjects_enc values are base64. assertEquals( "{\"h\":\"a\\/b\"}", SpanEnrichmentAccumulator.toJsonObject(Collections.singletonMap("h", "a/b"))); @@ -220,6 +222,11 @@ void stringifyDefaultCoversAllNativeShapes() { // Character scalar (CharSequence/Character branch). assertEquals("x", SpanEnrichmentAccumulator.stringifyDefault('x')); + assertEquals("42", SpanEnrichmentAccumulator.stringifyDefault(42)); + assertEquals("true", SpanEnrichmentAccumulator.stringifyDefault(true)); + assertEquals( + "[null]", + SpanEnrichmentAccumulator.stringifyDefault(Collections.singletonList(Double.NaN))); } @Test @@ -244,4 +251,31 @@ void noDataWhenEmpty() { final Map tags = acc.toSpanTags(); assertTrue(tags.isEmpty()); } + + @Test + void defaultsHandleIgnoredKeysAndDefaultsOnlyState() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + + acc.addDefault(null, "ignored"); + assertFalse(acc.hasData()); + + acc.addDefault("flag", "value"); + assertTrue(acc.hasData()); + assertEquals( + Collections.singletonMap( + SpanEnrichmentAccumulator.TAG_RUNTIME_DEFAULTS, "{\"flag\":\"value\"}"), + acc.toSpanTags()); + } + + @Test + void jsonEncoderCoversSeparatorsAndAsciiEscapes() { + final Map values = new java.util.LinkedHashMap<>(); + values.put("first", "\"\\/\b\f\n\r\t\u0001"); + values.put("second", "plain"); + + assertEquals( + "{\"first\":\"\\\"\\\\\\/\\b\\f\\n\\r\\t\\u0001\",\"second\":\"plain\"}", + SpanEnrichmentAccumulator.toJsonObject(values)); + assertEquals("short", SpanEnrichmentAccumulator.utf8SafeTruncate("short", 64)); + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index cc9ca3d886d..9846a0104b4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -159,12 +159,13 @@ include(":dd-java-agent:agent-aiguard") // Feature Flagging include( ":products:feature-flagging:feature-flagging-agent", + ":products:feature-flagging:feature-flagging-agent-runtime", ":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" + ":products:feature-flagging:feature-flagging-telemetry" ) // misc From 8bfa6f072a97ac8a7d4977c42e04fe8d63652e25 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 29 Jul 2026 00:32:22 -0600 Subject: [PATCH 4/4] fix(feature-flagging): handle wrapped HTTP cancellation --- .../openfeature/internal/http/CdnConfigurationSource.java | 3 +++ 1 file changed, 3 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 adf16d6d992..2d2e8b6f084 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 @@ -277,6 +277,9 @@ public TransportResponse fetch( 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; }