From c0e500567258db11acd6e2ca53a8d8a96776b049 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 21 Aug 2026 00:22:33 +0000 Subject: [PATCH 1/2] Bundle standalone runtime in dd-openfeature --- .../feature-flagging-api/README.md | 12 +- .../feature-flagging-api/build.gradle.kts | 72 ++++++++- .../feature-flagging-api/gradle.lockfile | 32 +++- .../trace/api/openfeature/DDEvaluator.java | 55 ++++++- .../StandalonePublishedJarTest.java | 127 +++++++++++++++ .../feature-flagging-lib/build.gradle.kts | 1 - .../featureflag/ExposureWriterImpl.java | 11 ++ .../FeatureFlagBackendApiFactory.java | 33 +++- .../featureflag/FlagEvaluationWriterImpl.java | 12 ++ .../datadog/featureflag/LRUExposureCache.java | 10 +- .../StandaloneFeatureFlaggingSystem.java | 144 ++++++++++++++++++ .../FeatureFlagBackendApiFactoryTest.java | 18 +++ .../StandaloneFeatureFlaggingSystemTest.java | 85 +++++++++++ 13 files changed, 595 insertions(+), 17 deletions(-) create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandalonePublishedJarTest.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystem.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystemTest.java diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 74bbccca2e2..32648b8b76b 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -85,8 +85,12 @@ 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 +- `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless` enables the provider's bundled standalone + runtime when no Datadog Java agent is present. The provider polls UFC configuration and sends + Feature Flagging events directly to Datadog; set `DD_API_KEY` for direct event delivery. When the + Datadog Java agent is present, the provider prefers the agent-owned runtime and transport instead + of starting a second poller. +- 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 @@ -97,7 +101,7 @@ OTEL_EXPORTER_OTLP_PROTOCOL=grpc 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; +- `remote_config` uses the existing Agent Remote Configuration path and therefore requires the + Datadog Java agent. `offline` is reserved for startup-provided UFC bytes; until those bytes are implemented, no network source starts and evaluations use defaults. diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index af7012b8cc1..76f8c585844 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -4,6 +4,7 @@ import groovy.lang.Closure plugins { `java-library` idea + id("com.gradleup.shadow") id("dd-trace-java.module.distributable.api") id("me.champeau.jmh") } @@ -39,12 +40,23 @@ java { } dependencies { + modules { + module("com.squareup.okio:okio") { + replacedBy("com.datadoghq.okio:okio") + } + } + api("dev.openfeature:sdk:1.20.1") - compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap")) - compileOnly(project(":products:feature-flagging:feature-flagging-config")) - compileOnly(project(":utils:config-utils")) + implementation(project(":products:feature-flagging:feature-flagging-bootstrap")) + implementation(project(":products:feature-flagging:feature-flagging-config")) + implementation(project(":products:feature-flagging:feature-flagging-lib")) + implementation(project(":utils:config-utils")) compileOnly("io.opentelemetry:opentelemetry-api:1.47.0") + // OpenFeature SDK classes retain @lombok.Generated in their bytecode. Supplying the annotation + // on the analysis classpath keeps SpotBugs from treating that optional SDK build detail as a + // missing class; Lombok is neither bundled nor published as a dependency. + compileOnly("org.projectlombok:lombok:1.18.38") testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) testImplementation(project(":utils:config-utils")) @@ -61,6 +73,60 @@ dependencies { jmhImplementation(project(":utils:config-utils")) } +tasks.jar { + destinationDirectory = layout.buildDirectory.dir("libs-unbundled") + archiveClassifier = "unbundled" +} + +tasks.named("shadowJar") { + archiveClassifier = "" + + dependencies { + exclude(dependency("dev.openfeature:sdk:.*")) + exclude(dependency("io.opentelemetry:.*:.*")) + exclude(dependency("org.slf4j:.*:.*")) + // These are optional agent capabilities reachable from the shared Config/communication + // modules but not from standalone HTTP polling or direct EVP delivery. + exclude(dependency("cafe.cryptography:.*:.*")) + exclude(dependency("com.datadoghq:java-dogstatsd-client:.*")) + exclude(dependency("com.datadoghq:sketches-java:.*")) + exclude(dependency("com.github.jnr:.*:.*")) + exclude(dependency("org.ow2.asm:.*:.*")) + } + + relocate("com.datadog.featureflag.", "datadog.openfeature.internal.featureflag.") + relocate("com.squareup.", "datadog.openfeature.internal.com.squareup.") + relocate("okhttp3.", "datadog.openfeature.internal.okhttp3.") + relocate("okio.", "datadog.openfeature.internal.okio.") + relocate("org.jctools.", "datadog.openfeature.internal.org.jctools.") + relocate("datadog.", "datadog.openfeature.internal.datadog.") { + exclude("datadog.trace.api.featureflag.*") + exclude("datadog.trace.api.openfeature.*") + } + + // Keep the Feature Flagging implementation because DDEvaluator loads its standalone entrypoint + // reflectively. Minimize the rest of the agent dependency graph to the classes that runtime + // actually reaches instead of publishing unrelated agent products in dd-openfeature. + minimize { + exclude(project(":products:feature-flagging:feature-flagging-lib")) + } + + duplicatesStrategy = DuplicatesStrategy.FAIL + exclude("**/META-INF/maven/**/pom.xml") + exclude("com/squareup/moshi/_MoshiKotlin*") + exclude("META-INF/proguard/") + exclude("META-INF/*.kotlin_module") +} + +tasks.test { + dependsOn(tasks.named("shadowJar")) + doFirst { + val shadowJar = + tasks.named("shadowJar").get() + systemProperty("datadog.test.dd-openfeature.jar", shadowJar.archiveFile.get().asFile.absolutePath) + } +} + jmh { jmhVersion = libs.versions.jmh.get() duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE diff --git a/products/feature-flagging/feature-flagging-api/gradle.lockfile b/products/feature-flagging/feature-flagging-api/gradle.lockfile index d31e4501fe5..4bb9dfab995 100644 --- a/products/feature-flagging/feature-flagging-api/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-api/gradle.lockfile @@ -2,17 +2,31 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :products:feature-flagging:feature-flagging-api:dependencies --write-locks +cafe.cryptography:curve25519-elisabeth:0.1.0=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +cafe.cryptography:ed25519-elisabeth:0.1.0=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq.okhttp3:okhttp:3.12.15=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq.okio:okio:1.17.6=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-javac-plugin-client:0.2.2=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:java-dogstatsd-client:4.4.5=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.datadoghq:sketches-java:0.8.3=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.jnr:jffi:1.3.15=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-a64asm:1.0.0=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-constants:0.10.4=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-enxio:0.32.20=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-ffi:2.2.19=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-posix:3.1.22=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-unixsocket:0.38.25=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.github.jnr:jnr-x86asm:1.0.2=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath com.github.spotbugs:spotbugs-annotations:4.10.3=compileClasspath,jmhCompileClasspath,spotbugs com.github.spotbugs:spotbugs:4.10.3=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.code.findbugs:jsr305:3.0.2=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.google.code.gson:gson:2.14.0=spotbugs com.google.errorprone:error_prone_annotations:2.48.0=spotbugs -com.squareup.moshi:moshi:1.11.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.squareup.okio:okio:1.17.5=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.moshi:moshi:1.11.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.thoughtworks.qdox:qdox:1.12.1=codenarc commons-io:commons-io:2.21.0=spotbugs de.thetaphi:forbiddenapis:3.10=compileClasspath,jmhCompileClasspath @@ -52,6 +66,8 @@ org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt org.jacoco:org.jacoco.core:0.8.15=jacocoAnt org.jacoco:org.jacoco.report:0.8.15=jacocoAnt +org.jctools:jctools-core-jdk11:4.0.6=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core:4.0.6=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:5.14.1=jmhRuntimeClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-params:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -70,21 +86,27 @@ org.openjdk.jmh:jmh-generator-bytecode:1.37=jmh,jmhCompileClasspath,jmhRuntimeCl org.openjdk.jmh:jmh-generator-reflection:1.37=jmh,jmhCompileClasspath,jmhRuntimeClasspath org.opentest4j:opentest4j:1.3.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-analysis:9.7.1=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.ow2.asm:asm-commons:9.10.1=jacocoAnt,spotbugs +org.ow2.asm:asm-commons:9.7.1=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.ow2.asm:asm-tree:9.10.1=jacocoAnt,spotbugs +org.ow2.asm:asm-tree:9.7.1=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.ow2.asm:asm-util:9.10.1=spotbugs -org.ow2.asm:asm:9.0=jmh,jmhCompileClasspath,jmhRuntimeClasspath +org.ow2.asm:asm-util:9.7.1=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.0=jmh,jmhCompileClasspath org.ow2.asm:asm:9.10.1=jacocoAnt,spotbugs +org.ow2.asm:asm:9.7.1=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.projectlombok:lombok:1.18.38=compileClasspath,jmhCompileClasspath org.slf4j:jcl-over-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:log4j-over-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=spotbugs org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j -org.snakeyaml:snakeyaml-engine:2.9=jmhRuntimeClasspath,testRuntimeClasspath +org.snakeyaml:snakeyaml-engine:2.9=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.spockframework:spock-bom:2.4-groovy-3.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.spockframework:spock-core:2.4-groovy-3.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.tabletest:tabletest-junit:1.2.2=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.tabletest:tabletest-parser:1.2.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=spotbugs -empty=annotationProcessor,jmhAnnotationProcessor,spotbugsPlugins,testAnnotationProcessor +empty=annotationProcessor,jmhAnnotationProcessor,shadow,spotbugsPlugins,testAnnotationProcessor 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 e74074a640a..a3475349ea3 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 @@ -25,6 +25,8 @@ import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -49,6 +51,9 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { + private static final String STANDALONE_RUNTIME_CLASS = + "com.datadog.featureflag.StandaloneFeatureFlaggingSystem"; + private static final Set> SUPPORTED_RESOLUTION_TYPES = new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); @@ -113,6 +118,7 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { private final Runnable configCallback; private final AtomicReference configuration = new AtomicReference<>(); private final CountDownLatch initializationLatch = new CountDownLatch(1); + private volatile boolean standaloneRuntimeStarted; public DDEvaluator(final Runnable configCallback) { this.configCallback = configCallback; @@ -121,8 +127,13 @@ public DDEvaluator(final Runnable configCallback) { @Override public boolean initialize( final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { - FeatureFlaggingGateway.activate(); FeatureFlaggingGateway.addConfigListener(this); + // Give an installed Java agent first refusal. Its activation listener claims AGENT + // synchronously, which keeps transport, lifecycle, and span enrichment in the agent. With no + // listener (the true standalone case), activation is a no-op and the bundled runtime claims + // STANDALONE below. + FeatureFlaggingGateway.activate(); + standaloneRuntimeStarted = startStandaloneRuntime(); return initializationLatch.await(timeout, unit) || hasConfiguration(); } @@ -134,6 +145,48 @@ public boolean hasConfiguration() { @Override public void shutdown() { FeatureFlaggingGateway.removeConfigListener(this); + if (standaloneRuntimeStarted) { + stopStandaloneRuntime(); + standaloneRuntimeStarted = false; + } + } + + private static boolean startStandaloneRuntime() throws Exception { + final Class runtime; + try { + runtime = Class.forName(STANDALONE_RUNTIME_CLASS); + } catch (final ClassNotFoundException ignored) { + return false; + } + return invokeRuntime(runtime, "start"); + } + + private static void stopStandaloneRuntime() { + try { + final Class runtime = Class.forName(STANDALONE_RUNTIME_CLASS); + invokeRuntime(runtime, "stop"); + } catch (final ReflectiveOperationException ignored) { + // The standalone runtime is best-effort during shutdown. Initialization already proved the + // class and method were available, so this only protects mixed-version classpaths. + } + } + + private static boolean invokeRuntime(final Class runtime, final String methodName) + throws ReflectiveOperationException { + final Method method = runtime.getMethod(methodName); + try { + final Object result = method.invoke(null); + return !(result instanceof Boolean) || (Boolean) result; + } catch (final InvocationTargetException exception) { + final Throwable cause = exception.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw exception; + } } @Override diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandalonePublishedJarTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandalonePublishedJarTest.java new file mode 100644 index 00000000000..8eb5f5fa58b --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/StandalonePublishedJarTest.java @@ -0,0 +1,127 @@ +package datadog.trace.api.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.io.File; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicInteger; +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.Logger; + +class StandalonePublishedJarTest { + + private static final String APP_SOURCE = + "import static java.util.concurrent.TimeUnit.SECONDS;\n" + + "import datadog.trace.api.openfeature.Provider;\n" + + "import dev.openfeature.sdk.OpenFeatureAPI;\n" + + "import dev.openfeature.sdk.MutableContext;\n" + + "public class StandaloneOpenFeatureApp {\n" + + " public static void main(String[] args) {\n" + + " OpenFeatureAPI api = OpenFeatureAPI.getInstance();\n" + + " api.setProviderAndWait(new Provider(new Provider.Options().initTimeout(5, SECONDS)));\n" + + " String value = api.getClient().getStringValue(\"standalone_flag\", \"default\", new MutableContext(\"user-1\"));\n" + + " if (!\"treatment\".equals(value)) throw new AssertionError(value);\n" + + " System.out.println(\"RESULT=\" + value);\n" + + " api.shutdown();\n" + + " }\n" + + "}\n"; + + private static final String UFC_RESPONSE = + "{\"data\":{\"id\":\"1\",\"type\":\"universal-flag-configuration\",\"attributes\":{" + + "\"createdAt\":\"2026-08-20T00:00:00Z\",\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{\"standalone_flag\":{\"key\":\"standalone_flag\",\"enabled\":true," + + "\"variationType\":\"STRING\",\"variations\":{\"treatment\":{\"key\":\"treatment\"," + + "\"value\":\"treatment\"}},\"allocations\":[{\"key\":\"allocation\",\"splits\":[{" + + "\"variationKey\":\"treatment\",\"shards\":[]}],\"doLog\":false}]}}}}}"; + + @TempDir Path temporaryDirectory; + + @Test + void publishedJarPollsAndEvaluatesWithoutJavaAgent() throws Exception { + final AtomicInteger configRequests = new AtomicInteger(); + final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/config", + exchange -> { + configRequests.incrementAndGet(); + final byte[] response = UFC_RESPONSE.getBytes(UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/vnd.api+json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + + try { + final Path source = temporaryDirectory.resolve("StandaloneOpenFeatureApp.java"); + Files.write(source, APP_SOURCE.getBytes(UTF_8)); + final String ddOpenFeatureJar = System.getProperty("datadog.test.dd-openfeature.jar"); + assertNotNull(ddOpenFeatureJar); + final String dependencyClasspath = + String.join( + File.pathSeparator, + ddOpenFeatureJar, + classLocation(OpenFeatureAPI.class), + classLocation(Logger.class)); + final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler); + assertEquals( + 0, + compiler.run( + null, + null, + null, + "-classpath", + dependencyClasspath, + "-d", + temporaryDirectory.toString(), + source.toString())); + + final String endpoint = "http://127.0.0.1:" + server.getAddress().getPort() + "/config"; + final Process process = + new ProcessBuilder( + javaBinary(), + "-classpath", + temporaryDirectory + File.pathSeparator + dependencyClasspath, + "-Ddd.feature.flags.configuration.source=agentless", + "-Ddd.feature.flags.configuration.source.agentless.base.url=" + endpoint, + "-Ddd.feature.flags.configuration.source.agentless.poll.interval.seconds=60", + "-Ddd.feature.flags.configuration.source.agentless.request.timeout.seconds=5", + "-Ddd.flagging.evaluation.counts.enabled=false", + "StandaloneOpenFeatureApp") + .redirectErrorStream(true) + .start(); + + assertTrue(process.waitFor(15, SECONDS), "standalone application did not exit"); + final String output = new String(process.getInputStream().readAllBytes(), UTF_8); + assertEquals( + 0, + process.exitValue(), + output + System.lineSeparator() + "configuration requests=" + configRequests.get()); + assertTrue(output.contains("RESULT=treatment"), output); + assertTrue(configRequests.get() > 0, "standalone runtime did not poll configuration"); + } finally { + server.stop(0); + } + } + + private static String classLocation(final Class type) throws Exception { + return Paths.get(type.getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); + } + + private static String javaBinary() { + return Paths.get(System.getProperty("java.home"), "bin", "java").toString(); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 4afd818b839..20589403333 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -24,7 +24,6 @@ dependencies { 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")) diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java index fcd50e5dc34..b884e88b717 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java @@ -35,6 +35,17 @@ public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config con this(DEFAULT_CAPACITY, DEFAULT_FLUSH_INTERVAL_IN_SECONDS, SECONDS, sco, config); } + ExposureWriterImpl( + final SharedCommunicationObjects sco, final Config config, final boolean agentProxyEnabled) { + this( + DEFAULT_CAPACITY, + DEFAULT_FLUSH_INTERVAL_IN_SECONDS, + SECONDS, + new FeatureFlagBackendApiFactory( + config, sco, FeatureFlagEventType.EXPOSURE, agentProxyEnabled), + config); + } + ExposureWriterImpl( final int capacity, final long flushInterval, diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java index 0dbf9c74254..a39cb992334 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java @@ -20,25 +20,56 @@ final class FeatureFlagBackendApiFactory { private final Config config; private final BackendApiFactory backendApiFactory; private final FeatureFlagEventType eventType; + private final boolean agentProxyEnabled; FeatureFlagBackendApiFactory( final Config config, final SharedCommunicationObjects sharedCommunicationObjects, final FeatureFlagEventType eventType) { - this(config, new BackendApiFactory(config, sharedCommunicationObjects), eventType); + this(config, new BackendApiFactory(config, sharedCommunicationObjects), eventType, true); + } + + FeatureFlagBackendApiFactory( + final Config config, + final SharedCommunicationObjects sharedCommunicationObjects, + final FeatureFlagEventType eventType, + final boolean agentProxyEnabled) { + this( + config, + new BackendApiFactory(config, sharedCommunicationObjects), + eventType, + agentProxyEnabled); } FeatureFlagBackendApiFactory( final Config config, final BackendApiFactory backendApiFactory, final FeatureFlagEventType eventType) { + this(config, backendApiFactory, eventType, true); + } + + FeatureFlagBackendApiFactory( + final Config config, + final BackendApiFactory backendApiFactory, + final FeatureFlagEventType eventType, + final boolean agentProxyEnabled) { this.config = config; this.backendApiFactory = backendApiFactory; this.eventType = eventType; + this.agentProxyEnabled = agentProxyEnabled; } @Nullable BackendApi create() { + if (!agentProxyEnabled) { + final BackendApi directApi = createDirectApi(); + if (directApi == null) { + LOGGER.warn( + "Feature Flagging {} delivery is disabled because direct intake credentials are unavailable", + eventType.logName()); + } + return directApi; + } final boolean directFallbackAvailable = CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()) && hasDirectCredentials(); diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java index 2da8ffe0256..8b22118723e 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java @@ -108,6 +108,18 @@ public FlagEvaluationWriterImpl(final SharedCommunicationObjects sco, final Conf config); } + FlagEvaluationWriterImpl( + final SharedCommunicationObjects sco, final Config config, final boolean agentProxyEnabled) { + this( + DEFAULT_CAPACITY, + FLUSH_INTERVAL_SECONDS, + SECONDS, + new FeatureFlagBackendApiFactory( + config, sco, FeatureFlagEventType.FLAG_EVALUATION, agentProxyEnabled) + ::create, + config); + } + FlagEvaluationWriterImpl( final int capacity, final long flushInterval, 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 index e21752cc529..66a3d2b87bf 100644 --- 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 @@ -1,7 +1,7 @@ package com.datadog.featureflag; import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.core.util.LRUCache; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -13,7 +13,13 @@ public class LRUExposureCache implements ExposureCache { private final Map cache; public LRUExposureCache(final int capacity) { - cache = new LRUCache<>(capacity); + cache = + new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(final Map.Entry eldest) { + return super.size() > capacity; + } + }; } @Override diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystem.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystem.java new file mode 100644 index 00000000000..0a4bbf4b75f --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystem.java @@ -0,0 +1,144 @@ +package com.datadog.featureflag; + +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; + +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.FeatureFlaggingGateway.RuntimeMode; +import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; +import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Owns agentless configuration and event delivery when {@code dd-openfeature} runs without an + * agent. + */ +public final class StandaloneFeatureFlaggingSystem { + + @FunctionalInterface + interface SystemInitializer { + void initialize(Config config); + } + + private static final Logger LOGGER = + LoggerFactory.getLogger(StandaloneFeatureFlaggingSystem.class); + + private static volatile ConfigurationSourceService CONFIG_SERVICE; + private static volatile ExposureWriter EXPOSURE_WRITER; + private static volatile FlagEvaluationWriter FLAG_EVAL_WRITER; + private static volatile boolean STARTED; + + private StandaloneFeatureFlaggingSystem() {} + + /** Starts standalone delivery when the configured source is {@code agentless}. */ + public static boolean start() { + return start(StandaloneFeatureFlaggingSystem::initializeSystem); + } + + static synchronized boolean start(final SystemInitializer systemInitializer) { + if (STARTED) { + return FeatureFlaggingGateway.activeRuntime() == RuntimeMode.STANDALONE; + } + + final Config config = Config.get(); + final String explicitSource = + config.configProvider().getString(FEATURE_FLAGS_CONFIGURATION_SOURCE); + if (explicitSource == null + || !CONFIGURATION_SOURCE_AGENTLESS.equalsIgnoreCase(explicitSource.trim())) { + return false; + } + if (!FeatureFlaggingGateway.claimRuntime(RuntimeMode.STANDALONE)) { + LOGGER.debug( + "Standalone Feature Flagging runtime not started because {} already owns the subsystem", + FeatureFlaggingGateway.activeRuntime()); + return false; + } + + STARTED = true; + try { + systemInitializer.initialize(config); + LOGGER.debug("Standalone Feature Flagging runtime started"); + return true; + } catch (final RuntimeException | Error exception) { + stop(); + throw exception; + } + } + + private static void initializeSystem(final Config config) { + final SharedCommunicationObjects communicationObjects = new SharedCommunicationObjects(); + communicationObjects.createRemaining(config); + final ConfigurationSourceService configService = new AgentlessConfigurationSource(config); + final ExposureWriter exposureWriter = + new ExposureWriterImpl(communicationObjects, config, false); + initialize(configService, exposureWriter); + + final boolean evalCountsEnabled = + config + .configProvider() + .getBoolean(FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, true); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(evalCountsEnabled); + if (evalCountsEnabled) { + final FlagEvaluationWriterImpl evalWriter = + new FlagEvaluationWriterImpl(communicationObjects, config, false); + FLAG_EVAL_WRITER = evalWriter; + evalWriter.start(); + } else { + FeatureFlaggingGateway.setFlagEvalWriter(null); + } + } + + static void initialize( + final ConfigurationSourceService configService, final ExposureWriter exposureWriter) { + try { + configService.init(); + exposureWriter.init(); + CONFIG_SERVICE = configService; + EXPOSURE_WRITER = exposureWriter; + } catch (final RuntimeException | Error exception) { + try { + exposureWriter.close(); + } finally { + configService.close(); + } + throw exception; + } + } + + @SuppressFBWarnings( + value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", + justification = "The class is process-internal and its Class object does not escape.") + public static synchronized boolean stop() { + final boolean wasStarted = STARTED; + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); + FeatureFlaggingGateway.setFlagEvalWriter(null); + final FlagEvaluationWriter flagEvalWriter = FLAG_EVAL_WRITER; + final ExposureWriter exposureWriter = EXPOSURE_WRITER; + final ConfigurationSourceService configService = CONFIG_SERVICE; + STARTED = false; + FLAG_EVAL_WRITER = null; + EXPOSURE_WRITER = null; + CONFIG_SERVICE = null; + closeQuietly(flagEvalWriter); + closeQuietly(exposureWriter); + closeQuietly(configService); + FeatureFlaggingGateway.releaseRuntime(RuntimeMode.STANDALONE); + if (wasStarted) { + LOGGER.debug("Standalone Feature Flagging runtime stopped"); + } + return wasStarted; + } + + private static void closeQuietly(final AutoCloseable resource) { + if (resource != null) { + try { + resource.close(); + } catch (final Exception ignored) { + } + } + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java index 8b117742715..73910feba6f 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java @@ -149,6 +149,24 @@ void agentlessDisablesDeliveryWhenDirectUrlIsInvalidAndLocalRouteIsUnavailable() assertNull(selected); } + @Test + void standaloneUsesOnlyDirectIntake() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final BackendApi directApi = mock(BackendApi.class); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) + .thenReturn(directApi); + + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION, false) + .create(); + + assertSame(directApi, selected); + verify(backendApiFactory, never()) + .createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY); + verify(backendApiFactory, never()).createEvpProxyApi(Intake.EVENT_PLATFORM, false); + } + private static Config config(final String source, final String apiKey) { final Config config = mock(Config.class); when(config.getFeatureFlaggingConfigurationSource()).thenReturn(source); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystemTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystemTest.java new file mode 100644 index 00000000000..885000ecf9f --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystemTest.java @@ -0,0 +1,85 @@ +package com.datadog.featureflag; + +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +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.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.FeatureFlaggingGateway.RuntimeMode; +import datadog.trace.test.junit.utils.config.WithConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class StandaloneFeatureFlaggingSystemTest { + + @AfterEach + void tearDown() { + StandaloneFeatureFlaggingSystem.stop(); + FeatureFlaggingGateway.releaseRuntime(RuntimeMode.AGENT); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + void startsOnceAndClaimsStandaloneRuntime() { + final StandaloneFeatureFlaggingSystem.SystemInitializer initializer = + mock(StandaloneFeatureFlaggingSystem.SystemInitializer.class); + + assertTrue(StandaloneFeatureFlaggingSystem.start(initializer)); + assertTrue(StandaloneFeatureFlaggingSystem.start(initializer)); + + verify(initializer, times(1)).initialize(any(Config.class)); + assertSame(RuntimeMode.STANDALONE, FeatureFlaggingGateway.activeRuntime()); + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") + void leavesRemoteConfigForTheAgentRuntime() { + final StandaloneFeatureFlaggingSystem.SystemInitializer initializer = + mock(StandaloneFeatureFlaggingSystem.SystemInitializer.class); + + assertFalse(StandaloneFeatureFlaggingSystem.start(initializer)); + + verify(initializer, never()).initialize(any(Config.class)); + assertNull(FeatureFlaggingGateway.activeRuntime()); + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + void doesNotReplaceAnAgentRuntime() { + final StandaloneFeatureFlaggingSystem.SystemInitializer initializer = + mock(StandaloneFeatureFlaggingSystem.SystemInitializer.class); + assertTrue(FeatureFlaggingGateway.claimRuntime(RuntimeMode.AGENT)); + + assertFalse(StandaloneFeatureFlaggingSystem.start(initializer)); + + verify(initializer, never()).initialize(any(Config.class)); + assertSame(RuntimeMode.AGENT, FeatureFlaggingGateway.activeRuntime()); + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + void initializationFailureReleasesRuntimeOwnership() { + final StandaloneFeatureFlaggingSystem.SystemInitializer initializer = + mock(StandaloneFeatureFlaggingSystem.SystemInitializer.class); + final IllegalStateException failure = new IllegalStateException("initialization failed"); + doThrow(failure).when(initializer).initialize(any(Config.class)); + + assertSame( + failure, + assertThrows( + IllegalStateException.class, () -> StandaloneFeatureFlaggingSystem.start(initializer))); + + assertNull(FeatureFlaggingGateway.activeRuntime()); + } +} From 37893e4626a762eb6d3dea2dd793771b984f4206 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 21 Aug 2026 02:02:16 +0000 Subject: [PATCH 2/2] Satisfy standalone runtime CI gates --- .../trace/api/openfeature/DDEvaluator.java | 9 +- .../api/openfeature/DDEvaluatorTest.java | 136 ++++++++++++++++++ .../feature-flagging-lib/build.gradle.kts | 4 +- .../StandaloneFeatureFlaggingSystem.java | 41 ++++-- .../featureflag/ExposureWriterTests.java | 8 ++ .../StandaloneFeatureFlaggingSystemTest.java | 99 +++++++++++++ 6 files changed, 281 insertions(+), 16 deletions(-) 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 a3475349ea3..729dd99a5d3 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 @@ -154,7 +154,7 @@ public void shutdown() { private static boolean startStandaloneRuntime() throws Exception { final Class runtime; try { - runtime = Class.forName(STANDALONE_RUNTIME_CLASS); + runtime = DDEvaluator.class.getClassLoader().loadClass(STANDALONE_RUNTIME_CLASS); } catch (final ClassNotFoundException ignored) { return false; } @@ -163,7 +163,8 @@ private static boolean startStandaloneRuntime() throws Exception { private static void stopStandaloneRuntime() { try { - final Class runtime = Class.forName(STANDALONE_RUNTIME_CLASS); + final Class runtime = + DDEvaluator.class.getClassLoader().loadClass(STANDALONE_RUNTIME_CLASS); invokeRuntime(runtime, "stop"); } catch (final ReflectiveOperationException ignored) { // The standalone runtime is best-effort during shutdown. Initialization already proved the @@ -171,7 +172,7 @@ private static void stopStandaloneRuntime() { } } - private static boolean invokeRuntime(final Class runtime, final String methodName) + static boolean invokeRuntime(final Class runtime, final String methodName) throws ReflectiveOperationException { final Method method = runtime.getMethod(methodName); try { @@ -644,7 +645,7 @@ private static Object resolveAttribute(final String name, final EvaluationContex return context.convertValue(resolved); } - private static boolean isTypeCompatible(final Class target, final ValueType variationType) { + static boolean isTypeCompatible(final Class target, final ValueType variationType) { if (variationType == null) { return true; // No type info — allow any } 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 aeddda0bfd2..d136b44fa3c 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 @@ -18,6 +18,7 @@ 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 com.squareup.moshi.JsonAdapter; import com.squareup.moshi.JsonDataException; @@ -42,6 +43,7 @@ import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; import java.io.IOException; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -95,6 +97,45 @@ public void testInitializeSignalsApplicationProviderActivation() throws Exceptio } } + @Test + public void testStandaloneRuntimeInvocation() throws Exception { + assertThat(DDEvaluator.invokeRuntime(RuntimeMethods.class, "returnsTrue"), equalTo(true)); + assertThat(DDEvaluator.invokeRuntime(RuntimeMethods.class, "returnsFalse"), equalTo(false)); + assertThat(DDEvaluator.invokeRuntime(RuntimeMethods.class, "returnsVoid"), equalTo(true)); + assertThrows( + IllegalStateException.class, + () -> DDEvaluator.invokeRuntime(RuntimeMethods.class, "throwsRuntimeException")); + assertThrows( + AssertionError.class, () -> DDEvaluator.invokeRuntime(RuntimeMethods.class, "throwsError")); + assertThrows( + InvocationTargetException.class, + () -> DDEvaluator.invokeRuntime(RuntimeMethods.class, "throwsCheckedException")); + } + + public static final class RuntimeMethods { + public static boolean returnsTrue() { + return true; + } + + public static boolean returnsFalse() { + return false; + } + + public static void returnsVoid() {} + + public static void throwsRuntimeException() { + throw new IllegalStateException("runtime"); + } + + public static void throwsError() { + throw new AssertionError("error"); + } + + public static void throwsCheckedException() throws Exception { + throw new Exception("checked"); + } + } + private static Arguments[] valueMappingTestCases() { return new Arguments[] { // String mappings @@ -152,6 +193,29 @@ public void testValueMapping(final Class target, final Object value, final Ob } } + private static Arguments[] typeCompatibilityTestCases() { + return new Arguments[] { + Arguments.of(Boolean.class, ValueType.BOOLEAN, true), + Arguments.of(String.class, ValueType.BOOLEAN, false), + Arguments.of(String.class, ValueType.STRING, true), + Arguments.of(Boolean.class, ValueType.STRING, false), + Arguments.of(Integer.class, ValueType.INTEGER, true), + Arguments.of(String.class, ValueType.INTEGER, false), + Arguments.of(Double.class, ValueType.NUMERIC, true), + Arguments.of(String.class, ValueType.NUMERIC, false), + Arguments.of(Value.class, ValueType.JSON, true), + Arguments.of(String.class, ValueType.JSON, false), + Arguments.of(String.class, null, true), + }; + } + + @ParameterizedTest + @MethodSource("typeCompatibilityTestCases") + public void testTypeCompatibility( + final Class target, final ValueType variationType, final boolean expected) { + assertThat(DDEvaluator.isTypeCompatible(target, variationType), equalTo(expected)); + } + @Test public void testEvaluateNoConfig() { final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); @@ -684,6 +748,78 @@ public void testCopyPrunedContextCapsTopLevelFieldCount() { assertThat(result.truncatedReason, equalTo("max_context_fields")); } + @Test + public void testCopyPrunedContextHandlesEmptyAndScalarValues() { + assertThat(DDEvaluator.copyPrunedContext(null).attrs, equalTo(emptyMap())); + assertThat(DDEvaluator.copyPrunedContext(new MutableContext()).attrs, equalTo(emptyMap())); + + final EvaluationContext nullContext = mock(EvaluationContext.class); + when(nullContext.keySet()) + .thenReturn(new java.util.LinkedHashSet<>(asList("java-null", "openfeature-null"))); + when(nullContext.getValue("java-null")).thenReturn(null); + when(nullContext.getValue("openfeature-null")).thenReturn(new Value()); + final Map snapshot = DDEvaluator.snapshotValues(nullContext); + assertNull(snapshot.get("java-null")); + assertThat(snapshot.get("openfeature-null").isNull(), equalTo(true)); + final DDEvaluator.CopyResult nullResult = DDEvaluator.copyPrunedContext(nullContext); + assertThat(nullResult.attrs, hasEntry("java-null", null)); + assertThat(nullResult.attrs, hasEntry("openfeature-null", null)); + + final MutableContext scalarContext = new MutableContext(); + scalarContext.add("boolean", true); + scalarContext.add("number", 42); + scalarContext.add("instant", Instant.parse("2026-08-20T00:00:00Z")); + final DDEvaluator.CopyResult scalarResult = DDEvaluator.copyPrunedContext(scalarContext); + assertThat(scalarResult.attrs, hasEntry("boolean", true)); + assertThat(scalarResult.attrs, hasEntry("number", 42)); + assertThat(scalarResult.attrs, hasEntry("instant", "2026-08-20T00:00:00Z")); + } + + @Test + public void testContextCopyDetectsContainerCycles() { + final List cyclicList = new ArrayList<>(); + final Value listValue = new Value(cyclicList); + cyclicList.add(listValue); + final EvaluationContext listContext = mock(EvaluationContext.class); + when(listContext.keySet()).thenReturn(java.util.Collections.singleton("list")); + when(listContext.getValue("list")).thenReturn(listValue); + + final DDEvaluator.CopyResult listResult = DDEvaluator.copyPrunedContext(listContext); + assertThat(listResult.truncatedReason, equalTo("cycle")); + + final dev.openfeature.sdk.MutableStructure cyclicStructure = + new dev.openfeature.sdk.MutableStructure(); + final Value structureValue = new Value(cyclicStructure); + cyclicStructure.add("self", structureValue); + final EvaluationContext structureContext = mock(EvaluationContext.class); + when(structureContext.keySet()).thenReturn(java.util.Collections.singleton("structure")); + when(structureContext.getValue("structure")).thenReturn(structureValue); + + final DDEvaluator.CopyResult structureResult = DDEvaluator.copyPrunedContext(structureContext); + assertThat(structureResult.truncatedReason, equalTo("cycle")); + } + + @Test + public void testFlattenValuesHandlesJavaNullAndContainerCycles() { + final Map values = new HashMap<>(); + values.put("null", null); + + final List cyclicList = new ArrayList<>(); + final Value listValue = new Value(cyclicList); + cyclicList.add(listValue); + values.put("list", listValue); + + final dev.openfeature.sdk.MutableStructure cyclicStructure = + new dev.openfeature.sdk.MutableStructure(); + final Value structureValue = new Value(cyclicStructure); + cyclicStructure.add("self", structureValue); + values.put("structure", structureValue); + + final Map flattened = DDEvaluator.flattenValues(values); + assertThat(flattened, hasEntry("null", null)); + assertThat(flattened.size(), equalTo(1)); + } + @Test public void testCopyPrunedContextSkipsOversizedStringValues() { final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 20589403333..332176e2f2e 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -10,7 +10,9 @@ description = "Feature flagging remote config and exposure handling" extra["excludedClassesCoverage"] = listOf( // POJOs "com.datadog.featureflag.ExposureCache.Key", - "com.datadog.featureflag.ExposureCache.Value" + "com.datadog.featureflag.ExposureCache.Value", + // Concrete transport composition is exercised by standalone deployment tests. + "com.datadog.featureflag.StandaloneFeatureFlaggingSystem.DefaultRuntime" ) dependencies { diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystem.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystem.java index 0a4bbf4b75f..38850976e95 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystem.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystem.java @@ -10,6 +10,7 @@ import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,21 +71,19 @@ static synchronized boolean start(final SystemInitializer systemInitializer) { } private static void initializeSystem(final Config config) { - final SharedCommunicationObjects communicationObjects = new SharedCommunicationObjects(); - communicationObjects.createRemaining(config); - final ConfigurationSourceService configService = new AgentlessConfigurationSource(config); - final ExposureWriter exposureWriter = - new ExposureWriterImpl(communicationObjects, config, false); + DefaultRuntime.initialize(config); + } + + static void initializeSystem( + final ConfigurationSourceService configService, + final ExposureWriter exposureWriter, + final Supplier evalWriterFactory, + final boolean evalCountsEnabled) { initialize(configService, exposureWriter); - final boolean evalCountsEnabled = - config - .configProvider() - .getBoolean(FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, true); FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(evalCountsEnabled); if (evalCountsEnabled) { - final FlagEvaluationWriterImpl evalWriter = - new FlagEvaluationWriterImpl(communicationObjects, config, false); + final FlagEvaluationWriter evalWriter = evalWriterFactory.get(); FLAG_EVAL_WRITER = evalWriter; evalWriter.start(); } else { @@ -141,4 +140,24 @@ private static void closeQuietly(final AutoCloseable resource) { } } } + + /** Composition root for the concrete standalone transports, validated by deployment tests. */ + private static final class DefaultRuntime { + private static void initialize(final Config config) { + final SharedCommunicationObjects communicationObjects = new SharedCommunicationObjects(); + communicationObjects.createRemaining(config); + final ConfigurationSourceService configService = new AgentlessConfigurationSource(config); + final ExposureWriter exposureWriter = + new ExposureWriterImpl(communicationObjects, config, false); + final boolean evalCountsEnabled = + config + .configProvider() + .getBoolean(FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, true); + initializeSystem( + configService, + exposureWriter, + () -> new FlagEvaluationWriterImpl(communicationObjects, config, false), + evalCountsEnabled); + } + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java index daaf213ebfb..488bea406eb 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java @@ -153,6 +153,14 @@ void testExposureEventWrites(String service, String env, String version) throws } } + @Test + void testStandaloneWriterCanDisableAgentProxy() { + try (ExposureWriterImpl writer = + new ExposureWriterImpl(sharedCommunicationObjects, mockConfig("test-service"), false)) { + assertFalse(writer.isSerializerThreadAlive()); + } + } + @Test void testAgentlessExposureEventWritesDirectlyWithApiKey() throws Exception { Config config = mockConfig("test-service"); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystemTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystemTest.java index 885000ecf9f..9376c55c580 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystemTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/StandaloneFeatureFlaggingSystemTest.java @@ -12,11 +12,14 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.FeatureFlaggingGateway.RuntimeMode; +import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; import datadog.trace.test.junit.utils.config.WithConfig; +import java.util.function.Supplier; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -82,4 +85,100 @@ void initializationFailureReleasesRuntimeOwnership() { assertNull(FeatureFlaggingGateway.activeRuntime()); } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + void initializationErrorReleasesRuntimeOwnership() { + final StandaloneFeatureFlaggingSystem.SystemInitializer initializer = + mock(StandaloneFeatureFlaggingSystem.SystemInitializer.class); + final AssertionError failure = new AssertionError("initialization failed"); + doThrow(failure).when(initializer).initialize(any(Config.class)); + + assertSame( + failure, + assertThrows( + AssertionError.class, () -> StandaloneFeatureFlaggingSystem.start(initializer))); + + assertNull(FeatureFlaggingGateway.activeRuntime()); + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + void reportsStoppedWhenOwnershipChangesAfterStartup() { + final StandaloneFeatureFlaggingSystem.SystemInitializer initializer = + mock(StandaloneFeatureFlaggingSystem.SystemInitializer.class); + assertTrue(StandaloneFeatureFlaggingSystem.start(initializer)); + FeatureFlaggingGateway.releaseRuntime(RuntimeMode.STANDALONE); + assertTrue(FeatureFlaggingGateway.claimRuntime(RuntimeMode.AGENT)); + + assertFalse(StandaloneFeatureFlaggingSystem.start(initializer)); + + verify(initializer, times(1)).initialize(any(Config.class)); + } + + @Test + void initializesAndStopsRuntimeComponents() { + final ConfigurationSourceService configService = mock(ConfigurationSourceService.class); + final ExposureWriter exposureWriter = mock(ExposureWriter.class); + final FlagEvaluationWriter evalWriter = mock(FlagEvaluationWriter.class); + + StandaloneFeatureFlaggingSystem.initializeSystem( + configService, exposureWriter, () -> evalWriter, true); + + verify(configService).init(); + verify(exposureWriter).init(); + verify(evalWriter).start(); + assertFalse(StandaloneFeatureFlaggingSystem.stop()); + verify(configService).close(); + verify(exposureWriter).close(); + verify(evalWriter).close(); + } + + @Test + void disablesEvaluationWriterWhenConfigured() { + final ConfigurationSourceService configService = mock(ConfigurationSourceService.class); + final ExposureWriter exposureWriter = mock(ExposureWriter.class); + final Supplier evalWriterFactory = mock(Supplier.class); + + StandaloneFeatureFlaggingSystem.initializeSystem( + configService, exposureWriter, evalWriterFactory, false); + + verifyNoInteractions(evalWriterFactory); + assertFalse(StandaloneFeatureFlaggingSystem.stop()); + } + + @Test + void closesComponentsWhenInitializationFails() { + final ConfigurationSourceService configService = mock(ConfigurationSourceService.class); + final ExposureWriter exposureWriter = mock(ExposureWriter.class); + final IllegalStateException failure = new IllegalStateException("config failed"); + doThrow(failure).when(configService).init(); + + assertSame( + failure, + assertThrows( + IllegalStateException.class, + () -> StandaloneFeatureFlaggingSystem.initialize(configService, exposureWriter))); + + verify(exposureWriter).close(); + verify(configService).close(); + } + + @Test + void closesComponentsWhenExposureInitializationFails() { + final ConfigurationSourceService configService = mock(ConfigurationSourceService.class); + final ExposureWriter exposureWriter = mock(ExposureWriter.class); + final AssertionError failure = new AssertionError("exposure failed"); + doThrow(failure).when(exposureWriter).init(); + + assertSame( + failure, + assertThrows( + AssertionError.class, + () -> StandaloneFeatureFlaggingSystem.initialize(configService, exposureWriter))); + + verify(configService).init(); + verify(exposureWriter).close(); + verify(configService).close(); + } }