diff --git a/.github/workflows/run-system-tests.yaml b/.github/workflows/run-system-tests.yaml index 4b0ad663659..8543920d88d 100644 --- a/.github/workflows/run-system-tests.yaml +++ b/.github/workflows/run-system-tests.yaml @@ -49,14 +49,18 @@ jobs: JAVA_11_HOME=$JAVA_HOME_11_X64 \ JAVA_17_HOME=$JAVA_HOME_17_X64 \ JAVA_21_HOME=$JAVA_HOME_21_X64 \ - ./gradlew clean :dd-java-agent:shadowJar \ + ./gradlew clean :dd-java-agent:shadowJar :dd-openfeature:jar \ --build-cache --parallel --stacktrace --no-daemon --max-workers=4 + mkdir -p workspace/system-tests-binaries + cp workspace/dd-java-agent/build/libs/dd-java-agent-*.jar \ + workspace/dd-openfeature/build/libs/dd-openfeature-*.jar \ + workspace/system-tests-binaries/ - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: binaries - path: workspace/dd-java-agent/build/libs/ + path: workspace/system-tests-binaries/ main: needs: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4af3997549c..382e460d8d8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -354,7 +354,7 @@ build: script: - if [ $CI_PIPELINE_SOURCE == "schedule" ] ; then ./gradlew resolveAndLockAll --write-locks $GRADLE_ARGS; fi - ./gradlew --version - - ./gradlew clean :dd-java-agent:shadowJar :dd-java-agent:check :dd-trace-api:jar :dd-trace-ot:shadowJar :products:feature-flagging:feature-flagging-api:jar -PskipTests -x spotlessCheck $GRADLE_ARGS + - ./gradlew clean :dd-java-agent:shadowJar :dd-java-agent:check :dd-trace-api:jar :dd-trace-ot:shadowJar :dd-openfeature:jar -PskipTests -x spotlessCheck $GRADLE_ARGS - echo UPSTREAM_TRACER_VERSION=$(java -jar workspace/dd-java-agent/build/libs/*.jar) >> upstream.env - echo "BUILD_JOB_NAME=$CI_JOB_NAME" >> build.env - echo "BUILD_JOB_ID=$CI_JOB_ID" >> build.env @@ -364,7 +364,7 @@ build: - 'workspace/dd-java-agent/build/libs/*.jar' - 'workspace/dd-trace-api/build/libs/*.jar' - 'workspace/dd-trace-ot/build/libs/*.jar' - - 'workspace/products/feature-flagging/feature-flagging-api/build/libs/*.jar' + - 'workspace/dd-openfeature/build/libs/*.jar' - 'upstream.env' - '.gradle/daemon/*/*.out.log' reports: @@ -481,11 +481,11 @@ publish-artifacts-to-s3: - aws s3 cp workspace/dd-java-agent/build/libs/dd-java-agent-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-java-agent.jar - aws s3 cp workspace/dd-trace-api/build/libs/dd-trace-api-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-trace-api.jar - aws s3 cp workspace/dd-trace-ot/build/libs/dd-trace-ot-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-trace-ot.jar - - aws s3 cp workspace/products/feature-flagging/feature-flagging-api/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-openfeature.jar + - aws s3 cp workspace/dd-openfeature/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-openfeature.jar - aws s3 cp workspace/dd-java-agent/build/libs/dd-java-agent-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-java-agent.jar - aws s3 cp workspace/dd-trace-api/build/libs/dd-trace-api-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-trace-api.jar - aws s3 cp workspace/dd-trace-ot/build/libs/dd-trace-ot-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-trace-ot.jar - - aws s3 cp workspace/products/feature-flagging/feature-flagging-api/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-openfeature.jar + - aws s3 cp workspace/dd-openfeature/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-openfeature.jar - | cat << EOF > links.json { @@ -583,6 +583,7 @@ validate_build: - cp workspace/dd-java-agent/build/libs/*.jar reference-artifacts/ - cp workspace/dd-trace-api/build/libs/*.jar reference-artifacts/ - cp workspace/dd-trace-ot/build/libs/*.jar reference-artifacts/ + - cp workspace/dd-openfeature/build/libs/*.jar reference-artifacts/ # Scheduled builds refresh dependency locks before creating the reference artifacts. # Refresh them here as well so the candidate uses the same dependency resolution. - if [ $CI_PIPELINE_SOURCE == "schedule" ] ; then ./gradlew resolveAndLockAll --write-locks $GRADLE_ARGS; fi @@ -596,6 +597,7 @@ validate_build: cp workspace/dd-java-agent/build/libs/*.jar check_reports/jar-comparison-artifacts/candidate/ cp workspace/dd-trace-api/build/libs/*.jar check_reports/jar-comparison-artifacts/candidate/ cp workspace/dd-trace-ot/build/libs/*.jar check_reports/jar-comparison-artifacts/candidate/ + cp workspace/dd-openfeature/build/libs/*.jar check_reports/jar-comparison-artifacts/candidate/ exit 1 fi after_script: diff --git a/dd-java-agent/src/test/java/datadog/trace/agent/TraceDisabledFeatureFlaggingShutdownTest.java b/dd-java-agent/src/test/java/datadog/trace/agent/TraceDisabledFeatureFlaggingShutdownTest.java deleted file mode 100644 index 675b60a717a..00000000000 --- a/dd-java-agent/src/test/java/datadog/trace/agent/TraceDisabledFeatureFlaggingShutdownTest.java +++ /dev/null @@ -1,115 +0,0 @@ -package datadog.trace.agent; - -import static java.util.Collections.emptyList; -import static java.util.Collections.singletonMap; -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.agent.test.IntegrationTestUtils; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; -import jvmbootstraptest.AgentLoadedChecker; -import jvmbootstraptest.FeatureFlaggingShutdownProbe; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.Timeout; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -@Timeout(value = 30, unit = TimeUnit.SECONDS) -class TraceDisabledFeatureFlaggingShutdownTest { - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void unusedFeatureFlagsDoNotEnableWholeAgentShutdown(boolean explicitlyEnabled) throws Exception { - final List options = options(false); - if (explicitlyEnabled) { - options.add("-Ddd.feature.flags.enabled=true"); - } - final String logs = run(AgentLoadedChecker.class, options); - assertFalse(logs.contains("Shutting down agent"), logs); - assertFalse(logs.contains("Feature Flagging system stopped"), logs); - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void activatedProviderFlushesBothEventTypesAtJvmExit(boolean tracing) throws Exception { - final LinkedBlockingQueue events = new LinkedBlockingQueue<>(); - try (MockWebServer server = new MockWebServer()) { - server.setDispatcher( - new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest request) { - if (request.getPath().equals("/info")) { - return new MockResponse() - .setBody( - "{\"endpoints\":[\"v0.4/traces\",\"v0.5/traces\",\"evp_proxy/v4/\"]," - + "\"evp_proxy_allowed_headers\":[\"DD-EVP-ORIGIN\",\"DD-EVP-ORIGIN-VERSION\"]}"); - } - if (request.getPath().startsWith("/evp_proxy/")) { - events.add(request); - return new MockResponse().setResponseCode(202).setBody("{}"); - } - return new MockResponse().setResponseCode(404); - } - }); - server.start(); - final List options = options(tracing); - options.add("-Ddd.trace.agent.url=" + server.url("/")); - options.add("-Ddd.feature.flags.configuration.source=agentless"); - options.add( - "-Ddd.feature.flags.configuration.source.agentless.base.url=" + server.url("/config")); - final String logs = run(FeatureFlaggingShutdownProbe.class, options); - assertTrue(logs.contains("EXIT_WITH_QUEUED_FEATURE_FLAG_EVENTS"), logs); - assertEquals(tracing, logs.contains("Shutting down agent"), logs); - assertTrue(logs.contains("Feature Flagging system stopped"), logs); - - final List paths = new ArrayList<>(); - for (int i = 0; i < 2; i++) { - final RecordedRequest event = events.poll(1, TimeUnit.SECONDS); - assertNotNull(event, logs); - paths.add(event.getPath()); - assertEquals("POST", event.getMethod()); - assertNull(event.getHeader("DD-API-KEY")); - assertTrue(event.getBody().readUtf8().contains("shutdown-flag")); - } - assertTrue(paths.contains("/evp_proxy/v4/api/v2/exposures"), paths.toString()); - assertTrue(paths.contains("/evp_proxy/v4/api/v2/flagevaluation"), paths.toString()); - assertEquals(0, events.size(), "Shutdown must not duplicate either batch"); - } - } - - private static List options(boolean tracing) { - return new ArrayList<>( - Arrays.asList( - "-Ddatadog.slf4j.simpleLogger.defaultLogLevel=debug", - "-Ddd.trace.enabled=" + tracing, - "-Ddd.jmxfetch.enabled=false", - "-Ddd.profiling.enabled=false", - "-Ddd.appsec.enabled=false", - "-Ddd.remote_config.enabled=false", - "-Ddd.telemetry.enabled=false")); - } - - private static String run(Class main, List options) throws Exception { - try (ByteArrayOutputStream output = new ByteArrayOutputStream(); - PrintStream stream = new PrintStream(output, true, StandardCharsets.UTF_8.name())) { - final int exit = - IntegrationTestUtils.runOnSeparateJvm( - main.getName(), options, emptyList(), singletonMap("DD_API_KEY", ""), stream); - final String logs = output.toString(StandardCharsets.UTF_8.name()); - assertEquals(0, exit, logs); - return logs; - } - } -} diff --git a/dd-java-agent/src/test/java/jvmbootstraptest/FeatureFlaggingShutdownProbe.java b/dd-java-agent/src/test/java/jvmbootstraptest/FeatureFlaggingShutdownProbe.java deleted file mode 100644 index 3b23b520395..00000000000 --- a/dd-java-agent/src/test/java/jvmbootstraptest/FeatureFlaggingShutdownProbe.java +++ /dev/null @@ -1,64 +0,0 @@ -package jvmbootstraptest; - -import java.util.Collections; -import java.util.Map; - -/** Activates the agent's production gateway and leaves both event types queued at JVM exit. */ -public class FeatureFlaggingShutdownProbe { - public static void main(String[] args) throws Exception { - final Class gateway = type("FeatureFlaggingGateway"); - // Agent installation can finish asynchronously on Java 8. This probe tests shutdown after - // activation, not provider initialization timing. - final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10); - Object writer; - do { - gateway.getMethod("activate").invoke(null); - writer = gateway.getMethod("getFlagEvalWriter").invoke(null); - if (writer != null) { - break; - } - Thread.sleep(10); - } while (System.nanoTime() < deadline); - if (writer == null) { - throw new AssertionError("Provider activation did not start the evaluation writer"); - } - final Class exposure = type("exposure.ExposureEvent"); - final Class allocation = type("exposure.Allocation"); - final Class flag = type("exposure.Flag"); - final Class variant = type("exposure.Variant"); - final Class subject = type("exposure.Subject"); - final Object event = - exposure - .getConstructor(long.class, allocation, flag, variant, subject) - .newInstance( - System.currentTimeMillis(), - allocation.getConstructor(String.class).newInstance("allocation"), - flag.getConstructor(String.class).newInstance("shutdown-flag"), - variant.getConstructor(String.class).newInstance("on"), - subject - .getConstructor(String.class, Map.class) - .newInstance("shutdown-user", Collections.emptyMap())); - gateway.getMethod("dispatch", exposure).invoke(null, event); - - final Class evaluation = type("flagevaluation.FlagEvalEvent"); - final Object evalEvent = - evaluation - .getConstructor( - String.class, String.class, String.class, String.class, long.class, Map.class) - .newInstance( - "shutdown-flag", - "on", - "allocation", - "shutdown-user", - System.currentTimeMillis(), - Collections.emptyMap()); - type("flagevaluation.FlagEvaluationWriter") - .getMethod("enqueue", evaluation) - .invoke(writer, evalEvent); - System.out.println("EXIT_WITH_QUEUED_FEATURE_FLAG_EVENTS"); - } - - private static Class type(String suffix) throws ClassNotFoundException { - return Class.forName("datadog.trace.api.featureflag." + suffix, true, null); - } -} diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts index 2401aa4491f..9a8e13e1b68 100644 --- a/dd-openfeature/build.gradle.kts +++ b/dd-openfeature/build.gradle.kts @@ -4,24 +4,29 @@ import me.champeau.gradle.japicmp.JapicmpTask plugins { `java-library` - idea - id("dd-trace-java.conventions.java") + id("dd-trace-java.module.distributable.api") id("dd-trace-java.version-file") id("dd-trace-java.jmh-conventions") } description = "Datadog OpenFeature provider SDK." -configure { - minJavaVersion.set(JavaVersion.VERSION_11) +// Set both JAR and Maven artifact name +val openFeatureArtifactId = "dd-openfeature" +base { + archivesName.set(openFeatureArtifactId) } -idea { - module { - jdkName = "11" +publishing { + publications.withType().configureEach { + artifactId = openFeatureArtifactId } } +configure { + minJavaVersion.set(JavaVersion.VERSION_11) +} + java { toolchain { languageVersion = JavaLanguageVersion.of(11) @@ -45,6 +50,16 @@ tasks.withType().configureEach { configureCompiler(JavaVersion.VERSION_11) } +tasks.withType().configureEach { + javadocTool = javaToolchains.javadocToolFor(java.toolchain) +} + +tasks.named("jar") { + manifest { + attributes("Automatic-Module-Name" to "com.datadog.openfeature") + } +} + // The agent bridge SPI versions, with the first SDK release shipping each of them. A released // version is frozen (only default methods can be added): check it against that release. // TODO Set the v1 baseline once the first SDK release with the agent bridge is published. @@ -71,3 +86,25 @@ bridgeSpiBaselines.forEach { (spi, baseline) -> } } } + +// The public API, outside the internal packages, must stay compatible with the previous release: +// each release keeps the API of all the previous ones. The previous release is the latest one +// before the version being built, so release branches compare against their own line. +val publicApiBaseline = "(,${version.toString().removeSuffix("-SNAPSHOT")})" +val japicmpPublicApi = tasks.register("japicmpPublicApi") { + description = "Checks the public API stays compatible with the previous dd-openfeature release." + val baselineSdk = "com.datadoghq:dd-openfeature:$publicApiBaseline" + oldArchives.from(configurations.detachedConfiguration(dependencies.create("$baselineSdk@jar"))) + oldClasspath.from(configurations.detachedConfiguration(dependencies.create(baselineSdk))) + newArchives.from(tasks.named("jar")) + newClasspath.from(configurations.named("runtimeClasspath")) + packageIncludes.addAll("com.datadog.openfeature", "datadog.trace.api.openfeature") + failOnModification.set(true) + failOnSourceIncompatibility.set(true) + // TODO Remove once the baseline is 1.69.0 or later: earlier releases link the agent bootstrap API. + ignoreMissingClasses.set(true) + txtOutputFile.set(layout.buildDirectory.file("reports/japicmp/public-api.txt")) +} +tasks.named("check") { + dependsOn(japicmpPublicApi) +} diff --git a/dd-openfeature/gradle.lockfile b/dd-openfeature/gradle.lockfile index 7a225b9fcf6..ae2426ad849 100644 --- a/dd-openfeature/gradle.lockfile +++ b/dd-openfeature/gradle.lockfile @@ -34,7 +34,7 @@ org.apache.commons:commons-math3:3.6.1=jmh,jmhCompileClasspath,jmhRuntimeClasspa org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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 diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java index 393b0ef300d..4466e1a53c6 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -17,6 +17,7 @@ import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; import com.datadog.openfeature.internal.ufc.ServerConfiguration; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; @@ -258,8 +259,13 @@ void stop() { } private void onConfiguration(@Nullable final ServerConfiguration configuration) { - this.configuration = configuration; - for (final Consumer listener : this.listeners) { + final Iterator> snapshot; + synchronized (this.listeners) { + this.configuration = configuration; + snapshot = this.listeners.iterator(); + } + while (snapshot.hasNext()) { + final Consumer listener = snapshot.next(); try { listener.accept(configuration); } catch (final RuntimeException e) { @@ -274,10 +280,13 @@ private void onConfiguration(@Nullable final ServerConfiguration configuration) * @param listener the listener to add. */ public void addConfigurationListener(final Consumer listener) { - this.listeners.add(listener); - final ServerConfiguration current = this.configuration; - if (current != null) { - listener.accept(current); + // Keep registration and replay atomic with notification snapshots. + synchronized (this.listeners) { + this.listeners.add(listener); + final ServerConfiguration current = this.configuration; + if (current != null) { + listener.accept(current); + } } } diff --git a/dd-openfeature/src/main/java/datadog/trace/api/openfeature/ExposureHook.java b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/ExposureHook.java new file mode 100644 index 00000000000..151b1ab3eea --- /dev/null +++ b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/ExposureHook.java @@ -0,0 +1,111 @@ +package datadog.trace.api.openfeature; + +import static java.util.Objects.requireNonNull; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import java.util.Map; +import java.util.function.Consumer; + +/** + * The exposure hook, under its former package name. + * + * @deprecated Use {@link com.datadog.openfeature.ExposureHook} instead. + */ +@Deprecated +public final class ExposureHook implements Hook { + private final com.datadog.openfeature.ExposureHook delegate; + + /** + * Creates an exposure hook. + * + * @param callback the callback receiving each completed evaluation. + * @throws NullPointerException if the callback is {@code null}. + */ + public ExposureHook(final Consumer callback) { + requireNonNull(callback, "callback"); + this.delegate = + new com.datadog.openfeature.ExposureHook( + evaluation -> callback.accept(new Evaluation(evaluation))); + } + + @Override + public void finallyAfter( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + this.delegate.finallyAfter(context, details, hints); + } + + /** + * A completed evaluation and its Datadog exposure decision, under its former package name. + * + * @deprecated Use {@link com.datadog.openfeature.ExposureHook.Evaluation} instead. + */ + @Deprecated + public static final class Evaluation { + private final com.datadog.openfeature.ExposureHook.Evaluation delegate; + + Evaluation(final com.datadog.openfeature.ExposureHook.Evaluation delegate) { + this.delegate = delegate; + } + + /** + * Gets the evaluation context. + * + * @return the evaluation context, including the targeting key. + */ + public EvaluationContext getContext() { + return this.delegate.getContext(); + } + + /** + * Gets the evaluation details. + * + * @return the flag key, value, variant, error and flag metadata of the evaluation. + */ + public FlagEvaluationDetails getDetails() { + return this.delegate.getDetails(); + } + + /** + * Tells whether the evaluation is an exposure, whether or not this subject was already exposed. + * + * @return {@code true} when the evaluation succeeded and selected an allocation that logs + * exposures. + */ + public boolean isExposure() { + return this.delegate.isExposure(); + } + + /** + * Tells whether this exposure repeats one already recorded for this subject. + * + * @return {@code true} for a repeated exposure, {@code false} for a new or changed exposure and + * for an evaluation that is not an exposure. + */ + public boolean isCacheHit() { + return this.delegate.isCacheHit(); + } + + /** + * Tells whether this is a new or changed exposure: the decision the provider uses for Datadog. + * + * @return {@code true} for a new or changed exposure. + */ + public boolean shouldSend() { + return this.delegate.shouldSend(); + } + + /** + * Gets the features of the selected split. + * + * @return the unmodifiable features, empty when the split has none. + */ + public Map getFeatures() { + return this.delegate.getFeatures(); + } + } +} diff --git a/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java new file mode 100644 index 00000000000..ba8a1f9968f --- /dev/null +++ b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -0,0 +1,54 @@ +package datadog.trace.api.openfeature; + +import java.util.concurrent.TimeUnit; + +/** + * The Datadog OpenFeature provider, under its former package name. + * + * @deprecated Use {@link com.datadog.openfeature.Provider} instead. + */ +@Deprecated +public class Provider extends com.datadog.openfeature.Provider { + /** Creates a provider waiting up to 30 seconds for the initial flag configuration. */ + public Provider() { + super(); + } + + /** + * Creates a provider. + * + * @param options the provider options. + */ + public Provider(final com.datadog.openfeature.Provider.Options options) { + super(options); + } + + /** + * Creates a provider. + * + * @param options the provider options. + */ + public Provider(final Options options) { + super(options); + } + + /** + * The provider options, under their former package name. + * + * @deprecated Use {@link com.datadog.openfeature.Provider.Options} instead. + */ + @Deprecated + public static class Options extends com.datadog.openfeature.Provider.Options { + @Override + public Options enableEvaluationTelemetryOptOutParameter(final boolean enabled) { + super.enableEvaluationTelemetryOptOutParameter(enabled); + return this; + } + + @Override + public Options initTimeout(final long timeout, final TimeUnit unit) { + super.initTimeout(timeout, unit); + return this; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java index 248edec0485..1104b34f8fc 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -1,6 +1,7 @@ package com.datadog.openfeature.internal; import static java.util.Collections.emptyMap; +import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.NANOSECONDS; import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -27,6 +28,10 @@ import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import org.junit.jupiter.api.Test; @@ -138,6 +143,61 @@ void remoteConfigurationDeliversParsedConfigurationsAndReplaysToLateListeners() assertTrue(source.closed); } + @Test + void concurrentRemovalIsDeliveredAfterTheInitialReplay() throws Exception { + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestAgentBridge(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + runtime.start(); + source.listener.accept(EMPTY_UFC); + final List received = new CopyOnWriteArrayList<>(); + final CountDownLatch replayStarted = new CountDownLatch(1); + final CountDownLatch finishReplay = new CountDownLatch(1); + final CountDownLatch removalStarted = new CountDownLatch(1); + final ExecutorService executor = Executors.newFixedThreadPool(2); + try { + final Future registration = + executor.submit( + () -> + runtime.addConfigurationListener( + configuration -> { + if (configuration != null) { + replayStarted.countDown(); + try { + assertTrue(finishReplay.await(5, SECONDS)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + received.add(configuration); + })); + assertTrue(replayStarted.await(5, SECONDS)); + final Future removal = + executor.submit( + () -> { + removalStarted.countDown(); + source.listener.accept(null); + }); + assertTrue(removalStarted.await(5, SECONDS)); + assertThrows(TimeoutException.class, () -> removal.get(100, MILLISECONDS)); + + finishReplay.countDown(); + registration.get(5, SECONDS); + removal.get(5, SECONDS); + + assertEquals(2, received.size()); + assertEquals("Test", received.get(0).environment.name); + assertNull(received.get(1)); + } finally { + finishReplay.countDown(); + executor.shutdownNow(); + runtime.stop(); + } + } + @Test void removedListenersAreNotNotified() { final TestConfigurationSource source = new TestConfigurationSource(); diff --git a/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java new file mode 100644 index 00000000000..ace0ac5c052 --- /dev/null +++ b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java @@ -0,0 +1,81 @@ +package datadog.trace.api.openfeature; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonMap; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ImmutableMetadata; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Checks the deprecated exposure hook alias delegates to the SDK hook. */ +@SuppressWarnings("deprecation") +class ExposureHookTest { + @Test + void aliasReportsTheExposureDecision() { + final List evaluations = new ArrayList<>(); + final ExposureHook hook = new ExposureHook(evaluations::add); + final HookContext context = context(); + final FlagEvaluationDetails details = + details( + ImmutableMetadata.builder() + .addString("allocationKey", "allocation") + .addBoolean("__dd_do_log", true) + .addBoolean("__dd_exposure_cache_hit", false) + .addString("__dd_feature.holdout.key", "holdout") + .build()); + + hook.finallyAfter(context, details, emptyMap()); + + assertEquals(1, evaluations.size()); + final ExposureHook.Evaluation evaluation = evaluations.get(0); + assertSame(context.getCtx(), evaluation.getContext()); + assertSame(details, evaluation.getDetails()); + assertTrue(evaluation.isExposure()); + assertFalse(evaluation.isCacheHit()); + assertTrue(evaluation.shouldSend()); + assertEquals(singletonMap("holdout.key", "holdout"), evaluation.getFeatures()); + } + + @Test + void aliasReportsANonExposure() { + final List evaluations = new ArrayList<>(); + final ExposureHook hook = new ExposureHook(evaluations::add); + + hook.finallyAfter(context(), details(ImmutableMetadata.builder().build()), emptyMap()); + + final ExposureHook.Evaluation evaluation = evaluations.get(0); + assertFalse(evaluation.isExposure()); + assertFalse(evaluation.isCacheHit()); + assertFalse(evaluation.shouldSend()); + assertTrue(evaluation.getFeatures().isEmpty()); + } + + @Test + void aliasRejectsANullCallback() { + assertThrows(NullPointerException.class, () -> new ExposureHook(null)); + } + + private static HookContext context() { + return HookContext.from( + "flag", FlagValueType.STRING, null, null, new ImmutableContext("user"), "default"); + } + + private static FlagEvaluationDetails details(final ImmutableMetadata metadata) { + return FlagEvaluationDetails.builder() + .flagKey("flag") + .variant("on") + .value("on") + .flagMetadata(metadata) + .build(); + } +} diff --git a/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java new file mode 100644 index 00000000000..f7b06ea9033 --- /dev/null +++ b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -0,0 +1,49 @@ +package datadog.trace.api.openfeature; + +import static java.lang.invoke.MethodHandles.publicLookup; +import static java.lang.invoke.MethodType.methodType; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.lang.invoke.MethodHandle; +import org.junit.jupiter.api.Test; + +/** Checks the deprecated provider alias stays source and binary compatible. */ +@SuppressWarnings("deprecation") +class ProviderTest { + @Test + void aliasIsTheDatadogProvider() { + final Provider provider = new Provider(); + + assertInstanceOf(com.datadog.openfeature.Provider.class, provider); + assertEquals("datadog-openfeature-provider", provider.getMetadata().getName()); + } + + @Test + void aliasOptOutSetterKeepsItsBinarySignature() throws Throwable { + final Provider.Options options = new Provider.Options(); + final MethodHandle setter = + publicLookup() + .findVirtual( + Provider.Options.class, + "enableEvaluationTelemetryOptOutParameter", + methodType(Provider.Options.class, boolean.class)); + + assertSame(options, (Provider.Options) setter.invokeExact(options, true)); + } + + @Test + void aliasOptionsKeepTheirType() { + final Provider.Options options = new Provider.Options(); + + final Provider.Options configured = + options.initTimeout(5, SECONDS).enableEvaluationTelemetryOptOutParameter(true); + + assertSame(options, configured); + assertEquals(5, configured.getTimeout()); + assertEquals(SECONDS, configured.getUnit()); + assertInstanceOf(com.datadog.openfeature.Provider.class, new Provider(configured)); + } +} diff --git a/dd-smoke-tests/openfeature/application/build.gradle b/dd-smoke-tests/openfeature/application/build.gradle index 4e2537caa7e..130fc74d876 100644 --- a/dd-smoke-tests/openfeature/application/build.gradle +++ b/dd-smoke-tests/openfeature/application/build.gradle @@ -31,7 +31,7 @@ if (hasProperty('featureFlaggingApiJar')) { } dependencies { - // OpenFeature SDK is an API dependency of feature-flagging-api but is not + // OpenFeature SDK is an API dependency of dd-openfeature but is not // transitively resolved when the jar is passed as a files() dependency. implementation 'dev.openfeature:sdk:1.23.0' implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/dd-smoke-tests/openfeature/application/src/main/java/datadog/smoketest/springboot/openfeature/OpenFeatureConfiguration.java b/dd-smoke-tests/openfeature/application/src/main/java/datadog/smoketest/springboot/openfeature/OpenFeatureConfiguration.java index f8a8ac51e45..8ed5c9083a4 100644 --- a/dd-smoke-tests/openfeature/application/src/main/java/datadog/smoketest/springboot/openfeature/OpenFeatureConfiguration.java +++ b/dd-smoke-tests/openfeature/application/src/main/java/datadog/smoketest/springboot/openfeature/OpenFeatureConfiguration.java @@ -1,6 +1,6 @@ package datadog.smoketest.springboot.openfeature; -import datadog.trace.api.openfeature.Provider; +import com.datadog.openfeature.Provider; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.OpenFeatureAPI; import org.springframework.context.annotation.Bean; diff --git a/dd-smoke-tests/openfeature/build.gradle b/dd-smoke-tests/openfeature/build.gradle index 726b6068fe9..ce7d3555799 100644 --- a/dd-smoke-tests/openfeature/build.gradle +++ b/dd-smoke-tests/openfeature/build.gradle @@ -15,12 +15,11 @@ smokeTestApp { artifactPath = 'libs/openfeature-smoketest.jar' sysProperty = 'datadog.smoketest.springboot.shadowJar.path' } - projectJar('featureFlaggingApiJar', project(':products:feature-flagging:feature-flagging-api')) + projectJar('featureFlaggingApiJar', project(':dd-openfeature')) } dependencies { testImplementation project(':dd-smoke-tests') - testImplementation project(':products:feature-flagging:feature-flagging-lib') } spotless { diff --git a/dd-smoke-tests/openfeature/gradle.lockfile b/dd-smoke-tests/openfeature/gradle.lockfile index f41834d95d2..147441f83b0 100644 --- a/dd-smoke-tests/openfeature/gradle.lockfile +++ b/dd-smoke-tests/openfeature/gradle.lockfile @@ -65,7 +65,7 @@ org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath 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 @@ -80,8 +80,8 @@ org.dom4j:dom4j:2.2.0=spotbugs org.gmetrics:GMetrics:2.1.0=codenarc org.hamcrest:hamcrest-core:1.3=testCompileClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core-jdk11:4.0.6=testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core:4.0.6=testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath @@ -116,7 +116,6 @@ org.slf4j:slf4j-api:1.7.36=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j org.slf4j:slf4j-api:2.0.18=spotbugs 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.2=testCompileClasspath,testRuntimeClasspath diff --git a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java index bf5bab30d05..aed85035b09 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java @@ -49,8 +49,6 @@ public final class ConfigDefaults { static final boolean DEFAULT_INJECT_DATADOG_ATTRIBUTE = true; static final String DEFAULT_SITE = "datadoghq.com"; static final String DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE = "agentless"; - static final int DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS = 30; - static final int DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS = 5; static final boolean DEFAULT_CODE_ORIGIN_FOR_SPANS_INTERFACE_SUPPORT = false; static final int DEFAULT_CODE_ORIGIN_MAX_USER_FRAMES = 8; diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 0b388f0c1b0..38738cf1447 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -89,8 +89,6 @@ import static datadog.trace.api.ConfigDefaults.DEFAULT_ELASTICSEARCH_BODY_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_ELASTICSEARCH_PARAMS_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_EXPERIMENTATAL_JEE_SPLIT_BY_DEPLOYMENT; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_GRPC_CLIENT_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_GRPC_SERVER_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_HEALTH_METRICS_ENABLED; @@ -746,9 +744,6 @@ import static datadog.trace.api.config.TracerConfig.WRITER_TYPE; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.api.telemetry.LogCollector.SEND_TELEMETRY; import static datadog.trace.bootstrap.instrumentation.api.WriterConstants.MULTI_WRITER_TYPE; @@ -1255,10 +1250,6 @@ public static String getHostName() { private final int remoteConfigMaxExtraServices; - private final String featureFlaggingConfigurationSourceAgentlessBaseUrl; - private final int featureFlaggingConfigurationSourcePollIntervalSeconds; - private final int featureFlaggingConfigurationSourceRequestTimeoutSeconds; - private final boolean dbmInjectSqlBaseHash; private final String dbmPropagationMode; private final boolean dbmPropagationOracleActionOnlyEnabled; @@ -2959,35 +2950,6 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) "Unsupported Feature Flagging configuration source; provider disabled: '{}'", configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE)); } - featureFlaggingConfigurationSourceAgentlessBaseUrl = - configProvider.getStringNotEmpty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, null); - int configuredFeatureFlaggingPollIntervalSeconds = - configProvider.getInteger( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS); - if (configuredFeatureFlaggingPollIntervalSeconds <= 0) { - log.warn( - "Invalid Feature Flagging agentless poll interval: {}. The value must be positive", - configuredFeatureFlaggingPollIntervalSeconds); - configuredFeatureFlaggingPollIntervalSeconds = - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; - } - featureFlaggingConfigurationSourcePollIntervalSeconds = - configuredFeatureFlaggingPollIntervalSeconds; - int configuredFeatureFlaggingRequestTimeoutSeconds = - configProvider.getInteger( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS); - if (configuredFeatureFlaggingRequestTimeoutSeconds <= 0) { - log.warn( - "Invalid Feature Flagging agentless request timeout: {}. The value must be positive", - configuredFeatureFlaggingRequestTimeoutSeconds); - configuredFeatureFlaggingRequestTimeoutSeconds = - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; - } - featureFlaggingConfigurationSourceRequestTimeoutSeconds = - configuredFeatureFlaggingRequestTimeoutSeconds; dynamicInstrumentationEnabled = configProvider.getBoolean( @@ -4931,18 +4893,6 @@ public String getFeatureFlaggingConfigurationSource() { return source == FeatureFlaggingSource.UNSET ? null : source.name().toLowerCase(Locale.ROOT); } - public String getFeatureFlaggingConfigurationSourceAgentlessBaseUrl() { - return featureFlaggingConfigurationSourceAgentlessBaseUrl; - } - - public int getFeatureFlaggingConfigurationSourcePollIntervalSeconds() { - return featureFlaggingConfigurationSourcePollIntervalSeconds; - } - - public int getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds() { - return featureFlaggingConfigurationSourceRequestTimeoutSeconds; - } - public boolean isDynamicInstrumentationEnabled() { return dynamicInstrumentationEnabled; } @@ -6908,12 +6858,6 @@ public String toString() { + isFeatureFlaggingProviderEnabled() + ", featureFlaggingConfigurationSource=" + getFeatureFlaggingConfigurationSource() - + ", featureFlaggingConfigurationSourceAgentlessBaseUrl=" - + featureFlaggingConfigurationSourceAgentlessBaseUrl - + ", featureFlaggingConfigurationSourcePollIntervalSeconds=" - + featureFlaggingConfigurationSourcePollIntervalSeconds - + ", featureFlaggingConfigurationSourceRequestTimeoutSeconds=" - + featureFlaggingConfigurationSourceRequestTimeoutSeconds + ", debuggerEnabled=" + dynamicInstrumentationEnabled + ", debuggerUploadTimeout=" diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java index b82e1aa6769..88a71f14565 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java @@ -1,8 +1,6 @@ package datadog.trace.api; import static datadog.trace.api.ConfigDefaults.DEFAULT_AGENT_HOST; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_HTTP_CLIENT_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_HTTP_SERVER_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_PARTIAL_FLUSH_MIN_SPANS; @@ -170,8 +168,6 @@ import static datadog.trace.api.env.FixedCapturedEnvironment.useFixedEnv; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.bootstrap.config.provider.ConfigProvider.withPropertiesOverride; import static java.util.Arrays.asList; @@ -3681,20 +3677,6 @@ void traceResourceRenamingAlwaysSimplifiedEndpointWithValue(String value, boolea assertEquals(expected, config.isTraceResourceRenamingAlwaysSimplifiedEndpoint()); } - @Test - void agentlessFeatureFlagTimingUsesPositiveConfiguredValues() throws Exception { - Properties properties = new Properties(); - properties.setProperty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, "60"); - properties.setProperty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, "4"); - - Config config = newConfig(withPropertiesOverride(properties)); - - assertEquals(60, config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()); - assertEquals(4, config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()); - } - @TableTest({ "scenario | value | expected ", "not set | | agentless ", @@ -3754,23 +3736,6 @@ void featureFlagConfigurationAppliesMigrationPrecedence( assertEquals(expectedSource, config.getFeatureFlaggingConfigurationSource()); } - @Test - void agentlessFeatureFlagTimingFallsBackForNonPositiveValues() throws Exception { - Properties properties = new Properties(); - properties.setProperty(FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, "0"); - properties.setProperty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, "-1"); - - Config config = newConfig(withPropertiesOverride(properties)); - - assertEquals( - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS, - config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()); - assertEquals( - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS, - config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()); - } - /** Reads a private {@link Config} field that has no getter. */ private static Object fieldValue(Config config, String fieldName) throws ReflectiveOperationException { diff --git a/products/feature-flagging/feature-flagging-agent/build.gradle.kts b/products/feature-flagging/feature-flagging-agent/build.gradle.kts index 7ba371d7dcc..b15250c5c0b 100644 --- a/products/feature-flagging/feature-flagging-agent/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-agent/build.gradle.kts @@ -1,7 +1,5 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.DependencyFilter import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar -import org.gradle.api.Action -import org.gradle.kotlin.dsl.project plugins { `java-library` diff --git a/products/feature-flagging/feature-flagging-agent/gradle.lockfile b/products/feature-flagging/feature-flagging-agent/gradle.lockfile index dc1fc729597..f295d3c4dc8 100644 --- a/products/feature-flagging/feature-flagging-agent/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-agent/gradle.lockfile @@ -47,7 +47,7 @@ org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath 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 @@ -65,8 +65,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,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core:4.0.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath 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 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 1ef61e70328..0b971d3f6f0 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 @@ -1,255 +1,61 @@ package com.datadog.featureflag; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; -import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_SHUTDOWN; -import static datadog.trace.util.AgentThreadFactory.newAgentThread; - import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; -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; +/** + * Starts the agent Feature Flags backend used by the {@code dd-openfeature} SDK instrumentation. + * Apart from registering the Remote Configuration product, the backend does no work until the SDK + * uses it. + */ public class FeatureFlaggingSystem { - - @FunctionalInterface - interface SystemInitializer { - void initialize(SharedCommunicationObjects sco, Config config); - } - private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlaggingSystem.class); - private static volatile ConfigurationSourceService CONFIG_SERVICE; - private static volatile ExposureWriter EXPOSURE_WRITER; - private static volatile FlagEvaluationWriter FLAG_EVAL_WRITER; - private static volatile SpanEnrichmentWriter SPAN_ENRICHMENT_WRITER; - private static volatile FeatureFlaggingGateway.ActivationListener ACTIVATION_LISTENER; - private static volatile boolean STARTED; - private static Thread SHUTDOWN_HOOK; + private static volatile FeatureFlagsBackend BACKEND; private FeatureFlaggingSystem() {} - public static void start(final SharedCommunicationObjects sco) { - start(sco, FeatureFlaggingSystem::initializeSystem); - } - - static synchronized void start( - final SharedCommunicationObjects sco, final SystemInitializer systemInitializer) { - if (STARTED) { + @SuppressFBWarnings( + value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", + justification = + "Agent-internal class; Class object does not escape to app code and lock only guards the subsystem lifecycle.") + public static synchronized void start(final SharedCommunicationObjects sco) { + if (BACKEND != null) { LOGGER.debug("Feature Flagging system already started"); return; } - LOGGER.debug("Feature Flagging system starting"); final Config config = Config.get(); - STARTED = true; - if (!config.isFeatureFlaggingProviderEnabled()) { LOGGER.debug("Feature Flagging system disabled"); return; } - - if (CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { - final FeatureFlaggingGateway.ActivationListener activationListener = - () -> activateAgentless(sco, config, systemInitializer); - ACTIVATION_LISTENER = activationListener; - FeatureFlaggingGateway.addActivationListener(activationListener); - LOGGER.debug("Feature Flagging system awaiting application provider activation"); - return; - } - - initializeOrRollBack(sco, config, systemInitializer); - } - - private static synchronized void activateAgentless( - final SharedCommunicationObjects sco, - final Config config, - final SystemInitializer systemInitializer) { - final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; - if (!STARTED || activationListener == null) { - return; - } - ACTIVATION_LISTENER = null; - FeatureFlaggingGateway.removeActivationListener(activationListener); - initializeOrRollBack(sco, config, systemInitializer); - } - - // Any failure leaves the subsystem fully stopped: stop() releases whatever initializeSystem - // managed to publish before it threw, so a later start() begins from a clean state. - private static void initializeOrRollBack( - final SharedCommunicationObjects sco, - final Config config, - final SystemInitializer systemInitializer) { + final FeatureFlagsBackend backend = new FeatureFlagsBackend(sco, config); try { - systemInitializer.initialize(sco, config); - if (!config.isTraceEnabled() && (EXPOSURE_WRITER != null || FLAG_EVAL_WRITER != null)) { - // Tracing already owns the pre-application-hook shutdown path. Without it, only an - // activated Feature Flagging subsystem needs a hook; do not stop other products. - final Thread hook = newAgentThread(FEATURE_FLAG_SHUTDOWN, FeatureFlaggingSystem::stop); - Runtime.getRuntime().addShutdownHook(hook); - SHUTDOWN_HOOK = hook; - } + backend.start(); } catch (final RuntimeException | Error e) { - stop(); + backend.close(); throw e; } - } - - 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 FeatureFlagRouteSelector routeSelector = new FeatureFlagRouteSelector(); - final ExposureWriter exposureWriter = new ExposureWriterImpl(sco, config, routeSelector); - 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(sco, config, routeSelector); - // Publish before start() so a failed start is still reachable by the rollback in stop(). - FLAG_EVAL_WRITER = evalWriter; - evalWriter.start(); - LOGGER.debug("Flag evaluation EVP writer started"); - } else { - FeatureFlaggingGateway.setFlagEvalWriter(null); - LOGGER.debug( - "Flag evaluation EVP writer disabled ({}=false)", - FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED); - } - - // APM span enrichment: agent-side listener for flag-evaluation seam events. Uses the process- - // wide singleton so a subsystem restart reuses the one already-registered trace interceptor - // (which the tracer cannot remove) instead of registering a second, rejected one. Cheap: it - // only accumulates once the provider's gate-on capture hook dispatches events, and registers - // its interceptor lazily on the first such event. - SPAN_ENRICHMENT_WRITER = SpanEnrichmentWriter.getInstance(); - SPAN_ENRICHMENT_WRITER.init(); - + BACKEND = backend; + FeatureFlaggingGateway.register(backend); LOGGER.debug("Feature Flagging system started"); } - static void initialize( - final ConfigurationSourceService configService, final ExposureWriter exposureWriter) { - try { - if (configService != null) { - configService.init(); - } - exposureWriter.init(); - CONFIG_SERVICE = configService; - EXPOSURE_WRITER = exposureWriter; - } catch (final RuntimeException | Error e) { - try { - exposureWriter.close(); - } finally { - if (configService != null) { - configService.close(); - } - } - throw e; - } - } - - static ConfigurationSourceService createConfigurationSourceService( - final SharedCommunicationObjects sco, final Config config) { - final String configurationSource = config.getFeatureFlaggingConfigurationSource(); - if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(configurationSource)) { - if (!config.isRemoteConfigEnabled()) { - throw new IllegalStateException("Feature Flagging system started without RC"); - } - return new RemoteConfigServiceImpl(sco, config); - } - if (CONFIGURATION_SOURCE_AGENTLESS.equals(configurationSource)) { - return new AgentlessConfigurationSource(config); - } - return null; - } - @SuppressFBWarnings( value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", justification = "Agent-internal class; Class object does not escape to app code and lock only guards the subsystem lifecycle.") public static synchronized void stop() { - final long deadline = FeatureFlagEventWriter.shutdownDeadline(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - FeatureFlaggingGateway.setFlagEvalWriter(null); - final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; - final FlagEvaluationWriter flagEvalWriter = FLAG_EVAL_WRITER; - final SpanEnrichmentWriter spanEnrichmentWriter = SPAN_ENRICHMENT_WRITER; - final ExposureWriter exposureWriter = EXPOSURE_WRITER; - final ConfigurationSourceService configService = CONFIG_SERVICE; - STARTED = false; - ACTIVATION_LISTENER = null; - FLAG_EVAL_WRITER = null; - SPAN_ENRICHMENT_WRITER = null; - EXPOSURE_WRITER = null; - CONFIG_SERVICE = null; - final Thread shutdownHook = SHUTDOWN_HOOK; - SHUTDOWN_HOOK = null; - if (shutdownHook != null && shutdownHook != Thread.currentThread()) { - try { - Runtime.getRuntime().removeShutdownHook(shutdownHook); - } catch (IllegalStateException ignored) { - // JVM shutdown has already started. The idempotent hook will observe the stopped state. - } - } - if (activationListener != null) { - FeatureFlaggingGateway.removeActivationListener(activationListener); + final FeatureFlagsBackend backend = BACKEND; + BACKEND = null; + FeatureFlaggingGateway.register(null); + if (backend != null) { + backend.close(); } - requestWriterShutdown(flagEvalWriter); - requestWriterShutdown(exposureWriter); - closeQuietly(spanEnrichmentWriter); - closeQuietly(configService); - awaitWriterTermination(flagEvalWriter, deadline); - awaitWriterTermination(exposureWriter, deadline); LOGGER.debug("Feature Flagging system stopped"); } - - static boolean isAwaitingApplicationActivation() { - return ACTIVATION_LISTENER != null; - } - - static boolean isShutdownHookRegistered() { - return SHUTDOWN_HOOK != null; - } - - private static void requestWriterShutdown(final AutoCloseable writer) { - if (writer instanceof FeatureFlagEventWriter) { - ((FeatureFlagEventWriter) writer).requestShutdown(); - } else { - closeQuietly(writer); - } - } - - private static void awaitWriterTermination(final AutoCloseable writer, final long deadline) { - if (writer instanceof FeatureFlagEventWriter) { - ((FeatureFlagEventWriter) writer).awaitTermination(deadline); - } - } - - static boolean isExposureWriterStarted() { - return EXPOSURE_WRITER != null; - } - - static boolean isConfigurationSourceStarted() { - return CONFIG_SERVICE != null; - } - - private static void closeQuietly(final AutoCloseable resource) { - if (resource != null) { - try { - resource.close(); - } catch (Exception ignored) { - } - } - } } diff --git a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlagRouteWiringTest.java b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlagRouteWiringTest.java deleted file mode 100644 index 8aedc5c06f8..00000000000 --- a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlagRouteWiringTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V4_EVP_PROXY_ENDPOINT; -import static datadog.trace.test.junit.utils.config.WithConfigExtension.injectSysConfig; -import static java.util.Collections.emptyMap; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.sun.net.httpserver.HttpServer; -import datadog.communication.ddagent.DDAgentFeaturesDiscovery; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -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 datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.test.junit.utils.config.WithConfig; -import java.net.InetSocketAddress; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -class FeatureFlagRouteWiringTest { - @Test - @Timeout(15) - @WithConfig(key = "feature.flags.configuration.source", value = "agentless") - @WithConfig(key = "API_KEY", value = "test-api-key", env = true) - @WithConfig(key = "site", value = "datadoghq.com") - void productionWritersShareFallbackForFutureBatches() throws Exception { - final HttpServer configurationServer = - HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - configurationServer.createContext( - "/", - exchange -> { - exchange.sendResponseHeaders(404, -1); - exchange.close(); - }); - configurationServer.start(); - injectSysConfig( - "feature.flags.configuration.source.agentless.base.url", - "http://127.0.0.1:" + configurationServer.getAddress().getPort() + "/config"); - final List requests = new CopyOnWriteArrayList<>(); - final CountDownLatch exposureDelivered = new CountDownLatch(1); - final OkHttpClient client = - new OkHttpClient.Builder() - .addInterceptor( - chain -> { - final Request request = chain.request(); - requests.add(request); - final boolean direct = - request.url().host().equals("event-platform-intake.datadoghq.com"); - if (direct && request.url().encodedPath().equals("/api/v2/exposures")) { - exposureDelivered.countDown(); - } - return new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(direct ? 202 : 404) - .message("test") - .body(ResponseBody.create(null, "{}")) - .build(); - }) - .build(); - final DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - when(discovery.supportsEvpProxyHeaders(any())).thenReturn(true); - when(discovery.supportsEvpProxy()).thenReturn(true); - when(discovery.getEvpProxyEndpoint()).thenReturn(V4_EVP_PROXY_ENDPOINT); - final SharedCommunicationObjects sco = - new SharedCommunicationObjects() { - @Override - public OkHttpClient getIntakeHttpClient() { - return client; - } - - @Override - public DDAgentFeaturesDiscovery featuresDiscovery(Config config) { - return discovery; - } - }; - sco.agentUrl = HttpUrl.get("http://localhost:8126/"); - sco.agentHttpClient = client; - // A terminal local configuration response avoids retry backoff before event activation. - try { - FeatureFlaggingSystem.start(sco); - FeatureFlaggingGateway.activate(); - FeatureFlaggingGateway.dispatch( - new ExposureEvent( - 1, - new Allocation("allocation"), - new Flag("flag"), - new Variant("on"), - new Subject("user", emptyMap()))); - assertTrue(exposureDelivered.await(5, TimeUnit.SECONDS)); - FeatureFlaggingGateway.getFlagEvalWriter() - .enqueue(new FlagEvalEvent("flag", "on", "allocation", "user", 1, emptyMap())); - FeatureFlaggingSystem.stop(); - - assertEquals(3, requests.size(), "One rejected local exposure, then two direct batches"); - assertEquals("/evp_proxy/v4/api/v2/exposures", requests.get(0).url().encodedPath()); - assertNull(requests.get(0).header("DD-API-KEY")); - assertEquals("/api/v2/exposures", requests.get(1).url().encodedPath()); - assertEquals("/api/v2/flagevaluation", requests.get(2).url().encodedPath()); - for (int i = 1; i < requests.size(); i++) { - assertEquals("event-platform-intake.datadoghq.com", requests.get(i).url().host()); - assertEquals("test-api-key", requests.get(i).header("DD-API-KEY")); - assertNull(requests.get(i).header("X-Datadog-EVP-Subdomain")); - } - } finally { - FeatureFlaggingSystem.stop(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - client.connectionPool().evictAll(); - client.dispatcher().executorService().shutdownNow(); - configurationServer.stop(0); - } - } -} diff --git a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlagShutdownBudgetTest.java b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlagShutdownBudgetTest.java deleted file mode 100644 index 3987d852b13..00000000000 --- a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlagShutdownBudgetTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.Collections.emptyMap; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import datadog.communication.BackendApi; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -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 datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import java.lang.reflect.Field; -import java.util.concurrent.CountDownLatch; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -@Timeout(15) -class FeatureFlagShutdownBudgetTest { - private final CountDownLatch bothPostsStarted = new CountDownLatch(2); - private final CountDownLatch releasePosts = new CountDownLatch(1); - private Thread stopping; - - @AfterEach - void cleanup() throws Exception { - releasePosts.countDown(); - if (stopping != null) { - stopping.join(5000); - assertFalse(stopping.isAlive()); - } - FeatureFlaggingSystem.stop(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @Test - void stopSignalsBothWritersBeforeWaitingForEither() throws Exception { - startWriters(); - stopping = new Thread(FeatureFlaggingSystem::stop); - stopping.start(); - assertTrue( - bothPostsStarted.await(500, MILLISECONDS), - "Exposure shutdown was delayed by the evaluation writer's wait"); - } - - @Test - void stopUsesOneBoundedBudgetForBothBlockedPosts() throws Exception { - startWriters(); - final long started = System.nanoTime(); - FeatureFlaggingSystem.stop(); - final long elapsed = NANOSECONDS.toMillis(System.nanoTime() - started); - assertTrue(elapsed < 1300, "Two writer waits consumed " + elapsed + " ms"); - } - - private void startWriters() throws Exception { - final BackendApi api = mock(BackendApi.class); - when(api.post(any(), any(), any(), any(), anyBoolean())) - .thenAnswer( - invocation -> { - bothPostsStarted.countDown(); - boolean interrupted = false; - while (releasePosts.getCount() != 0) { - try { - assertTrue(releasePosts.await(12, SECONDS)); - } catch (InterruptedException ignored) { - interrupted = true; - } - } - if (interrupted) { - Thread.currentThread().interrupt(); - } - return null; - }); - final ExposureWriterImpl exposures = - new ExposureWriterImpl(16, Long.MAX_VALUE, NANOSECONDS, () -> api, Config.get(), 5000); - final FlagEvaluationWriterImpl evaluations = - new FlagEvaluationWriterImpl(16, Long.MAX_VALUE, NANOSECONDS, () -> api, Config.get()); - setWriter("EXPOSURE_WRITER", exposures); - setWriter("FLAG_EVAL_WRITER", evaluations); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - exposures.init(); - evaluations.start(); - exposures.accept( - new ExposureEvent( - 1, - new Allocation("alloc"), - new Flag("flag"), - new Variant("on"), - new Subject("user", emptyMap()))); - evaluations.enqueue(new FlagEvalEvent("flag", "on", "alloc", "user", 1, emptyMap())); - } - - private static void setWriter(String name, Object writer) throws Exception { - final Field field = FeatureFlaggingSystem.class.getDeclaredField(name); - field.setAccessible(true); - field.set(null, writer); - } -} 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 23b71f97807..5e6cbfb5d48 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 @@ -2,452 +2,73 @@ import static datadog.trace.api.config.RemoteConfigConfig.REMOTE_CONFIGURATION_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; 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.ArgumentMatchers.eq; -import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.doThrow; 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.ddagent.DDAgentFeaturesDiscovery; import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.remoteconfig.Capabilities; -import datadog.remoteconfig.ConfigurationDeserializer; import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.Product; -import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; import datadog.trace.test.junit.utils.config.WithConfig; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +@WithConfig(key = FEATURE_FLAGS_ENABLED, value = "true") class FeatureFlaggingSystemTest { - @Test - @WithConfig(key = "trace.enabled", value = "false") - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - void traceDisabledHookFollowsActivationStopAndRestart() { - final SharedCommunicationObjects sco = sharedCommunicationObjects(); - final ExposureWriter writer = mock(ExposureWriter.class); - final FeatureFlaggingSystem.SystemInitializer initializer = - (ignoredSco, ignoredConfig) -> FeatureFlaggingSystem.initialize(null, writer); - for (int restart = 0; restart < 2; restart++) { - FeatureFlaggingSystem.start(sco, initializer); - assertFalse(FeatureFlaggingSystem.isShutdownHookRegistered()); - FeatureFlaggingGateway.activate(); - FeatureFlaggingGateway.activate(); - assertTrue(FeatureFlaggingSystem.isShutdownHookRegistered()); - FeatureFlaggingSystem.stop(); - assertFalse(FeatureFlaggingSystem.isShutdownHookRegistered()); - } - verify(writer, times(2)).init(); - verify(writer, times(2)).close(); - } - - @Test - @WithConfig(key = "trace.enabled", value = "false") - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - void failedActivationDoesNotLeaveAShutdownHook() { - final ExposureWriter writer = mock(ExposureWriter.class); - FeatureFlaggingSystem.start( - sharedCommunicationObjects(), - (sco, config) -> { - FeatureFlaggingSystem.initialize(null, writer); - throw new IllegalStateException("failed activation"); - }); - assertThrows(IllegalStateException.class, FeatureFlaggingGateway::activate); - assertFalse(FeatureFlaggingSystem.isShutdownHookRegistered()); - verify(writer).close(); - } - - @Test - @WithConfig(key = "trace.enabled", value = "true") - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - void tracingOwnsShutdownWithoutAnAdditionalFeatureFlagHook() { - FeatureFlaggingSystem.start( - sharedCommunicationObjects(), - (sco, config) -> FeatureFlaggingSystem.initialize(null, mock(ExposureWriter.class))); - FeatureFlaggingGateway.activate(); - assertFalse(FeatureFlaggingSystem.isShutdownHookRegistered()); - } - @AfterEach - void resetFlagEvaluationGateway() { + void stop() { FeatureFlaggingSystem.stop(); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://127.0.0.1:1") - void agentlessStartWaitsForApplicationProviderActivationWithoutPreparingDelivery() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - clearInvocations(sharedCommunicationObjects); - - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); - assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); - } - - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); - assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); } @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - void agentlessActivationInitializesSystemOnce() { - final SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - final FeatureFlaggingSystem.SystemInitializer systemInitializer = - mock(FeatureFlaggingSystem.SystemInitializer.class); - - FeatureFlaggingSystem.start(sharedCommunicationObjects, systemInitializer); - - verifyNoInteractions(systemInitializer); - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - - FeatureFlaggingGateway.activate(); - FeatureFlaggingGateway.activate(); - - verify(systemInitializer).initialize(eq(sharedCommunicationObjects), any(Config.class)); - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - void agentlessInitializationFailureCleansUpAndAllowsRetry() { - final SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - final FeatureFlaggingSystem.SystemInitializer failedInitializer = - mock(FeatureFlaggingSystem.SystemInitializer.class); - final IllegalStateException initializationFailure = - new IllegalStateException("system initialization failed"); - doThrow(initializationFailure) - .when(failedInitializer) - .initialize(any(SharedCommunicationObjects.class), any(Config.class)); - - FeatureFlaggingSystem.start(sharedCommunicationObjects, failedInitializer); - - final IllegalStateException thrown = - assertThrows(IllegalStateException.class, FeatureFlaggingGateway::activate); - - assertSame(initializationFailure, thrown); - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); - assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); - - final FeatureFlaggingSystem.SystemInitializer retryInitializer = - mock(FeatureFlaggingSystem.SystemInitializer.class); - FeatureFlaggingSystem.start(sharedCommunicationObjects, retryInitializer); - FeatureFlaggingGateway.activate(); + void startRegistersTheBackendAndStopUnregistersIt() { + FeatureFlaggingSystem.start(mock(SharedCommunicationObjects.class)); + assertInstanceOf(FeatureFlagsBackend.class, FeatureFlaggingGateway.backend()); - verify(retryInitializer).initialize(eq(sharedCommunicationObjects), any(Config.class)); + FeatureFlaggingSystem.stop(); + assertNull(FeatureFlaggingGateway.backend()); } @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://127.0.0.1:1") - void agentlessStopRemovesPendingApplicationProviderActivation() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - clearInvocations(sharedCommunicationObjects); - - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + void startIsIdempotent() { + final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); + FeatureFlaggingSystem.start(sco); + final FeatureFlaggingGateway.Backend backend = FeatureFlaggingGateway.backend(); + assertInstanceOf(FeatureFlagsBackend.class, backend); - FeatureFlaggingSystem.stop(); - clearInvocations(sharedCommunicationObjects); - FeatureFlaggingGateway.activate(); - - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); - } + FeatureFlaggingSystem.start(sco); + assertSame(backend, FeatureFlaggingGateway.backend()); } @Test - @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "true") @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") - void testFeatureFlagSystemInitialization() { - ConfigurationPoller poller = mock(ConfigurationPoller.class); - DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - when(discovery.supportsEvpProxy()).thenReturn(true); - when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); - when(sharedCommunicationObjects.configurationPoller(any(Config.class))).thenReturn(poller); - when(sharedCommunicationObjects.featuresDiscovery(any(Config.class))).thenReturn(discovery); - sharedCommunicationObjects.agentUrl = HttpUrl.get("http://localhost"); - sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - - FeatureFlaggingSystem.start(sharedCommunicationObjects); - FeatureFlaggingSystem.start(sharedCommunicationObjects); + void failedStartRollsBackTheRemoteConfigRegistration() { + final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + when(sco.configurationPoller(any())).thenReturn(poller); + doThrow(new IllegalStateException("poller failed")).when(poller).start(); - verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).addListener(eq(Product.FFE_FLAGS), any(ConfigurationDeserializer.class), any()); - verify(poller).start(); - assertTrue(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNotNull(FeatureFlaggingGateway.getFlagEvalWriter()); - - FeatureFlaggingSystem.stop(); - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - // stop() is idempotent: a second call must be a safe no-op. - FeatureFlaggingSystem.stop(); + assertThrows(IllegalStateException.class, () -> FeatureFlaggingSystem.start(sco)); + assertNull(FeatureFlaggingGateway.backend()); verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); verify(poller).removeListeners(Product.FFE_FLAGS); - verify(poller).stop(); - } - - @Test - @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "false") - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://localhost:1/config") - void testFlagEvaluationWriterCanBeDisabled() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - FeatureFlaggingGateway.setFlagEvalWriter(mock(FlagEvaluationWriter.class)); - - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - // Agentless defers initialization until the application provider activates. - FeatureFlaggingGateway.activate(); - - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } finally { - FeatureFlaggingSystem.stop(); - } - } - - @Test - void testFeatureFlagSystemShutdownClearsGatewayState() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - FeatureFlaggingGateway.setFlagEvalWriter(mock(FlagEvaluationWriter.class)); - - FeatureFlaggingSystem.stop(); - - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - void failedStartRollsBackPartiallyInitializedState() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - assertThrows( - IllegalStateException.class, () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - - // A failed start must leave nothing behind: no listener awaiting activation, no gateway - // writer, and STARTED cleared so a later start() is not swallowed as "already started". - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertThrows( - IllegalStateException.class, () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - void testThatRemoteConfigIsRequired() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - try { - assertThrows( - IllegalStateException.class, - () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - } finally { - FeatureFlaggingSystem.stop(); - } - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://localhost:1/config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - void agentlessConfigurationSourceUsesHttpServiceWithoutRemoteConfig() { - assertInstanceOf( - AgentlessConfigurationSource.class, - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://localhost:1/config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "true") - void agentlessConfigurationSourceStartsTelemetryWritersWithoutRemoteConfig() { - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects()); - // Agentless defers initialization until the application provider activates. - FeatureFlaggingGateway.activate(); - - assertTrue(FeatureFlaggingSystem.isExposureWriterStarted()); - assertTrue(FeatureFlaggingSystem.isConfigurationSourceStarted()); - assertTrue(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNotNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } finally { - FeatureFlaggingSystem.stop(); - } - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") - void explicitRemoteConfigUsesRemoteConfigService() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - when(sharedCommunicationObjects.configurationPoller(any(Config.class))) - .thenReturn(mock(ConfigurationPoller.class)); - - assertInstanceOf( - RemoteConfigServiceImpl.class, - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects, Config.get())); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "offline") - void offlineConfigurationSourceDoesNotStartNetworkSource() { - assertNull( - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); } @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "invalid") - void invalidConfigurationSourceDoesNotStartNetworkSource() { - assertNull( - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); - } - - @Test - void unsupportedNormalizedConfigurationSourceDoesNotStartNetworkSource() { - Config config = mock(Config.class); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn("invalid"); - - assertNull( - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), config)); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "offline") - void startWithOfflineConfigurationSourceDisablesSystem() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - try { - assertDoesNotThrow(() -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); - } - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "invalid") - void startWithInvalidConfigurationSourceDisablesSystem() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - try { - assertDoesNotThrow(() -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); - } - } - - @Test - void initializationFailureClosesConfigurationSourceAndExposureWriter() { - ConfigurationSourceService configService = mock(ConfigurationSourceService.class); - ExposureWriter exposureWriter = mock(ExposureWriter.class); - doThrow(new IllegalStateException("exposure init failed")).when(exposureWriter).init(); - - assertThrows( - IllegalStateException.class, - () -> FeatureFlaggingSystem.initialize(configService, exposureWriter)); - - verify(configService).init(); - verify(configService).close(); - verify(exposureWriter).close(); - } - - @Test - void initializationFailureWithoutConfigurationSourceClosesExposureWriter() { - ExposureWriter exposureWriter = mock(ExposureWriter.class); - doThrow(new IllegalStateException("exposure init failed")).when(exposureWriter).init(); - - assertThrows( - IllegalStateException.class, () -> FeatureFlaggingSystem.initialize(null, exposureWriter)); - - verify(exposureWriter).close(); - } - - @Test - void initializationFailureClosesConfigurationSourceWhenExposureWriterCloseFails() { - ConfigurationSourceService configService = mock(ConfigurationSourceService.class); - ExposureWriter exposureWriter = mock(ExposureWriter.class); - doThrow(new IllegalStateException("exposure init failed")).when(exposureWriter).init(); - doThrow(new IllegalArgumentException("exposure close failed")).when(exposureWriter).close(); - - assertThrows( - IllegalArgumentException.class, - () -> FeatureFlaggingSystem.initialize(configService, exposureWriter)); - - verify(configService).close(); - } - - private static SharedCommunicationObjects sharedCommunicationObjects() { - DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - when(discovery.supportsEvpProxy()).thenReturn(true); - when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); - return sharedCommunicationObjects(discovery); - } - - private static SharedCommunicationObjects sharedCommunicationObjects( - final DDAgentFeaturesDiscovery discovery) { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - when(sharedCommunicationObjects.featuresDiscovery(any(Config.class))).thenReturn(discovery); - sharedCommunicationObjects.agentUrl = HttpUrl.get("http://localhost"); - sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); - return sharedCommunicationObjects; + @WithConfig(key = FEATURE_FLAGS_ENABLED, value = "false") + void startDoesNothingWhenFeatureFlagsIsDisabled() { + FeatureFlaggingSystem.start(mock(SharedCommunicationObjects.class)); + assertNull(FeatureFlaggingGateway.backend()); } } diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md deleted file mode 100644 index 5aa7153906c..00000000000 --- a/products/feature-flagging/feature-flagging-api/README.md +++ /dev/null @@ -1,255 +0,0 @@ -# dd-openfeature - -Datadog OpenFeature Provider for Java. Implements the [OpenFeature](https://openfeature.dev/) `FeatureProvider` interface for Datadog's Feature Flags and Experimentation (FFE) product. - -Published as `com.datadoghq:dd-openfeature` on Maven Central. - -## Setup - -```xml - - com.datadoghq - dd-openfeature - ${dd-openfeature.version} - -``` - -The OpenFeature SDK (`dev.openfeature:sdk`) is included as a transitive dependency. - -The provider sends exposures from an OpenFeature provider hook. If you wrap it in OpenFeature's -`MultiProvider`, use `dev.openfeature:sdk` 1.23.0 or later: earlier versions do not run the hooks of -the providers inside a `MultiProvider`, so the provider sends no exposures there. - -### Evaluation metrics (optional) - -To enable evaluation metrics (`feature_flag.evaluations` counter), enable the Datadog Java agent's -OpenTelemetry metrics pipeline: - -```shell -DD_METRICS_OTEL_ENABLED=true -``` - -The provider records metrics through the OpenTelemetry Metrics API. Add `opentelemetry-api` if your -application does not already use the OpenTelemetry API for custom metrics: - -```xml - - io.opentelemetry - opentelemetry-api - 1.47.0 - -``` - -The OpenTelemetry SDK and OTLP exporter are not required on the application classpath. The Datadog -Java agent collects the API metric and exports it through the same OTLP pipeline as other custom OTel -metrics. - -## Usage - -```java -import datadog.trace.api.openfeature.Provider; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.Client; - -OpenFeatureAPI api = OpenFeatureAPI.getInstance(); -api.setProviderAndWait(new Provider()); -Client client = api.getClient(); - -boolean enabled = client.getBooleanValue("my-feature", false, - new MutableContext("user-123")); -``` - -## Per-subject evaluation telemetry opt-out - -Applications can keep flag evaluation enabled while excluding selected subjects from evaluation -telemetry. Configure the provider once, then pass the decision with each evaluation: - -```java -import datadog.trace.api.openfeature.Provider; -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.ImmutableContext; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.Value; -import java.util.Collections; - -OpenFeatureAPI api = OpenFeatureAPI.getInstance(); -api.setProviderAndWait(new Provider( - new Provider.Options().enableEvaluationTelemetryOptOutParameter(true))); -Client client = api.getClient(); - -// In a request handler, the application owns its consent and eligibility rules. -boolean optedOut = user.hasDeclinedEvaluationTelemetry(); -boolean enabled = client.getBooleanValue("my-feature", false, - new ImmutableContext(user.getId(), Collections.singletonMap( - "dd_evaluation_telemetry_opt_out", new Value(optedOut)))); -``` - -Use the per-evaluation context for concurrent requests. Do not replace the global context for each -subject. The key is exactly `dd_evaluation_telemetry_opt_out`, as in other Datadog SDKs. - -Alternatively, enable support through deployment configuration and use `new Provider()`: - -```shell -DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED=true -``` - -The equivalent JVM property is `-Ddd.feature.flags.evaluation.telemetry.per.subject.opt.out.enabled=true`. -The provider reads the normal Datadog configuration sources at construction. The default is `false`. -An explicit provider option overrides these sources, including an explicit `false`. -This setting only enables support for the subject attribute. It does not suppress telemetry for all subjects. - -Only boolean `true` suppresses telemetry. Missing, false, null, and other types preserve telemetry. -The provider suppresses telemetry only when the option is enabled before provider construction. - -The attribute remains available for targeting and exposure reporting with either activation setting. -Existing privacy and reporting settings apply. Targeting rules can use the attribute to select -assignments. Normal exposure deduplication applies when its value changes. - -Opt-out suppresses new evaluation EVP events and counts, OpenTelemetry evaluation metrics, and -automatic flag-related span enrichment. It does not change flag values, evaluation details, -application defaults, experiment assignment, or exposure logging. Application hooks and unrelated -tracing remain active. Previously recorded or queued data is not removed. - -## Evaluation metrics - -When `DD_METRICS_OTEL_ENABLED=true` and the OpenTelemetry API is on the classpath, the provider -records a `feature_flag.evaluations` counter. The Datadog Java agent exports it to the Datadog -Agent's OTLP receiver using the configured OpenTelemetry metrics export interval. - -### Configuration - -Configure the OTLP endpoint and protocol using the standard Datadog Java agent OpenTelemetry metrics -settings. For example, to export metrics over OTLP/gRPC: - -```shell -DD_METRICS_OTEL_ENABLED=true -OTEL_EXPORTER_OTLP_ENDPOINT=http://:4317 -OTEL_EXPORTER_OTLP_PROTOCOL=grpc -``` - -### Metric attributes - -| Attribute | Description | -|---|---| -| `feature_flag.key` | Flag key | -| `feature_flag.result.variant` | Resolved variant key | -| `feature_flag.result.reason` | Evaluation reason (lowercased) | -| `error.type` | Error code (lowercased, only on error) | -| `feature_flag.result.allocation_key` | Allocation key (when present) | - -## Exposures in your own data store - -The provider sends exposures to Datadog. It also puts its exposure decision and the selected split's -features into the flag metadata of every evaluation, so any OpenFeature hook can record exposures in -your own data store (see [Flag metadata](#flag-metadata)). - -For convenience, `dd-openfeature` ships `ExposureHook`, which reads that metadata for you. It -receives every completed evaluation, with the same exposure decision that the provider uses for -Datadog. - -```java -import datadog.trace.api.openfeature.ExposureHook; - -BlockingQueue queue = new LinkedBlockingQueue<>(10_000); - -OpenFeatureAPI.getInstance().addHooks(new ExposureHook(evaluation -> { - if (!evaluation.shouldSend()) { - return; - } - Map features = evaluation.getFeatures(); - queue.offer(new ExposureRecord( - evaluation.getDetails().getFlagKey(), - evaluation.getDetails().getVariant(), - evaluation.getContext().getTargetingKey(), - (String) features.get("holdout.key"), - (String) features.get("holdout.assignment_group"))); -})); -// A background thread drains the queue and writes to the data store. -``` - -- The callback runs on the evaluation thread. Do not write to the data store from the callback. - Copy what you need and hand it to a background queue. -- `shouldSend()` is true for a successful evaluation of an allocation that logs exposures, for a - subject not yet exposed to the same allocation, variant and serial id. The serial id counts only - with `dd-java-agent` 1.67.0 or later. These are exactly the exposures that the provider sends to - Datadog, as long as Datadog logging is on and the provider's own hooks run: evaluate through an - OpenFeature client, and inside a `MultiProvider` use OpenFeature SDK 1.23.0 or later. With an - older SDK, a provider inside a `MultiProvider` sends no exposures, and `shouldSend()` is true for - every exposure. -- `isExposure()` is true for every successful evaluation of an allocation that logs exposures, - including repeats. `isCacheHit()` tells repeats apart. -- An evaluation that fails, including one that a later `after` hook fails, is not an exposure. -- An exception from the callback does not affect the evaluation. The first one is logged at warn - level, later ones at debug level. - -### Features - -`getFeatures()` returns the features of the selected split whose destinations include `HOOK`. -Values are a `String`, `Double` or `Boolean`. Holdout splits carry: - -| Feature | Value | -|---|---| -| `holdout.key` | The holdout key | -| `holdout.assignment_group` | `status_quo` or `all_shipped` | - -The map is empty when the split has no such features. Datadog can add destinations later; the -provider ignores a destination it does not know and logs a warning once for each one. - -### Flag metadata - -The same data is in the evaluation's flag metadata, for hooks that use only the OpenFeature API. -Read it in the hook's `finallyAfter` stage, where the details match what the application receives, -and skip details that have an error code. `ExposureHook` does both. - -| Key | Type | Meaning | -|---|---|---| -| `allocationKey` | String | The selected allocation | -| `__dd_do_log` | Boolean | Whether the allocation logs exposures. Present when the allocation logs exposures or span enrichment is on | -| `__dd_exposure_cache_hit` | Boolean | The subject was already exposed. Present only when `__dd_do_log` is true | -| `__dd_split_serial_id` | Integer | The split's serial id. Present when `__dd_do_log` is present, the split has a serial id, and the installed `dd-java-agent` supports serial ids | -| `__dd_feature.` | String, Double or Boolean | One entry for each feature of the selected split that lists the `HOOK` destination | - -### Agent versions - -`dd-openfeature` and `dd-java-agent` ship separately. The provider turns a capability off when the -installed agent lacks it: - -| Capability | Needs `dd-java-agent` | Without it | -|---|---|---| -| Serial ids on splits (`__dd_split_serial_id`) | 1.65.0 | The key is absent | -| Serial ids on exposures sent to Datadog | 1.67.0 | Exposures are sent without a serial id, so holdout attribution is lost | -| Flag evaluation events sent to Datadog | 1.66.0 | No flag evaluation events are sent | -| Split features (`getFeatures()`, `__dd_feature.`) | 1.68.0 | `getFeatures()` is empty | - -### Stop sending exposures to Datadog - -```shell -DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED=false -``` - -The provider then sends no exposures to Datadog. `ExposureHook` callbacks still receive every -evaluation, and `shouldSend()` gives the same result as before. The default is `true`. - -This setting stops exposures only. Flag evaluation events have their own setting, -`DD_FLAGGING_EVALUATION_COUNTS_ENABLED`. The Java provider does not read dd-trace-py's -`DD_FFE_INTAKE_ENABLED`. - -## 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. diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts deleted file mode 100644 index ea58de3cd84..00000000000 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ /dev/null @@ -1,90 +0,0 @@ -import datadog.gradle.configureCompiler -import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension - -plugins { - `java-library` - id("dd-trace-java.module.distributable.api") - id("me.champeau.jmh") -} - -configure { - minJavaVersion.set(JavaVersion.VERSION_11) -} - -description = "Implementation of the OpenFeature Provider interface." - -extra["excludedClassesCoverage"] = listOf( - // Value classes with only equals/hashCode; the cache tests cover their use. - "datadog.trace.api.openfeature.ExposureDeduplicationCache.Key", - "datadog.trace.api.openfeature.ExposureDeduplicationCache.Sent" -) - -// Set both JAR and Maven artifact name -val openFeatureArtifactId = "dd-openfeature" -base { - archivesName.set(openFeatureArtifactId) -} - -publishing { - publications.withType().configureEach { - artifactId = openFeatureArtifactId - } -} - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(11) - } -} - -dependencies { - api("dev.openfeature:sdk:1.23.0") - - compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap")) - compileOnly(project(":products:feature-flagging:feature-flagging-config")) - compileOnly(project(":utils:config-utils")) - compileOnly("io.opentelemetry:opentelemetry-api:1.47.0") - - testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) - // SpanEnrichmentGate resolves FeatureFlaggingConfig at runtime. Without it on the test - // classpath the gate swallows a NoClassDefFoundError and reads as off, so the enrichment - // branch cannot be driven. - testImplementation(project(":products:feature-flagging:feature-flagging-config")) - testImplementation(project(":utils:config-utils")) - testImplementation(project(":components:environment")) - testImplementation("io.opentelemetry:opentelemetry-api:1.47.0") - testImplementation(libs.bundles.junit5) - testImplementation(libs.bundles.mockito) - testImplementation(libs.moshi) - - // The main source set gets the bootstrap/config types as compileOnly, so the JMH source set - // needs them on its own compile and runtime classpath to drive the hook end to end. - jmhImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) - jmhImplementation(project(":products:feature-flagging:feature-flagging-config")) - jmhImplementation(project(":utils:config-utils")) -} - -jmh { - jmhVersion = libs.versions.jmh.get() - duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE - if (project.hasProperty("jmhIncludes")) { - includes = listOf(project.property("jmhIncludes").toString()) - } - if (project.hasProperty("jmhProf")) { - profilers = listOf(project.property("jmhProf").toString()) - } -} - -tasks.withType().configureEach { - configureCompiler(JavaVersion.VERSION_11) -} - -tasks.withType().configureEach { - javadocTool = javaToolchains.javadocToolFor(java.toolchain) -} - -// 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 -} diff --git a/products/feature-flagging/feature-flagging-api/gradle.lockfile b/products/feature-flagging/feature-flagging-api/gradle.lockfile deleted file mode 100644 index fa0bfccc495..00000000000 --- a/products/feature-flagging/feature-flagging-api/gradle.lockfile +++ /dev/null @@ -1,89 +0,0 @@ -# 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-api:dependencies --write-locks -ch.qos.logback:logback-classic:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.javaparser:javaparser-core:3.25.6=codenarc -com.github.spotbugs:spotbugs-annotations:4.10.4=compileClasspath,jmhCompileClasspath,spotbugs -com.github.spotbugs:spotbugs:4.10.4=spotbugs -com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -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.thoughtworks.qdox:qdox:1.12.1=codenarc -commons-io:commons-io:2.21.0=spotbugs -de.thetaphi:forbiddenapis:3.11=compileClasspath,jmhCompileClasspath -dev.openfeature:sdk:1.23.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.leangen.geantyref:geantyref:1.3.16=jmhRuntimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-api:1.47.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-context:1.47.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jaxen:jaxen:2.0.6=spotbugs -net.bytebuddy:byte-buddy-agent:1.18.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.sf.jopt-simple:jopt-simple:5.0.4=jmh,jmhCompileClasspath,jmhRuntimeClasspath -net.sf.saxon:Saxon-HE:12.10=spotbugs -org.apache.ant:ant-antlr:1.10.14=codenarc -org.apache.ant:ant-junit:1.10.14=codenarc -org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-lang3:3.20.0=spotbugs -org.apache.commons:commons-math3:3.6.1=jmh,jmhCompileClasspath,jmhRuntimeClasspath -org.apache.commons:commons-text:1.15.0=spotbugs -org.apache.logging.log4j:log4j-api:2.26.1=spotbugs -org.apache.logging.log4j:log4j-core:2.26.1=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=jmhRuntimeClasspath,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=jmhRuntimeClasspath,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=jmhRuntimeClasspath,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=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 -org.junit.jupiter:junit-jupiter:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:1.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:1.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:1.14.1=jmhRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-core:4.4.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:4.4.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.openjdk.jmh:jmh-core:1.37=jmh,jmhCompileClasspath,jmhRuntimeClasspath -org.openjdk.jmh:jmh-generator-asm:1.37=jmh,jmhCompileClasspath,jmhRuntimeClasspath -org.openjdk.jmh:jmh-generator-bytecode:1.37=jmh,jmhCompileClasspath,jmhRuntimeClasspath -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-commons:9.10.1=jacocoAnt,spotbugs -org.ow2.asm:asm-tree:9.10.1=jacocoAnt,spotbugs -org.ow2.asm:asm-util:9.10.1=spotbugs -org.ow2.asm:asm:9.0=jmh,jmhCompileClasspath,jmhRuntimeClasspath -org.ow2.asm:asm:9.10.1=jacocoAnt,spotbugs -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=spotbugsSlf4j -org.slf4j:slf4j-api:2.0.18=spotbugs -org.slf4j:slf4j-api:2.0.19=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j -org.snakeyaml:snakeyaml-engine:2.9=jmhRuntimeClasspath,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 diff --git a/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java b/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java deleted file mode 100644 index e292424e53e..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java +++ /dev/null @@ -1,203 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.ImmutableStructure; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.Value; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Param; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.TearDown; -import org.openjdk.jmh.annotations.Warmup; -import org.openjdk.jmh.infra.Blackhole; - -/** - * Evaluation-thread benchmark for the OpenFeature hook: the inline cost a caller actually pays - * inside FlagEvalLoggingHook.finallyAfter, across context shapes. - * - *

This is the counterpart to FlagEvaluationHotPathBenchmark in feature-flagging-lib, which - * measures the writer queue and the worker-thread aggregation. The hook and the OpenFeature context - * types live in this module, so the inline cost has to be measured here. - * - *

The dominant inline cost under consent-on is DDEvaluator.copyPrunedContext: one bounded walk - * of the caller-owned EvaluationContext. contextCopy isolates that walk so its share of - * hookFinallyAfter is directly readable. Under consent-off the hook skips the copy entirely, which - * hookFinallyAfterConsentOff measures as the protected-path floor. - * - *

The writer used here is a no-op that discards events, so no queue or worker cost is included. - * - *

Run: {@code ./gradlew :products:feature-flagging:feature-flagging-api:jmh - * -PjmhIncludes=FlagEvalHookHotPathBenchmark}. - */ -@State(Scope.Benchmark) -@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) -@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(NANOSECONDS) -@Fork(value = 1) -public class FlagEvalHookHotPathBenchmark { - - /** - * Context shapes. The three 100-leaf shapes (flat/100attrs, nested/10structs_10fields, - * list/10lists_10items) carry the same leaf count under different structure, so the spread - * between them isolates shape cost from leaf count. - */ - @Param({ - "flat/0attrs", - "flat/10attrs", - "flat/100attrs", - "nested/10structs_10fields", - "list/10lists_10items" - }) - public String shape; - - private HookContext hookContext; - private FlagEvaluationDetails consentOnDetails; - private FlagEvaluationDetails consentOffDetails; - private FlagEvalLoggingHook hook; - - @Setup(Level.Trial) - public void setUp() { - final MutableContext ctx = buildContext(shape); - hookContext = - HookContext.builder() - .flagKey("bench-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - - consentOnDetails = details(true); - consentOffDetails = details(false); - - // Discarding writer: isolates hook-inline cost from queue mechanics. - hook = new FlagEvalLoggingHook<>(new NoOpWriter()); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @TearDown(Level.Trial) - public void tearDown() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - } - - /** Total inline cost under consent-on: scalar extraction, bounded context copy, and enqueue. */ - @Benchmark - public void hookFinallyAfter() { - hook.finallyAfter(hookContext, consentOnDetails, Collections.emptyMap()); - } - - /** Protected-path floor: consent-off skips the context copy, leaving scalar work plus enqueue. */ - @Benchmark - public void hookFinallyAfterConsentOff() { - hook.finallyAfter(hookContext, consentOffDetails, Collections.emptyMap()); - } - - /** The bounded context copy alone - the component that scales with context shape. */ - @Benchmark - public void contextCopy(final Blackhole blackhole) { - blackhole.consume(DDEvaluator.copyPrunedContext(hookContext.getCtx())); - } - - private static FlagEvaluationDetails details(final boolean observeFullEvaluationData) { - return FlagEvaluationDetails.builder() - .flagKey("bench-flag") - .value("on-value") - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata( - ImmutableMetadata.builder() - .addString("allocationKey", "alloc-1") - .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) - .addBoolean( - DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) - .build()) - .build(); - } - - private static MutableContext buildContext(final String shape) { - final MutableContext ctx = new MutableContext("bench-user"); - if ("flat/0attrs".equals(shape)) { - return ctx; - } - if ("flat/10attrs".equals(shape)) { - return addFlat(ctx, 10); - } - if ("flat/100attrs".equals(shape)) { - return addFlat(ctx, 100); - } - if ("nested/10structs_10fields".equals(shape)) { - for (int i = 0; i < 10; i++) { - final Map inner = new HashMap<>(); - for (int j = 0; j < 10; j++) { - inner.put("field" + j, new Value("value" + j)); - } - ctx.add("struct" + i, new ImmutableStructure(inner)); - } - return ctx; - } - if ("list/10lists_10items".equals(shape)) { - for (int i = 0; i < 10; i++) { - final List items = new ArrayList<>(10); - for (int j = 0; j < 10; j++) { - items.add(new Value("value" + j)); - } - ctx.add("list" + i, items); - } - return ctx; - } - throw new IllegalArgumentException("unknown benchmark shape: " + shape); - } - - private static MutableContext addFlat(final MutableContext ctx, final int count) { - for (int i = 0; i < count; i++) { - ctx.add("field" + i, "value" + i); - } - return ctx; - } - - /** Discards events so only hook-inline work is measured. */ - private static final class NoOpWriter implements FlagEvaluationWriter { - @Override - public void enqueue(final FlagEvalEvent event) {} - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - } -} 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 deleted file mode 100644 index 2afec791851..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ /dev/null @@ -1,1170 +0,0 @@ -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.Feature; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ParsedSemver; -import datadog.trace.api.featureflag.ufc.v1.Rule; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.ImmutableStructure; -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.ArrayList; -import java.util.Collections; -import java.util.Deque; -import java.util.HashMap; -import java.util.HashSet; -import java.util.IdentityHashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { - - private static final Logger log = LoggerFactory.getLogger(DDEvaluator.class); - private static final Set> SUPPORTED_RESOLUTION_TYPES = - new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); - - static final AtomicBoolean SPLIT_SERIAL_ID_SUPPORTED = - new AtomicBoolean(splitSerialIdSupported(Split.class)); - - static final AtomicBoolean EXPOSURE_SERIAL_ID_SUPPORTED = - new AtomicBoolean( - SPLIT_SERIAL_ID_SUPPORTED.get() && exposureSerialIdSupported(ExposureEvent.class)); - - static final AtomicBoolean SPLIT_FEATURES_SUPPORTED = - new AtomicBoolean(splitFeaturesSupported(Split.class)); - - static final AtomicBoolean EXPOSURE_FEATURES_SUPPORTED = - new AtomicBoolean(exposureFeaturesSupported(ExposureEvent.class)); - - /** An older agent's exposure event has no features; its exposures are then sent without them. */ - static boolean exposureFeaturesSupported(final Class eventClass) { - try { - eventClass.getConstructor( - long.class, - datadog.trace.api.featureflag.exposure.Allocation.class, - datadog.trace.api.featureflag.exposure.Flag.class, - datadog.trace.api.featureflag.exposure.Variant.class, - Subject.class, - Integer.class, - Map.class); - return true; - } catch (final NoSuchMethodException | LinkageError | RuntimeException e) { - log.debug("The installed Datadog Java agent does not send features on exposures", e); - return false; - } - } - - /** An older agent ships a Split without features; its evaluations then carry no features. */ - static boolean splitFeaturesSupported(final Class splitClass) { - try { - return splitClass.getField("features").getType() == List.class; - } catch (final NoSuchFieldException | LinkageError | RuntimeException e) { - log.debug("The installed Datadog Java agent does not carry split features", e); - return false; - } - } - - static boolean splitSerialIdSupported(final Class splitClass) { - try { - return splitClass.getField("serialId").getType() == Integer.class; - } catch (final NoSuchFieldException | LinkageError | RuntimeException e) { - log.warn( - "Feature flag serial ID reporting is unavailable with the installed Datadog Java " - + "agent, which does not carry a serial id on the flag configuration. Upgrade " - + "dd-java-agent to enable holdout attribution."); - log.debug("Unable to access the flag configuration serial ID", e); - return false; - } - } - - static boolean exposureSerialIdSupported(final Class eventClass) { - try { - eventClass.getConstructor( - long.class, - datadog.trace.api.featureflag.exposure.Allocation.class, - datadog.trace.api.featureflag.exposure.Flag.class, - datadog.trace.api.featureflag.exposure.Variant.class, - Subject.class, - Integer.class); - return true; - } catch (final NoSuchMethodException | LinkageError | RuntimeException e) { - log.warn( - "Feature flag exposure serial ID reporting is unavailable with the installed " - + "Datadog Java agent. Exposures are still reported, without the serial id, and " - + "span enrichment is unaffected. Upgrade dd-java-agent to enable holdout " - + "attribution on exposures."); - log.debug("Unable to access the exposure serial ID constructor", e); - return false; - } - } - - /** - * Maximum evaluation-context nesting depth captured on the hot path. Recursion runs on the - * caller's evaluation thread over a caller-owned Value tree, so an arbitrarily deep - * list/structure would overflow that thread's stack - and a StackOverflowError is not caught by - * the LinkageError | Exception guards that keep telemetry from breaking an evaluation. Values - * below the limit are truncated to null, the same way the cycle guard truncates. Kept aligned - * with the cross-SDK RFC target (4). - */ - static final int MAX_SNAPSHOT_DEPTH = 4; - - /** - * Maximum number of top-level context fields retained by copyPrunedContext. Bounds the width of - * the caller-supplied context and, transitively, the size of every FlagEvalEvent sitting in the - * async hand-off queue. Kept aligned with the cross-SDK RFC. - */ - static final int MAX_CONTEXT_FIELDS = 256; - - /** - * Maximum character length for a single context KEY retained by copyPrunedContext. Keys are - * stored verbatim in every full-tier bucket, so an unbounded key size would let a single caller - * inflate steady-state heap use. Longer keys cause the field to be skipped. - */ - static final int MAX_KEY_LENGTH = 256; - - /** - * Maximum character length for a single context string VALUE retained by copyPrunedContext. - * Longer values cause the field to be skipped (matches previous pruneContext behavior). - * Non-string scalars are not length-bounded. - */ - static final int MAX_VALUE_LENGTH = 256; - - /** - * Maximum number of elements walked per list encountered during copyPrunedContext. Bounds the - * fan-out of a single wide list at capture time so one caller cannot inflate the hot path with a - * huge but shallow structure. Elements past the limit are skipped. - */ - static final int MAX_LIST_ELEMENTS = 256; - - /** - * Maximum number of properties walked per structure encountered during copyPrunedContext. Same - * intent as MAX_LIST_ELEMENTS for structures. Properties past the limit are skipped. - */ - static final int MAX_STRUCTURE_PROPERTIES = 256; - - // Evaluation-metadata keys consumed by the span-enrichment capture hook (see SpanEnrichmentHook) - // and the exposure hook (see ExposureLoggingHook). Emitted when span enrichment is on or the - // allocation logs exposures. - static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; - static final String METADATA_DO_LOG = "__dd_do_log"; - // Emitted only when the allocation logs exposures: true when this subject's exposure was already - // sent, so the exposure hook does not send it again. - static final String METADATA_EXPOSURE_CACHE_HIT = "__dd_exposure_cache_hit"; - // Each feature of the selected split is copied to the metadata once for each destination it - // lists, under that destination's prefix. Only the HOOK prefix is a public contract; the - // other two carry features to Datadog's own exposure and flag-evaluation hooks. - static final String METADATA_FEATURE_PREFIX = "__dd_feature."; - static final String METADATA_EXPOSURE_FEATURE_PREFIX = "__dd_exposure_feature."; - static final String METADATA_EVALUATION_FEATURE_PREFIX = "__dd_evaluation_feature."; - // Present only when the split routes a feature to EVALUATION, so FlagEvalLoggingHook can skip - // scanning the metadata on every other evaluation. - static final String METADATA_HAS_EVALUATION_FEATURES = "__dd_has_evaluation_features"; - - static final String FEATURE_DESTINATION_HOOK = "HOOK"; - static final String FEATURE_DESTINATION_EXPOSURE = "EXPOSURE"; - static final String FEATURE_DESTINATION_EVALUATION = "EVALUATION"; - // A newer UFC can name destinations this SDK does not know; warn once per name, not per - // evaluation. - static final int MAX_WARNED_FEATURE_DESTINATIONS = 64; - static final Set WARNED_FEATURE_DESTINATIONS = ConcurrentHashMap.newKeySet(); - - // Stamped on every DD-produced evaluation (including PROVIDER_NOT_READY, with false). Missing - // key = non-DD provider; the hook falls back to false (fail-closed). - static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = "observe_full_evaluation_data"; - - // 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); - - public DDEvaluator(final Runnable configCallback) { - this.configCallback = configCallback; - } - - @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(); - } - - @Override - public boolean hasConfiguration() { - return configuration.get() != 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(); - } - } - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - // Snapshot the config once and thread observeFullEvaluationData through every - // ProviderEvaluation returned, so the hook's consent decision is pinned to this evaluation's - // config and cannot drift on a concurrent Remote Config swap. - final ServerConfiguration config = configuration.get(); - // Boolean.TRUE.equals covers both null (privacy-preserving default) and Boolean.FALSE without - // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. - final boolean observeFullEvaluationData = - config != null && Boolean.TRUE.equals(config.observeFullEvaluationData); - try { - if (config == null) { - return error(defaultValue, ErrorCode.PROVIDER_NOT_READY, null, observeFullEvaluationData); - } - - if (context == null) { - return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); - } - - final Flag flag = config.flags.get(key); - if (flag == null) { - if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { - return error( - defaultValue, - ErrorCode.PARSE_ERROR, - "invalid configuration for flag " + key, - observeFullEvaluationData); - } - return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, observeFullEvaluationData); - } - - if (!flag.enabled) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DISABLED.name()) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } - - if (flag.allocations == null) { - return error( - defaultValue, - ErrorCode.GENERAL, - "Missing allocations for flag " + key, - observeFullEvaluationData); - } - - final Instant now = Instant.now(); - final long evalTimestampMs = now.toEpochMilli(); - 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, - evalTimestampMs, - observeFullEvaluationData); - } else { - if (targetingKey == null) { - return error( - defaultValue, ErrorCode.TARGETING_KEY_MISSING, null, observeFullEvaluationData); - } - // 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, - evalTimestampMs, - observeFullEvaluationData); - } - } - } - } - } - - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DEFAULT.name()) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } catch (final PatternSyntaxException e) { - return error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); - } catch (final NumberFormatException e) { - return error( - defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); - } catch (final Exception e) { - return error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); - } - } - - private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { - return ImmutableMetadata.builder() - .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) - .build(); - } - - private static ProviderEvaluation error( - final T defaultValue, - final ErrorCode code, - final String errorMessage, - final boolean observeFullEvaluationData) { - // Under consent-off the errorMessage is dropped: exception messages from the outer catch blocks - // (NumberFormatException, generic Exception) can echo raw evaluation-context values, so they - // must never reach any consumer of ProviderEvaluation.getErrorMessage() — not just our own - // wire hook. Downstream (FlagEvalLoggingHook) falls back to ErrorCode.name(), so operators - // still get a stable signal like "TYPE_MISMATCH". - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.ERROR.name()) - .errorCode(code) - .errorMessage(observeFullEvaluationData ? errorMessage : null) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } - - private static boolean isEmpty(final List list) { - return list == null || list.isEmpty(); - } - - static boolean isAllocationActive(final Allocation allocation, final Instant now) { - final Instant startDate = allocation.startAtInstant(); - if (startDate != null && now.isBefore(startDate)) { - return false; - } - - final Instant endDate = allocation.endAtInstant(); - if (endDate != null && now.isAfter(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); - case SEMVER_EQ: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o == 0); - case SEMVER_NEQ: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o != 0); - case SEMVER_LT: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o < 0); - case SEMVER_LTE: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o <= 0); - case SEMVER_GT: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o > 0); - case SEMVER_GTE: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o >= 0); - default: - return false; - } - } - - private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { - // PatternSyntaxException is intentionally not caught here so it propagates to evaluate(), - // which maps it to ErrorCode.PARSE_ERROR. - final Pattern pattern = Pattern.compile(normalizeRegex(String.valueOf(conditionValue))); - return pattern.matcher(String.valueOf(attributeValue)).find(); - } - - private static String normalizeRegex(final String regex) { - return regex - .replace("[:alnum:]", "\\p{Alnum}") - .replace("[:alpha:]", "\\p{Alpha}") - .replace("[:digit:]", "\\p{Digit}") - .replace("[:lower:]", "\\p{Lower}") - .replace("[:upper:]", "\\p{Upper}") - .replace("[:space:]", "\\p{Space}"); - } - - private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { - if (!(conditionValue instanceof Iterable)) { - return false; - } - for (final Object value : (Iterable) conditionValue) { - if (valuesEqual(attributeValue, value)) { - return true; - } - } - return false; - } - - private static boolean valuesEqual(final Object a, final Object b) { - if (Objects.equals(a, b)) { - return true; - } - - if (a instanceof Number || b instanceof Number) { - return compareNumber(a, b, (first, second) -> first == second); - } - - return String.valueOf(a).equals(String.valueOf(b)); - } - - private static boolean compareNumber( - final Object attributeValue, final Object conditionValue, NumberComparator comparator) { - final double a = mapValue(Double.class, attributeValue); - final double b = mapValue(Double.class, conditionValue); - return comparator.compare(a, b); - } - - /** - * Evaluates a semantic version comparison operator. The attribute value must be a string that is - * a valid semantic version, and the comparand must have been pre-parsed during configuration - * validation. If either is missing or invalid, the condition does not match. - */ - private static boolean evaluateSemverCondition( - final Object attributeValue, - final ParsedSemver comparand, - final SemverComparator comparator) { - if (!(attributeValue instanceof String) || comparand == null) { - return false; - } - final ParsedSemver parsedAttribute = ParsedSemver.parse((String) attributeValue); - if (parsedAttribute == null) { - return false; - } - return comparator.compare(ParsedSemver.compare(parsedAttribute, comparand)); - } - - private static boolean matchesShard(final Shard shard, final String targetingKey) { - // The bootstrap model preserves its int ABI, so UFC uint32 values are stored as raw bits. - // Convert before arithmetic and comparison to retain their unsigned wire semantics. - final long totalShards = Integer.toUnsignedLong(shard.totalShards); - final long assignedShard = getShard(shard.salt, targetingKey, totalShards); - for (final ShardRange range : shard.ranges) { - final long rangeStart = Integer.toUnsignedLong(range.start); - final long rangeEnd = Integer.toUnsignedLong(range.end); - if (assignedShard >= rangeStart && assignedShard < rangeEnd) { - return true; - } - } - return false; - } - - private static long getShard( - final String salt, final String targetingKey, final long 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 intFromHash % totalShards; - } - - private static String getMD5Hash(final String input) { - try { - final MessageDigest md = MessageDigest.getInstance("MD5"); - final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); - final StringBuilder hexString = new StringBuilder(); - for (byte b : hashBytes) { - final String hex = Integer.toHexString(0xff & b); - if (hex.length() == 1) { - hexString.append('0'); - } - hexString.append(hex); - } - return hexString.toString(); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("MD5 algorithm not available", e); - } - } - - private static ProviderEvaluation resolveVariant( - final Class target, - final String key, - final T defaultValue, - final Flag flag, - final String variationKey, - final Allocation allocation, - final Split split, - final EvaluationContext context, - final long evalTimestampMs, - final boolean observeFullEvaluationData) { - final Variant variant = flag.variations.get(variationKey); - if (variant == null) { - return error( - defaultValue, - ErrorCode.GENERAL, - "Variant not found for: " + variationKey, - observeFullEvaluationData); - } - - if (!isTypeCompatible(target, flag.variationType)) { - return error( - defaultValue, - ErrorCode.TYPE_MISMATCH, - "Requested type " - + target.getSimpleName() - + " does not match flag variationType " - + flag.variationType.name(), - observeFullEvaluationData); - } - - 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(), - observeFullEvaluationData); - } - - // Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time, - // not hook-fire time. Passed to the hook via provider metadata "__dd_eval_timestamp_ms". - final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = - ImmutableMetadata.builder() - .addString("flagKey", flag.key) - .addString("variationType", flag.variationType.name()) - .addString("allocationKey", allocation.key) - .addLong("__dd_eval_timestamp_ms", evalTimestampMs) - .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); - // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment and - // for the exposure hook, which sends the exposure after the evaluation succeeds. An evaluation - // that neither logs exposures nor enriches spans carries neither key. - // __dd_split_serial_id is omitted when the split carries no serial id. - final boolean doLog = allocation.doLog != null && allocation.doLog; - if (SPAN_ENRICHMENT_ENABLED || doLog) { - if (SPLIT_SERIAL_ID_SUPPORTED.get() && split.serialId != null) { - metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); - } - metadataBuilder.addBoolean(METADATA_DO_LOG, doLog); - } - if (SPLIT_FEATURES_SUPPORTED.get()) { - addSplitFeatures(metadataBuilder, split); - } - if (doLog) { - metadataBuilder.addBoolean( - METADATA_EXPOSURE_CACHE_HIT, - ExposureDeduplicationCache.INSTANCE.contains( - flag.key, - context.getTargetingKey(), - allocation.key, - variant.key, - SPLIT_SERIAL_ID_SUPPORTED.get() ? exposureSerialId(split.serialId) : null)); - } - final ProviderEvaluation result = - ProviderEvaluation.builder() - .value(mappedValue) - .reason( - !isEmpty(allocation.rules) - ? Reason.TARGETING_MATCH.name() - : allocation.startAt != null || allocation.endAt != null - ? Reason.DEFAULT.name() - : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) - .variant(variant.key) - .flagMetadata(metadataBuilder.build()) - .build(); - return result; - } - - private static void addSplitFeatures( - final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder, final Split split) { - if (split.features == null) { - return; - } - Set seenKeys = null; - boolean evaluationFeatures = false; - for (final Object item : split.features) { - if (!(item instanceof Feature)) { - continue; - } - final Feature feature = (Feature) item; - if (feature.key == null || feature.destinations == null) { - continue; - } - if (seenKeys == null) { - seenKeys = new HashSet<>(); - } - if (!seenKeys.add(feature.key)) { - continue; - } - for (final String destination : feature.destinations) { - final String prefix = metadataPrefix(destination); - if (prefix != null) { - addFeature(metadataBuilder, prefix + feature.key, feature.value); - evaluationFeatures |= prefix.equals(METADATA_EVALUATION_FEATURE_PREFIX); - } else if (WARNED_FEATURE_DESTINATIONS.size() < MAX_WARNED_FEATURE_DESTINATIONS - && WARNED_FEATURE_DESTINATIONS.add(destination)) { - log.warn("Ignoring unknown feature destination {}", destination); - } - } - } - if (evaluationFeatures) { - metadataBuilder.addBoolean(METADATA_HAS_EVALUATION_FEATURES, true); - } - } - - private static String metadataPrefix(final String destination) { - if (FEATURE_DESTINATION_HOOK.equals(destination)) { - return METADATA_FEATURE_PREFIX; - } - if (FEATURE_DESTINATION_EXPOSURE.equals(destination)) { - return METADATA_EXPOSURE_FEATURE_PREFIX; - } - if (FEATURE_DESTINATION_EVALUATION.equals(destination)) { - return METADATA_EVALUATION_FEATURE_PREFIX; - } - return null; - } - - private static void addFeature( - final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder, - final String key, - final Object value) { - if (value instanceof String) { - metadataBuilder.addString(key, (String) value); - } else if (value instanceof Boolean) { - metadataBuilder.addBoolean(key, (Boolean) value); - } else if (value instanceof Number) { - metadataBuilder.addDouble(key, ((Number) value).doubleValue()); - } - } - - /** The serial id an exposure carries: none when the agent's exposure event predates it. */ - static Integer exposureSerialId(final Integer splitSerialId) { - return EXPOSURE_SERIAL_ID_SUPPORTED.get() && SPLIT_SERIAL_ID_SUPPORTED.get() - ? splitSerialId - : null; - } - - private static Object resolveAttribute(final String name, final EvaluationContext context) { - // Special handling for "id" attribute: if not explicitly provided, use targeting key - if ("id".equals(name) && !context.keySet().contains(name)) { - return context.getTargetingKey(); - } - final Value resolved = context.getValue(name); - return context.convertValue(resolved); - } - - private static boolean isTypeCompatible(final Class target, final ValueType variationType) { - if (variationType == null) { - return true; // No type info — allow any - } - switch (variationType) { - case BOOLEAN: - return target == Boolean.class; - case STRING: - return target == String.class; - case INTEGER: - return target == Integer.class; - case NUMERIC: - return target == Double.class; - case JSON: - return target == Value.class; - default: - return true; // Unknown types pass through — mapValue errors caught as GENERAL - } - } - - @SuppressWarnings("unchecked") - static T mapValue(final Class target, final Object value) { - if (value == null) { - return null; - } - if (!SUPPORTED_RESOLUTION_TYPES.contains(target)) { - throw new IllegalArgumentException("Type not supported: " + target); - } - if (target.isInstance(value)) { - return target.cast(value); - } - if (target == String.class) { - return (T) String.valueOf(value); - } - if (target == Boolean.class) { - if (value instanceof Number) { - return (T) (Boolean) (parseDouble(value) != 0); - } - return (T) Boolean.valueOf(value.toString()); - } - if (target == Integer.class) { - final Double number = parseDouble(value); - return (T) (Integer) number.intValue(); - } - if (target == Double.class) { - final Double number = parseDouble(value); - return (T) number; - } - return (T) Value.objectToValue(value); - } - - private static Double parseDouble(final Object value) { - if (value instanceof Number) { - return ((Number) value).doubleValue(); - } - return Double.parseDouble(String.valueOf(value)); - } - - static ExposureEvent exposureEvent( - final String flag, - final String allocationKey, - final String variantKey, - final EvaluationContext context, - final Integer serialId, - final Map features) { - final long timestamp = System.currentTimeMillis(); - // Exposure types share names with the imported UFC Allocation, Flag, and Variant types. - final datadog.trace.api.featureflag.exposure.Allocation allocation = - new datadog.trace.api.featureflag.exposure.Allocation(allocationKey); - final datadog.trace.api.featureflag.exposure.Flag exposureFlag = - new datadog.trace.api.featureflag.exposure.Flag(flag); - final datadog.trace.api.featureflag.exposure.Variant variant = - new datadog.trace.api.featureflag.exposure.Variant(variantKey); - final Subject subject = new Subject(context.getTargetingKey(), flattenContext(context)); - - if (!EXPOSURE_SERIAL_ID_SUPPORTED.get()) { - return new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject); - } - if (features.isEmpty() || !EXPOSURE_FEATURES_SUPPORTED.get()) { - return new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject, serialId); - } - return new ExposureEvent( - timestamp, allocation, exposureFlag, variant, subject, serialId, features); - } - - static AbstractMap flattenContext(final EvaluationContext context) { - return flattenValues(snapshotValues(context)); - } - - static Map snapshotValues(final EvaluationContext context) { - final HashMap values = new HashMap<>(); - final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); - for (final String key : context.keySet()) { - values.put(key, snapshotValue(context.getValue(key), seenContainers, 0)); - } - return values; - } - - private static Value snapshotValue( - final Value value, final Set seenContainers, final int depth) { - if (value == null) { - return null; - } else if (value.isNull()) { - return new Value(); - } else if (value.isBoolean()) { - return new Value(value.asBoolean()); - } else if (value.isNumber()) { - final Object number = value.asObject(); - return number instanceof Integer - ? new Value((Integer) number) - : new Value(((Number) number).doubleValue()); - } else if (value.isString()) { - return new Value(value.asString()); - } else if (value.isInstant()) { - return new Value(value.asInstant()); - } else if (value.isList()) { - final List list = value.asList(); - if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(list)) { - return new Value(); - } - final List snapshot = new ArrayList<>(list.size()); - for (final Value item : list) { - snapshot.add(snapshotValue(item, seenContainers, depth + 1)); - } - seenContainers.remove(list); - return new Value(Collections.unmodifiableList(snapshot)); - } else if (value.isStructure()) { - final Structure structure = value.asStructure(); - if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(structure)) { - return new Value(); - } - final Map snapshot = new HashMap<>(); - for (final String key : structure.keySet()) { - snapshot.put(key, snapshotValue(structure.getValue(key), seenContainers, depth + 1)); - } - seenContainers.remove(structure); - return new Value(new ImmutableStructure(snapshot)); - } - throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); - } - - static AbstractMap flattenValues(final Map values) { - final HashMap result = new HashMap<>(); - final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); - for (final Map.Entry root : values.entrySet()) { - final Deque deque = new LinkedList<>(); - deque.push(new FlattenEntry(root.getKey(), root.getValue())); - while (!deque.isEmpty()) { - final FlattenEntry entry = deque.pop(); - final Value value = entry.value; - if (value == null) { - result.put(entry.key, null); - } else if (value.isList()) { - final List list = value.asList(); - if (seenContainers.add(list)) { - for (int i = 0; i < list.size(); i++) { - deque.push(new FlattenEntry(entry.key + "[" + i + "]", list.get(i))); - } - } - } else if (value.isStructure()) { - final Structure structure = value.asStructure(); - if (seenContainers.add(structure)) { - for (final String property : structure.keySet()) { - deque.push( - new FlattenEntry(entry.key + "." + property, structure.getValue(property))); - } - } - } else { - result.put(entry.key, convertValue(value)); - } - } - } - return result; - } - - private static Object convertValue(final Value value) { - if (value == null || value.isNull()) { - return null; - } else if (value.isBoolean()) { - return value.asBoolean(); - } else if (value.isNumber()) { - return value.asObject(); - } else if (value.isString()) { - return value.asString(); - } else if (value.isInstant()) { - return value.asInstant().toString(); - } - throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); - } - - // Reason-code bitmask constants used by copyPrunedContext to track which caps fired. - static final int REASON_MAX_CONTEXT_FIELDS = 1; - static final int REASON_MAX_KEY_LENGTH = 1 << 1; - static final int REASON_MAX_VALUE_LENGTH = 1 << 2; - static final int REASON_MAX_LIST_ELEMENTS = 1 << 3; - static final int REASON_MAX_STRUCTURE_PROPERTIES = 1 << 4; - static final int REASON_MAX_SNAPSHOT_DEPTH = 1 << 5; - static final int REASON_CYCLE = 1 << 6; - - /** Sorted reason-code strings, indexed by their bit position in the bitmask. */ - private static final String[] REASON_NAMES = { - "max_context_fields", - "max_key_length", - "max_value_length", - "max_list_elements", - "max_structure_properties", - "max_snapshot_depth", - "cycle", - }; - - /** - * Builds the sorted, comma-separated reason tag string from a bitmask of fired reason codes. - * Returns null when no reason bit is set (no truncation occurred). The returned string is ready - * to use directly as the "reason:..." tag value. - */ - static String truncationReasonTag(final int reasonMask) { - if (reasonMask == 0) { - return null; - } - final StringBuilder sb = new StringBuilder(); - for (int bit = 0; bit < REASON_NAMES.length; bit++) { - if ((reasonMask & (1 << bit)) != 0) { - if (sb.length() > 0) { - sb.append(','); - } - sb.append(REASON_NAMES[bit]); - } - } - return sb.toString(); - } - - /** - * Result of copyPrunedContext: the pruned attribute map plus an optional reason tag describing - * which caps fired during the walk. truncatedReason is null when no truncation occurred, so - * callers can skip the telemetry path with a single null check. - */ - static final class CopyResult { - final Map attrs; - - /** Non-null when at least one cap fired; ready to use as the "reason:..." tag value. */ - final String truncatedReason; - - CopyResult(final Map attrs, final String truncatedReason) { - this.attrs = attrs; - this.truncatedReason = truncatedReason; - } - } - - /** - * Single-pass bounded copy of a caller-owned EvaluationContext into the flattened, pruned map - * stored on a FlagEvalEvent and later canonicalized by the aggregator. - * - *

Every retained-size dimension is capped inline so the hot path performs work proportional to - * what is kept, never to what the caller supplied: MAX_CONTEXT_FIELDS - stop iterating the - * top-level context past this many retained fields MAX_KEY_LENGTH - skip fields whose flattened - * key exceeds this length (also enforced on every path segment produced by descending into - * lists/structures) MAX_VALUE_LENGTH - skip string values exceeding this length MAX_LIST_ELEMENTS - * - stop iterating a list past this many elements MAX_STRUCTURE_PROPERTIES - stop iterating a - * structure past this many properties MAX_SNAPSHOT_DEPTH - stop descending into lists/structures - * past this depth Cycle guard - identity-tracked containers currently on the recursion stack are - * treated as leaves - * - *

All numeric limits are named constants so they can be tuned independently. - * - *

Returns a CopyResult whose attrs is an empty map for null/empty input and whose - * truncatedReason is non-null when at least one cap fired. The returned map is a plain HashMap; - * canonical-key sorting happens once in the aggregator, off the hot path. - */ - static CopyResult copyPrunedContext(final EvaluationContext context) { - if (context == null) { - return new CopyResult(Collections.emptyMap(), null); - } - final Set keys = context.keySet(); - if (keys.isEmpty()) { - return new CopyResult(Collections.emptyMap(), null); - } - final HashMap out = new HashMap<>(); - final Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); - final int[] reasonMask = {0}; - for (final String key : keys) { - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - break; - } - if (EvaluationContext.TARGETING_KEY.equals(key)) { - continue; - } - copyPrunedValue(out, key, context.getValue(key), seen, 0, reasonMask); - } - final Map attrs = out.isEmpty() ? Collections.emptyMap() : out; - return new CopyResult(attrs, truncationReasonTag(reasonMask[0])); - } - - private static void copyPrunedValue( - final Map out, - final String key, - final Value value, - final Set seen, - final int depth, - final int[] reasonMask) { - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - return; - } - if (key.length() > MAX_KEY_LENGTH) { - reasonMask[0] |= REASON_MAX_KEY_LENGTH; - return; - } - if (value == null || value.isNull()) { - out.put(key, null); - return; - } - if (value.isString()) { - final String s = value.asString(); - if (s.length() > MAX_VALUE_LENGTH) { - reasonMask[0] |= REASON_MAX_VALUE_LENGTH; - return; - } - out.put(key, s); - return; - } - if (value.isBoolean() || value.isNumber() || value.isInstant()) { - out.put(key, convertValue(value)); - return; - } - if (value.isList()) { - final List list = value.asList(); - if (depth >= MAX_SNAPSHOT_DEPTH) { - reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; - return; - } - if (!seen.add(list)) { - reasonMask[0] |= REASON_CYCLE; - return; - } - if (list.size() > MAX_LIST_ELEMENTS) { - reasonMask[0] |= REASON_MAX_LIST_ELEMENTS; - } - final int limit = Math.min(list.size(), MAX_LIST_ELEMENTS); - for (int i = 0; i < limit; i++) { - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - break; - } - copyPrunedValue(out, key + "[" + i + "]", list.get(i), seen, depth + 1, reasonMask); - } - seen.remove(list); - return; - } - if (value.isStructure()) { - final Structure structure = value.asStructure(); - if (depth >= MAX_SNAPSHOT_DEPTH) { - reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; - return; - } - if (!seen.add(structure)) { - reasonMask[0] |= REASON_CYCLE; - return; - } - int walked = 0; - for (final String property : structure.keySet()) { - if (walked >= MAX_STRUCTURE_PROPERTIES) { - reasonMask[0] |= REASON_MAX_STRUCTURE_PROPERTIES; - break; - } - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - break; - } - walked++; - copyPrunedValue( - out, key + "." + property, structure.getValue(property), seen, depth + 1, reasonMask); - } - seen.remove(structure); - } - } - - @FunctionalInterface - private interface NumberComparator { - boolean compare(double a, double b); - } - - @FunctionalInterface - private interface SemverComparator { - boolean compare(int ordering); - } - - private static class FlattenEntry { - private final String key; - private final Value value; - - private FlattenEntry(final String key, final Value value) { - this.key = key; - this.value = value; - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DatadogExposureLoggingGate.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DatadogExposureLoggingGate.java deleted file mode 100644 index 41585f4c929..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DatadogExposureLoggingGate.java +++ /dev/null @@ -1,32 +0,0 @@ -package datadog.trace.api.openfeature; - -import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; -import datadog.trace.bootstrap.config.provider.ConfigProvider; -import java.util.function.BooleanSupplier; - -/** - * Reads {@code DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED} with full {@link ConfigProvider} - * precedence. When false, exposures are not sent to Datadog, but the exposure cache and customer - * {@link ExposureHook}s keep working. On by default. - */ -final class DatadogExposureLoggingGate { - - private DatadogExposureLoggingGate() {} - - static boolean isEnabled() { - return isEnabled( - () -> - ConfigProvider.getInstance() - .getBoolean( - FeatureFlaggingConfig.FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED, true)); - } - - static boolean isEnabled(final BooleanSupplier setting) { - try { - return setting.getAsBoolean(); - } catch (final Throwable t) { - // Keep the default rather than silently stop sending exposures. - return true; - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutHook.java deleted file mode 100644 index f64d9966a9f..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutHook.java +++ /dev/null @@ -1,55 +0,0 @@ -package datadog.trace.api.openfeature; - -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.Value; -import java.util.Map; -import java.util.Optional; - -/** Gates a Datadog finally-only evaluation telemetry hook, not exposure or application hooks. */ -final class EvaluationTelemetryOptOutHook implements Hook { - private static final String PARAMETER = "dd_evaluation_telemetry_opt_out"; - private static final String DECISION = "datadog.evaluationTelemetryOptOut"; - private final Hook delegate; - - EvaluationTelemetryOptOutHook(final Hook delegate) { - this.delegate = delegate; - } - - @Override - public Optional before( - final HookContext context, final Map hints) { - // Provider before hooks see the merged context, including application before hooks. - // HookData belongs to this evaluation, including nested and concurrent calls. - context.getHookData().set(DECISION, isOptedOut(context.getCtx())); - return Optional.empty(); - } - - @Override - public void error( - final HookContext context, final Exception error, final Map hints) { - // A failing application before hook can prevent our before hook from running. - // Preserve its decision before application error hooks can change the context. - if (context.getHookData().get(DECISION) == null) { - context.getHookData().set(DECISION, isOptedOut(context.getCtx())); - } - } - - @Override - public void finallyAfter( - final HookContext context, - final FlagEvaluationDetails details, - final Map hints) { - if (Boolean.TRUE.equals(context.getHookData().get(DECISION)) || isOptedOut(context.getCtx())) { - return; - } - delegate.finallyAfter(context, details, hints); - } - - private static boolean isOptedOut(final EvaluationContext context) { - final Value value = context == null ? null : context.getValue(PARAMETER); - return value != null && value.isBoolean() && Boolean.TRUE.equals(value.asBoolean()); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java deleted file mode 100644 index f4c9cacffdb..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java +++ /dev/null @@ -1,17 +0,0 @@ -package datadog.trace.api.openfeature; - -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ProviderEvaluation; -import java.util.concurrent.TimeUnit; - -interface Evaluator { - - boolean initialize(long timeout, TimeUnit timeUnit, EvaluationContext context) throws Exception; - - boolean hasConfiguration(); - - void shutdown(); - - ProviderEvaluation evaluate( - Class target, String key, T defaultValue, EvaluationContext context); -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureDeduplicationCache.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureDeduplicationCache.java deleted file mode 100644 index c7d7a9623c8..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureDeduplicationCache.java +++ /dev/null @@ -1,117 +0,0 @@ -package datadog.trace.api.openfeature; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -/** - * Remembers the last exposure sent for each (flag, subject). An exposure is sent again only when - * its allocation, variant or serial id changes. Thread-safe. - */ -final class ExposureDeduplicationCache { - - // The same size as the agent's exposure writer cache, which still deduplicates on its own. The - // two caches refresh entries at different times, so once full they can evict different - // subjects. - static final int CAPACITY = 1 << 16; - - static final ExposureDeduplicationCache INSTANCE = new ExposureDeduplicationCache(CAPACITY); - - private final Map entries; - - ExposureDeduplicationCache(final int capacity) { - this.entries = new BoundedLru(capacity); - } - - synchronized boolean contains( - final String flag, - final String subject, - final String allocation, - final String variant, - final Integer serialId) { - return new Sent(allocation, variant, serialId).equals(entries.get(new Key(flag, subject))); - } - - synchronized void record( - final String flag, - final String subject, - final String allocation, - final String variant, - final Integer serialId) { - entries.put(new Key(flag, subject), new Sent(allocation, variant, serialId)); - } - - synchronized int size() { - return entries.size(); - } - - synchronized void clear() { - entries.clear(); - } - - private static final class Key { - private final String flag; - private final String subject; - - Key(final String flag, final String subject) { - this.flag = flag; - this.subject = subject; - } - - @Override - public boolean equals(final Object o) { - if (!(o instanceof Key)) { - 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); - } - } - - private static final class Sent { - private final String allocation; - private final String variant; - private final Integer serialId; - - Sent(final String allocation, final String variant, final Integer serialId) { - this.allocation = allocation; - this.variant = variant; - this.serialId = serialId; - } - - @Override - public boolean equals(final Object o) { - if (!(o instanceof Sent)) { - return false; - } - final Sent entry = (Sent) o; - return Objects.equals(allocation, entry.allocation) - && Objects.equals(variant, entry.variant) - && Objects.equals(serialId, entry.serialId); - } - - @Override - public int hashCode() { - return Objects.hash(allocation, variant, serialId); - } - } - - private static final class BoundedLru extends LinkedHashMap { - private final int capacity; - - BoundedLru(final int capacity) { - super(16, 0.75f, true); - this.capacity = capacity; - } - - @Override - protected boolean removeEldestEntry(final Map.Entry eldest) { - return size() > capacity; - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java deleted file mode 100644 index 01f8a479c53..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java +++ /dev/null @@ -1,143 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.Objects.requireNonNull; - -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * An OpenFeature hook that hands each completed flag evaluation to a callback, with the Datadog - * exposure decision, so an application can record exposures in its own data store. - * - *

This hook is optional. The provider puts the same data in the flag metadata, so any - * OpenFeature hook can read it (see "Flag metadata" in the README). This class reads it for you. - * - *

Register it with {@code OpenFeatureAPI.getInstance().addHooks(new ExposureHook(callback))} or - * on a client. The callback runs for every evaluation, including errors and repeats. Use {@link - * Evaluation#shouldSend()} to keep the new or changed exposures. A changed serial id counts as a - * change only with {@code dd-java-agent} 1.67.0 or later; with an older agent, only a changed - * allocation or variant does. - * - *

When the provider's own hooks run and {@code - * DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED} is on, these are exactly the exposures that - * Datadog sends. With that setting off, Datadog receives no exposures, and {@code shouldSend()} - * gives the same results. The provider's hooks run when the application evaluates through an - * OpenFeature client. Inside a {@code MultiProvider} they run only with OpenFeature SDK 1.23.0 or - * later. With an older SDK, Datadog receives no exposures from a provider inside a {@code - * MultiProvider}, and {@code shouldSend()} is true for every exposure. - * - *

The callback runs on the evaluation thread: hand the data to a background queue instead of - * writing it from the callback. An exception from the callback does not affect the evaluation. The - * first one is logged at warn level, later ones at debug level. - */ -public final class ExposureHook implements Hook { - - private static final Logger log = LoggerFactory.getLogger(ExposureHook.class); - - private final Consumer callback; - private final AtomicBoolean warned = new AtomicBoolean(); - - public ExposureHook(final Consumer callback) { - this.callback = requireNonNull(callback, "callback"); - } - - @Override - public void finallyAfter( - final HookContext context, - final FlagEvaluationDetails details, - final Map hints) { - if (details == null) { - return; - } - try { - callback.accept(new Evaluation(context.getCtx(), details)); - } catch (final LinkageError | RuntimeException e) { - if (warned.compareAndSet(false, true)) { - log.warn("Exposure callback failed; later failures are logged at debug level", e); - } else { - log.debug("Exposure callback failed", e); - } - } - } - - /** A completed evaluation and its Datadog exposure decision. */ - public static final class Evaluation { - private final EvaluationContext context; - private final FlagEvaluationDetails details; - - Evaluation(final EvaluationContext context, final FlagEvaluationDetails details) { - this.context = context; - this.details = details; - } - - /** The evaluation context, including the targeting key. */ - public EvaluationContext getContext() { - return context; - } - - /** The flag key, value, variant, error and flag metadata of the evaluation. */ - public FlagEvaluationDetails getDetails() { - return details; - } - - /** - * True when the evaluation succeeded and selected an allocation that logs exposures, whether or - * not this subject was already exposed. - */ - public boolean isExposure() { - final Map values = metadata(); - return details.getErrorCode() == null - && details.getVariant() != null - && values.get("allocationKey") instanceof String - && Boolean.TRUE.equals(values.get(DDEvaluator.METADATA_DO_LOG)); - } - - /** - * True when this exposure repeats one already recorded for this subject: same allocation, - * variant and serial id. False for a new or changed exposure, and for an evaluation that is not - * an exposure. - */ - public boolean isCacheHit() { - return isExposure() && Boolean.TRUE.equals(cacheHitStamp()); - } - - /** - * True for a new or changed exposure: the decision the provider uses for Datadog. The class - * Javadoc says when that decision reaches Datadog. - */ - public boolean shouldSend() { - return isExposure() && Boolean.FALSE.equals(cacheHitStamp()); - } - - private Object cacheHitStamp() { - return metadata().get(DDEvaluator.METADATA_EXPOSURE_CACHE_HIT); - } - - // The ImmutableMetadata getters build a debug message for every absent key. - private Map metadata() { - final ImmutableMetadata metadata = details.getFlagMetadata(); - return metadata == null - ? Collections.emptyMap() - : metadata.asUnmodifiableMap(); - } - - /** - * The features of the selected split, such as {@code holdout.key} and {@code - * holdout.assignment_group}. Values are a String, Double or Boolean. Empty when the split has - * none. - */ - public Map getFeatures() { - return SplitFeatureMetadata.featuresWithPrefix( - details.getFlagMetadata(), DDEvaluator.METADATA_FEATURE_PREFIX); - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureLoggingHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureLoggingHook.java deleted file mode 100644 index 67ab6b27cfd..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureLoggingHook.java +++ /dev/null @@ -1,92 +0,0 @@ -package datadog.trace.api.openfeature; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Sends the exposure for a logged allocation to the agent once the evaluation has succeeded. - * - *

It runs at the finally stage because OpenFeature runs provider hooks first: an after hook - * added by the application could still throw and turn the result into the default value. At the - * finally stage the details carry that error, so a failed evaluation sends no exposure and does not - * mark the subject as exposed. - */ -class ExposureLoggingHook implements Hook { - - private static final Logger log = LoggerFactory.getLogger(ExposureLoggingHook.class); - private static final AtomicBoolean WARNED = new AtomicBoolean(); - - static final ExposureLoggingHook INSTANCE = - new ExposureLoggingHook<>( - ExposureDeduplicationCache.INSTANCE, DatadogExposureLoggingGate.isEnabled()); - - private final ExposureDeduplicationCache cache; - // When false the hook still records exposures, so customer ExposureHooks see the same cache - // verdicts whether or not exposures go to Datadog. - private final boolean sendToDatadog; - - ExposureLoggingHook(final ExposureDeduplicationCache cache, final boolean sendToDatadog) { - this.cache = cache; - this.sendToDatadog = sendToDatadog; - } - - @Override - public void finallyAfter( - final HookContext ctx, - final FlagEvaluationDetails details, - final Map hints) { - try { - if (details == null || details.getErrorCode() != null) { - return; - } - final ImmutableMetadata metadata = details.getFlagMetadata(); - if (metadata == null) { - return; - } - // The ImmutableMetadata getters build a debug message for every absent key. - final Map values = metadata.asUnmodifiableMap(); - if (!Boolean.TRUE.equals(values.get(DDEvaluator.METADATA_DO_LOG)) - || !Boolean.FALSE.equals(values.get(DDEvaluator.METADATA_EXPOSURE_CACHE_HIT))) { - return; - } - final String flag = details.getFlagKey(); - final Object allocationValue = values.get("allocationKey"); - final String allocationKey = - allocationValue instanceof String ? (String) allocationValue : null; - final String variantKey = details.getVariant(); - final EvaluationContext context = ctx == null ? null : ctx.getCtx(); - if (allocationKey == null || variantKey == null || context == null) { - return; - } - final Object serialValue = values.get(DDEvaluator.METADATA_SPLIT_SERIAL_ID); - final Integer serialId = - DDEvaluator.exposureSerialId( - serialValue instanceof Integer ? (Integer) serialValue : null); - cache.record(flag, context.getTargetingKey(), allocationKey, variantKey, serialId); - if (sendToDatadog) { - FeatureFlaggingGateway.dispatch( - DDEvaluator.exposureEvent( - flag, - allocationKey, - variantKey, - context, - serialId, - SplitFeatureMetadata.featuresWithPrefix( - metadata, DDEvaluator.METADATA_EXPOSURE_FEATURE_PREFIX))); - } - } catch (LinkageError e) { - // Never let exposure reporting break flag evaluation. - if (WARNED.compareAndSet(false, true)) { - log.warn("Could not send a feature flag exposure; later failures are not logged", e); - } - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java deleted file mode 100644 index 41426fd3683..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java +++ /dev/null @@ -1,221 +0,0 @@ -package datadog.trace.api.openfeature; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * OpenFeature Hook that captures flag evaluation events for EVP flagevaluation emission. - * - *

Contract: finallyAfter does scalar metadata extraction, a single bounded copy of the - * evaluation context, and a non-blocking offer to the writer's bounded queue. Aggregation and - * posting are deferred to the writer's worker thread. - * - *

Hot-path cost: under consent-on (observeFullEvaluationData=true) DDEvaluator.copyPrunedContext - * performs one bounded walk of the caller-owned EvaluationContext, applying every retained-size cap - * inline so work is proportional to what is kept, never to what the caller supplied. The returned - * map is capped by field count, key length, value length, list width, structure width, and depth. - * Under consent-off the context is dropped on emit, so the copy is skipped entirely and the hot - * path is scalar-only. - * - *

This hook is registered alongside the existing OTel FlagEvalMetricsHook - it does NOT replace - * it. - * - *

The writer is resolved lazily on each call, so the hook is always safe to register - if the - * writer is absent (killswitch off or not yet started) it is a no-op. - */ -class FlagEvalLoggingHook implements Hook { - - private static final Logger log = LoggerFactory.getLogger(FlagEvalLoggingHook.class); - - static final AtomicBoolean FEATURES_SUPPORTED = - new AtomicBoolean(featuresSupported(FlagEvalEvent.class)); - - /** - * An older agent's flag-evaluation event has no features; its rows are then sent without them. - */ - static boolean featuresSupported(final Class eventClass) { - try { - eventClass.getConstructor( - String.class, - String.class, - String.class, - String.class, - String.class, - long.class, - boolean.class, - Map.class, - Map.class); - return true; - } catch (final NoSuchMethodException | LinkageError | RuntimeException e) { - log.debug("The installed Datadog Java agent does not send features on flag evaluations", e); - return false; - } - } - - /** - * Singleton instance: always registered when the provider is created; harmless when writer=null - * (killswitch off or not yet started). - */ - static final FlagEvalLoggingHook INSTANCE = new FlagEvalLoggingHook<>(); - - /** - * Writer resolver. Production instances resolve through FeatureFlaggingGateway; tests can inject - * a direct writer or a resolver that simulates old-bootstrap linkage failures. - */ - private final Supplier writerSupplier; - - /** Production constructor - resolves writer from gateway. */ - FlagEvalLoggingHook() { - this(FeatureFlaggingGateway::getFlagEvalWriter); - } - - /** Test-only constructor - injects a writer directly, bypassing the gateway. */ - FlagEvalLoggingHook(final FlagEvaluationWriter writer) { - this(() -> writer); - } - - /** Test-only constructor - injects a writer resolver directly, bypassing the gateway. */ - FlagEvalLoggingHook(final Supplier writerSupplier) { - this.writerSupplier = writerSupplier; - } - - /** - * Capture + non-blocking enqueue only; no flattening, aggregation, or I/O. Runs at the finally - * stage so it covers success, error, and default-value paths. - * - *

The context snapshot taken here scales with context size/nesting - see the class javadoc for - * why it cannot be deferred. - */ - @Override - public void finallyAfter( - final HookContext ctx, - final FlagEvaluationDetails details, - final Map hints) { - try { - if (details == null) { - return; - } - if (!FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()) { - return; - } - - final FlagEvaluationWriter w = writerSupplier.get(); - if (w == null) { - return; - } - - // Pre-queue guard: if the queue is already saturated, avoid the context-copy work whose - // result would be discarded on offer(). Best-effort — the queue can still be full at - // offer() time — but flips a full snapshot into an O(1) check on the drop path. - if (!w.hasCapacityForEnqueue()) { - w.countPreQueueOverflow(); - return; - } - - // Scalar extraction - individual typed metadata reads, no JSON, no flattening - final String flagKey = details.getFlagKey(); - final ImmutableMetadata metadata = details.getFlagMetadata(); - - // allocationKey: "allocationKey" (camelCase) - consistent with FlagEvalMetricsHook.java - // The ImmutableMetadata getters build a debug message for every absent key. - final Map values = - metadata == null ? Collections.emptyMap() : metadata.asUnmodifiableMap(); - final Object allocationValue = values.get("allocationKey"); - final String allocationKey = - allocationValue instanceof String ? (String) allocationValue : null; - - // eval-time: from flag metadata "__dd_eval_timestamp_ms" (Long), fallback to hook-fire time. - // ImmutableMetadata.getLong available since sdk 1.4+. - final Object evalTimeValue = values.get("__dd_eval_timestamp_ms"); - final Long evalTimeObj = evalTimeValue instanceof Long ? (Long) evalTimeValue : null; - final long evalTimeMs = evalTimeObj != null ? evalTimeObj : System.currentTimeMillis(); - - // variant: the OpenFeature variant key (same source as the OTel FlagEvalMetricsHook), NOT the - // evaluated value. A null variant means no variant was selected (runtime default). - final String variant = details.getVariant(); - - // targetingKey from evaluation context - final String targetingKey = - ctx != null && ctx.getCtx() != null ? ctx.getCtx().getTargetingKey() : null; - - // Consent is read from metadata stamped by DDEvaluator (pinned to its ServerConfiguration). - // Missing key = non-DD provider → false, the privacy-preserving default. - final Object consentValue = values.get(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA); - final Boolean consentFromMetadata = - consentValue instanceof Boolean ? (Boolean) consentValue : null; - final boolean observeFullEvaluationData = consentFromMetadata != null && consentFromMetadata; - - // Error message: prefer the human-readable message under consent-on; under consent-off the - // provider's raw message can echo evaluation-context values (e.g. NumberFormatException: - // "For input string: \"jane.doe@...\""), so replace it with the ErrorCode name — a stable, - // PII-free signal. Same substitution path is used when the message is absent regardless of - // consent (some providers populate only the code). Null on success. - String errorMessage = observeFullEvaluationData ? details.getErrorMessage() : null; - if ((errorMessage == null || errorMessage.isEmpty()) && details.getErrorCode() != null) { - errorMessage = details.getErrorCode().name(); - } - if (errorMessage != null && errorMessage.isEmpty()) { - errorMessage = null; - } - - // On the protected path (consent-off) the evaluation context is dropped on emit and never - // consulted by the aggregator, so skip the bounded copy entirely — the copy cost only - // applies under consent-on. - final Map attrs; - if (observeFullEvaluationData && ctx != null && ctx.getCtx() != null) { - // Bounded copy of the caller's mutable context (see DDEvaluator.copyPrunedContext for - // every retained-size cap). Runs inline because the event is consumed asynchronously - // and the source context is caller-owned; work is proportional to what is retained. - final DDEvaluator.CopyResult copy = DDEvaluator.copyPrunedContext(ctx.getCtx()); - if (copy.truncatedReason != null) { - w.countContextTruncated(copy.truncatedReason); - } - attrs = copy.attrs; - } else { - attrs = Collections.emptyMap(); - } - - // Features come from the flag configuration, not the evaluation context, so they are sent - // whatever the consent. - final Map features = - Boolean.TRUE.equals(values.get(DDEvaluator.METADATA_HAS_EVALUATION_FEATURES)) - ? SplitFeatureMetadata.featuresWithPrefix( - metadata, DDEvaluator.METADATA_EVALUATION_FEATURE_PREFIX) - : Collections.emptyMap(); - w.enqueue( - features.isEmpty() || !FEATURES_SUPPORTED.get() - ? new FlagEvalEvent( - flagKey, - variant, - allocationKey, - targetingKey, - errorMessage, - evalTimeMs, - observeFullEvaluationData, - attrs) - : new FlagEvalEvent( - flagKey, - variant, - allocationKey, - targetingKey, - errorMessage, - evalTimeMs, - observeFullEvaluationData, - attrs, - features)); - } catch (LinkageError e) { - // Never let EVP recording break flag evaluation - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java deleted file mode 100644 index eba3b7ba4b5..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java +++ /dev/null @@ -1,99 +0,0 @@ -package datadog.trace.api.openfeature; - -import dev.openfeature.sdk.ErrorCode; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.common.AttributeKey; -import io.opentelemetry.api.common.Attributes; -import io.opentelemetry.api.common.AttributesBuilder; -import io.opentelemetry.api.metrics.LongCounter; -import io.opentelemetry.api.metrics.Meter; -import java.io.Closeable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -class FlagEvalMetrics implements Closeable { - - private static final Logger log = LoggerFactory.getLogger(FlagEvalMetrics.class); - - private static final String METER_NAME = "ddtrace.openfeature"; - private static final String METRIC_NAME = "feature_flag.evaluations"; - private static final String METRIC_UNIT = "{evaluation}"; - private static final String METRIC_DESC = "Number of feature flag evaluations"; - - private static final AttributeKey ATTR_FLAG_KEY = - AttributeKey.stringKey("feature_flag.key"); - private static final AttributeKey ATTR_VARIANT = - AttributeKey.stringKey("feature_flag.result.variant"); - private static final AttributeKey ATTR_REASON = - AttributeKey.stringKey("feature_flag.result.reason"); - private static final AttributeKey ATTR_ERROR_TYPE = AttributeKey.stringKey("error.type"); - private static final AttributeKey ATTR_ALLOCATION_KEY = - AttributeKey.stringKey("feature_flag.result.allocation_key"); - - private volatile LongCounter counter; - - FlagEvalMetrics() { - try { - Meter meter = GlobalOpenTelemetry.get().getMeterProvider().meterBuilder(METER_NAME).build(); - counter = - meter - .counterBuilder(METRIC_NAME) - .setUnit(METRIC_UNIT) - .setDescription(METRIC_DESC) - .build(); - - log.debug("Flag evaluation metrics initialized"); - } catch (NoClassDefFoundError e) { - log.error( - "OpenTelemetry API is not on the classpath — evaluation metrics disabled. Add" - + " opentelemetry-api to your dependencies and enable DD_METRICS_OTEL_ENABLED to" - + " export flag evaluation metrics through the Datadog Java agent.", - e); - counter = null; - } catch (Exception e) { - log.error("Failed to initialize flag evaluation metrics", e); - counter = null; - } - } - - /** Package-private constructor for testing with a mock counter. */ - FlagEvalMetrics(LongCounter counter) { - this.counter = counter; - } - - void record( - String flagKey, String variant, String reason, ErrorCode errorCode, String allocationKey) { - LongCounter c = counter; - if (c == null) { - return; - } - try { - AttributesBuilder builder = - Attributes.builder() - .put(ATTR_FLAG_KEY, flagKey) - .put(ATTR_VARIANT, variant != null ? variant : "") - .put(ATTR_REASON, reason != null ? reason.toLowerCase() : "unknown"); - - if (errorCode != null) { - builder.put(ATTR_ERROR_TYPE, errorCode.name().toLowerCase()); - } - - if (allocationKey != null && !allocationKey.isEmpty()) { - builder.put(ATTR_ALLOCATION_KEY, allocationKey); - } - - c.add(1, builder.build()); - } catch (Exception e) { - log.debug("Failed to record flag evaluation metric for {}", flagKey, e); - } - } - - @Override - public void close() { - shutdown(); - } - - void shutdown() { - counter = null; - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java deleted file mode 100644 index a3e0dbfc83c..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java +++ /dev/null @@ -1,41 +0,0 @@ -package datadog.trace.api.openfeature; - -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import java.util.Map; - -class FlagEvalMetricsHook implements Hook { - - private final FlagEvalMetrics metrics; - - FlagEvalMetricsHook(FlagEvalMetrics metrics) { - this.metrics = metrics; - } - - @Override - public void finallyAfter( - HookContext ctx, FlagEvaluationDetails details, Map hints) { - if (metrics == null || details == null) { - return; - } - try { - String flagKey = details.getFlagKey(); - String variant = details.getVariant(); - String reason = details.getReason(); - ErrorCode errorCode = details.getErrorCode(); - - String allocationKey = null; - ImmutableMetadata metadata = details.getFlagMetadata(); - if (metadata != null) { - allocationKey = metadata.getString("allocationKey"); - } - - metrics.record(flagKey, variant, reason, errorCode, allocationKey); - } catch (Exception e) { - // Never let metrics recording break flag evaluation - } - } -} 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 deleted file mode 100644 index b5dc224392c..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ /dev/null @@ -1,379 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; -import datadog.trace.bootstrap.config.provider.ConfigProvider; -import de.thetaphi.forbiddenapis.SuppressForbidden; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.EventProvider; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.Metadata; -import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.ProviderEvent; -import dev.openfeature.sdk.ProviderEventDetails; -import dev.openfeature.sdk.Value; -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.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -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; - private final AtomicReference initializationState = - new AtomicReference<>(InitializationState.NOT_STARTED); - private final FlagEvalMetrics flagEvalMetrics; - private final FlagEvalMetricsHook flagEvalMetricsHook; - // Span enrichment: null unless the gate is on, so the feature has no idle overhead when off. - private final SpanEnrichmentHook spanEnrichmentHook; - // Precomputed hook list returned by getProviderHooks() on every evaluation. Immutable and built - // once so gate-off evaluation allocates nothing on this hot path. - private final List providerHooks; - - public Provider() { - this(DEFAULT_OPTIONS, null); - } - - public Provider(final Options options) { - this(options, null); - } - - Provider(final Options options, final Evaluator evaluator) { - this(options, evaluator, null); - } - - /** - * @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test - * seam); when null, the gate is read via {@link SpanEnrichmentGate}. - */ - Provider( - final Options options, - final Evaluator evaluator, - final Boolean spanEnrichmentEnabledOverride) { - this.options = options; - this.evaluator = evaluator; - FlagEvalMetrics metrics = null; - FlagEvalMetricsHook hook = null; - try { - metrics = new FlagEvalMetrics(); - hook = new FlagEvalMetricsHook(metrics); - } catch (LinkageError | Exception e) { - // This outer catch fires when the metrics helper itself can't load (OTel API absent). - log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); - } - this.flagEvalMetrics = metrics; - this.flagEvalMetricsHook = hook; - - // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle - // per-evaluation overhead. - final boolean spanEnrichmentEnabled = - spanEnrichmentEnabledOverride != null - ? spanEnrichmentEnabledOverride - : SpanEnrichmentGate.isEnabled(); - this.spanEnrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; - - // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) - // allocates nothing, including when the gate is off. - final List hooks = new ArrayList<>(4); - if (flagEvalMetricsHook != null) { - hooks.add(flagEvalMetricsHook); - } - // EVP flagevaluation hook: always registered; no-op when writer is absent (killswitch off). - // Writer is resolved lazily from FeatureFlaggingGateway.getFlagEvalWriter() on each call. - try { - final Hook flagEvalLoggingHook = buildFlagEvalLoggingHook(); - if (flagEvalLoggingHook != null) { - hooks.add(flagEvalLoggingHook); - } - } catch (LinkageError | Exception e) { - // Keep older bootstrap/API combinations working: EVP recording is best-effort. - } - if (spanEnrichmentHook != null) { - hooks.add(spanEnrichmentHook); - } - if (options.isEvaluationTelemetryOptOutParameterEnabled()) { - for (int i = 0; i < hooks.size(); i++) { - hooks.set(i, new EvaluationTelemetryOptOutHook(hooks.get(i))); - } - } - // Exposure logging is independent of evaluation telemetry opt-out. - try { - hooks.add(buildExposureLoggingHook()); - } catch (LinkageError e) { - log.warn("Feature flag exposure reporting is unavailable with the installed agent", e); - } - this.providerHooks = - hooks.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(hooks); - - // Announce the span-enrichment state at startup (matches the reference implementation). - // "enabled" only when the gate is on (the capture hook was constructed), otherwise "disabled". - if (spanEnrichmentHook != null) { - log.info("{} span enrichment enabled", METADATA); - } else { - log.info("{} span enrichment disabled", METADATA); - } - } - - @Override - public void initialize(final EvaluationContext context) throws Exception { - initializationState.set(InitializationState.INITIALIZING); - try { - evaluator = buildEvaluator(); - if (!evaluator.initialize(options.getTimeout(), options.getUnit(), context)) { - if (markInitialConfigReceivedReady()) { - return; - } - markInitializationError(); - throw new ProviderNotReadyError( - "Provider timed-out while waiting for initial configuration"); - } - if (!evaluator.hasConfiguration() || !markSuccessfulInitializationReady()) { - markInitializationError(); - throw new ProviderNotReadyError( - "Provider timed-out while waiting for initial configuration"); - } - } catch (final OpenFeatureError e) { - markInitializationError(); - throw e; - } catch (final Throwable e) { - markInitializationError(); - throw new FatalError("Failed to initialize provider, is the tracer configured?", e); - } - } - - void onConfigurationChange() { - if (evaluator == null || !evaluator.hasConfiguration()) { - onConfigurationUnavailable(); - return; - } - - final InitializationState state = initializationState.get(); - if (state == InitializationState.INITIALIZING) { - initializationState.compareAndSet( - InitializationState.INITIALIZING, InitializationState.INITIAL_CONFIG_RECEIVED); - return; - } - if (state == InitializationState.INITIAL_CONFIG_RECEIVED) { - return; - } - if (state == InitializationState.ERROR - && initializationState.compareAndSet( - InitializationState.ERROR, InitializationState.READY)) { - emit( - ProviderEvent.PROVIDER_READY, - ProviderEventDetails.builder().message("Provider ready").build()); - return; - } - if (initializationState.get() != InitializationState.READY) { - return; - } - emit( - ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, - ProviderEventDetails.builder().message("New configuration received").build()); - } - - private void onConfigurationUnavailable() { - if (initializationState.compareAndSet( - InitializationState.INITIAL_CONFIG_RECEIVED, InitializationState.ERROR)) { - return; - } - if (!initializationState.compareAndSet(InitializationState.READY, InitializationState.ERROR)) { - return; - } - emit( - ProviderEvent.PROVIDER_ERROR, - ProviderEventDetails.builder() - .message("Configuration unavailable") - .errorCode(ErrorCode.PROVIDER_NOT_READY) - .build()); - } - - private boolean markInitialConfigReceivedReady() { - return initializationState.get() == InitializationState.READY - || initializationState.compareAndSet( - InitializationState.INITIAL_CONFIG_RECEIVED, InitializationState.READY); - } - - private boolean markSuccessfulInitializationReady() { - return markInitialConfigReceivedReady() - || initializationState.compareAndSet( - InitializationState.INITIALIZING, InitializationState.READY); - } - - private void markInitializationError() { - InitializationState state = initializationState.get(); - while (state != InitializationState.READY && state != InitializationState.ERROR) { - if (initializationState.compareAndSet(state, InitializationState.ERROR)) { - return; - } - state = initializationState.get(); - } - } - - private Evaluator buildEvaluator() throws Exception { - if (evaluator != null) { - return evaluator; - } - final Class evaluatorClass = loadEvaluatorClass(); - final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); - return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); - } - - @Override - public List getProviderHooks() { - return providerHooks; - } - - Hook buildFlagEvalLoggingHook() { - return FlagEvalLoggingHook.INSTANCE; - } - - Hook buildExposureLoggingHook() { - return ExposureLoggingHook.INSTANCE; - } - - @Override - public void shutdown() { - if (flagEvalMetrics != null) { - flagEvalMetrics.shutdown(); - } - // Span enrichment needs no provider-close cleanup here: the capture hook holds no tracer state. - // The agent-side write tier owns the interceptor and per-trace state and is torn down with the - // feature-flagging subsystem, not per provider. - if (evaluator != null) { - evaluator.shutdown(); - } - } - - // Visible for tests: expose whether span enrichment is wired (gate-on) without leaking the impl. - SpanEnrichmentHook spanEnrichmentHook() { - return spanEnrichmentHook; - } - - @Override - public Metadata getMetadata() { - return this; - } - - @Override - public String getName() { - return METADATA; - } - - @Override - public ProviderEvaluation getBooleanEvaluation( - final String key, final Boolean defaultValue, final EvaluationContext ctx) { - return evaluator.evaluate(Boolean.class, key, defaultValue, ctx); - } - - @Override - public ProviderEvaluation getStringEvaluation( - final String key, final String defaultValue, final EvaluationContext ctx) { - return evaluator.evaluate(String.class, key, defaultValue, ctx); - } - - @Override - public ProviderEvaluation getIntegerEvaluation( - final String key, final Integer defaultValue, final EvaluationContext ctx) { - return evaluator.evaluate(Integer.class, key, defaultValue, ctx); - } - - @Override - public ProviderEvaluation getDoubleEvaluation( - final String key, final Double defaultValue, final EvaluationContext ctx) { - return evaluator.evaluate(Double.class, key, defaultValue, ctx); - } - - @Override - public ProviderEvaluation getObjectEvaluation( - final String key, final Value defaultValue, final EvaluationContext ctx) { - 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, - INITIAL_CONFIG_RECEIVED, - READY, - ERROR - } - - public static class Options { - - private long timeout = 30; - private TimeUnit unit = SECONDS; - private Boolean evaluationTelemetryOptOutParameterEnabled; - - /** - * Enables per-evaluation telemetry opt-out through the {@code dd_evaluation_telemetry_opt_out} - * context attribute. Overrides {@code - * DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED}, including when false. - * Without an explicit option, the provider reads configuration at construction. Disabled by - * default. - * - *

Only boolean {@code true} suppresses new Datadog evaluation events, evaluation metrics, - * and automatic flag span enrichment. Missing or other values preserve telemetry. This does not - * change flag results, exposure logging, application hooks, or previously recorded data. - * - *

The attribute remains available for targeting and exposure reporting. Existing privacy and - * reporting settings apply. - * - * @param enabled whether to honor the attribute; configure before constructing the provider - * @return these options - */ - public Options enableEvaluationTelemetryOptOutParameter(final boolean enabled) { - this.evaluationTelemetryOptOutParameterEnabled = enabled; - return this; - } - - private boolean isEvaluationTelemetryOptOutParameterEnabled() { - if (evaluationTelemetryOptOutParameterEnabled != null) { - return evaluationTelemetryOptOutParameterEnabled; - } - try { - return ConfigProvider.getInstance() - .getBoolean( - FeatureFlaggingConfig - .FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED, - false); - } catch (final LinkageError | Exception e) { - // Keep provider construction compatible when agent configuration classes are unavailable. - return false; - } - } - - public Options initTimeout(final long timeout, final TimeUnit unit) { - this.timeout = timeout; - this.unit = unit; - return this; - } - - public long getTimeout() { - return timeout; - } - - public TimeUnit getUnit() { - return unit; - } - } -} 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 deleted file mode 100644 index 64739f0ad39..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java +++ /dev/null @@ -1,25 +0,0 @@ -package datadog.trace.api.openfeature; - -import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; -import datadog.trace.bootstrap.config.provider.ConfigProvider; - -/** - * 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. - */ -final class SpanEnrichmentGate { - - private SpanEnrichmentGate() {} - - static boolean isEnabled() { - try { - return ConfigProvider.getInstance() - .getBoolean(FeatureFlaggingConfig.EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED, false); - } 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 deleted file mode 100644 index 40e998a6546..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java +++ /dev/null @@ -1,149 +0,0 @@ -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; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.Structure; -import dev.openfeature.sdk.Value; -import java.time.Instant; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * 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. - * - *

Capture branch (frozen Node reference): - * - *

    - *
  • serial id present → dispatch a serial-id event (the write side records the serial id, plus - * the subject when {@code __dd_do_log} AND a targeting key) - *
  • else variant missing (runtime default) → dispatch a runtime-default event with the value - * unwrapped to a native Java type - *
- * - *

All work is wrapped in try/catch — enrichment must NEVER break flag evaluation. - */ -class SpanEnrichmentHook implements Hook { - - private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentHook.class); - - // The metadata keys the DDEvaluator attaches for span enrichment (single source of truth there). - static final String METADATA_SERIAL_ID = DDEvaluator.METADATA_SPLIT_SERIAL_ID; - static final String METADATA_DO_LOG = DDEvaluator.METADATA_DO_LOG; - - @Override - public void finallyAfter( - final HookContext ctx, - final FlagEvaluationDetails details, - final Map hints) { - if (details == null) { - return; - } - try { - final ImmutableMetadata metadata = details.getFlagMetadata(); - 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))); - } 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()))); - } - } catch (final Throwable t) { - // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. - log.debug("Span-enrichment capture failed for flag {}", details.getFlagKey(), t); - } - } - - private static String targetingKey(final HookContext ctx) { - if (ctx == null) { - return null; - } - final EvaluationContext evaluationContext = ctx.getCtx(); - if (evaluationContext == null) { - return null; - } - final String key = evaluationContext.getTargetingKey(); - return (key == null || key.isEmpty()) ? null : key; - } - - /** - * Unwraps an OpenFeature {@link Value} to a native Java representation so the runtime default can - * cross the (JDK-types-only) seam and be JSON-serialized on the write side exactly like Node's - * {@code JSON.stringify}. Non-{@code Value} inputs (already native) are returned as-is. - */ - static Object unwrapDefaultValue(final Object value) { - return value instanceof Value ? unwrapValue((Value) value) : value; - } - - /** - * Recursively unwraps an OpenFeature {@link Value} into its native Java representation: - * structures become {@code Map}, lists become {@code List}, and scalars - * become their boxed value (or {@code null}). Nested {@link Value}s are unwrapped at every level - * so a structure containing further structures/lists serializes correctly. - */ - 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 out = new ArrayList<>(list == null ? 0 : list.size()); - if (list != null) { - for (final Value element : list) { - out.add(unwrapValue(element)); - } - } - return out; - } - if (value.isBoolean()) { - return value.asBoolean(); - } - if (value.isString()) { - return value.asString(); - } - if (value.isNumber()) { - // Preserve integral vs fractional so the rendered JSON number matches Node. - final Double d = value.asDouble(); - if (d != null && d == Math.rint(d) && !Double.isInfinite(d)) { - final Integer i = value.asInteger(); - if (i != null) { - return i; - } - } - return d; - } - final Instant instant = value.asInstant(); - if (instant != null) { - return instant.toString(); - } - // Unknown shape: fall back to the wrapped object's own representation. - return value.asObject(); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SplitFeatureMetadata.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SplitFeatureMetadata.java deleted file mode 100644 index ac24054871f..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SplitFeatureMetadata.java +++ /dev/null @@ -1,36 +0,0 @@ -package datadog.trace.api.openfeature; - -import dev.openfeature.sdk.ImmutableMetadata; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -/** - * Reads the split features that {@link DDEvaluator} copies into flag metadata. It depends only on - * the OpenFeature API, so {@link ExposureHook} keeps working where the Datadog Java agent is - * absent. - */ -final class SplitFeatureMetadata { - - private SplitFeatureMetadata() {} - - /** The features stored in the metadata under {@code prefix}, keyed without the prefix. */ - static Map featuresWithPrefix( - final ImmutableMetadata metadata, final String prefix) { - if (metadata == null) { - return Collections.emptyMap(); - } - Map features = null; - for (final Map.Entry entry : metadata.asUnmodifiableMap().entrySet()) { - if (entry.getKey().startsWith(prefix)) { - if (features == null) { - features = new HashMap<>(); - } - features.put(entry.getKey().substring(prefix.length()), entry.getValue()); - } - } - return features == null - ? Collections.emptyMap() - : Collections.unmodifiableMap(features); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java deleted file mode 100644 index 0fba4c9b5c0..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonList; -import static 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.mockito.Mockito.mock; - -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.ProviderEvaluation; -import java.util.HashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -/** - * Drives the span-enrichment branch of {@link DDEvaluator}, which the ordinary test task cannot - * reach: the gate is read once into a static final field at class load. The property is set here - * rather than through {@code @WithConfig} because that extension rewrites {@code Config.INSTANCE}, - * which this module does not use, and the forked task gives this class its own JVM where nothing - * has loaded the evaluator yet. - */ -class DDEvaluatorSpanEnrichmentForkedTest { - - static { - System.setProperty("dd.experimental.flagging.provider.span.enrichment.enabled", "true"); - } - - @Test - void enrichmentMetadataCarriesTheSplitSerialId() { - final ProviderEvaluation result = evaluate(340132); - - assertNotNull( - result.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG), - "span enrichment must be on, or the assertions below pass vacuously"); - assertEquals( - Integer.valueOf(340132), - result.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } - - /** - * Simulates legacy exposure support while Split.serialId remains available. Falling back to the - * five-argument exposure constructor must not suppress the serial id in enrichment metadata. - */ - @Test - void enrichmentMetadataSurvivesAnAgentWithoutTheExposureConstructor() { - final boolean previous = DDEvaluator.EXPOSURE_SERIAL_ID_SUPPORTED.getAndSet(false); - try { - assertEquals( - Integer.valueOf(340132), - evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } finally { - DDEvaluator.EXPOSURE_SERIAL_ID_SUPPORTED.set(previous); - } - } - - @Test - void enrichmentMetadataOmitsTheSerialIdWhenTheAgentSplitHasNoField() { - final boolean previous = DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.getAndSet(false); - try { - assertNull( - evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } finally { - DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.set(previous); - } - } - - @Test - void enrichmentMetadataOmitsTheSerialIdWhenTheSplitHasNone() { - assertNull(evaluate(null).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } - - private static ProviderEvaluation evaluate(final Integer serialId) { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), serialId); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); - final Map flags = new HashMap<>(); - flags.put( - "target", - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); - - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", true, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); - return evaluator.evaluate(Integer.class, "target", 23, ctx); - } -} 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 deleted file mode 100644 index 953450807f7..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ /dev/null @@ -1,1471 +0,0 @@ -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.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.assertFalse; -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.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.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.ParsedSemver; -import datadog.trace.api.featureflag.ufc.v1.Rule; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.HookContext; -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.Instant; -import java.time.format.DateTimeFormatter; -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 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 { - - private static final String CANONICAL_FIXTURE_PATH = - "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; - private static final Moshi MOSHI = - new Moshi.Builder() - .add(Date.class, new DateAdapter()) - .add(ShardAdapter.FACTORY) - .add(FlagMapAdapter.FACTORY) - .build(); - private static final JsonAdapter CONFIG_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); - private static final Type FIXTURE_LIST_TYPE = - Types.newParameterizedType(List.class, FixtureCase.class); - private static final JsonAdapter> FIXTURE_LIST_ADAPTER = - MOSHI.adapter(FIXTURE_LIST_TYPE); - private static final ThreadLocal> INVALID_FLAGS_HOLDER = - ThreadLocal.withInitial(HashMap::new); - private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; - - @Test - public void testInitializeSignalsApplicationProviderActivation() throws Exception { - 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 { - 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(Long.class, 42L, 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)); - } - } - - @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)); - } - - @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(); - } - } - - @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)); - - evaluator.accept(mock(ServerConfiguration.class)); - assertThat(initialized.get(1, SECONDS), equalTo(true)); - } finally { - executor.shutdownNow(); - evaluator.shutdown(); - } - } - - @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)); - } - - @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("", "", false, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); - - 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)); - - details = evaluator.evaluate(Integer.class, "empty-allocation", 23, ctx); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat(details.getErrorCode(), nullValue()); - } - - @Test - public void testEvaluateUnsignedShardRange() { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - // The selected shard is above Integer.MAX_VALUE, so this test proves that evaluation uses - // unsigned semantics after binary-compatible int storage. - final Shard shard = - new Shard( - "salt", - singletonList(new ShardRange((int) 3_699_531_192L, (int) 3_699_531_193L)), - (int) MAX_UNSIGNED_INT); - final Split split = new Split(singletonList(shard), "on", emptyMap(), null); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); - final Map flags = new HashMap<>(); - flags.put( - "target", - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", false, null, flags)); - - final EvaluationContext context = - new MutableContext("target").setTargetingKey("high-shard-user"); - final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, context); - - assertThat(details.getValue(), equalTo(1)); - assertThat(details.getReason(), equalTo("SPLIT")); - assertThat(details.getVariant(), equalTo("on")); - } - - // ---- observeFullEvaluationData metadata is stamped from the evaluator's ServerConfiguration - // ---- - // - // Every code path that returns a ProviderEvaluation must stamp the consent boolean so downstream - // hooks can honour it. These tests exercise each stamp site with both consent values (on/off) so - // a mutation to any stamp — deleting the line, hardcoding the value — flips at least one - // assertion. - - // -- success path: resolveVariant (variant metadata builder) -- - - @Test - public void observeFullEvaluationDataStampedTrueOnResolvedVariant() { - final ProviderEvaluation details = evaluateMatchingFlag(true); - - assertThat(details.getReason(), equalTo("STATIC")); - assertThat(details.getVariant(), equalTo("on")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataStampedFalseOnResolvedVariant() { - // Symmetric consent-off assertion. Paired with the consent-on test above this pins the - // resolveVariant metadata line (DDEvaluator.java: METADATA_OBSERVE_FULL_EVALUATION_DATA) so - // deleting it or hardcoding either value would fail at least one assertion. - final ProviderEvaluation details = evaluateMatchingFlag(false); - - assertThat(details.getReason(), equalTo("STATIC")); - assertThat(details.getVariant(), equalTo("on")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // -- DISABLED path: flag.enabled=false -- - - @Test - public void observeFullEvaluationDataStampedTrueOnDisabledFlag() { - final ProviderEvaluation details = evaluateDisabledFlag(true); - - assertThat(details.getReason(), equalTo("DISABLED")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataStampedFalseOnDisabledFlag() { - final ProviderEvaluation details = evaluateDisabledFlag(false); - - assertThat(details.getReason(), equalTo("DISABLED")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // -- DEFAULT path: no allocation matches -- - - @Test - public void observeFullEvaluationDataStampedTrueOnDefault() { - // Allocation exists but has empty splits, so the loop finishes without returning and we fall - // through to the DEFAULT branch. - final ProviderEvaluation details = evaluateWithEmptySplits(true); - - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataStampedFalseOnDefault() { - final ProviderEvaluation details = evaluateWithEmptySplits(false); - - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // -- error paths: FLAG_NOT_FOUND / PROVIDER_NOT_READY (via consentMetadata in error()) -- - - @Test - public void observeFullEvaluationDataStampedOnFlagNotFoundError() { - // Was previously named "…OnSuccess" but actually exercises the error() helper's stamp via - // FLAG_NOT_FOUND — kept for that stamp site, correctly named. - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", true, null, new HashMap<>())); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); - final ProviderEvaluation details = - evaluator.evaluate(Integer.class, "unknown-flag", 23, ctx); - - assertThat(details.getErrorCode(), equalTo(ErrorCode.FLAG_NOT_FOUND)); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - final ProviderEvaluation details = - evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - @Test - public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { - // The field is boxed so Moshi tolerates a malformed consent value in the UFC JSON without - // aborting the whole parse. The evaluator must then interpret null as the privacy-preserving - // default. An auto-unbox at the read site (config.observeFullEvaluationData) would NPE here. - final Map flags = new HashMap<>(); - flags.put("target", new Flag("target", true, ValueType.INTEGER, emptyMap(), emptyList())); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); - final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, ctx); - - // Flags still evaluate — availability preserved despite the malformed consent field. - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // ---- exposure events carry the split's serial id ---- - - @Test - public void exposureCarriesTheSplitSerialId() { - assertEquals(Integer.valueOf(340132), exposureFor(340132).serial_id); - } - - @Test - public void exposureCarriesSerialIdZero() { - assertEquals(Integer.valueOf(0), exposureFor(0).serial_id); - } - - @Test - public void exposureOmitsSerialIdWhenTheSplitHasNone() { - assertNull(exposureFor(null).serial_id); - } - - @Test - public void legacyExposureApiDispatchesAnExposureWithoutSerialId() { - final boolean previous = DDEvaluator.EXPOSURE_SERIAL_ID_SUPPORTED.getAndSet(false); - try { - assertNull(exposureFor(7).serial_id); - } finally { - DDEvaluator.EXPOSURE_SERIAL_ID_SUPPORTED.set(previous); - } - } - - // ---- old-agent bootstrap probe ---- - - /** A Split from an agent that predates the serial id: the field does not exist. */ - static final class SplitWithoutSerialId {} - - /** A Split whose serialId is not the Integer the dispatch site reads. */ - static final class SplitWithWrongSerialIdType { - public long serialId; - } - - /** An ExposureEvent from an agent that predates the serial id: only the five-arg constructor. */ - static final class LegacyExposureEvent { - // Qualify exposure types that share names with the imported UFC types. - LegacyExposureEvent( - final long timestamp, - final datadog.trace.api.featureflag.exposure.Allocation allocation, - final datadog.trace.api.featureflag.exposure.Flag flag, - final datadog.trace.api.featureflag.exposure.Variant variant, - final Subject subject) {} - } - - /** - * Positive control. The probe must agree with the bootstrap actually on the classpath, or the - * negative cases below would pass for the wrong reason and the feature would ship switched off. - */ - @Test - public void probeAcceptsTheBootstrapOnTheClasspath() { - assertTrue(DDEvaluator.splitSerialIdSupported(Split.class)); - assertTrue(DDEvaluator.exposureSerialIdSupported(ExposureEvent.class)); - assertTrue(DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.get()); - assertTrue(DDEvaluator.EXPOSURE_SERIAL_ID_SUPPORTED.get()); - } - - @Test - public void probeRejectsAnAgentWhoseSplitHasNoSerialId() { - assertFalse(DDEvaluator.splitSerialIdSupported(SplitWithoutSerialId.class)); - } - - @Test - public void probeRejectsAnAgentWhoseSerialIdIsNotAnInteger() { - assertFalse(DDEvaluator.splitSerialIdSupported(SplitWithWrongSerialIdType.class)); - } - - @Test - public void probeRejectsAnAgentWithoutTheSerialIdConstructor() { - assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class)); - } - - /** - * Tests legacy bootstrap behavior when Split.serialId exists but the exposure event has only the - * five-argument constructor. Split support must remain independent of exposure constructor - * support. - */ - @Test - public void probeKeepsSplitSupportWhenOnlyTheEventConstructorIsMissing() { - assertTrue(DDEvaluator.splitSerialIdSupported(Split.class)); - assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class)); - } - - /** - * Evaluates a logging allocation whose split carries the given serial id, then runs the exposure - * hook as OpenFeature would after a successful evaluation, and returns the single dispatched - * exposure. Span enrichment is off here, as it is by default, so this also pins that the serial - * id reaches the hook without span enrichment. - */ - @SuppressWarnings({"unchecked", "rawtypes"}) - private static ExposureEvent exposureFor(final Integer serialId) { - ExposureDeduplicationCache.INSTANCE.clear(); - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), serialId); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.TRUE); - final Map flags = new HashMap<>(); - flags.put( - "target", - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", true, null, flags)); - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); - - final List dispatched = new ArrayList<>(); - final FeatureFlaggingGateway.ExposureListener listener = dispatched::add; - FeatureFlaggingGateway.addExposureListener(listener); - try { - final ProviderEvaluation evaluation = - evaluator.evaluate(Integer.class, "target", 23, ctx); - assertTrue(dispatched.isEmpty(), "resolving a flag must not send an exposure by itself"); - final HookContext hookContext = - HookContext.builder() - .flagKey("target") - .type(FlagValueType.INTEGER) - .defaultValue(23) - .ctx(ctx) - .build(); - ExposureLoggingHook.INSTANCE.finallyAfter( - hookContext, - (FlagEvaluationDetails) FlagEvaluationDetails.from(evaluation, "target"), - emptyMap()); - } finally { - FeatureFlaggingGateway.removeExposureListener(listener); - ExposureDeduplicationCache.INSTANCE.clear(); - } - assertEquals(1, dispatched.size()); - return dispatched.get(0); - } - - // Builds a flag that reaches resolveVariant: enabled, one allocation with no rules, one split - // with empty shards (so the shard-match branch is skipped and the split is picked immediately), - // and a single "on" variant whose value maps to the requested Integer type. - private static ProviderEvaluation evaluateMatchingFlag( - final boolean observeFullEvaluationData) { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), null); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); - return evaluateFlag( - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), - observeFullEvaluationData); - } - - private static ProviderEvaluation evaluateDisabledFlag( - final boolean observeFullEvaluationData) { - return evaluateFlag( - new Flag("target", false, ValueType.INTEGER, emptyMap(), null), observeFullEvaluationData); - } - - private static ProviderEvaluation evaluateWithEmptySplits( - final boolean observeFullEvaluationData) { - // Enabled, allocations present, allocation active, no rules, empty splits → falls through the - // for-loop to the DEFAULT return. - final Allocation allocation = - new Allocation("alloc-1", null, null, null, emptyList(), Boolean.FALSE); - return evaluateFlag( - new Flag("target", true, ValueType.INTEGER, emptyMap(), singletonList(allocation)), - observeFullEvaluationData); - } - - private static ProviderEvaluation evaluateFlag( - final Flag flag, final boolean observeFullEvaluationData) { - final Map flags = new HashMap<>(); - flags.put("target", flag); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); - return evaluator.evaluate(Integer.class, "target", 23, ctx); - } - - // ---- error message redaction respects observeFullEvaluationData ---- - - @Test - public void numericConditionOnTargetingKeyDropsExceptionMessageUnderConsentOff() { - // Rule {attribute:"id", operator:GT, value:0} + "id" not in context → - // DDEvaluator.resolveAttribute - // falls back to the targeting key, so Double.parseDouble("jane.doe@datadoghq.com") throws - // NumberFormatException. The exception message echoes the raw context value verbatim, so it - // must be dropped when observeFullEvaluationData=false. - final ProviderEvaluation details = - evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); - - assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); - assertNull(details.getErrorMessage(), "consent-off must not surface the raw exception message"); - } - - @Test - public void numericConditionOnTargetingKeyPreservesExceptionMessageUnderConsentOn() { - // Symmetric case: with consent on, the raw exception message flows through unchanged so - // operators keep the diagnostic detail they opted in to. - final ProviderEvaluation details = - evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", true); - - assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); - assertThat(details.getErrorMessage(), equalTo("For input string: \"jane.doe@datadoghq.com\"")); - } - - @Test - public void numericConditionOnTargetingKeyErrorMessageNeverContainsPiiUnderConsentOff() { - // Belt-and-suspenders: independent of the exact null/empty form, the raw PII value must never - // appear in the message under consent-off. Guards against future changes that might replace - // null with a redacted string or a code-name suffix. - final ProviderEvaluation details = - evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); - - final String message = details.getErrorMessage(); - assertFalse( - message != null && message.contains("jane.doe@datadoghq.com"), - "consent-off errorMessage must not contain raw context values"); - } - - private static ProviderEvaluation evaluateWithNumericRuleOnId( - final String targetingKey, final boolean observeFullEvaluationData) { - final Map flags = new HashMap<>(); - final List rules = - singletonList( - new Rule(singletonList(new ConditionConfiguration(ConditionOperator.GT, "id", 0)))); - // Split must be non-empty so the allocation is considered a match target; its contents don't - // matter because the rule throws before a split is picked. - final Allocation allocation = - new Allocation("alloc", rules, null, null, emptyList(), Boolean.FALSE); - flags.put( - "num-rule", - new Flag("num-rule", true, ValueType.INTEGER, emptyMap(), singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); - - final EvaluationContext ctx = new MutableContext(targetingKey); - return evaluator.evaluate(Integer.class, "num-rule", 23, ctx); - } - - @Test - public void testAllocationDateAbiAndInstantAccessors() throws Exception { - final Date startAt = Date.from(Instant.parse("2024-01-01T00:00:00Z")); - final Date endAt = Date.from(Instant.parse("2024-12-31T23:59:59Z")); - final Allocation allocation = - new Allocation("allocation", emptyList(), startAt, endAt, emptyList(), true); - - assertThat(Allocation.class.getField("startAt").getType(), equalTo(Date.class)); - assertThat(Allocation.class.getField("endAt").getType(), equalTo(Date.class)); - assertThat(allocation.startAtInstant(), equalTo(startAt.toInstant())); - assertThat(allocation.endAtInstant(), equalTo(endAt.toInstant())); - } - - @Test - public void testAllocationWindowHonorsMicrosecondPrecision() { - final Instant startAt = Instant.parse("2024-01-01T00:00:00.123456Z"); - final Instant endAt = Instant.parse("2024-01-01T00:00:00.987654Z"); - final Allocation allocation = - Allocation.fromInstants("allocation", emptyList(), startAt, endAt, emptyList(), true); - - assertThat( - DDEvaluator.isAllocationActive(allocation, startAt.minusNanos(1_000)), equalTo(false)); - assertThat(DDEvaluator.isAllocationActive(allocation, startAt), equalTo(true)); - assertThat(DDEvaluator.isAllocationActive(allocation, endAt), equalTo(true)); - assertThat(DDEvaluator.isAllocationActive(allocation, endAt.plusNanos(1_000)), equalTo(false)); - } - - // --- SemVer condition evaluation tests (ported from Go evaluator_test.go) --- - - private static Flag semverFlag(final ConditionOperator operator, final String comparand) { - final ParsedSemver parsed = ParsedSemver.parse(comparand); - final ConditionConfiguration condition = - new ConditionConfiguration(operator, "version", comparand); - condition.semverComparand = parsed; - final Rule rule = new Rule(singletonList(condition)); - final Split split = new Split(emptyList(), "on", null, null); - final Allocation allocation = - Allocation.fromInstants( - "targeted", singletonList(rule), null, null, singletonList(split), false); - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", true)); - return new Flag("test-flag", true, ValueType.BOOLEAN, variations, singletonList(allocation)); - } - - private static EvaluationContext semverContext(final Object version) { - final Map attributes = new HashMap<>(); - if (version != null) { - attributes.put("version", version); - } - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - context.setTargetingKey("subject"); - return context; - } - - static Arguments[] semverConditionTestCases() { - return new Arguments[] { - // Equal - Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3", "1.2.3", true), - Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.4", "1.2.3", false), - Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3.4.5.6", "1.2.3.4.5.6", true), - Arguments.of(ConditionOperator.SEMVER_GT, "1.2.3.4.5.7", "1.2.3.4.5.6", true), - // Not equal - Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.4", "1.2.3", true), - Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.3", "1.2.3", false), - // Less than - Arguments.of(ConditionOperator.SEMVER_LT, "1.9.9", "2.0.0", true), - Arguments.of(ConditionOperator.SEMVER_LT, "2.0.0", "2.0.0", false), - // Less than or equal - Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.0", "2.0.0", true), - Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.1", "2.0.0", false), - // Greater than - Arguments.of(ConditionOperator.SEMVER_GT, "1.0.1", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GT, "1.0.0", "1.0.0", false), - // Greater than or equal - Arguments.of(ConditionOperator.SEMVER_GTE, "1.0.0", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GTE, "0.9.9", "1.0.0", false), - // Prerelease ordering - Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.1", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.2", "1.0.0-beta.11", true), - // Build metadata is ignored - Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+build.42", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+exp.sha.5114f85", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_NEQ, "4.0.0+build.42", "4.0.0", false), - Arguments.of(ConditionOperator.SEMVER_LT, "4.0.0+build.42", "4.0.0", false), - Arguments.of(ConditionOperator.SEMVER_LTE, "4.0.0+build.42", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GT, "4.0.0+build.42", "4.0.0", false), - Arguments.of(ConditionOperator.SEMVER_GTE, "4.0.0+build.42", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_EQ, "1.0.0+linux", "1.0.0+darwin", true), - // Invalid attribute does not match - Arguments.of(ConditionOperator.SEMVER_NEQ, "not-a-version", "1.0.0", false), - Arguments.of(ConditionOperator.SEMVER_GTE, "1.2", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GTE, "v1.2.3", "1.0.0", false), - Arguments.of(ConditionOperator.SEMVER_GTE, "18446744073709551616.0.0", "1.0.0", false), - // Non-string attribute does not match - Arguments.of(ConditionOperator.SEMVER_EQ, 1.2, "1.2.0", false), - }; - } - - @ParameterizedTest(name = "{0} attr={1} comparand={2} -> {3}") - @MethodSource("semverConditionTestCases") - public void testEvaluateSemverCondition( - final ConditionOperator operator, - final Object attribute, - final String comparand, - final boolean wantMatch) { - final Map flags = new HashMap<>(); - flags.put("test-flag", semverFlag(operator, comparand)); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(attribute)); - - if (wantMatch) { - assertThat(details.getValue(), equalTo(true)); - assertThat(details.getReason(), equalTo("TARGETING_MATCH")); - } else { - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo("DEFAULT")); - } - } - - @Test - public void testEvaluateSemverConditionMissingAttribute() { - final Map flags = new HashMap<>(); - flags.put("test-flag", semverFlag(ConditionOperator.SEMVER_EQ, "1.2.3")); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(null)); - - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo("DEFAULT")); - } - - @Test - public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { - // A flag with an invalid semver comparand is dropped during parsing. - // The evaluator should return PARSE_ERROR when the flag is queried. - final Map flags = new HashMap<>(); - final Map invalidFlags = new HashMap<>(); - invalidFlags.put("invalid-semver", "invalid_semver_comparand"); - final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); - config.invalidFlags = invalidFlags; - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(config); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "invalid-semver", false, semverContext("1.2.3")); - - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); - } - - @Test - public void testEvaluateMalformedFlagReturnsParseError() { - final Map flags = new HashMap<>(); - final Map invalidFlags = new HashMap<>(); - invalidFlags.put("malformed-flag", "invalid_flag"); - final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); - config.invalidFlags = invalidFlags; - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(config); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "malformed-flag", false, new MutableContext()); - - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); - } - - 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))); - arguments.add( - Arguments.of( - mapOf("plan", "gold", "cohort", "gold"), mapOf("plan", "gold", "cohort", "gold"))); - final Instant instant = Instant.parse("2026-07-10T12:34:56Z"); - arguments.add(Arguments.of(mapOf("instant", instant), mapOf("instant", instant.toString()))); - 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())); - } - } - - @Test - public void testDeeplyNestedContextIsTruncatedRatherThanOverflowingTheStack() { - Value nested = new Value("leaf"); - for (int i = 0; i < 10_000; i++) { - nested = new Value(singletonList(nested)); - } - final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); - - final Map result = DDEvaluator.flattenContext(context); - - final StringBuilder truncatedKey = new StringBuilder("deep"); - for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { - truncatedKey.append("[0]"); - } - assertThat(result.size(), equalTo(1)); - assertThat(result, hasEntry(truncatedKey.toString(), null)); - } - - @Test - public void testCopyPrunedContextCapsTopLevelFieldCount() { - final MutableContext context = new MutableContext(); - for (int i = 0; i < DDEvaluator.MAX_CONTEXT_FIELDS + 100; i++) { - context.add(String.format("k%04d", i), "v"); - } - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs.size(), equalTo(DDEvaluator.MAX_CONTEXT_FIELDS)); - assertThat(result.truncatedReason, equalTo("max_context_fields")); - } - - @Test - public void testCopyPrunedContextSkipsOversizedStringValues() { - final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; - java.util.Arrays.fill(longChars, 'x'); - final MutableContext context = new MutableContext(); - context.add("keep", "ok"); - context.add("drop", new String(longChars)); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("keep", "ok")); - assertThat(result.attrs.containsKey("drop"), equalTo(false)); - assertThat(result.truncatedReason, equalTo("max_value_length")); - } - - @Test - public void testCopyPrunedContextSkipsOversizedKeys() { - final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; - java.util.Arrays.fill(longKeyChars, 'k'); - final MutableContext context = new MutableContext(); - context.add("keep", "ok"); - context.add(new String(longKeyChars), "drop"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("keep", "ok")); - assertThat(result.attrs.size(), equalTo(1)); - assertThat(result.truncatedReason, equalTo("max_key_length")); - } - - @Test - public void testCopyPrunedContextCapsListWidth() { - final List wide = new java.util.ArrayList<>(); - for (int i = 0; i < DDEvaluator.MAX_LIST_ELEMENTS + 50; i++) { - wide.add(Value.objectToValue("v" + i)); - } - final EvaluationContext context = new MutableContext().add("list", wide); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs.containsKey("list[0]"), equalTo(true)); - assertThat( - result.attrs.containsKey("list[" + (DDEvaluator.MAX_LIST_ELEMENTS - 1) + "]"), - equalTo(true)); - assertThat( - result.attrs.containsKey("list[" + DDEvaluator.MAX_LIST_ELEMENTS + "]"), equalTo(false)); - assertThat(result.truncatedReason, equalTo("max_list_elements")); - } - - @Test - public void testCopyPrunedContextCapsStructureWidth() { - final dev.openfeature.sdk.MutableStructure wide = new dev.openfeature.sdk.MutableStructure(); - for (int i = 0; i < DDEvaluator.MAX_STRUCTURE_PROPERTIES + 50; i++) { - wide.add(String.format("p%04d", i), "v"); - } - final EvaluationContext context = new MutableContext().add("struct", wide); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - long structKeys = result.attrs.keySet().stream().filter(k -> k.startsWith("struct.")).count(); - assertThat(structKeys, equalTo((long) DDEvaluator.MAX_STRUCTURE_PROPERTIES)); - assertThat(result.truncatedReason, equalTo("max_structure_properties")); - } - - @Test - public void testCopyPrunedContextTruncatesDeepNesting() { - Value nested = new Value("leaf"); - for (int i = 0; i < 10_000; i++) { - nested = new Value(singletonList(nested)); - } - final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - final StringBuilder truncatedKey = new StringBuilder("deep"); - for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { - truncatedKey.append("[0]"); - } - // The recursion stops on the first list element at MAX_SNAPSHOT_DEPTH; deeper elements are - // never walked and no entry is emitted for them. - assertThat(result.attrs.containsKey(truncatedKey.toString()), equalTo(false)); - assertThat(result.attrs.size(), equalTo(0)); - assertThat(result.truncatedReason, equalTo("max_snapshot_depth")); - } - - @Test - public void testCopyPrunedContextExcludesTargetingKey() { - final MutableContext context = new MutableContext("user-42").add("region", "us-east-1"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("region", "us-east-1")); - assertThat(result.attrs.containsKey("targetingKey"), equalTo(false)); - assertThat(result.truncatedReason, equalTo(null)); - } - - @Test - public void testCopyPrunedContextNoTruncationReturnsNullReason() { - final MutableContext context = new MutableContext("user-1"); - context.add("region", "us-east-1"); - context.add("tier", "gold"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("region", "us-east-1")); - assertThat(result.truncatedReason, equalTo(null)); - } - - @Test - public void testCopyPrunedContextMultipleReasonsAreSortedAndDeduplicated() { - final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; - java.util.Arrays.fill(longChars, 'x'); - final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; - java.util.Arrays.fill(longKeyChars, 'k'); - final MutableContext context = new MutableContext(); - context.add("keep", "ok"); - context.add("dropValue", new String(longChars)); - context.add(new String(longKeyChars), "dropKey"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - // Both max_key_length and max_value_length fired; sorted alphabetically, no duplicates. - assertThat(result.truncatedReason, equalTo("max_key_length,max_value_length")); - } - - @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 { - INVALID_FLAGS_HOLDER.get().clear(); - try { - final ServerConfiguration config = - CONFIG_ADAPTER.fromJson(read(fixtureRoot().resolve("ufc-config.json"))); - final Map invalidFlags = new HashMap<>(INVALID_FLAGS_HOLDER.get()); - if (!invalidFlags.isEmpty()) { - config.invalidFlags = invalidFlags; - } - validateAndCacheSemverComparands(config); - return config; - } finally { - INVALID_FLAGS_HOLDER.get().clear(); - } - } - - /** - * Validates and caches SemVer comparands for all SEMVER_* conditions in the configuration. Flags - * with invalid comparands are removed from the flags map and tracked in invalidFlags, matching - * the behavior of {@link com.datadog.featureflag.UniversalFlagConfigParser}. - */ - private static void validateAndCacheSemverComparands(final ServerConfiguration config) { - if (config.flags == null) { - return; - } - final Map invalidFlags = - config.invalidFlags == null ? new HashMap<>() : new HashMap<>(config.invalidFlags); - final Map flagsToRemove = new HashMap<>(); - for (final Map.Entry entry : config.flags.entrySet()) { - final String flagKey = entry.getKey(); - final Flag flag = entry.getValue(); - if (flag.allocations == null) { - continue; - } - boolean invalid = false; - for (final Allocation allocation : flag.allocations) { - if (allocation.rules == null) { - continue; - } - for (final Rule rule : allocation.rules) { - if (rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition.operator == null) { - continue; - } - switch (condition.operator) { - case SEMVER_EQ: - case SEMVER_NEQ: - case SEMVER_LT: - case SEMVER_LTE: - case SEMVER_GT: - case SEMVER_GTE: - if (!(condition.value instanceof String)) { - invalid = true; - break; - } - final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); - if (parsed == null) { - invalid = true; - break; - } - condition.semverComparand = parsed; - break; - default: - break; - } - } - } - } - if (invalid) { - flagsToRemove.put(flagKey, flag); - invalidFlags.put(flagKey, "invalid_semver_comparand"); - } - } - for (final String flagKey : flagsToRemove.keySet()) { - config.flags.remove(flagKey); - } - if (!invalidFlags.isEmpty()) { - config.invalidFlags = invalidFlags; - } - } - - private static List canonicalTestCases() throws IOException { - final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); - final List result = new ArrayList<>(); - - try (final Stream paths = Files.list(evaluationCases)) { - final List files = - paths - .filter(path -> path.getFileName().toString().endsWith(".json")) - .sorted((left, right) -> left.getFileName().compareTo(right.getFileName())) - .collect(Collectors.toList()); - for (final Path file : files) { - final List testCases = FIXTURE_LIST_ADAPTER.fromJson(read(file)); - if (testCases == null) { - throw new JsonDataException("Fixture file did not contain an array: " + file); - } - for (int index = 0; index < testCases.size(); index++) { - final FixtureCase testCase = testCases.get(index); - testCase.fileName = file.getFileName().toString(); - testCase.index = index; - result.add(testCase); - } - } - } - - assertThat(result.size(), greaterThan(0)); - return result; - } - - private static Path fixtureRoot() { - Path directory = Paths.get("").toAbsolutePath(); - while (directory != null) { - final Path candidate = directory.resolve(CANONICAL_FIXTURE_PATH); - if (Files.exists(candidate.resolve("ufc-config.json")) - && Files.isDirectory(candidate.resolve("evaluation-cases"))) { - return candidate; - } - directory = directory.getParent(); - } - throw new IllegalStateException("Unable to find canonical FFE fixtures"); - } - - private static String read(final Path path) throws IOException { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static EvaluationContext context(final FixtureCase testCase) { - final Map attributes = - testCase.attributes == null ? emptyMap() : testCase.attributes; - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - if (testCase.targetingKey != null) { - context.setTargetingKey(testCase.targetingKey); - } - return context; - } - - private static Class targetType(final String variationType) { - switch (variationType) { - case "BOOLEAN": - return Boolean.class; - case "INTEGER": - return Integer.class; - case "NUMERIC": - return Double.class; - case "STRING": - return String.class; - case "JSON": - return Value.class; - default: - throw new IllegalArgumentException("Unsupported variationType: " + variationType); - } - } - - private static Object mapFixtureValue(final Class targetType, final Object value) { - return DDEvaluator.mapValue(targetType, value); - } - - private static Map mapOf(final Object... props) { - final Map result = new HashMap<>(props.length << 1); - int index = 0; - 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(); - } - - /** - * Mirrors the production parser's binary-compatible uint32 representation for fixture parsing. - */ - private static final class ShardAdapter extends JsonAdapter { - private static final JsonAdapter.Factory FACTORY = - (type, annotations, moshi) -> { - if (!annotations.isEmpty() || type != Shard.class) { - return null; - } - return new ShardAdapter(moshi.adapter(ShardJson.class)); - }; - - private final JsonAdapter delegate; - - private ShardAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Override - public Shard fromJson(final JsonReader reader) throws IOException { - final ShardJson shard = delegate.fromJson(reader); - if (shard == null) { - return null; - } - final List ranges; - if (shard.ranges == null) { - ranges = null; - } else { - ranges = new ArrayList<>(); - for (final ShardRangeJson range : shard.ranges) { - ranges.add( - range == null - ? null - : new ShardRange( - toUnsignedInt(range.start, "range start"), - toUnsignedInt(range.end, "range end"))); - } - } - return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); - } - - @Override - public void toJson(final JsonWriter writer, final Shard value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - private static int toUnsignedInt(final Long value, final String fieldName) { - if (value == null) { - return 0; - } - if (value < 0 || value > MAX_UNSIGNED_INT) { - throw new IllegalArgumentException("flag contains an invalid " + fieldName + " value"); - } - return value.intValue(); - } - - private static final class ShardJson { - String salt; - List ranges; - Long totalShards; - } - - private static final class ShardRangeJson { - Long start; - Long end; - } - - /** Reads the flags map with per-flag failure isolation, matching the production parser. */ - private static final class FlagMapAdapter extends JsonAdapter> { - private static final Type FLAGS_TYPE = - Types.newParameterizedType(Map.class, String.class, Flag.class); - - private static final JsonAdapter.Factory FACTORY = - (type, annotations, moshi) -> { - if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { - return null; - } - return new FlagMapAdapter(moshi.adapter(Flag.class)); - }; - - private final JsonAdapter flagAdapter; - - private FlagMapAdapter(final JsonAdapter flagAdapter) { - this.flagAdapter = flagAdapter; - } - - @Override - public Map fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - final Map flags = new HashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String flagKey = reader.nextName(); - final Object rawFlag = reader.readJsonValue(); - try { - final Flag flag = flagAdapter.fromJsonValue(rawFlag); - if (flag != null) { - validateFlag(flagKey, flag); - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException ignored) { - INVALID_FLAGS_HOLDER.get().put(flagKey, "invalid_flag"); - // A malformed flag must not prevent valid flags in the same configuration from loading. - } - } - reader.endObject(); - return flags; - } - - private static void validateFlag(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (final Allocation allocation : flag.allocations) { - if (allocation == null) { - continue; - } - validateConditionOperands(flagKey, allocation); - if (allocation.splits == null) { - continue; - } - for (final Split split : allocation.splits) { - if (split == null) { - continue; - } - if (split.shards == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains a split with missing shards"); - } - for (final Shard shard : split.shards) { - if (shard == null - || Integer.toUnsignedLong(shard.totalShards) == 0 - || shard.ranges == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains invalid shards"); - } - for (final ShardRange range : shard.ranges) { - if (range == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains an invalid shard range"); - } - } - } - } - } - } - - private static void validateConditionOperands( - final String flagKey, final Allocation allocation) { - if (allocation.rules == null) { - return; - } - for (final Rule rule : allocation.rules) { - if (rule == null || rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition == null || condition.operator == null) { - continue; - } - switch (condition.operator) { - case LT: - case LTE: - case GT: - case GTE: - if (!(condition.value instanceof Number)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-numeric condition"); - } - break; - case ONE_OF: - case NOT_ONE_OF: - if (!(condition.value instanceof List)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-list condition"); - } - break; - case IS_NULL: - if (!(condition.value instanceof Boolean)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-boolean condition"); - } - break; - default: - break; - } - } - } - } - - @Override - public void toJson(final JsonWriter writer, final Map value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - private static final class DateAdapter extends JsonAdapter { - @Override - public Date fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - try { - return Date.from( - DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(reader.nextString(), Instant::from)); - } catch (final Exception ignored) { - return null; - } - } - - @Override - public void toJson(final JsonWriter writer, final Date value) throws IOException { - if (value == null) { - writer.nullValue(); - return; - } - writer.value(value.toInstant().toString()); - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DatadogExposureLoggingDisabledForkedTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DatadogExposureLoggingDisabledForkedTest.java deleted file mode 100644 index 6494203d798..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DatadogExposureLoggingDisabledForkedTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonList; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import datadog.trace.api.openfeature.Provider.Options; -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.OpenFeatureAPI; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -/** - * Turns off Datadog exposure logging through the real setting. The gate is read once, when - * ExposureLoggingHook loads, so the property is set before that and the forked task gives this - * class its own JVM. - */ -class DatadogExposureLoggingDisabledForkedTest { - - static { - System.setProperty("dd.feature.flags.exposures.datadog.logging.enabled", "false"); - } - - private final List dispatched = new ArrayList<>(); - private final FeatureFlaggingGateway.ExposureListener listener = dispatched::add; - - @AfterEach - void tearDown() { - FeatureFlaggingGateway.removeExposureListener(listener); - OpenFeatureAPI.getInstance().clearHooks(); - OpenFeatureAPI.getInstance().shutdown(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - ExposureDeduplicationCache.INSTANCE.clear(); - } - - @Test - void sendsNoExposuresToDatadogWhileExposureHooksKeepTheirDecisions() throws Exception { - final List evaluations = new ArrayList<>(); - FeatureFlaggingGateway.dispatch(configuration()); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait( - new Provider( - new Options().initTimeout(10, SECONDS), new DDEvaluator(mock(Runnable.class)))); - api.addHooks(new ExposureHook(evaluations::add)); - FeatureFlaggingGateway.addExposureListener(listener); - final Client client = api.getClient(); - - client.getIntegerDetails("flag", 0, new MutableContext("user-1")); - client.getIntegerDetails("flag", 0, new MutableContext("user-1")); - - assertTrue(dispatched.isEmpty(), "nothing is sent to Datadog"); - assertEquals(2, evaluations.size()); - assertTrue(evaluations.get(0).shouldSend()); - assertFalse(evaluations.get(1).shouldSend(), "the cache still records the first exposure"); - } - - private static ServerConfiguration configuration() { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), 7); - final Allocation allocation = - new Allocation("allocation", null, null, null, singletonList(split), Boolean.TRUE); - final Map flags = new HashMap<>(); - flags.put( - "flag", new Flag("flag", true, ValueType.INTEGER, variations, singletonList(allocation))); - return new ServerConfiguration("", "", false, null, flags); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutTest.java deleted file mode 100644 index 34cb51bbbba..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutTest.java +++ /dev/null @@ -1,683 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.Arrays.asList; -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonList; -import static java.util.Collections.singletonMap; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.junit.jupiter.api.Assertions.assertAll; -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 static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.RETURNS_DEEP_STUBS; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import datadog.environment.EnvironmentVariables; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.SpanEnrichmentEvent; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -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.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableContext; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.Value; -import dev.openfeature.sdk.multiprovider.MultiProvider; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.OpenTelemetry; -import io.opentelemetry.api.common.Attributes; -import io.opentelemetry.api.metrics.LongCounter; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Stream; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.NullSource; -import org.junit.jupiter.params.provider.ValueSource; - -/** - * Exercises real OpenFeature resolution and telemetry hooks, with capture at each output boundary. - */ -class EvaluationTelemetryOptOutTest { - private static final String PARAMETER = "dd_evaluation_telemetry_opt_out"; - private static final String ENVIRONMENT_VARIABLE = - "DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED"; - private static final String SYSTEM_PROPERTY = - "dd.feature.flags.evaluation.telemetry.per.subject.opt.out.enabled"; - private EnvironmentVariables.EnvironmentVariablesProvider previousEnvironment; - private String previousProperty; - private String configuredEnvironmentValue; - - private final List events = new CopyOnWriteArrayList<>(); - private final List spans = new CopyOnWriteArrayList<>(); - private final List exposures = new CopyOnWriteArrayList<>(); - private final List customerEvaluations = new CopyOnWriteArrayList<>(); - private final AtomicInteger metrics = new AtomicInteger(); - private final FeatureFlaggingGateway.SpanEnrichmentListener spanListener = spans::add; - private final FeatureFlaggingGateway.ExposureListener exposureListener = exposures::add; - private final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - private FlagEvaluationWriter previousWriter; - private boolean previousEnqueueEnabled; - private Client client; - - @BeforeEach - void setUp() { - previousEnvironment = EnvironmentVariables.provider; - previousProperty = System.getProperty(SYSTEM_PROPERTY); - System.clearProperty(SYSTEM_PROPERTY); - EnvironmentVariables.provider = - new EnvironmentVariables.EnvironmentVariablesProvider() { - @Override - public String get(final String name) { - return ENVIRONMENT_VARIABLE.equals(name) - ? configuredEnvironmentValue - : previousEnvironment.get(name); - } - }; - api.setEvaluationContext(new MutableContext()); - GlobalOpenTelemetry.resetForTest(); - final OpenTelemetry otel = mock(OpenTelemetry.class, RETURNS_DEEP_STUBS); - final LongCounter counter = mock(LongCounter.class); - when(otel.getMeterProvider() - .meterBuilder("ddtrace.openfeature") - .build() - .counterBuilder("feature_flag.evaluations") - .setUnit("{evaluation}") - .setDescription("Number of feature flag evaluations") - .build()) - .thenReturn(counter); - doAnswer(invocation -> metrics.addAndGet(1)).when(counter).add(eq(1L), any(Attributes.class)); - GlobalOpenTelemetry.set(otel); - - previousWriter = FeatureFlaggingGateway.getFlagEvalWriter(); - previousEnqueueEnabled = FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled(); - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(true); - doAnswer(invocation -> events.add(invocation.getArgument(0))).when(writer).enqueue(any()); - FeatureFlaggingGateway.setFlagEvalWriter(writer); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - FeatureFlaggingGateway.addSpanEnrichmentListener(spanListener); - FeatureFlaggingGateway.addExposureListener(exposureListener); - ExposureDeduplicationCache.INSTANCE.clear(); - api.addHooks(new ExposureHook(customerEvaluations::add)); - } - - @AfterEach - void tearDown() { - EnvironmentVariables.provider = previousEnvironment; - if (previousProperty == null) { - System.clearProperty(SYSTEM_PROPERTY); - } else { - System.setProperty(SYSTEM_PROPERTY, previousProperty); - } - api.clearHooks(); - api.setEvaluationContext(new MutableContext()); - api.shutdown(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - FeatureFlaggingGateway.removeSpanEnrichmentListener(spanListener); - FeatureFlaggingGateway.removeExposureListener(exposureListener); - FeatureFlaggingGateway.setFlagEvalWriter(previousWriter); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(previousEnqueueEnabled); - ExposureDeduplicationCache.INSTANCE.clear(); - GlobalOpenTelemetry.resetForTest(); - } - - @Test - void environmentEnablesPerSubjectOptOut() throws Exception { - configuredEnvironmentValue = "true"; - start(new Provider.Options()); - final FlagEvaluationDetails details = evaluate("subject-1", new Value(true)); - assertEquals(1, details.getValue()); - assertEquals("on", details.getVariant()); - assertTelemetryCounts(0); - assertEquals(1, exposures.size()); - evaluate("subject-2", new Value(false)); - evaluate("subject-3", null); - assertTelemetryCounts(2); - assertEquals(3, exposures.size()); - } - - @ParameterizedTest - @NullSource - @ValueSource(strings = {"false", "invalid", ""}) - void environmentDefaultsToDisabled(final String value) throws Exception { - configuredEnvironmentValue = value; - start(new Provider.Options()); - evaluate("subject-1", new Value(true)); - assertTelemetryCounts(1); - assertEquals(1, exposures.size()); - } - - @Test - void explicitFalseOverridesEnabledEnvironment() throws Exception { - configuredEnvironmentValue = "true"; - start(new Provider.Options().enableEvaluationTelemetryOptOutParameter(false)); - evaluate("subject-1", new Value(true)); - assertTelemetryCounts(1); - assertEquals(1, exposures.size()); - } - - @Test - void explicitTrueOverridesDisabledEnvironment() throws Exception { - configuredEnvironmentValue = "false"; - start(new Provider.Options().enableEvaluationTelemetryOptOutParameter(true)); - evaluate("subject-1", new Value(true)); - assertTelemetryCounts(0); - assertEquals(1, exposures.size()); - } - - @Test - void systemPropertyOverridesEnvironment() throws Exception { - configuredEnvironmentValue = "true"; - System.setProperty(SYSTEM_PROPERTY, "false"); - start(new Provider.Options()); - evaluate("subject-1", new Value(true)); - assertTelemetryCounts(1); - } - - @Test - void environmentIsCapturedAtProviderConstruction() throws Exception { - configuredEnvironmentValue = "true"; - final Provider.Options options = new Provider.Options(); - start(options); - configuredEnvironmentValue = "false"; - evaluate("subject-1", new Value(true)); - assertTelemetryCounts(0); - api.shutdown(); - start(options); - evaluate("subject-2", new Value(true)); - assertTelemetryCounts(1); - } - - @Test - void optedOutSubjectKeepsAssignmentAndExposureButEmitsNoEvaluationTelemetry() throws Exception { - start(); - - final FlagEvaluationDetails details = evaluate("subject-1", new Value(true)); - - assertNull(details.getErrorCode()); - assertEquals(1, details.getValue()); - assertEquals("on", details.getVariant()); - assertEquals(1, exposures.size()); - assertEquals(1, customerEvaluations.size()); - assertTelemetryCounts(0); - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void telemetryAttributeRemainsAvailableForTargetingAndExposure(final boolean enabled) - throws Exception { - start(new Provider.Options().enableEvaluationTelemetryOptOutParameter(enabled)); - - for (final boolean observeFullEvaluationData : new boolean[] {false, true}) { - FeatureFlaggingGateway.dispatch(targetingConfiguration(observeFullEvaluationData)); - final String subject = "subject-" + observeFullEvaluationData; - final int previousEvents = events.size(); - final int previousExposures = exposures.size(); - - final FlagEvaluationDetails allowed = evaluate(subject, new Value(false)); - final FlagEvaluationDetails optedOut = evaluate(subject, new Value(true)); - final FlagEvaluationDetails repeated = evaluate(subject, new Value(true)); - final FlagEvaluationDetails stringValue = - evaluate(subject + "-string", new Value("true")); - - assertNull(allowed.getErrorCode()); - assertEquals(1, allowed.getValue()); - assertEquals("on", allowed.getVariant()); - for (final FlagEvaluationDetails details : asList(optedOut, repeated, stringValue)) { - assertNull(details.getErrorCode()); - assertEquals(2, details.getValue(), "the ordinary targeting rule sees the attribute"); - assertEquals("targeted", details.getVariant()); - } - assertEquals( - previousExposures + 3, exposures.size(), "normal exposure deduplication applies"); - assertEquals(false, exposures.get(previousExposures).subject.attributes.get(PARAMETER)); - assertEquals(true, exposures.get(previousExposures + 1).subject.attributes.get(PARAMETER)); - assertEquals("true", exposures.get(previousExposures + 2).subject.attributes.get(PARAMETER)); - assertTelemetryCounts(previousEvents + (enabled ? 2 : 4)); - - if (observeFullEvaluationData) { - assertEquals(false, events.get(previousEvents).attrs.get(PARAMETER)); - assertEquals("true", events.get(events.size() - 1).attrs.get(PARAMETER)); - if (!enabled) { - assertEquals(true, events.get(previousEvents + 1).attrs.get(PARAMETER)); - } - } else { - for (final FlagEvalEvent event : events.subList(previousEvents, events.size())) { - assertTrue(event.attrs.isEmpty(), "normal evaluation privacy rules still apply"); - } - } - } - } - - @Test - void allowedSubjectEmitsEachSignal() throws Exception { - start(); - - evaluate("subject-1", new Value(false)); - - assertTelemetryCounts(1); - assertEquals(1, exposures.size()); - assertEquals(1, customerEvaluations.size()); - } - - @ParameterizedTest - @MethodSource("nonOptOutValues") - void onlyBooleanTrueSuppressesTelemetry(final Value value) throws Exception { - start(); - - evaluate("subject-1", value); - - assertTelemetryCounts(1); - assertEquals(1, exposures.size()); - } - - static Stream nonOptOutValues() { - return Stream.of( - Arguments.of((Value) null), - Arguments.of(new Value()), - Arguments.of(new Value(false)), - Arguments.of(new Value("true")), - Arguments.of(new Value(1)), - Arguments.of(new Value(singletonList(new Value(true))))); - } - - @Test - void theParameterHasNoEffectUnlessEnabled() throws Exception { - start(new Provider.Options()); - - evaluate("subject-1", new Value(true)); - - assertTelemetryCounts(1); - assertEquals(1, exposures.size()); - } - - @Test - void theParameterIsCaseSensitiveAndDoesNotAcceptTheOldName() throws Exception { - start(); - - client.getIntegerDetails( - "flag", - 0, - new MutableContext("subject-1") - .add("dd_telemetry_opt_out", true) - .add("ddEvaluationTelemetryOptOut", true)); - - assertTelemetryCounts(1); - } - - @Test - void providerOptionsAreCapturedAtConstruction() throws Exception { - final Provider.Options options = - new Provider.Options().enableEvaluationTelemetryOptOutParameter(true); - start(options); - options.enableEvaluationTelemetryOptOutParameter(false); - - evaluate("subject-1", new Value(true)); - - assertTelemetryCounts(0); - } - - @Test - void eachEvaluationUsesItsOwnDecisionWithoutChangingExistingEvents() throws Exception { - start(); - - evaluate("same-subject", new Value(false)); - evaluate("same-subject", new Value(true)); - assertTelemetryCounts(1); - evaluate("same-subject", new Value(false)); - - assertTelemetryCounts(2); - assertEquals(1, exposures.size(), "exposure deduplication remains independent"); - assertEquals(3, customerEvaluations.size()); - } - - @Test - void missingAndWrongTypeFlagsKeepDefaultsWithoutEmittingTelemetry() throws Exception { - start(); - final EvaluationContext context = context("subject-1", new Value(true)); - - final FlagEvaluationDetails missing = client.getIntegerDetails("missing", 42, context); - final FlagEvaluationDetails wrongType = - client.getStringDetails("flag", "default", context); - - assertEquals(ErrorCode.FLAG_NOT_FOUND, missing.getErrorCode()); - assertEquals(42, missing.getValue()); - assertEquals(ErrorCode.TYPE_MISMATCH, wrongType.getErrorCode()); - assertEquals("default", wrongType.getValue()); - assertTelemetryCounts(0); - assertEquals(0, exposures.size()); - assertEquals(2, customerEvaluations.size()); - - client.getIntegerDetails("missing", 42, context("subject-2", new Value(false))); - assertTelemetryCounts(1); - } - - @Test - void contextMergingHonorsThePerEvaluationOverride() throws Exception { - start(); - api.setEvaluationContext(new MutableContext().add(PARAMETER, true)); - - client.getIntegerDetails("flag", 0, new ImmutableContext("subject-1")); - evaluate("subject-2", new Value(false)); - - assertTelemetryCounts(1); - assertEquals("subject-2", events.get(0).targetingKey); - assertEquals(2, exposures.size()); - } - - @Test - void applicationBeforeHooksCanSupplyTheOptOutDecision() throws Exception { - start(); - api.addHooks( - new Hook() { - @Override - public Optional before( - final HookContext context, final Map hints) { - return Optional.of(new ImmutableContext(singletonMap(PARAMETER, new Value(true)))); - } - }); - - evaluate("subject-1", new Value(false)); - - assertTelemetryCounts(0); - assertEquals(1, exposures.size()); - } - - @Test - void applicationAfterHooksCannotUndoTheEvaluationDecision() throws Exception { - start(); - final MutableContext invocationContext = new MutableContext("subject-1").add(PARAMETER, true); - api.addHooks( - new Hook() { - @Override - public void after( - final HookContext context, - final FlagEvaluationDetails details, - final Map hints) { - invocationContext.add(PARAMETER, false); - } - }); - - final FlagEvaluationDetails details = - client.getIntegerDetails("flag", 0, invocationContext); - - assertNull(details.getErrorCode()); - assertTelemetryCounts(0); - assertEquals(1, exposures.size()); - } - - @Test - void optingOutInAnAfterHookStopsTelemetryBeforeEmission() throws Exception { - start(); - final MutableContext invocationContext = new MutableContext("subject-1").add(PARAMETER, false); - api.addHooks( - new Hook() { - @Override - public void after( - final HookContext context, - final FlagEvaluationDetails details, - final Map hints) { - invocationContext.add(PARAMETER, true); - } - }); - - final FlagEvaluationDetails details = - client.getIntegerDetails("flag", 0, invocationContext); - - assertNull(details.getErrorCode()); - assertTelemetryCounts(0); - assertEquals(1, exposures.size()); - } - - @Test - void aFailingBeforeHookDoesNotLeakErrorTelemetry() throws Exception { - start(); - final MutableContext invocationContext = new MutableContext("subject-1").add(PARAMETER, true); - api.addHooks( - new Hook() { - @Override - public Optional before( - final HookContext context, final Map hints) { - throw new IllegalStateException("application hook failed"); - } - - @Override - public void error( - final HookContext context, - final Exception error, - final Map hints) { - invocationContext.add(PARAMETER, false); - } - }); - - final FlagEvaluationDetails details = - client.getIntegerDetails("flag", 0, invocationContext); - - assertEquals(ErrorCode.GENERAL, details.getErrorCode()); - assertEquals(0, details.getValue()); - assertTelemetryCounts(0); - assertEquals(0, exposures.size()); - assertEquals(1, customerEvaluations.size()); - } - - @Test - void nestedEvaluationsKeepIndependentDecisions() throws Exception { - start(); - api.addHooks( - new Hook() { - @Override - public void after( - final HookContext context, - final FlagEvaluationDetails details, - final Map hints) { - if ("outer".equals(context.getCtx().getTargetingKey())) { - assertEquals(1, evaluate("inner", new Value(false)).getValue()); - } - } - }); - - assertEquals(1, evaluate("outer", new Value(true)).getValue()); - - assertTelemetryCounts(1); - assertEquals("inner", events.get(0).targetingKey); - assertEquals(2, exposures.size()); - assertEquals(2, customerEvaluations.size()); - } - - @Test - void concurrentSubjectsCannotChangeEachOthersDecision() throws Exception { - start(); - final CountDownLatch optedOutEvaluated = new CountDownLatch(1); - final CountDownLatch allowOptedOutToFinish = new CountDownLatch(1); - api.addHooks( - new Hook() { - @Override - public void after( - final HookContext context, - final FlagEvaluationDetails details, - final Map hints) { - if ("opted-out".equals(context.getCtx().getTargetingKey())) { - optedOutEvaluated.countDown(); - try { - assertTrue(allowOptedOutToFinish.await(10, SECONDS)); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(e); - } - } - } - }); - final ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - final Future> pending = - executor.submit(() -> evaluate("opted-out", new Value(true))); - assertTrue(optedOutEvaluated.await(10, SECONDS)); - assertEquals(1, evaluate("allowed", new Value(false)).getValue()); - allowOptedOutToFinish.countDown(); - assertNull(pending.get(10, SECONDS).getErrorCode()); - - assertTelemetryCounts(1); - assertEquals("allowed", events.get(0).targetingKey); - assertEquals(2, exposures.size()); - } finally { - allowOptedOutToFinish.countDown(); - executor.shutdownNow(); - } - } - - @Test - void independentProvidersKeepTheirOwnActivationSetting() throws Exception { - start(); - api.setProviderAndWait( - "unrestricted", new Provider(new Provider.Options(), new DDEvaluator(() -> {}), true)); - - evaluate("opted-out", new Value(true)); - api.getClient("unrestricted") - .getIntegerDetails("flag", 0, context("unrestricted-subject", new Value(true))); - - assertTelemetryCounts(1); - assertEquals("unrestricted-subject", events.get(0).targetingKey); - assertEquals(2, exposures.size()); - } - - @Test - void optOutWorksInsideAMultiProvider() throws Exception { - start(); - api.setProviderAndWait( - new MultiProvider( - singletonList( - new Provider( - new Provider.Options().enableEvaluationTelemetryOptOutParameter(true), - new DDEvaluator(() -> {}), - true)))); - - assertEquals(1, evaluate("opted-out", new Value(true)).getValue()); - assertTelemetryCounts(0); - assertEquals(1, exposures.size()); - - assertEquals(1, evaluate("allowed", new Value(false)).getValue()); - assertTelemetryCounts(1); - assertEquals(2, exposures.size()); - } - - @Test - void missingConfigurationKeepsTheDefaultWithoutEmittingTelemetry() throws Exception { - start(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - - final FlagEvaluationDetails details = evaluate("subject-1", new Value(true)); - - assertEquals(ErrorCode.PROVIDER_NOT_READY, details.getErrorCode()); - assertEquals(0, details.getValue()); - assertTelemetryCounts(0); - assertEquals(0, exposures.size()); - assertEquals(1, customerEvaluations.size()); - } - - private void start() throws Exception { - start(new Provider.Options().enableEvaluationTelemetryOptOutParameter(true)); - } - - private static ServerConfiguration targetingConfiguration( - final boolean observeFullEvaluationData) { - final Rule rule = - new Rule( - singletonList( - new ConditionConfiguration( - ConditionOperator.ONE_OF, PARAMETER, singletonList(true)))); - final Allocation targeted = - new Allocation( - "targeted", - singletonList(rule), - null, - null, - singletonList(new Split(emptyList(), "targeted", emptyMap(), 7)), - true); - final Allocation fallback = - new Allocation( - "fallback", - null, - null, - null, - singletonList(new Split(emptyList(), "on", emptyMap(), 7)), - true); - final Map variants = new HashMap<>(); - variants.put("on", new Variant("on", 1)); - variants.put("targeted", new Variant("targeted", 2)); - final Flag flag = - new Flag("flag", true, ValueType.INTEGER, variants, asList(targeted, fallback)); - return new ServerConfiguration( - "", "", observeFullEvaluationData, null, singletonMap("flag", flag)); - } - - private void start(final Provider.Options options) throws Exception { - final Allocation allocation = - new Allocation( - "allocation", - null, - null, - null, - singletonList(new Split(emptyList(), "on", emptyMap(), 7)), - true); - final Flag flag = - new Flag( - "flag", - true, - ValueType.INTEGER, - singletonMap("on", new Variant("on", 1)), - singletonList(allocation)); - FeatureFlaggingGateway.dispatch( - new ServerConfiguration("", "", false, null, singletonMap("flag", flag))); - api.setProviderAndWait(new Provider(options, new DDEvaluator(() -> {}), true)); - client = api.getClient(); - } - - private FlagEvaluationDetails evaluate(final String subject, final Value optOut) { - return client.getIntegerDetails("flag", 0, context(subject, optOut)); - } - - private static EvaluationContext context(final String subject, final Value optOut) { - return new MutableContext( - subject, optOut == null ? emptyMap() : singletonMap(PARAMETER, optOut)); - } - - private void assertTelemetryCounts(final int expected) { - assertAll( - () -> assertEquals(expected, events.size(), "evaluation EVP enqueue count"), - () -> assertEquals(expected, metrics.get(), "OTel evaluation count"), - () -> assertEquals(expected, spans.size(), "automatic span enrichment count")); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java deleted file mode 100644 index d76b8d68b8f..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java +++ /dev/null @@ -1,582 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonList; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.Feature; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import datadog.trace.api.openfeature.Provider.Options; -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.OpenFeatureAPI; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class ExposureHookTest { - - private final List sentToDatadog = new ArrayList<>(); - private final FeatureFlaggingGateway.ExposureListener listener = sentToDatadog::add; - private final List evaluations = new ArrayList<>(); - private Client client; - - @BeforeEach - void setUp() { - ExposureDeduplicationCache.INSTANCE.clear(); - FeatureFlaggingGateway.addExposureListener(listener); - } - - @AfterEach - void tearDown() { - FeatureFlaggingGateway.removeExposureListener(listener); - OpenFeatureAPI.getInstance().clearHooks(); - OpenFeatureAPI.getInstance().shutdown(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - ExposureDeduplicationCache.INSTANCE.clear(); - } - - @Test - void shouldSendMatchesTheExposuresSentToDatadog() throws Exception { - start(true, configuration(Boolean.TRUE, null)); - - evaluate("user-1"); - evaluate("user-1"); - evaluate("user-2"); - - assertEquals(Arrays.asList(true, false, true), shouldSend()); - assertEquals(2, sentToDatadog.size()); - assertEquals("user-1", sentToDatadog.get(0).subject.id); - assertEquals("user-2", sentToDatadog.get(1).subject.id); - } - - @Test - void customerHookStillReceivesExposuresWhenDatadogLoggingIsOff() throws Exception { - start(false, configuration(Boolean.TRUE, null)); - - evaluate("user-1"); - evaluate("user-1"); - evaluate("user-2"); - - assertTrue(sentToDatadog.isEmpty()); - assertEquals(Arrays.asList(true, false, true), shouldSend()); - } - - @Test - void aFailedEvaluationIsNotAnExposure() throws Exception { - start(true, configuration(Boolean.TRUE, null)); - OpenFeatureAPI.getInstance() - .addHooks( - new Hook() { - @Override - public void after( - final HookContext ctx, - final FlagEvaluationDetails details, - final Map hints) { - throw new IllegalStateException("application validation failed"); - } - }); - - final FlagEvaluationDetails details = evaluate("user-1"); - - assertEquals(ErrorCode.GENERAL, details.getErrorCode()); - final ExposureHook.Evaluation evaluation = evaluations.get(0); - assertFalse(evaluation.isExposure()); - assertFalse(evaluation.isCacheHit()); - assertFalse(evaluation.shouldSend()); - } - - @Test - void anAllocationThatDoesNotLogExposuresIsNotAnExposure() throws Exception { - start(true, configuration(Boolean.FALSE, null)); - - evaluate("user-1"); - - final ExposureHook.Evaluation evaluation = evaluations.get(0); - assertFalse(evaluation.isExposure()); - assertFalse(evaluation.isCacheHit()); - assertFalse(evaluation.shouldSend()); - assertEquals("flag", evaluation.getDetails().getFlagKey()); - assertEquals("user-1", evaluation.getContext().getTargetingKey()); - } - - @Test - void exposesTheFeaturesOfTheSelectedSplit() throws Exception { - start( - true, - configuration( - Boolean.TRUE, - Arrays.asList( - new Feature("holdout.key", "q4-global", singletonList("HOOK")), - new Feature("holdout.assignment_group", "status_quo", singletonList("HOOK")), - new Feature("holdout.weight", 0.5, Arrays.asList("EVALUATION", "HOOK")), - new Feature( - "holdout.should_include_in_holdout_analysis", - true, - Arrays.asList("HOOK", "SOME_FUTURE_DESTINATION")), - new Feature("bandit.policy_id", "p2", Arrays.asList("EXPOSURE", "EVALUATION")), - new Feature("unknown.only", "x", singletonList("SOME_FUTURE_DESTINATION"))))); - - final FlagEvaluationDetails details = evaluate("user-1"); - - final Map features = evaluations.get(0).getFeatures(); - assertEquals(4, features.size()); - assertFalse( - features.containsKey("bandit.policy_id"), - "a feature without HOOK is not delivered to hooks"); - assertFalse( - features.containsKey("unknown.only"), - "a feature with only unknown destinations goes nowhere"); - assertEquals("q4-global", features.get("holdout.key")); - assertEquals("status_quo", features.get("holdout.assignment_group")); - assertEquals(0.5, features.get("holdout.weight")); - assertEquals(true, features.get("holdout.should_include_in_holdout_analysis")); - assertEquals( - "q4-global", - details.getFlagMetadata().getString(DDEvaluator.METADATA_FEATURE_PREFIX + "holdout.key")); - assertThrows(UnsupportedOperationException.class, () -> features.put("other", "value")); - } - - @Test - void warnsOnceForEachUnknownDestination() throws Exception { - DDEvaluator.WARNED_FEATURE_DESTINATIONS.clear(); - start( - true, - configuration( - Boolean.TRUE, - Arrays.asList( - new Feature("holdout.key", "q4-global", Arrays.asList("HOOK", "NEW_A")), - new Feature("other", "x", Arrays.asList("NEW_A", "NEW_B", "EXPOSURE"))))); - - evaluate("user-1"); - evaluate("user-2"); - - assertEquals( - new HashSet<>(Arrays.asList("NEW_A", "NEW_B")), DDEvaluator.WARNED_FEATURE_DESTINATIONS); - assertEquals(1, evaluations.get(1).getFeatures().size()); - DDEvaluator.WARNED_FEATURE_DESTINATIONS.clear(); - } - - @Test - void stopsRememberingUnknownDestinationsAtTheCap() throws Exception { - DDEvaluator.WARNED_FEATURE_DESTINATIONS.clear(); - for (int i = 0; i < DDEvaluator.MAX_WARNED_FEATURE_DESTINATIONS; i++) { - DDEvaluator.WARNED_FEATURE_DESTINATIONS.add("OLD_" + i); - } - try { - start( - true, - configuration( - Boolean.TRUE, - singletonList( - new Feature("holdout.key", "q4-global", Arrays.asList("HOOK", "NEW_A"))))); - - evaluate("user-1"); - - assertEquals( - DDEvaluator.MAX_WARNED_FEATURE_DESTINATIONS, - DDEvaluator.WARNED_FEATURE_DESTINATIONS.size()); - assertFalse(DDEvaluator.WARNED_FEATURE_DESTINATIONS.contains("NEW_A")); - assertEquals( - Collections.singletonMap("holdout.key", "q4-global"), evaluations.get(0).getFeatures()); - } finally { - DDEvaluator.WARNED_FEATURE_DESTINATIONS.clear(); - } - } - - @Test - void routesEachFeatureToItsDestinations() throws Exception { - start( - true, - configuration( - Boolean.TRUE, - Arrays.asList( - new Feature("holdout.key", "q4-global", singletonList("HOOK")), - new Feature("bandit.arm", "a", singletonList("EXPOSURE")), - new Feature("experiment.tag", "pricing", singletonList("EVALUATION")), - new Feature("bandit.policy_id", "p2", Arrays.asList("EXPOSURE", "EVALUATION"))))); - - final FlagEvaluationDetails details = evaluate("user-1"); - - final ImmutableMetadata metadata = details.getFlagMetadata(); - assertEquals( - Collections.singletonMap("holdout.key", "q4-global"), - SplitFeatureMetadata.featuresWithPrefix(metadata, DDEvaluator.METADATA_FEATURE_PREFIX)); - final Map exposureFeatures = new HashMap<>(); - exposureFeatures.put("bandit.arm", "a"); - exposureFeatures.put("bandit.policy_id", "p2"); - assertEquals( - exposureFeatures, - SplitFeatureMetadata.featuresWithPrefix( - metadata, DDEvaluator.METADATA_EXPOSURE_FEATURE_PREFIX)); - final Map evaluationFeatures = new HashMap<>(); - evaluationFeatures.put("experiment.tag", "pricing"); - evaluationFeatures.put("bandit.policy_id", "p2"); - assertEquals( - evaluationFeatures, - SplitFeatureMetadata.featuresWithPrefix( - metadata, DDEvaluator.METADATA_EVALUATION_FEATURE_PREFIX)); - assertEquals( - Collections.singletonMap("holdout.key", "q4-global"), evaluations.get(0).getFeatures()); - assertEquals(exposureFeatures, sentToDatadog.get(0).features); - } - - @Test - void aRepeatedKeyKeepsTheFirstFeatureForEveryDestination() throws Exception { - start( - true, - configuration( - Boolean.TRUE, - Arrays.asList( - new Feature("holdout.key", "q4-global", singletonList("HOOK")), - new Feature("holdout.key", "q1-global", Arrays.asList("HOOK", "EXPOSURE"))))); - - final FlagEvaluationDetails details = evaluate("user-1"); - - assertEquals( - Collections.singletonMap("holdout.key", "q4-global"), evaluations.get(0).getFeatures()); - assertTrue( - SplitFeatureMetadata.featuresWithPrefix( - details.getFlagMetadata(), DDEvaluator.METADATA_EXPOSURE_FEATURE_PREFIX) - .isEmpty(), - "the repeated feature's EXPOSURE destination is ignored with it"); - assertNull(sentToDatadog.get(0).features); - } - - @Test - void sendsEvaluationFeaturesForAnAllocationThatDoesNotLogExposures() throws Exception { - final AtomicReference captured = new AtomicReference<>(); - FeatureFlaggingGateway.setFlagEvalWriter(capturingWriter(captured)); - try { - start( - true, - configuration( - Boolean.FALSE, - singletonList(new Feature("bandit.policy_id", "p2", singletonList("EVALUATION"))))); - - evaluate("user-1"); - - assertEquals(Collections.singletonMap("bandit.policy_id", "p2"), captured.get().features); - assertTrue(sentToDatadog.isEmpty(), "the allocation does not log exposures"); - } finally { - FeatureFlaggingGateway.setFlagEvalWriter(null); - } - } - - @Test - void sendsExposuresWithoutFeaturesWhenTheAgentLacksThem() throws Exception { - start( - true, - configuration( - Boolean.TRUE, - singletonList(new Feature("bandit.policy_id", "p2", singletonList("EXPOSURE"))))); - DDEvaluator.EXPOSURE_FEATURES_SUPPORTED.set(false); - try { - evaluate("user-1"); - } finally { - DDEvaluator.EXPOSURE_FEATURES_SUPPORTED.set(true); - } - - assertEquals(1, sentToDatadog.size()); - assertNull(sentToDatadog.get(0).features); - } - - @Test - void featureSupportChecksRejectEventsFromAnOlderAgent() { - assertTrue(DDEvaluator.exposureFeaturesSupported(ExposureEvent.class)); - assertFalse(DDEvaluator.exposureFeaturesSupported(SerialIdExposureEvent.class)); - assertTrue(FlagEvalLoggingHook.featuresSupported(FlagEvalEvent.class)); - assertFalse(FlagEvalLoggingHook.featuresSupported(LegacyFlagEvalEvent.class)); - } - - /** An exposure event from an agent that has serial ids but predates features. */ - public static final class SerialIdExposureEvent { - public SerialIdExposureEvent( - final long timestamp, - final datadog.trace.api.featureflag.exposure.Allocation allocation, - final datadog.trace.api.featureflag.exposure.Flag flag, - final datadog.trace.api.featureflag.exposure.Variant variant, - final datadog.trace.api.featureflag.exposure.Subject subject, - final Integer serialId) {} - } - - /** A flag-evaluation event from an agent that predates features. */ - public static final class LegacyFlagEvalEvent { - public LegacyFlagEvalEvent( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final String errorMessage, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map attrs) {} - } - - @Test - void evaluatesWithoutFeaturesWhenTheAgentSplitHasNone() throws Exception { - start( - true, - configuration( - Boolean.TRUE, - singletonList(new Feature("holdout.key", "q4-global", singletonList("HOOK"))))); - DDEvaluator.SPLIT_FEATURES_SUPPORTED.set(false); - final FlagEvaluationDetails details; - try { - details = evaluate("user-1"); - } finally { - DDEvaluator.SPLIT_FEATURES_SUPPORTED.set(true); - } - - assertNull(details.getErrorCode()); - assertEquals(Integer.valueOf(1), details.getValue()); - assertTrue(evaluations.get(0).getFeatures().isEmpty()); - } - - @Test - void hasNoFeaturesWhenTheSplitHasNone() throws Exception { - start(true, configuration(Boolean.TRUE, null)); - - evaluate("user-1"); - - assertTrue(evaluations.get(0).getFeatures().isEmpty()); - } - - @Test - void aFailingCallbackDoesNotAffectTheEvaluation() throws Exception { - start(true, configuration(Boolean.TRUE, null)); - OpenFeatureAPI.getInstance() - .addHooks( - new ExposureHook( - evaluation -> { - throw new IllegalStateException("warehouse unavailable"); - })); - - final FlagEvaluationDetails details = evaluate("user-1"); - - assertNull(details.getErrorCode()); - assertEquals(Integer.valueOf(1), details.getValue()); - assertEquals(1, sentToDatadog.size()); - } - - @Test - void anEvaluationIsAnExposureOnlyWithEverythingAnExposureNeeds() { - assertFalse(evaluation("on", null).isExposure()); - assertFalse(evaluation(null, exposureMetadata("allocation", true, false)).isExposure()); - assertFalse(evaluation("on", exposureMetadata(null, true, false)).isExposure()); - assertFalse(evaluation("on", exposureMetadata("allocation", false, false)).isExposure()); - assertTrue(evaluation("on", exposureMetadata("allocation", true, false)).shouldSend()); - assertFalse(evaluation("on", exposureMetadata("allocation", true, false)).isCacheHit()); - assertTrue(evaluation("on", exposureMetadata("allocation", true, true)).isCacheHit()); - assertFalse( - evaluation("on", exposureMetadata("allocation", false, true)).isCacheHit(), - "an evaluation that is not an exposure is never a cache hit"); - assertFalse(evaluation("on", exposureMetadata("allocation", true, true)).shouldSend()); - } - - @Test - void featuresIgnoreOtherMetadataAndAreEmptyWithoutMetadata() { - assertTrue(evaluation("on", null).getFeatures().isEmpty()); - final ExposureHook.Evaluation evaluation = - evaluation( - "on", - ImmutableMetadata.builder() - .addString("allocationKey", "allocation") - .addString(DDEvaluator.METADATA_FEATURE_PREFIX + "holdout.key", "q4-global") - .build()); - - assertEquals(1, evaluation.getFeatures().size()); - assertEquals("q4-global", evaluation.getFeatures().get("holdout.key")); - } - - @Test - void datadogLoggingStaysOnWhenItsSettingCannotBeRead() { - assertTrue(DatadogExposureLoggingGate.isEnabled()); - assertFalse(DatadogExposureLoggingGate.isEnabled(() -> false)); - assertTrue( - DatadogExposureLoggingGate.isEnabled( - () -> { - throw new NoClassDefFoundError("old agent"); - })); - } - - @Test - void theHookContainsCallbackFailures() { - final HookContext context = - HookContext.builder() - .flagKey("flag") - .type(FlagValueType.INTEGER) - .defaultValue(0) - .ctx(new MutableContext("user-1")) - .build(); - final FlagEvaluationDetails details = - FlagEvaluationDetails.builder().flagKey("flag").value(1).build(); - - assertDoesNotThrow( - () -> - new ExposureHook( - evaluation -> { - throw new IllegalStateException("warehouse unavailable"); - }) - .finallyAfter(context, details, emptyMap())); - assertDoesNotThrow( - () -> - new ExposureHook( - evaluation -> { - throw new NoClassDefFoundError("missing class"); - }) - .finallyAfter(context, details, emptyMap())); - } - - @Test - void requiresACallback() { - assertThrows(NullPointerException.class, () -> new ExposureHook(null)); - } - - @Test - void skipsTheCallbackWhenThereAreNoDetails() { - final List seen = new ArrayList<>(); - final HookContext context = - HookContext.builder() - .flagKey("flag") - .type(FlagValueType.INTEGER) - .defaultValue(0) - .ctx(new MutableContext("user-1")) - .build(); - - new ExposureHook(seen::add).finallyAfter(context, null, Collections.emptyMap()); - - assertTrue(seen.isEmpty()); - } - - @Test - void featuresAreSkippedWithAnAgentWhoseSplitHasNoFeatures() { - assertTrue(DDEvaluator.splitFeaturesSupported(Split.class)); - assertFalse(DDEvaluator.splitFeaturesSupported(LegacySplit.class)); - } - - /** A Split from an agent that predates features. */ - static final class LegacySplit { - public Integer serialId; - } - - private void start(final boolean sendToDatadog, final ServerConfiguration configuration) - throws Exception { - FeatureFlaggingGateway.dispatch(configuration); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait( - new Provider( - new Options().initTimeout(10, SECONDS), new DDEvaluator(mock(Runnable.class))) { - @Override - Hook buildExposureLoggingHook() { - return new ExposureLoggingHook<>(ExposureDeduplicationCache.INSTANCE, sendToDatadog); - } - }); - api.addHooks(new ExposureHook(evaluations::add)); - client = api.getClient(); - } - - private static ExposureHook.Evaluation evaluation( - final String variant, final ImmutableMetadata metadata) { - final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = - FlagEvaluationDetails.builder().flagKey("flag").value(1).variant(variant); - if (metadata != null) { - builder.flagMetadata(metadata); - } - return new ExposureHook.Evaluation(new MutableContext("user-1"), builder.build()); - } - - private static ImmutableMetadata exposureMetadata( - final String allocationKey, final boolean doLog, final boolean cacheHit) { - final ImmutableMetadata.ImmutableMetadataBuilder builder = - ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_DO_LOG, doLog) - .addBoolean(DDEvaluator.METADATA_EXPOSURE_CACHE_HIT, cacheHit); - if (allocationKey != null) { - builder.addString("allocationKey", allocationKey); - } - return builder.build(); - } - - private FlagEvaluationDetails evaluate(final String targetingKey) { - return client.getIntegerDetails("flag", 0, new MutableContext(targetingKey)); - } - - private List shouldSend() { - final List result = new ArrayList<>(); - for (final ExposureHook.Evaluation evaluation : evaluations) { - result.add(evaluation.shouldSend()); - } - return result; - } - - private static ServerConfiguration configuration( - final Boolean doLog, final List features) { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), 7, features); - final Allocation allocation = - new Allocation("allocation", null, null, null, singletonList(split), doLog); - final Map flags = new HashMap<>(); - flags.put( - "flag", new Flag("flag", true, ValueType.INTEGER, variations, singletonList(allocation))); - return new ServerConfiguration("", "", false, null, flags); - } - - private static FlagEvaluationWriter capturingWriter(final AtomicReference ref) { - return new FlagEvaluationWriter() { - @Override - public void enqueue(final FlagEvalEvent event) { - ref.set(event); - } - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - }; - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookWithoutAgentTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookWithoutAgentTest.java deleted file mode 100644 index efd42915b6d..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookWithoutAgentTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.Collections.singletonMap; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.MutableContext; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.Constructor; -import org.junit.jupiter.api.Test; - -/** - * Loads ExposureHook in a class loader that hides DDEvaluator and the Datadog Java agent classes, - * as in an application without the agent. The normal test classpath always has them, so only a - * loader like this sees ExposureHook reach for them. - */ -class ExposureHookWithoutAgentTest { - - @Test - void exposureHookWorksWithoutTheAgentClasses() throws Exception { - final ClassLoader loader = new AgentlessClassLoader(getClass().getClassLoader()); - Class.forName("datadog.trace.api.openfeature.ExposureHook", true, loader); - final Class evaluationClass = - Class.forName("datadog.trace.api.openfeature.ExposureHook$Evaluation", true, loader); - final Constructor constructor = - evaluationClass.getDeclaredConstructor( - EvaluationContext.class, FlagEvaluationDetails.class); - constructor.setAccessible(true); - final FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("flag") - .value(1) - .variant("on") - .flagMetadata( - ImmutableMetadata.builder() - .addString("allocationKey", "allocation") - .addBoolean("__dd_do_log", true) - .addBoolean("__dd_exposure_cache_hit", false) - .addString("__dd_feature.holdout.key", "q4-global") - .build()) - .build(); - - final Object evaluation = constructor.newInstance(new MutableContext("user-1"), details); - - assertTrue((Boolean) evaluationClass.getMethod("isExposure").invoke(evaluation)); - assertTrue((Boolean) evaluationClass.getMethod("shouldSend").invoke(evaluation)); - assertEquals( - singletonMap("holdout.key", "q4-global"), - evaluationClass.getMethod("getFeatures").invoke(evaluation)); - } - - private static final class AgentlessClassLoader extends ClassLoader { - AgentlessClassLoader(final ClassLoader parent) { - super(parent); - } - - @Override - protected Class loadClass(final String name, final boolean resolve) - throws ClassNotFoundException { - synchronized (getClassLoadingLock(name)) { - if (name.startsWith("datadog.trace.api.featureflag.") - || name.startsWith("datadog.trace.api.openfeature.DDEvaluator")) { - throw new ClassNotFoundException(name); - } - if (name.startsWith("datadog.trace.api.openfeature.ExposureHook") - || name.equals("datadog.trace.api.openfeature.SplitFeatureMetadata")) { - Class loaded = findLoadedClass(name); - if (loaded == null) { - final byte[] bytes = classBytes(name); - loaded = defineClass(name, bytes, 0, bytes.length); - } - if (resolve) { - resolveClass(loaded); - } - return loaded; - } - return super.loadClass(name, resolve); - } - } - - private byte[] classBytes(final String name) throws ClassNotFoundException { - try (InputStream in = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) { - if (in == null) { - throw new ClassNotFoundException(name); - } - final ByteArrayOutputStream out = new ByteArrayOutputStream(); - final byte[] buffer = new byte[8192]; - int read; - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - return out.toByteArray(); - } catch (final IOException e) { - throw new ClassNotFoundException(name, e); - } - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureLoggingHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureLoggingHookTest.java deleted file mode 100644 index 5879bfa8689..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureLoggingHookTest.java +++ /dev/null @@ -1,257 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonList; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import datadog.trace.api.openfeature.Provider.Options; -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.multiprovider.MultiProvider; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class ExposureLoggingHookTest { - - private final List dispatched = new ArrayList<>(); - private final FeatureFlaggingGateway.ExposureListener listener = dispatched::add; - private Client client; - - @BeforeEach - void setUp() throws Exception { - ExposureDeduplicationCache.INSTANCE.clear(); - FeatureFlaggingGateway.dispatch(configuration("on", Boolean.TRUE)); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait( - new Provider( - new Options().initTimeout(10, SECONDS), new DDEvaluator(mock(Runnable.class)))); - client = api.getClient(); - FeatureFlaggingGateway.addExposureListener(listener); - } - - @AfterEach - void tearDown() { - FeatureFlaggingGateway.removeExposureListener(listener); - OpenFeatureAPI.getInstance().clearHooks(); - OpenFeatureAPI.getInstance().shutdown(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - ExposureDeduplicationCache.INSTANCE.clear(); - } - - @Test - void sendsExposuresWhenWrappedInAMultiProvider() throws Exception { - OpenFeatureAPI.getInstance() - .setProviderAndWait( - new MultiProvider( - singletonList( - new Provider( - new Options().initTimeout(10, SECONDS), - new DDEvaluator(mock(Runnable.class)))))); - - evaluate("user-1"); - evaluate("user-1"); - - assertEquals(1, dispatched.size()); - assertEquals("user-1", dispatched.get(0).subject.id); - } - - @Test - void deduplicatesWithAnAgentThatPredatesSerialIdExposures() { - final boolean previous = DDEvaluator.EXPOSURE_SERIAL_ID_SUPPORTED.getAndSet(false); - try { - evaluate("user-1"); - evaluate("user-1"); - } finally { - DDEvaluator.EXPOSURE_SERIAL_ID_SUPPORTED.set(previous); - } - - assertEquals(1, dispatched.size(), "a repeat is deduplicated without serial ids"); - assertNull(dispatched.get(0).serial_id); - } - - @Test - void sendsOneExposurePerSubject() { - evaluate("user-1"); - evaluate("user-1"); - evaluate("user-2"); - - assertEquals(2, dispatched.size()); - assertEquals("user-1", dispatched.get(0).subject.id); - assertEquals("user-2", dispatched.get(1).subject.id); - assertEquals("flag", dispatched.get(0).flag.key); - assertEquals("allocation", dispatched.get(0).allocation.key); - assertEquals("on", dispatched.get(0).variant.key); - assertEquals(Integer.valueOf(7), dispatched.get(0).serial_id); - } - - @Test - void stampsWhetherTheSubjectWasAlreadyExposed() { - assertEquals( - Boolean.FALSE, - evaluate("user-1").getFlagMetadata().getBoolean(DDEvaluator.METADATA_EXPOSURE_CACHE_HIT)); - assertEquals( - Boolean.TRUE, - evaluate("user-1").getFlagMetadata().getBoolean(DDEvaluator.METADATA_EXPOSURE_CACHE_HIT)); - } - - @Test - void sendsAgainWhenTheVariantChanges() { - evaluate("user-1"); - FeatureFlaggingGateway.dispatch(configuration("off", Boolean.TRUE)); - evaluate("user-1"); - - assertEquals(2, dispatched.size()); - assertEquals("off", dispatched.get(1).variant.key); - } - - @Test - void aFailedEvaluationSendsNothingAndLeavesTheSubjectUnexposed() { - final Hook failingAfterHook = - new Hook() { - @Override - public void after( - final HookContext ctx, - final FlagEvaluationDetails details, - final Map hints) { - throw new IllegalStateException("application validation failed"); - } - }; - OpenFeatureAPI.getInstance().addHooks(failingAfterHook); - - final FlagEvaluationDetails failed = evaluate("user-1"); - - assertEquals(ErrorCode.GENERAL, failed.getErrorCode()); - assertTrue(dispatched.isEmpty()); - assertEquals(0, ExposureDeduplicationCache.INSTANCE.size()); - - OpenFeatureAPI.getInstance().clearHooks(); - evaluate("user-1"); - - assertEquals(1, dispatched.size()); - } - - @Test - void sendsNothingForAnAllocationThatDoesNotLogExposures() { - FeatureFlaggingGateway.dispatch(configuration("on", Boolean.FALSE)); - - final FlagEvaluationDetails details = evaluate("user-1"); - - assertTrue(dispatched.isEmpty()); - assertNull(details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_EXPOSURE_CACHE_HIT)); - } - - @Test - void sendsNothingWithoutTheDetailsAnExposureNeeds() { - final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(10); - final ExposureLoggingHook hook = new ExposureLoggingHook<>(cache, true); - final HookContext withContext = hookContext(new MutableContext("user-1")); - - hook.finallyAfter(withContext, null, emptyMap()); - hook.finallyAfter(withContext, details("variant", null), emptyMap()); - hook.finallyAfter( - withContext, details("variant", exposureMetadata(false, false, "a")), emptyMap()); - hook.finallyAfter( - withContext, details("variant", exposureMetadata(true, true, "a")), emptyMap()); - hook.finallyAfter( - withContext, details("variant", exposureMetadata(true, false, null)), emptyMap()); - hook.finallyAfter(withContext, details(null, exposureMetadata(true, false, "a")), emptyMap()); - hook.finallyAfter(null, details("variant", exposureMetadata(true, false, "a")), emptyMap()); - - assertTrue(dispatched.isEmpty()); - assertEquals(0, cache.size()); - - hook.finallyAfter( - withContext, details("variant", exposureMetadata(true, false, "a")), emptyMap()); - - assertEquals(1, dispatched.size()); - assertEquals(1, cache.size()); - } - - @Test - void cacheEvictsTheLeastRecentlyUsedSubject() { - final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(2); - cache.record("flag", "user-1", "allocation", "on", 7); - cache.record("flag", "user-2", "allocation", "on", 7); - assertTrue(cache.contains("flag", "user-1", "allocation", "on", 7)); - cache.record("flag", "user-3", "allocation", "on", 7); - - assertTrue(cache.contains("flag", "user-1", "allocation", "on", 7)); - assertFalse(cache.contains("flag", "user-2", "allocation", "on", 7)); - assertTrue(cache.contains("flag", "user-3", "allocation", "on", 7)); - assertFalse(cache.contains("flag", "user-1", "allocation", "on", 8)); - } - - private static HookContext hookContext(final MutableContext context) { - return HookContext.builder() - .flagKey("flag") - .type(FlagValueType.INTEGER) - .defaultValue(0) - .ctx(context) - .build(); - } - - private static FlagEvaluationDetails details( - final String variant, final ImmutableMetadata metadata) { - final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = - FlagEvaluationDetails.builder().flagKey("flag").value(1).variant(variant); - if (metadata != null) { - builder.flagMetadata(metadata); - } - return builder.build(); - } - - private static ImmutableMetadata exposureMetadata( - final boolean doLog, final boolean cacheHit, final String allocationKey) { - final ImmutableMetadata.ImmutableMetadataBuilder builder = - ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_DO_LOG, doLog) - .addBoolean(DDEvaluator.METADATA_EXPOSURE_CACHE_HIT, cacheHit); - if (allocationKey != null) { - builder.addString("allocationKey", allocationKey); - } - return builder.build(); - } - - private FlagEvaluationDetails evaluate(final String targetingKey) { - return client.getIntegerDetails("flag", 0, new MutableContext(targetingKey)); - } - - private static ServerConfiguration configuration(final String variantKey, final Boolean doLog) { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - variations.put("off", new Variant("off", 0)); - final Split split = new Split(emptyList(), variantKey, emptyMap(), 7); - final Allocation allocation = - new Allocation("allocation", null, null, null, singletonList(split), doLog); - final Map flags = new HashMap<>(); - flags.put( - "flag", new Flag("flag", true, ValueType.INTEGER, variations, singletonList(allocation))); - return new ServerConfiguration("", "", false, null, flags); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java deleted file mode 100644 index 9fd6c79e350..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java +++ /dev/null @@ -1,776 +0,0 @@ -package datadog.trace.api.openfeature; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -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 static org.mockito.ArgumentMatchers.any; -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 static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.MutableStructure; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.Value; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link FlagEvalLoggingHook}: context capture, non-blocking enqueue, eval-time - * metadata, absent-variant detection, and killswitch-via-writer-null behaviour. - */ -class FlagEvalLoggingHookTest { - - @BeforeEach - void enableFlagEvaluationEnqueue() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @AfterEach - void resetFlagEvaluationEnqueue() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - // Clear any dispatched UFC so an observeFullEvaluationData value can't leak into other tests - // that share the static gateway. - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - - // ---- helpers ---- - - /** - * Creates a writer that captures the enqueued event for assertion. Uses an anonymous class since - * FlagEvaluationWriter has multiple abstract methods. - */ - private FlagEvaluationWriter capturingWriter(final AtomicReference ref) { - return new FlagEvaluationWriter() { - @Override - public void enqueue(final FlagEvalEvent event) { - ref.set(event); - } - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - }; - } - - private static FlagEvalLoggingHook hookWithWriter(final FlagEvaluationWriter writer) { - return new FlagEvalLoggingHook<>(writer); - } - - private static FlagEvaluationDetails details( - final String flagKey, - final Object value, - final String variant, - final String reason, - final ImmutableMetadata metadata) { - final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = - FlagEvaluationDetails.builder().flagKey(flagKey).value(value).reason(reason); - if (variant != null) { - builder.variant(variant); - } - if (metadata != null) { - builder.flagMetadata(metadata); - } - return builder.build(); - } - - private static HookContext hookCtxWithTargetingKey( - final String flagKey, final String targetingKey) { - final MutableContext ctx = new MutableContext(targetingKey); - return HookContext.builder() - .flagKey(flagKey) - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - } - - // ---- test: hook calls writer.enqueue once with flagKey, variant, allocationKey ---- - - @Test - void finallyAfterEnqueuesEventWithAllBasicFields() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - details( - "my-flag", - "on-value", - "on", - Reason.TARGETING_MATCH.name(), - ImmutableMetadata.builder().addString("allocationKey", "alloc-1").build()); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get(), "writer.enqueue must be called once"); - final FlagEvalEvent e = captured.get(); - assertEquals("my-flag", e.flagKey); - assertEquals("on", e.variant, "variant must be the OpenFeature variant key"); - assertEquals("alloc-1", e.allocationKey); - } - - @Test - void finallyAfterAddsEvaluationFeaturesWhateverTheConsent() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - hook.finallyAfter( - null, - details( - "my-flag", - "on-value", - "on", - Reason.TARGETING_MATCH.name(), - ImmutableMetadata.builder() - .addString("allocationKey", "alloc-1") - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) - .addString( - DDEvaluator.METADATA_EVALUATION_FEATURE_PREFIX + "bandit.policy_id", "p2") - .addBoolean(DDEvaluator.METADATA_HAS_EVALUATION_FEATURES, true) - .addString(DDEvaluator.METADATA_FEATURE_PREFIX + "holdout.key", "q4-global") - .addString(DDEvaluator.METADATA_EXPOSURE_FEATURE_PREFIX + "bandit.arm", "a") - .build()), - Collections.emptyMap()); - - assertEquals( - Collections.singletonMap("bandit.policy_id", "p2"), - captured.get().features, - "only EVALUATION features reach the event, also without consent"); - } - - @Test - void finallyAfterSendsNoFeaturesWhenTheAgentLacksThem() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - FlagEvalLoggingHook.FEATURES_SUPPORTED.set(false); - try { - hook.finallyAfter( - null, - details( - "my-flag", - "on-value", - "on", - Reason.TARGETING_MATCH.name(), - ImmutableMetadata.builder() - .addString("allocationKey", "alloc-1") - .addString( - DDEvaluator.METADATA_EVALUATION_FEATURE_PREFIX + "bandit.policy_id", "p2") - .addBoolean(DDEvaluator.METADATA_HAS_EVALUATION_FEATURES, true) - .build()), - Collections.emptyMap()); - } finally { - FlagEvalLoggingHook.FEATURES_SUPPORTED.set(true); - } - - assertTrue(captured.get().features.isEmpty()); - } - - // ---- variant comes from details.getVariant(), NOT details.getValue() ---- - - @Test - void variantIsTheVariantKeyNotTheEvaluatedValue() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - // value and variant DIFFER, so a value-vs-variant mistake is detectable. - final FlagEvaluationDetails det = - details( - "g1-flag", - "the-evaluated-value", // value - "the-variant-key", // variant - Reason.TARGETING_MATCH.name(), - null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "the-variant-key", - captured.get().variant, - "variant must be sourced from details.getVariant(), not details.getValue()"); - } - - // ---- test: evalTimeMs from metadata "__dd_eval_timestamp_ms" ---- - - @Test - void evalTimeMsComesFromMetadataWhenPresent() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final long expectedTimestamp = 1_700_000_000_000L; - final FlagEvaluationDetails det = - details( - "ts-flag", - "v", - "v", - Reason.SPLIT.name(), - ImmutableMetadata.builder() - .addString("allocationKey", "a") - .addLong("__dd_eval_timestamp_ms", expectedTimestamp) - .build()); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - expectedTimestamp, - captured.get().evalTimeMs, - "evalTimeMs must come from __dd_eval_timestamp_ms metadata when present"); - } - - // ---- test: evalTimeMs falls back to System.currentTimeMillis() when absent ---- - - @Test - void evalTimeMsFallsBackToCurrentTimeWhenMetadataAbsent() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final long before = System.currentTimeMillis(); - final FlagEvaluationDetails det = - details("ts-flag", "v", "v", Reason.SPLIT.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - final long after = System.currentTimeMillis(); - assertNotNull(captured.get()); - final long ts = captured.get().evalTimeMs; - assertTrue( - ts >= before && ts <= after, - "evalTimeMs must fall back to hook-fire time when metadata absent. got: " + ts); - } - - // ---- test: absent variant -> variant is null -> runtime default ---- - - @Test - void absentVariantProducesNullVariant() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - // A runtime default returns the default value but no variant. - final FlagEvaluationDetails det = - details("def-flag", "default-value", null, Reason.DEFAULT.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertNull(captured.get().variant, "Absent variant must stay null (runtime default)"); - } - - // ---- test: error message captured from details (error object support) ---- - - @Test - void errorMessageCapturedFromDetailsUnderConsentOn() { - // With observeFullEvaluationData=true the provider's raw message is preserved verbatim so - // operators keep the diagnostic detail they opted in to. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.TYPE_MISMATCH) - .errorMessage("value does not match declared type") - .flagMetadata(consentOnMetadata()) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "value does not match declared type", - captured.get().errorMessage, - "errorMessage must be captured from the evaluation details under consent-on"); - } - - @Test - void errorMessageReplacedByErrorCodeUnderConsentOff() { - // Defense-in-depth: even if a provider hands us a raw message under consent-off (a bug in the - // provider, or a third-party provider that doesn't distinguish consent tiers), the hook must - // substitute the ErrorCode name so raw PII from exception messages never reaches the wire. - // Uses a PII-looking marker exactly like the wire-level guards in FlagEvaluationWriterImplTest. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.TYPE_MISMATCH) - .errorMessage("For input string: \"jane.doe@datadoghq.com\"") - .flagMetadata(consentOffMetadata()) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "TYPE_MISMATCH", - captured.get().errorMessage, - "consent-off must replace the raw message with the ErrorCode name"); - assertFalse( - captured.get().errorMessage.contains("jane.doe@datadoghq.com"), - "raw PII must never survive into the enqueued event under consent-off"); - } - - @Test - void errorMessageDroppedWhenConsentOffAndNoErrorCode() { - // Edge case: no ErrorCode available (unusual — providers should set one for ERROR reason). - // Consent-off drops the message and there's nothing to substitute, so the enqueued event has - // no error message at all. This is the strictest privacy-preserving outcome. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorMessage("For input string: \"jane.doe@datadoghq.com\"") - .flagMetadata(consentOffMetadata()) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertNull(captured.get().errorMessage); - } - - // ---- test: error code used as fallback message when error message is empty ---- - - @Test - void errorCodeUsedAsFallbackWhenMessageEmpty() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "FLAG_NOT_FOUND", - captured.get().errorMessage, - "error code name must be used when no error message is present"); - } - - // ---- test: success path has no error message ---- - - @Test - void successPathHasNullErrorMessage() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - details("ok-flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertNull(captured.get().errorMessage, "success path must have no error message"); - } - - // ---- test: hook does NO aggregation on the hook thread ---- - - @Test - void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(true); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - // Capacity check gates the enqueue; exactly one enqueue call; no start/close. - verify(writer, times(1)).hasCapacityForEnqueue(); - verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); - verify(writer, never()).countPreQueueOverflow(); - verify(writer, never()).countContextTruncated(any()); - verify(writer, never()).close(); - verify(writer, never()).start(); - } - - @Test - void finallyAfterSkipsEnqueueAndCountsDropWhenQueueSaturated() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(false); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - // Pre-queue guard fires: one capacity check, one drop count, and no enqueue at all. - verify(writer, times(1)).hasCapacityForEnqueue(); - verify(writer, times(1)).countPreQueueOverflow(); - verify(writer, never()).enqueue(any(FlagEvalEvent.class)); - } - - @Test - void countContextTruncatedCalledWhenContextIsTruncated() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(true); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - // Context with one oversized string value — triggers max_value_length. - final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; - java.util.Arrays.fill(longChars, 'x'); - final MutableContext ctx = new MutableContext("user-1"); - ctx.add("oversized", new String(longChars)); - final HookContext hookCtx = - HookContext.builder() - .flagKey("flag") - .type(dev.openfeature.sdk.FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - final FlagEvaluationDetails det = - details( - "flag", - "v", - "v", - dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), - consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - verify(writer, times(1)).countContextTruncated("max_value_length"); - verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); - } - - @Test - void countContextTruncatedNotCalledWhenNoTruncation() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(true); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - final MutableContext ctx = new MutableContext("user-1"); - ctx.add("region", "us-east-1"); - final HookContext hookCtx = - HookContext.builder() - .flagKey("flag") - .type(dev.openfeature.sdk.FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - final FlagEvaluationDetails det = - details( - "flag", - "v", - "v", - dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), - consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - verify(writer, never()).countContextTruncated(any()); - verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); - } - - @Test - void enqueueDisabledIsNoOpBeforeWriterLookup() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - final AtomicReference writerResolved = new AtomicReference<>(false); - final FlagEvalLoggingHook hook = - new FlagEvalLoggingHook<>( - () -> { - writerResolved.set(true); - throw new AssertionError("writer should not be resolved when enqueue is disabled"); - }); - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(hookCtxWithTargetingKey("flag", "user-1"), det, Collections.emptyMap()); - - assertFalse(writerResolved.get()); - } - - // ---- test: writer=null -> no-op (killswitch off / not yet started) ---- - - @Test - void writerNullIsNoOp() { - final FlagEvalLoggingHook hook = hookWithWriter(null); - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - // Must not throw; nothing is enqueued - hook.finallyAfter(null, det, Collections.emptyMap()); - } - - @Test - void writerLookupLinkageErrorIsNoOp() { - final FlagEvalLoggingHook hook = - new FlagEvalLoggingHook<>( - () -> { - throw new NoSuchMethodError("old bootstrap"); - }); - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - assertDoesNotThrow(() -> hook.finallyAfter(null, det, Collections.emptyMap())); - } - - // ---- test: details=null -> no-op ---- - - @Test - void detailsNullIsNoOp() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - // Should not throw - hook.finallyAfter(null, null, Collections.emptyMap()); - - verifyNoInteractions(writer); - } - - // ---- test: targetingKey extracted from evaluation context ---- - - @Test - void targetingKeyExtractedFromContext() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - details("ctx-flag", "v", "v", Reason.SPLIT.name(), null); - - final HookContext hookCtx = hookCtxWithTargetingKey("ctx-flag", "user-42"); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "user-42", - captured.get().targetingKey, - "targetingKey must be extracted from the evaluation context"); - } - - @Test - void contextAttributesAreFlattenedAndConvertedInline() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final Map profile = new HashMap<>(); - profile.put("tier", "gold"); - final Map attributes = new HashMap<>(); - attributes.put("score", 42); - attributes.put("profile", profile); - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - context.setTargetingKey("user-42"); - - final HookContext hookCtx = - HookContext.builder() - .flagKey("ctx-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(context) - .build(); - final FlagEvaluationDetails det = - details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - // Flatten now happens inline in DDEvaluator#copyPrunedContext on the hot path, so the event - // arrives with attrs already populated (no deferred supplier). - final Map attrs = captured.get().attrs; - assertFalse(attrs.isEmpty(), "hook must flatten context inline"); - assertEquals(42, attrs.get("score")); - assertEquals("gold", attrs.get("profile.tier")); - assertFalse(attrs.containsKey("targetingKey")); - assertTrue( - attrs.values().stream().noneMatch(Value.class::isInstance), - "context attrs must contain converted scalar values, not OpenFeature Value wrappers"); - } - - @Test - void contextAttributesUseEnqueueTimeSnapshot() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final MutableContext context = new MutableContext("user-42"); - context.add("region", "us-east-1"); - final MutableStructure profile = new MutableStructure(); - profile.add("tier", "gold"); - context.add("profile", profile); - final List cohorts = new ArrayList<>(); - cohorts.add(Value.objectToValue("beta")); - context.add("cohorts", cohorts); - - final HookContext hookCtx = - HookContext.builder() - .flagKey("ctx-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(context) - .build(); - final FlagEvaluationDetails det = - details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - context.add("region", "eu-west-1"); - context.add("late", "ignored"); - profile.add("tier", "platinum"); - profile.add("late", "ignored"); - cohorts.set(0, Value.objectToValue("ga")); - cohorts.add(Value.objectToValue("late")); - - assertNotNull(captured.get()); - final Map attrs = captured.get().attrs; - assertEquals("us-east-1", attrs.get("region")); - assertEquals("gold", attrs.get("profile.tier")); - assertEquals("beta", attrs.get("cohorts[0]")); - assertFalse(attrs.containsKey("late")); - assertFalse(attrs.containsKey("profile.late")); - assertFalse(attrs.containsKey("cohorts[1]")); - } - - // ---- observeFullEvaluationData is read from evaluation metadata, never the gateway ---- - - @Test - void readsObserveFullEvaluationDataTrueFromEvaluationMetadata() { - assertTrue(enqueuedEventWithConsentMetadata(true).observeFullEvaluationData); - } - - @Test - void readsObserveFullEvaluationDataFalseFromEvaluationMetadata() { - assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); - } - - @Test - void observeFullEvaluationDataDefaultsToFalseWhenMetadataAbsent() { - // No metadata at all: fail-closed toward privacy. - assertFalse(enqueuedEventWithConsentMetadata(null).observeFullEvaluationData); - } - - @Test - void protectedPathSkipsEvaluationContextCapture() { - // Consent off → the hook must not snapshot the evaluation context at all. Verified by mutating - // the context after finallyAfter returns and asserting the enqueued event still sees nothing. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final MutableContext context = new MutableContext("user-1"); - context.add("region", "us-east-1"); - - final HookContext hookCtx = - HookContext.builder() - .flagKey("ctx-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(context) - .build(); - final ImmutableMetadata consentOff = - ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) - .build(); - - hook.finallyAfter( - hookCtx, - details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOff), - Collections.emptyMap()); - context.add("region", "eu-west-1"); - - assertNotNull(captured.get()); - assertTrue(captured.get().attrs.isEmpty()); - } - - @Test - void ignoresGatewayConsentEvenWhenItDisagreesWithMetadata() { - // Gateway says on, metadata says off; hook must trust metadata. - FeatureFlaggingGateway.dispatch(observeConfig(true)); - try { - assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); - } finally { - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - } - - /** - * Fires the hook once for a simple targeted evaluation whose metadata carries the given consent - * value ({@code null} = key absent) and returns the enqueued event. - */ - private FlagEvalEvent enqueuedEventWithConsentMetadata(final Boolean consent) { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - final ImmutableMetadata metadata = - consent == null - ? null - : ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, consent) - .build(); - hook.finallyAfter( - hookCtxWithTargetingKey("obs-flag", "user-1"), - details("obs-flag", "on", "on", Reason.TARGETING_MATCH.name(), metadata), - Collections.emptyMap()); - assertNotNull(captured.get(), "writer.enqueue must be called once"); - return captured.get(); - } - - private static ImmutableMetadata consentOnMetadata() { - return ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) - .build(); - } - - private static ImmutableMetadata consentOffMetadata() { - return ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) - .build(); - } - - private static ServerConfiguration observeConfig(final boolean observeFullEvaluationData) { - return new ServerConfiguration( - "2024-04-17T19:40:53.716Z", - "SERVER", - observeFullEvaluationData, - null, - Collections.emptyMap()); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java deleted file mode 100644 index 322a06d2de7..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package datadog.trace.api.openfeature; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; - -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.Reason; -import java.util.Collections; -import org.junit.jupiter.api.Test; - -class FlagEvalMetricsHookTest { - - @Test - void finallyAfterRecordsBasicEvaluation() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("my-flag") - .value("on-value") - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata( - ImmutableMetadata.builder().addString("allocationKey", "default-alloc").build()) - .build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics) - .record( - eq("my-flag"), - eq("on"), - eq(Reason.TARGETING_MATCH.name()), - isNull(), - eq("default-alloc")); - } - - @Test - void finallyAfterRecordsErrorEvaluation() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("missing-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics) - .record( - eq("missing-flag"), - isNull(), - eq(Reason.ERROR.name()), - eq(ErrorCode.FLAG_NOT_FOUND), - isNull()); - } - - @Test - void finallyAfterHandlesNullFlagMetadata() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("my-flag") - .value(true) - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics) - .record(eq("my-flag"), eq("on"), eq(Reason.TARGETING_MATCH.name()), isNull(), isNull()); - } - - @Test - void finallyAfterHandlesNullVariantAndReason() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder().flagKey("my-flag").value("default").build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics).record(eq("my-flag"), isNull(), isNull(), isNull(), isNull()); - } - - @Test - void finallyAfterNeverThrows() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - // Should not throw even with completely null inputs - hook.finallyAfter(null, null, null); - - verifyNoInteractions(metrics); - } - - @Test - void finallyAfterIsNoOpWhenMetricsIsNull() { - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(null); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("my-flag") - .value(true) - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .build(); - - // Should not throw - hook.finallyAfter(null, details, Collections.emptyMap()); - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java deleted file mode 100644 index 214fadc7274..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java +++ /dev/null @@ -1,165 +0,0 @@ -package datadog.trace.api.openfeature; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; - -import dev.openfeature.sdk.ErrorCode; -import io.opentelemetry.api.common.Attributes; -import io.opentelemetry.api.metrics.LongCounter; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -class FlagEvalMetricsTest { - - @Test - void recordBasicAttributes() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.key", "my-flag"); - assertAttribute(attrs, "feature_flag.result.variant", "on"); - assertAttribute(attrs, "feature_flag.result.reason", "targeting_match"); - assertNoAttribute(attrs, "error.type"); - assertNoAttribute(attrs, "feature_flag.result.allocation_key"); - } - - @Test - void recordErrorAttributes() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("missing-flag", "", "ERROR", ErrorCode.FLAG_NOT_FOUND, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.key", "missing-flag"); - assertAttribute(attrs, "feature_flag.result.variant", ""); - assertAttribute(attrs, "feature_flag.result.reason", "error"); - assertAttribute(attrs, "error.type", "flag_not_found"); - assertNoAttribute(attrs, "feature_flag.result.allocation_key"); - } - - @Test - void recordTypeMismatchError() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "", "ERROR", ErrorCode.TYPE_MISMATCH, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "error.type", "type_mismatch"); - } - - @Test - void recordWithAllocationKey() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "on", "TARGETING_MATCH", null, "default-allocation"); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.result.allocation_key", "default-allocation"); - } - - @Test - void recordOmitsEmptyAllocationKey() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "on", "TARGETING_MATCH", null, ""); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertNoAttribute(attrs, "feature_flag.result.allocation_key"); - } - - @Test - void recordNullVariantBecomesEmptyString() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", null, "DEFAULT", null, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.result.variant", ""); - } - - @Test - void recordNullReasonBecomesUnknown() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "on", null, null, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.result.reason", "unknown"); - } - - @Test - void recordIsNoOpWhenCounterIsNull() { - FlagEvalMetrics metrics = new FlagEvalMetrics((LongCounter) null); - // Should not throw - metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); - } - - @Test - void shutdownClearsCounter() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.shutdown(); - metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); - - verifyNoInteractions(counter); - } - - @Test - void defaultConstructorUsesOpenTelemetryApiOnly() { - try (FlagEvalMetrics metrics = new FlagEvalMetrics()) { - metrics.record("count-flag", "on", "STATIC", null, "default-alloc"); - } - } - - private static void assertAttribute(Attributes attrs, String key, String expected) { - String value = - attrs.asMap().entrySet().stream() - .filter(e -> e.getKey().getKey().equals(key)) - .map(e -> e.getValue().toString()) - .findFirst() - .orElse(null); - if (!expected.equals(value)) { - throw new AssertionError("Expected attribute " + key + "=" + expected + " but got " + value); - } - } - - private static void assertNoAttribute(Attributes attrs, String key) { - boolean present = attrs.asMap().keySet().stream().anyMatch(k -> k.getKey().equals(key)); - if (present) { - throw new AssertionError("Expected no attribute " + key + " but it was present"); - } - } -} 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 deleted file mode 100644 index 2156eae7fdf..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ /dev/null @@ -1,510 +0,0 @@ -package datadog.trace.api.openfeature; - -import static datadog.trace.api.openfeature.Provider.METADATA; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doAnswer; -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.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -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.ImmutableMetadata; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.OpenFeatureAPI; -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.CompletableFuture; -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 org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -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 ProviderTest { - - private static final long EVENT_TIMEOUT_SECONDS = 10; - - private ExecutorService executor; - - @BeforeEach - public void setup() { - executor = Executors.newSingleThreadExecutor(); - } - - @AfterEach - public void tearDown() { - executor.shutdownNow(); - OpenFeatureAPI.getInstance().shutdown(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @Test - public void testSetProvider() throws Exception { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final CompletableFuture readyEvent = new CompletableFuture<>(); - api.onProviderReady(readyEvent::complete); - api.setProvider(new Provider()); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - } - - @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)); - provider.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - } - - @Test - public void testSetProviderAndWaitTimeoutRecoversWhenConfigurationArrives() throws Exception { - final CompletableFuture readyEvent = new CompletableFuture<>(); - final Consumer readyEventHandler = completingHandler(readyEvent); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final Client client = api.getClient(); - client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); - - assertThrows( - ProviderNotReadyError.class, - () -> api.setProviderAndWait(new Provider(new Options().initTimeout(10, MILLISECONDS)))); - - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); - assertFalse(readyEvent.isDone()); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - - final EventDetails eventDetails = readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - assertThat(eventDetails.getProviderName(), equalTo(METADATA)); - verify(readyEventHandler, times(1)).accept(any()); - } - - @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; - } - - @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); - api.setProviderAndWait(providerRef[0]); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - } - - @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)); - } - - @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() throws Exception { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - api.setProviderAndWait(new Provider()); - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - - final CompletableFuture errorEvent = new CompletableFuture<>(); - final CompletableFuture readyEvent = new CompletableFuture<>(); - final CompletableFuture configChangedEvent = new CompletableFuture<>(); - final Consumer errorEventHandler = completingHandler(errorEvent); - final Consumer readyEventHandler = completingHandler(readyEvent); - final Consumer configChangedEventHandler = completingHandler(configChangedEvent); - client.on(ProviderEvent.PROVIDER_ERROR, errorEventHandler); - client.on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, configChangedEventHandler); - - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - final EventDetails eventDetails = errorEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); - 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, readyEventHandler); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - configChangedEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - verify(errorEventHandler, times(1)).accept(any()); - verify(readyEventHandler, times(1)).accept(any()); - verify(configChangedEventHandler, 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"); - } - })); - } - - @Test - public void testGetProviderHooksReturnsFlagEvalMetricsHook() { - Provider provider = - new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); - List hooks = provider.getProviderHooks(); - // OTel FlagEvalMetricsHook (index 0), FlagEvalLoggingHook (index 1), ExposureLoggingHook (last) - assertThat(hooks.size(), equalTo(3)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); - assertThat(hooks.get(1) instanceof FlagEvalLoggingHook, equalTo(true)); - assertThat(hooks.get(2) instanceof ExposureLoggingHook, equalTo(true)); - } - - @Test - public void testGetProviderHooksSkipsFlagEvalLoggingHookOnLinkageFailure() { - Provider provider = - new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)) { - @Override - Hook buildFlagEvalLoggingHook() { - throw new NoClassDefFoundError("old bootstrap"); - } - }; - - List hooks = provider.getProviderHooks(); - - assertThat(hooks.size(), equalTo(2)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); - assertThat(hooks.get(1) instanceof ExposureLoggingHook, equalTo(true)); - } - - @Test - public void testClientEvaluationRoutesThroughFlagEvalLoggingHook() throws Exception { - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - final AtomicReference captured = new AtomicReference<>(); - FeatureFlaggingGateway.setFlagEvalWriter(capturingWriter(captured)); - final Evaluator evaluator = mock(Evaluator.class); - when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); - when(evaluator.hasConfiguration()).thenReturn(true); - when(evaluator.evaluate(eq(String.class), eq("logged-flag"), eq("default"), any())) - .thenReturn( - ProviderEvaluation.builder() - .value("value") - .reason("STATIC") - .variant("variant-1") - .flagMetadata( - ImmutableMetadata.builder() - .addString("allocationKey", "allocation-1") - .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) - .build()) - .build()); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); - final MutableContext context = new MutableContext("user-1"); - context.add("region", "us-east-1"); - - final FlagEvaluationDetails details = - api.getClient().getStringDetails("logged-flag", "default", context); - - assertThat(details.getValue(), equalTo("value")); - final FlagEvalEvent event = captured.get(); - assertThat(event.flagKey, equalTo("logged-flag")); - assertThat(event.variant, equalTo("variant-1")); - assertThat(event.allocationKey, equalTo("allocation-1")); - assertThat(event.targetingKey, equalTo("user-1")); - assertThat(event.evalTimeMs, equalTo(1_700_000_000_000L)); - assertThat(event.attrs.get("region"), equalTo("us-east-1")); - } - - @Test - public void testGetProviderHooksReturnsFlagEvalMetricsHookWithAndWithoutSpanEnrichment() { - final Evaluator evaluator = mock(Evaluator.class); - final Provider providerWithoutSpanEnrichment = - new Provider(new Options(), evaluator, Boolean.FALSE); - final Provider providerWithSpanEnrichment = - new Provider(new Options(), evaluator, Boolean.TRUE); - - assertHasFlagEvalMetricsHook(providerWithoutSpanEnrichment); - assertHasFlagEvalMetricsHook(providerWithSpanEnrichment); - } - - @Test - public void testShutdownCleansUpEvaluator() throws Exception { - final Evaluator evaluator = mock(Evaluator.class); - when(evaluator.initialize(eq(10L), eq(MILLISECONDS), any())).thenReturn(true); - when(evaluator.hasConfiguration()).thenReturn(true); - final Provider provider = - new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator, Boolean.FALSE); - - provider.initialize(null); - provider.shutdown(); - - verify(evaluator).shutdown(); - // After shutdown, getProviderHooks still returns the OTel, logging and exposure hooks - assertThat(provider.getProviderHooks().size(), equalTo(3)); - } - - private static void assertHasFlagEvalMetricsHook(final Provider provider) { - assertTrue( - provider.getProviderHooks().stream().anyMatch(FlagEvalMetricsHook.class::isInstance), - "flag evaluation metrics hook should be registered"); - } - - 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) - }; - } - - @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)); - } - - 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(); - } - - @SuppressWarnings("unchecked") - private static Consumer completingHandler( - final CompletableFuture event) { - final Consumer handler = mock(Consumer.class); - doAnswer( - invocation -> { - event.complete(invocation.getArgument(0)); - return null; - }) - .when(handler) - .accept(any()); - return handler; - } - - private static FlagEvaluationWriter capturingWriter(final AtomicReference ref) { - return new FlagEvaluationWriter() { - @Override - public void enqueue(final FlagEvalEvent event) { - ref.set(event); - } - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - }; - } -} 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 deleted file mode 100644 index 2ce0931e116..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java +++ /dev/null @@ -1,241 +0,0 @@ -package datadog.trace.api.openfeature; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.SpanEnrichmentEvent; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.ImmutableStructure; -import dev.openfeature.sdk.Value; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * 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. - */ -class SpanEnrichmentHookTest { - - private final List captured = new ArrayList<>(); - private final FeatureFlaggingGateway.SpanEnrichmentListener listener = captured::add; - - @BeforeEach - void register() { - FeatureFlaggingGateway.addSpanEnrichmentListener(listener); - } - - @AfterEach - void deregister() { - FeatureFlaggingGateway.removeSpanEnrichmentListener(listener); - } - - // ---- helpers ---- - - private static FlagEvaluationDetails details( - final String flagKey, - final String variant, - final Object value, - final ImmutableMetadata metadata) { - return FlagEvaluationDetails.builder() - .flagKey(flagKey) - .variant(variant) - .value(value) - .flagMetadata(metadata) - .build(); - } - - private static ImmutableMetadata metadata(final Integer serialId, final boolean doLog) { - final ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); - if (serialId != null) { - builder.addInteger(SpanEnrichmentHook.METADATA_SERIAL_ID, serialId); - } - builder.addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, doLog); - return builder.build(); - } - - private static HookContext ctx(final String flagKey, final String targetingKey) { - return HookContext.from( - flagKey, - FlagValueType.STRING, - null, - null, - targetingKey == null ? new ImmutableContext() : new ImmutableContext(targetingKey), - "default"); - } - - // ---- serial-id branch ---- - - @Test - void serialIdWithDoLogDispatchesSerialAndSubject() { - new SpanEnrichmentHook() - .finallyAfter( - ctx("flag", "user-1"), - details("flag", "on", "v", metadata(42, true)), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final SpanEnrichmentEvent event = captured.get(0); - assertTrue(event.hasSerialId()); - assertEquals(42, event.serialId()); - assertTrue(event.doLog()); - assertEquals("user-1", event.targetingKey()); - } - - @Test - void serialIdWithoutDoLogStillDispatchesSerialButNotDoLog() { - new SpanEnrichmentHook() - .finallyAfter( - ctx("flag", "user-1"), - details("flag", "on", "v", metadata(7, false)), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final SpanEnrichmentEvent event = captured.get(0); - assertTrue(event.hasSerialId()); - assertEquals(7, event.serialId()); - assertFalse( - event.doLog(), "doLog=false must be carried through so the write side skips subject"); - } - - @Test - void wrongTypedSerialIdDispatchesNothing() { - // Defensive: a non-integer value under the serial-id key (wrong type) is ignored, not crashed. - final ImmutableMetadata bad = - ImmutableMetadata.builder() - .addString(SpanEnrichmentHook.METADATA_SERIAL_ID, "not-a-number") - .addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, true) - .build(); - new SpanEnrichmentHook() - .finallyAfter( - ctx("flag", "user-1"), details("flag", "on", "v", bad), Collections.emptyMap()); - assertTrue(captured.isEmpty(), "a wrong-typed serial id must never break eval or dispatch"); - } - - // ---- runtime-default branch (missing variant) ---- - - @Test - void missingVariantDispatchesRuntimeDefaultWithNativeMap() { - final Map objectValue = Collections.singletonMap("k", "val"); - new SpanEnrichmentHook() - .finallyAfter( - ctx("obj-flag", "user-1"), - details("obj-flag", null, objectValue, ImmutableMetadata.builder().build()), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final SpanEnrichmentEvent event = captured.get(0); - assertFalse(event.hasSerialId()); - assertEquals("obj-flag", event.flagKey()); - assertEquals( - objectValue, event.defaultValue(), "a native map default passes through unchanged"); - } - - @Test - void missingVariantUnwrapsOpenFeatureValueStructureToNativeMap() { - final Map inner = new LinkedHashMap<>(); - inner.put("enabled", new Value(true)); - final Value structureDefault = new Value(new ImmutableStructure(inner)); - - new SpanEnrichmentHook() - .finallyAfter( - ctx("struct-flag", "user-1"), - details("struct-flag", null, structureDefault, ImmutableMetadata.builder().build()), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final Object value = captured.get(0).defaultValue(); - // Crucially a native Map, NOT an OpenFeature Value — the seam carries only JDK types. - final Map asMap = assertInstanceOf(Map.class, value); - assertEquals(Boolean.TRUE, asMap.get("enabled")); - } - - // ---- unwrapDefaultValue (the Value -> native conversion) ---- - - @Test - void unwrapConvertsValueScalarsToNative() { - assertEquals("hello", SpanEnrichmentHook.unwrapDefaultValue(new Value("hello"))); - assertEquals(Boolean.TRUE, SpanEnrichmentHook.unwrapDefaultValue(new Value(true))); - assertEquals(7, SpanEnrichmentHook.unwrapDefaultValue(new Value(7))); - assertNull(SpanEnrichmentHook.unwrapDefaultValue(new Value())); - } - - @Test - void unwrapConvertsValueListToNativeList() { - final Value listDefault = - new Value(Arrays.asList(new Value("a"), new Value(2), new Value(true))); - final Object out = SpanEnrichmentHook.unwrapDefaultValue(listDefault); - assertEquals(Arrays.asList("a", 2, Boolean.TRUE), out); - } - - @Test - void unwrapPassesNativeValuesThrough() { - final Map native0 = Collections.singletonMap("a", "b"); - assertEquals(native0, SpanEnrichmentHook.unwrapDefaultValue(native0)); - assertEquals("plain", SpanEnrichmentHook.unwrapDefaultValue("plain")); - assertNull(SpanEnrichmentHook.unwrapDefaultValue(null)); - } - - // ---- error isolation ---- - - @Test - void nullDetailsDispatchesNothing() { - new SpanEnrichmentHook().finallyAfter(null, null, null); - assertTrue(captured.isEmpty()); - } - - // ---- Provider gating ---- - - @Test - void gateOffConstructsNoHook() { - final Provider provider = new Provider(new Provider.Options(), null, Boolean.FALSE); - assertNull(provider.spanEnrichmentHook(), "gate off => no span-enrichment hook"); - for (final Hook hook : provider.getProviderHooks()) { - assertFalse( - hook instanceof SpanEnrichmentHook, "gate off => SpanEnrichmentHook never registered"); - } - } - - @Test - void gateOnConstructsHookAndRegistersItInProviderHooks() { - final Provider provider = new Provider(new Provider.Options(), null, Boolean.TRUE); - assertTrue(provider.spanEnrichmentHook() != null, "gate on => hook constructed"); - boolean registered = false; - for (final Hook hook : provider.getProviderHooks()) { - if (hook instanceof SpanEnrichmentHook) { - registered = true; - } - } - assertTrue(registered, "gate on => SpanEnrichmentHook registered in getProviderHooks"); - } - - @Test - void getProviderHooksReturnsSameInstanceEachCall() { - final Provider gateOff = new Provider(new Provider.Options(), null, Boolean.FALSE); - assertTrue( - gateOff.getProviderHooks() == gateOff.getProviderHooks(), - "gate off => getProviderHooks allocates nothing (same instance)"); - - final Provider gateOn = new Provider(new Provider.Options(), null, Boolean.TRUE); - assertTrue( - gateOn.getProviderHooks() == gateOn.getProviderHooks(), - "gate on => getProviderHooks allocates nothing (same instance)"); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts b/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts index 94f08ae55fe..3a60a4599da 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts @@ -4,30 +4,7 @@ plugins { id("dd-trace-java.module.bootstrap-component") } -description = "Feature flagging remote common module (bootstrap classloader)" - -extra["excludedClassesCoverage"] = listOf( - // Feature lags POJOs - "datadog.trace.api.featureflag.exposure.Allocation", - "datadog.trace.api.featureflag.exposure.ExposureEvent", - "datadog.trace.api.featureflag.exposure.ExposuresRequest", - "datadog.trace.api.featureflag.exposure.Flag", - "datadog.trace.api.featureflag.exposure.Subject", - "datadog.trace.api.featureflag.exposure.Variant", - "datadog.trace.api.featureflag.ufc.v1.Allocation", - "datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration", - "datadog.trace.api.featureflag.ufc.v1.ConditionOperator", - "datadog.trace.api.featureflag.ufc.v1.Environment", - "datadog.trace.api.featureflag.ufc.v1.Feature", - "datadog.trace.api.featureflag.ufc.v1.Flag", - "datadog.trace.api.featureflag.ufc.v1.Rule", - "datadog.trace.api.featureflag.ufc.v1.ServerConfiguration", - "datadog.trace.api.featureflag.ufc.v1.Shard", - "datadog.trace.api.featureflag.ufc.v1.ShardRange", - "datadog.trace.api.featureflag.ufc.v1.Split", - "datadog.trace.api.featureflag.ufc.v1.ValueType", - "datadog.trace.api.featureflag.ufc.v1.Variant", -) +description = "Feature flagging bridge between the SDK instrumentation and the agent (bootstrap classloader)" dependencies { testImplementation(libs.bundles.junit5) diff --git a/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile b/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile index f6a80637793..425897f716a 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile @@ -30,7 +30,7 @@ org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath 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 diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java index 191841dfabd..1f1cfcaca76 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java @@ -1,46 +1,16 @@ package datadog.trace.api.featureflag; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import java.io.IOException; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import javax.annotation.Nullable; -public abstract class FeatureFlaggingGateway { - - public interface ConfigListener extends Consumer {} - - public interface ActivationListener { - void activate(); - } - - public interface ExposureListener extends Consumer {} - - public interface SpanEnrichmentListener extends Consumer {} - - private static final List CONFIG_LISTENERS = new CopyOnWriteArrayList<>(); - private static final List ACTIVATION_LISTENERS = new CopyOnWriteArrayList<>(); - private static final List EXPOSURE_LISTENERS = new CopyOnWriteArrayList<>(); - private static final List SPAN_ENRICHMENT_LISTENERS = - new CopyOnWriteArrayList<>(); - - private static final AtomicReference CURRENT_CONFIG = - new AtomicReference<>(); - - /** - * The active EVP flagevaluation writer. Registered by {@code FlagEvaluationWriterImpl.start()} - * when the killswitch {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED} is on (default). Read by - * {@code FlagEvalLoggingHook} to route evaluations into the two-tier aggregator. {@code null} - * when the EVP path is disabled. - */ - private static final AtomicReference FLAG_EVAL_WRITER = - new AtomicReference<>(); - - private static volatile boolean flagEvalEnqueueEnabled = true; +/** + * Bridges the Feature Flags SDK instrumentation helpers to the agent Feature Flags backend. + * + *

Both sides ship in the agent jar, so this contract is internal to the agent and only carries + * JDK types. The cross-version contract with the SDK is owned by the SDK and checked by muzzle. + */ +public final class FeatureFlaggingGateway { private static volatile Backend backend; private FeatureFlaggingGateway() {} @@ -62,93 +32,6 @@ public static Backend backend() { return backend; } - public static void addConfigListener(final ConfigListener listener) { - CONFIG_LISTENERS.add(listener); - final ServerConfiguration current = CURRENT_CONFIG.get(); - if (current != null) { - listener.accept(current); - } - } - - public static void removeConfigListener(final ConfigListener listener) { - CONFIG_LISTENERS.remove(listener); - } - - public static void dispatch(final ServerConfiguration config) { - CURRENT_CONFIG.set(config); - CONFIG_LISTENERS.forEach(listener -> listener.accept(config)); - } - - public static void addActivationListener(final ActivationListener listener) { - ACTIVATION_LISTENERS.add(listener); - } - - public static void removeActivationListener(final ActivationListener listener) { - ACTIVATION_LISTENERS.remove(listener); - } - - /** Signals that application code initialized the Datadog OpenFeature provider. */ - public static void activate() { - ACTIVATION_LISTENERS.forEach(ActivationListener::activate); - } - - public static void addExposureListener(final ExposureListener listener) { - EXPOSURE_LISTENERS.add(listener); - } - - public static void removeExposureListener(final ExposureListener listener) { - EXPOSURE_LISTENERS.remove(listener); - } - - public static void dispatch(final ExposureEvent event) { - EXPOSURE_LISTENERS.forEach(listener -> listener.accept(event)); - } - - /** - * Registers the active EVP flagevaluation writer. Called by {@code - * FlagEvaluationWriterImpl.start()} when the feature is enabled. Replaces any previously - * registered writer. - * - * @param writer the writer to register, or {@code null} to deregister - */ - public static void setFlagEvalWriter(final FlagEvaluationWriter writer) { - FLAG_EVAL_WRITER.set(writer); - } - - /** - * Enables or disables enqueueing EVP flagevaluation events on the OpenFeature hook path. This is - * populated from {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED} at feature-flagging startup and - * cleared during shutdown before the writer drains. - */ - public static void setFlagEvaluationEnqueueEnabled(final boolean enabled) { - flagEvalEnqueueEnabled = enabled; - } - - /** - * Returns the active EVP flagevaluation writer, or {@code null} when disabled (killswitch off or - * not yet started). - */ - public static FlagEvaluationWriter getFlagEvalWriter() { - return FLAG_EVAL_WRITER.get(); - } - - /** Returns whether EVP flagevaluation hook events may be enqueued. */ - public static boolean isFlagEvaluationEnqueueEnabled() { - return flagEvalEnqueueEnabled; - } - - public static void addSpanEnrichmentListener(final SpanEnrichmentListener listener) { - SPAN_ENRICHMENT_LISTENERS.add(listener); - } - - public static void removeSpanEnrichmentListener(final SpanEnrichmentListener listener) { - SPAN_ENRICHMENT_LISTENERS.remove(listener); - } - - public static void dispatch(final SpanEnrichmentEvent event) { - SPAN_ENRICHMENT_LISTENERS.forEach(listener -> listener.accept(event)); - } - /** The agent services exposed to the Feature Flags SDK. */ public interface Backend { /** diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java deleted file mode 100644 index a7518c71743..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java +++ /dev/null @@ -1,82 +0,0 @@ -package datadog.trace.api.featureflag; - -/** - * Bootstrap-classloader carrier for a single feature-flag evaluation that must be reflected onto - * the local-root APM span (span enrichment). It crosses the app-classloader → agent-classloader - * boundary through {@link FeatureFlaggingGateway}, so it holds only JDK types — never an - * OpenFeature or tracer type — exactly like the sibling exposure/config payloads. - * - *

The capture side (the published {@code dd-openfeature} provider) decides which of the two - * shapes applies and unwraps any OpenFeature {@code Value} to its native Java form before - * dispatching; the write side (agent-side listener) resolves the active local root and accumulates. - * - *

    - *
  • serial-id ({@link #serialId(int, boolean, String)}) — a UFC split with a serial id, - * plus the {@code doLog} flag and the (optional) targeting key used to record the subject. - *
  • runtime-default ({@link #runtimeDefault(String, Object)}) — a flag that resolved to - * its runtime default (missing variant); carries the flag key and the native default value. - *
- */ -public final class SpanEnrichmentEvent { - - private final boolean serialIdPresent; - private final int serialId; - private final boolean doLog; - private final String targetingKey; - private final String flagKey; - private final Object defaultValue; - - private SpanEnrichmentEvent( - final boolean serialIdPresent, - final int serialId, - final boolean doLog, - final String targetingKey, - final String flagKey, - final Object defaultValue) { - this.serialIdPresent = serialIdPresent; - this.serialId = serialId; - this.doLog = doLog; - this.targetingKey = targetingKey; - this.flagKey = flagKey; - this.defaultValue = defaultValue; - } - - /** A UFC split evaluation carrying a serial id (and, when {@code doLog}, a subject). */ - public static SpanEnrichmentEvent serialId( - final int serialId, final boolean doLog, final String targetingKey) { - return new SpanEnrichmentEvent(true, serialId, doLog, targetingKey, null, null); - } - - /** - * A runtime-default evaluation (missing variant). {@code value} must already be unwrapped to a - * native Java type (Map/List/scalar/null) by the caller. - */ - public static SpanEnrichmentEvent runtimeDefault(final String flagKey, final Object value) { - return new SpanEnrichmentEvent(false, 0, false, null, flagKey, value); - } - - /** True for the serial-id shape; false for the runtime-default shape. */ - public boolean hasSerialId() { - return serialIdPresent; - } - - public int serialId() { - return serialId; - } - - public boolean doLog() { - return doLog; - } - - public String targetingKey() { - return targetingKey; - } - - public String flagKey() { - return flagKey; - } - - public Object defaultValue() { - return defaultValue; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java deleted file mode 100644 index 53df4b4b512..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java +++ /dev/null @@ -1,9 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -public class Allocation { - public final String key; - - public Allocation(final String key) { - this.key = key; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java deleted file mode 100644 index a2cc090adf2..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java +++ /dev/null @@ -1,54 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -import java.util.Map; - -public class ExposureEvent { - // milliseconds since epoch as given by System.currentTimeMillis() - public final long timestamp; - public final Allocation allocation; - public final Flag flag; - public final Variant variant; - public final Subject subject; - - public final Integer serial_id; - - // Split features routed to the EXPOSURE destination. Null when there are none, so the field is - // omitted from the exposure payload. - public final Map features; - - public ExposureEvent( - final long timestamp, - final Allocation allocation, - final Flag flag, - final Variant variant, - final Subject subject) { - this(timestamp, allocation, flag, variant, subject, null); - } - - public ExposureEvent( - final long timestamp, - final Allocation allocation, - final Flag flag, - final Variant variant, - final Subject subject, - final Integer serialId) { - this(timestamp, allocation, flag, variant, subject, serialId, null); - } - - public ExposureEvent( - final long timestamp, - final Allocation allocation, - final Flag flag, - final Variant variant, - final Subject subject, - final Integer serialId, - final Map features) { - this.timestamp = timestamp; - this.allocation = allocation; - this.flag = flag; - this.variant = variant; - this.subject = subject; - this.serial_id = serialId; - this.features = features; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java deleted file mode 100644 index 3b25028c245..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -import java.util.List; -import java.util.Map; - -public class ExposuresRequest { - - public final Map context; - public final List exposures; - - public ExposuresRequest(final Map context, final List exposures) { - this.context = context; - this.exposures = exposures; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java deleted file mode 100644 index c13b123ad6c..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java +++ /dev/null @@ -1,9 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -public class Flag { - public final String key; - - public Flag(final String key) { - this.key = key; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java deleted file mode 100644 index ebd381e7069..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java +++ /dev/null @@ -1,13 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -import java.util.Map; - -public class Subject { - public final String id; - public final Map attributes; - - public Subject(final String id, final Map attributes) { - this.id = id; - this.attributes = attributes; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java deleted file mode 100644 index dfc4cbe38c7..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java +++ /dev/null @@ -1,9 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -public class Variant { - public final String key; - - public Variant(final String key) { - this.key = key; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java deleted file mode 100644 index d73285a9aa9..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java +++ /dev/null @@ -1,127 +0,0 @@ -package datadog.trace.api.featureflag.flagevaluation; - -import java.util.Collections; -import java.util.Map; - -/** - * Lightweight data record capturing a single flag evaluation for EVP flagevaluation emission. - * - *

This is the currency passed from the FlagEvalLoggingHook (feature-flagging-api) to the - * FlagEvaluationWriter (feature-flagging-lib) via a non-blocking bounded queue. - * - *

Scalar fields and context attributes are captured at hook-fire time on the evaluation thread. - * No aggregation happens here. - */ -public final class FlagEvalEvent { - - /** The feature flag key. Never null. */ - public final String flagKey; - - /** - * The OpenFeature variant key selected for the evaluation. Null means the default value was - * returned (runtime default). - */ - public final String variant; - - /** The allocation key from flag metadata ("allocationKey"). May be null. */ - public final String allocationKey; - - /** The targeting key from the evaluation context. May be null. */ - public final String targetingKey; - - /** - * The evaluation error message when the evaluation failed, else null. Sourced from the - * OpenFeature evaluation details (error message, falling back to the error code). - */ - public final String errorMessage; - - /** - * Evaluation timestamp in milliseconds since epoch. Stamped at eval-entry time from flag metadata - * key __dd_eval_timestamp_ms, or falls back to hook-fire time when absent. This ensures - * first/last_evaluation reflect evaluation time, not hook-fire time. - */ - public final long evalTimeMs; - - /** - * Flattened evaluation context attributes. Used for the full-tier canonical context key. May be - * empty but never null. - */ - public final Map attrs; - - /** - * PII consent from the ServerConfiguration used by the evaluation. When false (privacy-preserving - * default), the targeting key is hashed and the per-evaluation context is omitted on emission. - */ - public final boolean observeFullEvaluationData; - - /** - * Split features routed to the EVALUATION destination. They come from the flag configuration, not - * the evaluation context, so they are kept whatever the consent. May be empty but never null. - */ - public final Map features; - - /** Convenience constructor; consent defaults to the privacy-preserving false. */ - public FlagEvalEvent( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final Map attrs) { - this(flagKey, variant, allocationKey, targetingKey, null, evalTimeMs, false, attrs); - } - - /** Convenience constructor; consent defaults to the privacy-preserving false. */ - public FlagEvalEvent( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final String errorMessage, - final long evalTimeMs, - final Map attrs) { - this(flagKey, variant, allocationKey, targetingKey, errorMessage, evalTimeMs, false, attrs); - } - - public FlagEvalEvent( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final String errorMessage, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map attrs) { - this( - flagKey, - variant, - allocationKey, - targetingKey, - errorMessage, - evalTimeMs, - observeFullEvaluationData, - attrs, - null); - } - - public FlagEvalEvent( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final String errorMessage, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map attrs, - final Map features) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.targetingKey = targetingKey; - this.errorMessage = errorMessage; - this.evalTimeMs = evalTimeMs; - this.observeFullEvaluationData = observeFullEvaluationData; - this.attrs = attrs != null ? attrs : Collections.emptyMap(); - this.features = features != null ? features : Collections.emptyMap(); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java deleted file mode 100644 index 65ffaf80591..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java +++ /dev/null @@ -1,46 +0,0 @@ -package datadog.trace.api.featureflag.flagevaluation; - -/** - * Defines an EVP flagevaluation writer responsible for aggregating flag evaluation events and - * flushing them to the EVP proxy. - * - *

Implementations must use a background thread (serializing handler) for aggregation and - * transport. The enqueue method must be non-blocking and callable from the OpenFeature hook thread - * without backpressure. - */ -public interface FlagEvaluationWriter extends AutoCloseable { - - /** - * Non-blocking enqueue of a flag evaluation event. May silently drop the event if the internal - * bounded queue is full (best-effort, observable via drop counter). - */ - void enqueue(FlagEvalEvent event); - - /** - * Reports whether the internal queue currently has room for another event. Producers should - * consult this before performing expensive context-copy work so a saturated queue is observed as - * an O(1) read rather than a full snapshot followed by a discarded offer. - * - *

Best-effort: the worker can drain (or a peer producer can fill) between this check and the - * next enqueue, so callers must still tolerate offer failure. - */ - boolean hasCapacityForEnqueue(); - - /** Counts one queue-overflow drop without offering an event. */ - void countPreQueueOverflow(); - - /** - * Counts one evaluation whose context was truncated by copyPrunedContext. The reason string is - * the sorted, comma-separated set of cap names that fired (e.g. - * "max_key_length,max_value_length"). Each unique reason string is counted separately so - * telemetry can distinguish which caps are hot. - */ - void countContextTruncated(String reason); - - /** Starts the background serializing thread. Must be called once after construction. */ - void start(); - - /** Stops the background thread and releases resources. */ - @Override - void close(); -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java deleted file mode 100644 index b572e7e5aff..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java +++ /dev/null @@ -1,80 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.time.Instant; -import java.util.Date; -import java.util.List; - -public class Allocation { - public final String key; - public final List rules; - public final Date startAt; - public final Date endAt; - public final List splits; - public final Boolean doLog; - - private final transient Instant preciseStartAt; - private final transient Instant preciseEndAt; - - public Allocation( - final String key, - final List rules, - final Date startAt, - final Date endAt, - final List splits, - final Boolean doLog) { - this( - key, - rules, - startAt, - endAt, - splits, - doLog, - startAt == null ? null : startAt.toInstant(), - endAt == null ? null : endAt.toInstant()); - } - - private Allocation( - final String key, - final List rules, - final Date startAt, - final Date endAt, - final List splits, - final Boolean doLog, - final Instant preciseStartAt, - final Instant preciseEndAt) { - this.key = key; - this.rules = rules; - this.startAt = startAt; - this.endAt = endAt; - this.splits = splits; - this.doLog = doLog; - this.preciseStartAt = preciseStartAt; - this.preciseEndAt = preciseEndAt; - } - - public static Allocation fromInstants( - final String key, - final List rules, - final Instant startAt, - final Instant endAt, - final List splits, - final Boolean doLog) { - return new Allocation( - key, - rules, - startAt == null ? null : Date.from(startAt), - endAt == null ? null : Date.from(endAt), - splits, - doLog, - startAt, - endAt); - } - - public Instant startAtInstant() { - return preciseStartAt != null ? preciseStartAt : startAt == null ? null : startAt.toInstant(); - } - - public Instant endAtInstant() { - return preciseEndAt != null ? preciseEndAt : endAt == null ? null : endAt.toInstant(); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java deleted file mode 100644 index ae693674acc..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java +++ /dev/null @@ -1,18 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -public class ConditionConfiguration { - public final ConditionOperator operator; - public final String attribute; - public final Object value; - - // The validated, parsed SemVer condition value. Set during configuration preprocessing - // (not from JSON) when the operator is a SEMVER_* operator. - public transient ParsedSemver semverComparand; - - public ConditionConfiguration( - final ConditionOperator operator, final String attribute, final Object value) { - this.operator = operator; - this.attribute = attribute; - this.value = value; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java deleted file mode 100644 index 1e864da2b6a..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java +++ /dev/null @@ -1,19 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -public enum ConditionOperator { - LT, - LTE, - GT, - GTE, - MATCHES, - NOT_MATCHES, - ONE_OF, - NOT_ONE_OF, - IS_NULL, - SEMVER_EQ, - SEMVER_NEQ, - SEMVER_LT, - SEMVER_LTE, - SEMVER_GT, - SEMVER_GTE -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java deleted file mode 100644 index 86d04c7d8ef..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java +++ /dev/null @@ -1,9 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -public class Environment { - public final String name; - - public Environment(String name) { - this.name = name; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Feature.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Feature.java deleted file mode 100644 index 495b547462f..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Feature.java +++ /dev/null @@ -1,19 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.util.List; - -/** Key-value data that the UFC attaches to a split, with where SDKs deliver it. */ -public class Feature { - public final String key; - // A String, Double or Boolean; the parser drops features with any other value. - public final Object value; - // Open strings such as HOOK, EXPOSURE or EVALUATION. Never empty: the parser drops a feature - // without destinations. - public final List destinations; - - public Feature(final String key, final Object value, final List destinations) { - this.key = key; - this.value = value; - this.destinations = destinations; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java deleted file mode 100644 index 529e3145344..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java +++ /dev/null @@ -1,25 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.util.List; -import java.util.Map; - -public class Flag { - public final String key; - public final boolean enabled; - public final ValueType variationType; - public final Map variations; - public final List allocations; - - public Flag( - final String key, - final boolean enabled, - final ValueType variationType, - final Map variations, - final List allocations) { - this.key = key; - this.enabled = enabled; - this.variationType = variationType; - this.variations = variations; - this.allocations = allocations; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java deleted file mode 100644 index 03ef7590fa1..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java +++ /dev/null @@ -1,318 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.util.Arrays; - -/** - * ParsedSemver is the language-neutral representation of the SemVer subset used by FFE. Owning this - * parser and comparator keeps behavior consistent across SDKs and lets configuration preprocessing - * cache comparands instead of reparsing them during every evaluation. - * - *

Core identifiers (major, minor, patch) are limited to unsigned 64-bit integers, stored in - * {@code long} fields and compared with unsigned semantics. Numeric prerelease identifiers may be - * arbitrarily large and are compared by length-then-lexicographic order. Build metadata is - * validated during parsing but not retained because it does not affect SemVer precedence. - */ -public final class ParsedSemver { - - /** Sentinel returned by {@link #parse(String)} when the input is not a valid semantic version. */ - public static final ParsedSemver INVALID = null; - - private final long[] core; // unsigned, with omitted minor and patch normalized to zero - private final int coreLength; - private final String prerelease; - - ParsedSemver(final long[] core, final int coreLength, final String prerelease) { - this.core = core; - this.coreLength = coreLength; - this.prerelease = prerelease; - } - - /** - * Parses a semantic version string using the syntax supported by FFE. - * - * @param version the version string to parse - * @return a {@link ParsedSemver} instance, or {@code null} if the input is not a valid semantic - * version - */ - public static ParsedSemver parse(final String version) { - long[] core = new long[3]; - int coreSize = 0; - int next = 0; - while (true) { - final int end = coreIdentifierEnd(version, next); - if (end == -1) { - return INVALID; - } - final long component; - try { - component = Long.parseUnsignedLong(version.substring(next, end)); - } catch (final NumberFormatException e) { - return INVALID; // overflow - } - if (coreSize == core.length) { - core = Arrays.copyOf(core, core.length * 2); - } - core[coreSize++] = component; - next = end; - if (next == version.length() || version.charAt(next) != '.') { - break; - } - next++; - } - final int coreLength = Math.max(3, coreSize); - if (next == version.length()) { - return new ParsedSemver(core, coreLength, ""); - } - - // Parse prerelease and/or build metadata. - String remainder = version.substring(next); - String prerelease = ""; - if (remainder.charAt(0) == '-') { - remainder = remainder.substring(1); - final int buildStart = remainder.indexOf('+'); - if (buildStart == -1) { - if (!validSemverIdentifiers(remainder, false)) { - return INVALID; - } - return new ParsedSemver(core, coreLength, remainder); - } - prerelease = remainder.substring(0, buildStart); - if (!validSemverIdentifiers(prerelease, false)) { - return INVALID; - } - remainder = remainder.substring(buildStart + 1); - } else if (remainder.charAt(0) == '+') { - remainder = remainder.substring(1); - } else { - return INVALID; - } - if (!validSemverIdentifiers(remainder, true)) { - return INVALID; - } - return new ParsedSemver(core, coreLength, prerelease); - } - - /** - * Compares two parsed semantic versions by precedence. Build metadata is intentionally ignored. - * - * @param left the left operand - * @param right the right operand - * @return negative if {@code left} precedes {@code right}, positive if {@code left} follows - * {@code right}, zero if they have equal precedence - */ - public static int compare(final ParsedSemver left, final ParsedSemver right) { - final int coreLength = Math.max(left.coreLength, right.coreLength); - for (int i = 0; i < coreLength; i++) { - final int cmp = Long.compareUnsigned(left.coreComponent(i), right.coreComponent(i)); - if (cmp != 0) { - return cmp; - } - } - return comparePrerelease(left.prerelease, right.prerelease); - } - - /** - * Finds the end of a core version identifier (major, minor, or patch), rejecting leading zeros - * except for the value zero itself. - */ - private static int coreIdentifierEnd(final String version, final int start) { - if (start >= version.length() || !isASCIIDigit(version.charAt(start))) { - return -1; - } - if (version.charAt(start) == '0') { - return start + 1; - } - - int end = start; - while (end < version.length() && isASCIIDigit(version.charAt(end))) { - end++; - } - return end; - } - - private long coreComponent(final int index) { - return index < coreLength ? core[index] : 0; - } - - /** - * Returns the number of significant core components, excluding trailing zero components that - * {@link #compare} treats as equal to omitted components. This keeps {@link #equals} and {@link - * #hashCode} consistent with precedence comparison so that versions like {@code 1.2.3} and {@code - * 1.2.3.0} are equal and hash identically. - */ - private int effectiveCoreLength() { - int length = coreLength; - while (length > 0 && core[length - 1] == 0L) { - length--; - } - return length; - } - - /** - * Validates dot-separated identifiers. Permits leading zeros for build metadata only; numeric - * prerelease identifiers reject them. - */ - private static boolean validSemverIdentifiers( - final String value, final boolean allowLeadingZeros) { - int identifierStart = 0; - boolean identifierNumeric = true; - for (int i = 0; i <= value.length(); i++) { - if (i == value.length() || value.charAt(i) == '.') { - if (i == identifierStart) { - return false; // empty identifier - } - if (!allowLeadingZeros - && identifierNumeric - && i - identifierStart > 1 - && value.charAt(identifierStart) == '0') { - return false; // leading zero in numeric identifier - } - identifierStart = i + 1; - identifierNumeric = true; - continue; - } - - final char c = value.charAt(i); - if (!isASCIIAlphanumeric(c) && c != '-') { - return false; - } - if (!isASCIIDigit(c)) { - identifierNumeric = false; - } - } - return true; - } - - private static int comparePrerelease(final String left, final String right) { - if (left.equals(right)) { - return 0; - } - if (left.isEmpty()) { - return 1; // release > prerelease - } - if (right.isEmpty()) { - return -1; // prerelease < release - } - - int leftPos = 0; - int rightPos = 0; - while (true) { - // Extract next identifier from each side - int leftDot = left.indexOf('.', leftPos); - int rightDot = right.indexOf('.', rightPos); - final String leftIdentifier = - leftDot == -1 ? left.substring(leftPos) : left.substring(leftPos, leftDot); - final String rightIdentifier = - rightDot == -1 ? right.substring(rightPos) : right.substring(rightPos, rightDot); - - final int ordering = compareIdentifier(leftIdentifier, rightIdentifier); - if (ordering != 0) { - return ordering; - } - - if (leftDot == -1) { - if (rightDot == -1) { - return 0; - } - return -1; // left has fewer identifiers - } - if (rightDot == -1) { - return 1; // right has fewer identifiers - } - leftPos = leftDot + 1; - rightPos = rightDot + 1; - } - } - - private static int compareIdentifier(final String left, final String right) { - final boolean leftNumeric = isNumericIdentifier(left); - final boolean rightNumeric = isNumericIdentifier(right); - - if (leftNumeric && rightNumeric) { - // Numeric identifiers: compare by length first (longer = larger), then lexicographically - if (left.length() < right.length()) { - return -1; - } - if (left.length() > right.length()) { - return 1; - } - return left.compareTo(right); - } else if (leftNumeric) { - return -1; // numeric < alphanumeric - } else if (rightNumeric) { - return 1; // alphanumeric > numeric - } - return left.compareTo(right); - } - - private static boolean isNumericIdentifier(final String value) { - for (int i = 0; i < value.length(); i++) { - if (!isASCIIDigit(value.charAt(i))) { - return false; - } - } - return true; - } - - private static boolean isASCIIDigit(final char c) { - return c >= '0' && c <= '9'; - } - - private static boolean isASCIIAlphanumeric(final char c) { - return isASCIIDigit(c) || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); - } - - // --- Accessors for testing --- - - long getMajor() { - return core[0]; - } - - long getMinor() { - return core[1]; - } - - long getPatch() { - return core[2]; - } - - String getPrerelease() { - return prerelease; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (!(o instanceof ParsedSemver)) { - return false; - } - // Delegate to compare so equality matches precedence (SEMVER_EQ) and sorted collections, even - // when versions differ only by trailing zero components such as 1.2.3 and 1.2.3.0. - return compare(this, (ParsedSemver) o) == 0; - } - - @Override - public int hashCode() { - // Hash over the trailing-zero-stripped core so that compare-equal versions hash identically. - final int effectiveLength = effectiveCoreLength(); - int result = 1; - for (int i = 0; i < effectiveLength; i++) { - result = 31 * result + Long.hashCode(core[i]); - } - return 31 * result + prerelease.hashCode(); - } - - @Override - public String toString() { - final StringBuilder value = new StringBuilder(); - for (int i = 0; i < coreLength; i++) { - if (i > 0) { - value.append('.'); - } - value.append(Long.toUnsignedString(coreComponent(i))); - } - return value.append(prerelease.isEmpty() ? "" : "-" + prerelease).toString(); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java deleted file mode 100644 index ed0fe60f366..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java +++ /dev/null @@ -1,11 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.util.List; - -public class Rule { - public final List conditions; - - public Rule(final List conditions) { - this.conditions = conditions; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java deleted file mode 100644 index 60f39fc666f..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java +++ /dev/null @@ -1,34 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.util.Collections; -import java.util.Map; - -public class ServerConfiguration { - public final String createdAt; - public final String format; - // Boxed on purpose. Moshi's reflective adapter for a primitive boolean field aborts the whole - // UFC parse when the JSON value is null or not a boolean; with a Boolean field it tolerates - // null (and other malformed values are still caught locally) so a malformed consent field - // doesn't strand a fresh pod on PROVIDER_NOT_READY. Read sites must use - // Boolean.TRUE.equals(...) so null falls to the privacy-preserving default. - public final Boolean observeFullEvaluationData; - public final Environment environment; - public final Map flags; - - // Flags that could not be parsed or validated. The key is the flag key; the value is the error - // type (e.g. "invalid_semver_comparand"). Set during configuration preprocessing, not from JSON. - public transient Map invalidFlags = Collections.emptyMap(); - - public ServerConfiguration( - final String createdAt, - final String format, - final Boolean observeFullEvaluationData, - final Environment environment, - final Map flags) { - this.createdAt = createdAt; - this.format = format; - this.observeFullEvaluationData = observeFullEvaluationData; - this.environment = environment; - this.flags = flags; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java deleted file mode 100644 index fef1e5f04b4..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java +++ /dev/null @@ -1,22 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.util.List; - -/** - * A UFC shard. - * - *

{@code totalShards} stores the unsigned 32-bit wire value in an {@code int} to preserve this - * bootstrap model's binary compatibility. Consumers must use {@link Integer#toUnsignedLong(int)} - * before comparing it or using it for arithmetic. - */ -public class Shard { - public final String salt; - public final List ranges; - public final int totalShards; - - public Shard(final String salt, final List ranges, final int totalShards) { - this.salt = salt; - this.ranges = ranges; - this.totalShards = totalShards; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java deleted file mode 100644 index 4a8c5078daf..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java +++ /dev/null @@ -1,18 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -/** - * A UFC shard range. - * - *

{@code start} and {@code end} store unsigned 32-bit wire values in {@code int} fields to - * preserve this bootstrap model's binary compatibility. Consumers must use {@link - * Integer#toUnsignedLong(int)} before comparing them. - */ -public class ShardRange { - public final int start; - public final int end; - - public ShardRange(final int start, final int end) { - this.start = start; - this.end = end; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java deleted file mode 100644 index 5e98f90dabc..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java +++ /dev/null @@ -1,38 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.util.List; -import java.util.Map; - -public class Split { - public final List shards; - public final String variationKey; - public final Map extraLogging; - // Nullable Integer (not primitive int): the serialId is absent in some UFC shapes. Populated by - // Moshi reflective deserialization from the UFC "serialId" JSON field. Surfaced as - // __dd_split_serial_id in eval metadata for APM span enrichment. - public final Integer serialId; - // Null when the UFC split has no features. A provider must check that this field exists before - // reading it, because an older agent ships a Split without it. - public final List features; - - public Split( - final List shards, - final String variationKey, - final Map extraLogging, - final Integer serialId) { - this(shards, variationKey, extraLogging, serialId, null); - } - - public Split( - final List shards, - final String variationKey, - final Map extraLogging, - final Integer serialId, - final List features) { - this.shards = shards; - this.variationKey = variationKey; - this.extraLogging = extraLogging; - this.serialId = serialId; - this.features = features; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java deleted file mode 100644 index 6f0a973a379..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java +++ /dev/null @@ -1,9 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -public enum ValueType { - BOOLEAN, - INTEGER, - NUMERIC, - STRING, - JSON -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java deleted file mode 100644 index 26e30aa1aad..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java +++ /dev/null @@ -1,11 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -public class Variant { - public final String key; - public final Object value; - - public Variant(final String key, final Object value) { - this.key = key; - this.value = value; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java index 887a153f0a1..73bf6c24170 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java @@ -1,115 +1,25 @@ package datadog.trace.api.featureflag; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class FeatureFlaggingGatewayTest { - - private FeatureFlaggingGateway.ConfigListener configListener; - private FeatureFlaggingGateway.ActivationListener activationListener; - private FeatureFlaggingGateway.ExposureListener exposureListener; - private FeatureFlaggingGateway.SpanEnrichmentListener spanEnrichmentListener; - private ServerConfiguration firstConfiguration; - private ServerConfiguration secondConfiguration; - private ExposureEvent firstExposure; - private ExposureEvent secondExposure; - - @BeforeEach - void setUp() { - configListener = mock(FeatureFlaggingGateway.ConfigListener.class); - activationListener = mock(FeatureFlaggingGateway.ActivationListener.class); - exposureListener = mock(FeatureFlaggingGateway.ExposureListener.class); - spanEnrichmentListener = mock(FeatureFlaggingGateway.SpanEnrichmentListener.class); - firstConfiguration = mock(ServerConfiguration.class); - secondConfiguration = mock(ServerConfiguration.class); - firstExposure = mock(ExposureEvent.class); - secondExposure = mock(ExposureEvent.class); - } - @AfterEach void tearDown() { - FeatureFlaggingGateway.removeConfigListener(configListener); - FeatureFlaggingGateway.removeActivationListener(activationListener); - FeatureFlaggingGateway.removeExposureListener(exposureListener); - FeatureFlaggingGateway.removeSpanEnrichmentListener(spanEnrichmentListener); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @Test - void testProviderActivationListener() { - FeatureFlaggingGateway.addActivationListener(activationListener); - - FeatureFlaggingGateway.activate(); - - verify(activationListener).activate(); - verifyNoMoreInteractions(activationListener); - } - - @Test - void testAttachingAConfigListener() { - clearCurrentServerConfiguration(); - - FeatureFlaggingGateway.addConfigListener(configListener); - FeatureFlaggingGateway.dispatch(firstConfiguration); - - verify(configListener).accept(firstConfiguration); - verifyNoMoreInteractions(configListener); - - FeatureFlaggingGateway.dispatch(secondConfiguration); - - verify(configListener).accept(secondConfiguration); - verifyNoMoreInteractions(configListener); + FeatureFlaggingGateway.register(null); } @Test - void testAttachingAListenerAfterConfigured() { - FeatureFlaggingGateway.dispatch(firstConfiguration); - FeatureFlaggingGateway.addConfigListener(configListener); - - verify(configListener).accept(firstConfiguration); - verifyNoMoreInteractions(configListener); - } - - @Test - void testAttachingAnExposureListener() { - FeatureFlaggingGateway.addExposureListener(exposureListener); - FeatureFlaggingGateway.dispatch(firstExposure); - - verify(exposureListener).accept(firstExposure); - verifyNoMoreInteractions(exposureListener); - - FeatureFlaggingGateway.dispatch(secondExposure); - - verify(exposureListener).accept(secondExposure); - verifyNoMoreInteractions(exposureListener); - } - - @Test - void testAttachingASpanEnrichmentListener() { - final SpanEnrichmentEvent firstEvent = SpanEnrichmentEvent.serialId(42, true, "user-1"); - final SpanEnrichmentEvent secondEvent = SpanEnrichmentEvent.runtimeDefault("flag", "value"); - - FeatureFlaggingGateway.addSpanEnrichmentListener(spanEnrichmentListener); - FeatureFlaggingGateway.dispatch(firstEvent); - - verify(spanEnrichmentListener).accept(firstEvent); - verifyNoMoreInteractions(spanEnrichmentListener); - - FeatureFlaggingGateway.dispatch(secondEvent); - - verify(spanEnrichmentListener).accept(secondEvent); - verifyNoMoreInteractions(spanEnrichmentListener); - } - - private static void clearCurrentServerConfiguration() { - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + void registersAndUnregistersTheBackend() { + assertNull(FeatureFlaggingGateway.backend()); + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + assertSame(backend, FeatureFlaggingGateway.backend()); + FeatureFlaggingGateway.register(null); + assertNull(FeatureFlaggingGateway.backend()); } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java deleted file mode 100644 index 55e7a8943ac..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package datadog.trace.api.featureflag; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import org.junit.jupiter.api.Test; - -class SpanEnrichmentEventTest { - - @Test - void serialIdEventCarriesItsFields() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.serialId(42, true, "user-1"); - - assertTrue(event.hasSerialId()); - assertEquals(42, event.serialId()); - assertTrue(event.doLog()); - assertEquals("user-1", event.targetingKey()); - assertNull(event.flagKey()); - assertNull(event.defaultValue()); - } - - @Test - void serialIdEventWithoutDoLogOrTargetingKey() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.serialId(7, false, null); - - assertTrue(event.hasSerialId()); - assertEquals(7, event.serialId()); - assertFalse(event.doLog()); - assertNull(event.targetingKey()); - } - - @Test - void runtimeDefaultEventCarriesItsFields() { - final Object value = Collections.singletonMap("k", "v"); - final SpanEnrichmentEvent event = SpanEnrichmentEvent.runtimeDefault("flag", value); - - assertFalse(event.hasSerialId()); - assertEquals("flag", event.flagKey()); - assertEquals(value, event.defaultValue()); - assertEquals(0, event.serialId()); - assertFalse(event.doLog()); - assertNull(event.targetingKey()); - } - - @Test - void runtimeDefaultEventAllowsNullValue() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.runtimeDefault("flag", null); - - assertFalse(event.hasSerialId()); - assertEquals("flag", event.flagKey()); - assertNull(event.defaultValue()); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java deleted file mode 100644 index 6470364c660..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package datadog.trace.api.featureflag.flagevaluation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class FlagEvalEventTest { - - @Test - void storesFieldsWithContextAttributes() { - final Map attrs = Collections.singletonMap("tier", "gold"); - - final FlagEvalEvent event = - new FlagEvalEvent("my-flag", "on", "allocation-1", "target-1", 123L, attrs); - - assertEquals("my-flag", event.flagKey); - assertEquals("on", event.variant); - assertEquals("allocation-1", event.allocationKey); - assertEquals("target-1", event.targetingKey); - assertNull(event.errorMessage); - assertEquals(123L, event.evalTimeMs); - assertSame(attrs, event.attrs); - } - - @Test - void storesErrorMessageAndDefaultsNullContextAttributes() { - final Map attrs = null; - final FlagEvalEvent event = - new FlagEvalEvent("my-flag", null, null, null, "type mismatch", 456L, attrs); - - assertEquals("type mismatch", event.errorMessage); - assertTrue(event.attrs.isEmpty()); - } - - @Test - void observeFullEvaluationDataDefaultsToFalseOnConvenienceConstructors() { - final Map attrs = Collections.emptyMap(); - assertFalse(new FlagEvalEvent("f", "on", "a", "t", 1L, attrs).observeFullEvaluationData); - assertFalse(new FlagEvalEvent("f", "on", "a", "t", null, 1L, attrs).observeFullEvaluationData); - } - - @Test - void storesExplicitObserveFullEvaluationData() { - final Map attrs = Collections.emptyMap(); - assertTrue( - new FlagEvalEvent("f", "on", "a", "t", null, 1L, true, attrs).observeFullEvaluationData); - } - - @Test - void storesFeaturesAndDefaultsThemToEmpty() { - final Map features = Collections.singletonMap("bandit.policy_id", "p2"); - - assertSame( - features, - new FlagEvalEvent("f", "on", "a", "t", null, 1L, false, Collections.emptyMap(), features) - .features); - assertTrue( - new FlagEvalEvent("f", "on", "a", "t", null, 1L, false, Collections.emptyMap(), null) - .features.isEmpty()); - assertTrue( - new FlagEvalEvent("f", "on", "a", "t", 1L, Collections.emptyMap()).features.isEmpty()); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java deleted file mode 100644 index e3d78ac4b86..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java +++ /dev/null @@ -1,220 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class ParsedSemverTest { - - // --- Parse tests (ported from Go semver_test.go TestParseSemver) --- - - static Stream validVersions() { - return Stream.of( - Arguments.of("0.0.0", 0L, 0L, 0L, ""), - Arguments.of( - "18446744073709551615.18446744073709551615.18446744073709551615", - Long.parseUnsignedLong("18446744073709551615"), - Long.parseUnsignedLong("18446744073709551615"), - Long.parseUnsignedLong("18446744073709551615"), - ""), - Arguments.of("1.2.3-alpha.1", 1L, 2L, 3L, "alpha.1"), - Arguments.of("1.2.3-18446744073709551616", 1L, 2L, 3L, "18446744073709551616"), - Arguments.of("1.2.3+build.001", 1L, 2L, 3L, ""), - Arguments.of("1.2.3-alpha-1+build.001", 1L, 2L, 3L, "alpha-1")); - } - - @ParameterizedTest(name = "valid: {0}") - @MethodSource("validVersions") - void testParseValid( - final String version, - final long major, - final long minor, - final long patch, - final String prerelease) { - final ParsedSemver parsed = ParsedSemver.parse(version); - assertTrue(parsed != null, "expected " + version + " to parse"); - assertEquals(major, parsed.getMajor()); - assertEquals(minor, parsed.getMinor()); - assertEquals(patch, parsed.getPatch()); - assertEquals(prerelease, parsed.getPrerelease()); - } - - static Stream invalidVersions() { - return Stream.of( - "", - "x", - "v1.2.3", - "01.2.3", - "1.02.3", - "1.2.03", - "18446744073709551616.0.0", - "0.18446744073709551616.0", - "0.0.18446744073709551616", - "1.2.3-", - "1.2.3+", - "1.2.3-alpha..1", - "1.2.3+build..1", - "1.2.3-01", - "1.2.3-alpha_1", - "1.2.3-alpha+build+other", - "1.2.3-α", - " 1.2.3", - "1.2.3 "); - } - - @ParameterizedTest(name = "invalid: {0}") - @MethodSource("invalidVersions") - void testParseInvalid(final String version) { - assertNull(ParsedSemver.parse(version), "expected " + version + " to be invalid"); - } - - // --- Compare tests (ported from Go semver_test.go TestCompareSemver) --- - - /** The canonical SemVer precedence ordering from the spec. */ - private static final String[] ORDERED_VERSIONS = { - "1.0.0-alpha", - "1.0.0-alpha.1", - "1.0.0-alpha.beta", - "1.0.0-beta", - "1.0.0-beta.2", - "1.0.0-beta.11", - "1.0.0-rc.1", - "1.0.0", - "1.0.1", - "1.1.0", - "2.0.0", - }; - - @Test - void normalizesMissingCorePartsAndComparesExtendedCoreParts() { - assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18"), ParsedSemver.parse("18.0.0"))); - assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18.0"), ParsedSemver.parse("18.0.0"))); - assertEquals( - 0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0"))); - assertEquals( - 0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0.0"))); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("1.2.3.4"), ParsedSemver.parse("1.2.3.5")) < 0); - assertTrue(ParsedSemver.parse("1.2.3.4.5.6") != null); - assertTrue(ParsedSemver.parse("1.2.3.4.5.6.7") != null); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5"), ParsedSemver.parse("1.2.3.4.6")) < 0); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5.6"), ParsedSemver.parse("1.2.3.4.5.7")) - < 0); - assertTrue( - ParsedSemver.compare( - ParsedSemver.parse("1.2.3.4.5.6.7"), ParsedSemver.parse("1.2.3.4.5.6.8")) - < 0); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("18.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("18.0.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); - } - - @Test - void testCompareSemverOrdering() { - for (int i = 0; i < ORDERED_VERSIONS.length; i++) { - final ParsedSemver left = ParsedSemver.parse(ORDERED_VERSIONS[i]); - assertTrue(left != null, "left parse failed: " + ORDERED_VERSIONS[i]); - for (int j = 0; j < ORDERED_VERSIONS.length; j++) { - final ParsedSemver right = ParsedSemver.parse(ORDERED_VERSIONS[j]); - assertTrue(right != null, "right parse failed: " + ORDERED_VERSIONS[j]); - final int ordering = ParsedSemver.compare(left, right); - if (i < j) { - assertTrue(ordering < 0, ORDERED_VERSIONS[i] + " should precede " + ORDERED_VERSIONS[j]); - } else if (i > j) { - assertTrue(ordering > 0, ORDERED_VERSIONS[i] + " should follow " + ORDERED_VERSIONS[j]); - } else { - assertEquals(0, ordering); - } - } - } - } - - @Test - void testCompareSemverArbitrarilyLargeNumericPrerelease() { - final ParsedSemver left = ParsedSemver.parse("1.0.0-99999999999999999999"); - assertTrue(left != null); - final ParsedSemver right = ParsedSemver.parse("1.0.0-100000000000000000000"); - assertTrue(right != null); - assertTrue(ParsedSemver.compare(left, right) < 0); - } - - @Test - void testValueObjectMethods() { - final ParsedSemver value = ParsedSemver.parse("1.2.3-alpha"); - final ParsedSemver equalValue = ParsedSemver.parse("1.2.3-alpha"); - final ParsedSemver release = ParsedSemver.parse("1.2.3"); - assertTrue(value != null); - assertTrue(equalValue != null); - assertTrue(release != null); - assertTrue(value.equals(value)); - assertTrue(value.equals(equalValue)); - assertFalse(value.equals(null)); - assertFalse(value.equals("1.2.3-alpha")); - assertEquals(value.hashCode(), equalValue.hashCode()); - assertEquals("1.2.3-alpha", value.toString()); - assertEquals("1.2.3", release.toString()); - } - - @Test - void testCompareSemverNumericPrereleaseIdentifiersLexicographically() { - final ParsedSemver left = ParsedSemver.parse("1.0.0-10"); - assertTrue(left != null); - final ParsedSemver right = ParsedSemver.parse("1.0.0-11"); - assertTrue(right != null); - assertTrue(ParsedSemver.compare(left, right) < 0); - } - - @Test - void testEqualsAndHashCodeNormalizeTrailingZeroComponents() { - // Versions that compare as equal must also be equals() and share a hashCode, even when they - // differ only by trailing zero components (1.2.3 vs 1.2.3.0 vs 1.2.3.0.0). - final ParsedSemver base = ParsedSemver.parse("1.2.3"); - final ParsedSemver trailingZero = ParsedSemver.parse("1.2.3.0"); - final ParsedSemver trailingZeros = ParsedSemver.parse("1.2.3.0.0"); - assertTrue(base != null); - assertTrue(trailingZero != null); - assertTrue(trailingZeros != null); - assertEquals(0, ParsedSemver.compare(base, trailingZero)); - assertEquals(0, ParsedSemver.compare(base, trailingZeros)); - assertEquals(base, trailingZero); - assertEquals(base, trailingZeros); - assertEquals(trailingZero, trailingZeros); - assertEquals(base.hashCode(), trailingZero.hashCode()); - assertEquals(base.hashCode(), trailingZeros.hashCode()); - assertEquals(trailingZero.hashCode(), trailingZeros.hashCode()); - - // All-zero versions of varying length are also compare-equal and must hash identically. - final ParsedSemver zero = ParsedSemver.parse("0"); - final ParsedSemver zeroZero = ParsedSemver.parse("0.0.0.0"); - assertTrue(zero != null); - assertTrue(zeroZero != null); - assertEquals(0, ParsedSemver.compare(zero, zeroZero)); - assertEquals(zero, zeroZero); - assertEquals(zero.hashCode(), zeroZero.hashCode()); - - // Distinct versions must remain unequal and may differ in hash. - final ParsedSemver release = ParsedSemver.parse("1.2.4"); - assertTrue(release != null); - assertNotEquals(base, release); - } - - @Test - void testCompareSemverBuildMetadataIsIgnored() { - final ParsedSemver left = ParsedSemver.parse("1.0.0+build.1"); - assertTrue(left != null); - final ParsedSemver right = ParsedSemver.parse("1.0.0+build.2"); - assertTrue(right != null); - assertEquals(0, ParsedSemver.compare(left, right)); - } -} diff --git a/products/feature-flagging/feature-flagging-config/gradle.lockfile b/products/feature-flagging/feature-flagging-config/gradle.lockfile index adf89247afa..58285597cfb 100644 --- a/products/feature-flagging/feature-flagging-config/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-config/gradle.lockfile @@ -26,7 +26,7 @@ org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath 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 diff --git a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java index d95088b20c9..785412e787d 100644 --- a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java +++ b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java @@ -1,45 +1,21 @@ package datadog.trace.api.featureflag.config; +/** + * The Feature Flags settings the agent needs: the product switch and the configuration source. The + * {@code dd-openfeature} SDK resolves them with the same semantics, see {@code + * ConfigurationResolutionParityTest} in the {@code dd-openfeature-connector} instrumentation. + */ public class FeatureFlaggingConfig { public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; public static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; - public static final String FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED = - "feature.flags.evaluation.telemetry.per.subject.opt.out.enabled"; public static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled"; - /** - * Opt-in gate for APM span enrichment with feature-flag evaluation metadata. DISTINCT from {@link - * #FEATURE_FLAGS_ENABLED} and OFF by default — enabling the provider does not enable span - * enrichment. - */ - public static final String EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED = - "experimental.flagging.provider.span.enrichment.enabled"; - - /** - * Killswitch for the EVP {@code flagevaluation} emission path. Default: enabled. Disabling it - * turns off EVP flag-evaluation counts while leaving the OTel {@code feature_flag.evaluations} - * metric path untouched. Maps to {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED}. - */ - public static final String FLAGGING_EVALUATION_COUNTS_ENABLED = - "flagging.evaluation.counts.enabled"; - - /** - * Turns off sending exposures to Datadog while customer exposure hooks keep receiving them. - * Default: enabled. Maps to {@code DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED}. - */ - public static final String FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED = - "feature.flags.exposures.datadog.logging.enabled"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = "feature.flags.configuration.source"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = - "feature.flags.configuration.source.agentless.base.url"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = - "feature.flags.configuration.source.agentless.poll.interval.seconds"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS = - "feature.flags.configuration.source.agentless.request.timeout.seconds"; + + private FeatureFlaggingConfig() {} } diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 49a37d50b66..5539b495e70 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -2,47 +2,23 @@ plugins { `java-library` id("dd-trace-java.version-file") id("dd-trace-java.module.product-library") - id("me.champeau.jmh") } -description = "Feature flagging remote config and exposure handling" - -extra["excludedClassesCoverage"] = listOf( - // POJOs - "com.datadog.featureflag.ExposureCache.Key", - "com.datadog.featureflag.ExposureCache.Value" -) +description = "Feature flagging agent backend: remote config, event proxy and span enrichment" dependencies { api(libs.slf4j) - api(libs.moshi) - api(libs.jctools) api(project(":communication")) implementation(project(":internal-api")) api(project(":products:feature-flagging:feature-flagging-bootstrap")) compileOnly(project(":products:feature-flagging:feature-flagging-config")) - 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(libs.okhttp3.mockwebserver) testImplementation(project(":products:feature-flagging:feature-flagging-config")) testImplementation(project(":utils:test-utils")) testImplementation(project(":dd-java-agent:testing")) } - -jmh { - jmhVersion = libs.versions.jmh.get() - duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE - if (project.hasProperty("jmhIncludes")) { - includes = listOf(project.property("jmhIncludes").toString()) - } - if (project.hasProperty("jmhProf")) { - profilers = listOf(project.property("jmhProf").toString()) - } -} diff --git a/products/feature-flagging/feature-flagging-lib/gradle.lockfile b/products/feature-flagging/feature-flagging-lib/gradle.lockfile index b9ace1f0388..79a810ea0c1 100644 --- a/products/feature-flagging/feature-flagging-lib/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-lib/gradle.lockfile @@ -36,7 +36,6 @@ com.google.j2objc:j2objc-annotations:3.1=jmhRuntimeClasspath,testCompileClasspat com.google.re2j:re2j:1.8=jmhRuntimeClasspath,testRuntimeClasspath com.squareup.moshi:moshi:1.11.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.squareup.okhttp3:logging-interceptor:3.12.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.squareup.okhttp3:mockwebserver:3.12.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.squareup.okhttp3:okhttp:3.12.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.squareup.okio:okio:1.17.5=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.thoughtworks.qdox:qdox:1.12.1=codenarc @@ -48,7 +47,6 @@ io.leangen.geantyref:geantyref:1.3.16=jmhRuntimeClasspath,testRuntimeClasspath io.sqreen:libsqreen:17.5.0=jmhRuntimeClasspath,testRuntimeClasspath javax.servlet:javax.servlet-api:3.1.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jaxen:jaxen:2.0.6=spotbugs -junit:junit:4.12=testCompileClasspath junit:junit:4.13.2=jmhRuntimeClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.18.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.18.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -64,7 +62,7 @@ org.apache.commons:commons-math3:3.6.1=jmh,jmhCompileClasspath,jmhRuntimeClasspa org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath 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 @@ -77,14 +75,14 @@ org.codehaus.groovy:groovy:3.0.25=jmhRuntimeClasspath,testCompileClasspath,testR org.codenarc:CodeNarc:3.7.0=codenarc org.dom4j:dom4j:2.2.0=spotbugs org.gmetrics:GMetrics:2.1.0=codenarc -org.hamcrest:hamcrest-core:1.3=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=jmhRuntimeClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=jmhRuntimeClasspath,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.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.jctools:jctools-core-jdk11:4.0.6=jmhCompileClasspath,jmhRuntimeClasspath,testRuntimeClasspath +org.jctools:jctools-core:4.0.6=jmhCompileClasspath,jmhRuntimeClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:5.14.1=jmhRuntimeClasspath,testRuntimeClasspath diff --git a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java b/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java deleted file mode 100644 index bbec5d65229..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.communication.BackendApiFactory; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.intake.Intake; -import de.thetaphi.forbiddenapis.SuppressForbidden; -import java.util.HashMap; -import java.util.Map; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Group; -import org.openjdk.jmh.annotations.GroupThreads; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.Warmup; -import org.openjdk.jmh.infra.Blackhole; - -/** - * Producer-contention benchmark for {@link FlagEvaluationWriterImpl#enqueue}. - * - *

Every flag evaluation in every application thread calls {@code enqueue}, so producer-side - * scaling is the property that matters. {@link FlagEvaluationHotPathBenchmark#writerEnqueue} - * measures the single-threaded cost and cannot see contention; this benchmark measures how enqueue - * behaves as producer count grows. - * - *

Why groups rather than {@code @Threads}: the hand-off queue is a JCTools - * {@code mpscBlockingConsumerArrayQueue} - multi-producer, single-consumer. Calling {@code - * poll()} from several threads breaks that contract, so producer threads cannot drain their own - * events. Each group therefore pairs N producers with exactly one consumer. - * - *

Why the consumer drains in batches: with more producers than consumers the - * queue would otherwise saturate, after which {@code offer} fast-fails and the measurement turns - * into {@code AtomicLong} contention on the overflow counter instead of the enqueue path. The - * consumer polls up to {@link #DRAIN_BATCH} events per invocation to keep the queue off its - * capacity limit. Overflow is reported at the end of each iteration so a saturated - and therefore - * invalid - run is visible rather than silent. - * - *

Run: {@code ./gradlew :products:feature-flagging:feature-flagging-lib:jmh - * -PjmhIncludes=FlagEvaluationEnqueueContentionBenchmark}. - */ -@State(Scope.Benchmark) -@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) -@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(NANOSECONDS) -@Fork(value = 1) -public class FlagEvaluationEnqueueContentionBenchmark { - - /** Events the single consumer drains per invocation, so it can keep up with N producers. */ - static final int DRAIN_BATCH = 32; - - private static final int NUM_FLAGS = 100; - private static final int NUM_USERS = 50; - private static final int NUM_FIELDS = 10; - - private Map attrs; - private String[] flagKeys; - private String[] targetingKeys; - private FlagEvaluationWriterImpl writer; - - /** Per-producer cursor: a shared counter would add its own cache-line contention. */ - @State(Scope.Thread) - public static class ProducerCursor { - int cursor; - } - - @Setup(Level.Iteration) - public void setUp() { - // enqueue() no-ops unless the gateway gate is on; set it explicitly so the benchmark is not - // silently measuring an early return. - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - - attrs = new HashMap<>(); - for (int i = 0; i < NUM_FIELDS; i++) { - attrs.put("field" + i, "value"); - } - flagKeys = keys("bench-flag-", NUM_FLAGS); - targetingKeys = keys("bench-user-", NUM_USERS); - - final Config config = Config.get(); - final BackendApiFactory factory = new BackendApiFactory(config, null); - // Capacity well above what the batch-draining consumer should ever let build up. - writer = - new FlagEvaluationWriterImpl( - 1 << 20, - Long.MAX_VALUE, - NANOSECONDS, - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), - config); - } - - /** - * Reports queue overflow so a saturated run - where the consumer failed to keep up and the - * numbers no longer describe the enqueue path - is visible in the benchmark output. - */ - @org.openjdk.jmh.annotations.TearDown(Level.Iteration) - @SuppressForbidden - public void reportOverflow() { - final long dropped = writer.droppedQueueOverflow(); - if (dropped > 0) { - System.out.println( - "\nWARNING: queue overflowed " - + dropped - + " times - consumer could not keep up, enqueue timings for this iteration are" - + " measuring overflow accounting, not the enqueue path."); - } - } - - // ---- 1 producer: uncontended baseline ---- - - @Benchmark - @Group("producers1") - @GroupThreads(1) - public void enqueue1(final ProducerCursor c) { - writer.enqueue(nextEvent(c)); - } - - @Benchmark - @Group("producers1") - @GroupThreads(1) - public void drain1(final Blackhole blackhole) { - drain(blackhole); - } - - // ---- 4 producers ---- - - @Benchmark - @Group("producers4") - @GroupThreads(4) - public void enqueue4(final ProducerCursor c) { - writer.enqueue(nextEvent(c)); - } - - @Benchmark - @Group("producers4") - @GroupThreads(1) - public void drain4(final Blackhole blackhole) { - drain(blackhole); - } - - // ---- 16 producers ---- - - @Benchmark - @Group("producers16") - @GroupThreads(16) - public void enqueue16(final ProducerCursor c) { - writer.enqueue(nextEvent(c)); - } - - @Benchmark - @Group("producers16") - @GroupThreads(1) - public void drain16(final Blackhole blackhole) { - drain(blackhole); - } - - private void drain(final Blackhole blackhole) { - for (int i = 0; i < DRAIN_BATCH; i++) { - final FlagEvalEvent event = writer.pollQueuedEventForTest(); - if (event == null) { - break; - } - blackhole.consume(event); - } - } - - private FlagEvalEvent nextEvent(final ProducerCursor c) { - final int i = c.cursor++; - return new FlagEvalEvent( - flagKeys[Math.floorMod(i, flagKeys.length)], - "variant-" + Math.floorMod(i, 4), - "alloc-" + Math.floorMod(i, flagKeys.length), - targetingKeys[Math.floorMod(i, targetingKeys.length)], - null, - 1_700_000_000_000L + i, - attrs); - } - - private static String[] keys(final String prefix, final int count) { - final String[] out = new String[count]; - for (int i = 0; i < count; i++) { - out[i] = prefix + i; - } - return out; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java b/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java deleted file mode 100644 index fbd467b88d2..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.communication.BackendApiFactory; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.intake.Intake; -import java.util.HashMap; -import java.util.Map; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Param; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.Warmup; -import org.openjdk.jmh.infra.Blackhole; - -/** - * Writer-side benchmark for EVP {@code flagevaluation} recording: the bounded-queue enqueue and the - * worker-thread aggregation of already-captured events. - * - *

Scope limitation: this benchmark starts from a pre-built FlagEvalEvent - * holding a ready-made flat attribute map. It therefore does NOT cover the OpenFeature hook's own - * inline capture cost - in particular the bounded evaluation-context copy that - * FlagEvalLoggingHook.finallyAfter performs synchronously on the evaluation thread under - * consent-on. That cost is measured separately by FlagEvalHookHotPathBenchmark in - * feature-flagging-api, where the hook and the OpenFeature context types live. Do not read - * writerEnqueue as the total per-evaluation cost paid by the caller. - * - *

Events are built with observeFullEvaluationData=true so the aggregator actually canonicalizes - * the context. Under the consent-off default it drops attrs and skips canonicalization, which makes - * every field-count profile measure the same scalar-only work. - * - *

Run: {@code ./gradlew :products:feature-flagging:feature-flagging-lib:jmh - * -PjmhIncludes=FlagEvaluationHotPathBenchmark}. - */ -@State(Scope.Benchmark) -@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) -@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(NANOSECONDS) -@Fork(value = 1) -public class FlagEvaluationHotPathBenchmark { - - @Param({ - "typical/100flags_50users_10fields", - "stress/10flags_1000users_250fields", - "scale/2500flags_500users_20fields" - }) - public String profile; - - private Map attrs; - private String[] flagKeys; - private String[] targetingKeys; - private int cursor; - private FlagEvaluationWriterImpl writer; - private FlagEvaluationWriterImpl.SerializingHandlerForTest handler; - - @Setup(Level.Iteration) - public void setUp() { - final Profile p = Profile.fromName(profile); - - attrs = new HashMap<>(); - for (int i = 0; i < p.numFields; i++) { - attrs.put("field" + i, "value"); - } - flagKeys = keys("bench-flag-", p.numFlags); - targetingKeys = keys("bench-user-", p.numUsers); - cursor = 0; - - final Config config = Config.get(); - final BackendApiFactory factory = new BackendApiFactory(config, null); - final Map ddContext = new HashMap<>(); - ddContext.put("service", "bench-service"); - handler = - FlagEvaluationWriterImpl.createHandlerForTest( - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), ddContext); - - // Capacity large enough that the benchmark never overflows within a measurement window. - writer = - new FlagEvaluationWriterImpl( - 1 << 20, - Long.MAX_VALUE, - NANOSECONDS, - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), - config); - } - - /** - * Queue-mechanics cost only: event allocation plus the non-blocking bounded-queue offer. Excludes - * the hook's inline context snapshot - see the class javadoc. - */ - @Benchmark - public void writerEnqueue(final Blackhole blackhole) { - final FlagEvalEvent event = nextEvent(); - writer.enqueue(event); - blackhole.consume(writer.pollQueuedEventForTest()); - blackhole.consume(event); - } - - /** Worker-thread cost: materialize context, prune, canonicalize, and aggregate. */ - @Benchmark - public void workerAggregate(final Blackhole blackhole) { - final FlagEvalEvent event = nextEvent(); - handler.aggregateEvent(event); - if ((cursor % 10_000) == 0) { - handler.clearAggregationForTest(); - } - blackhole.consume(handler.fullTierSizeForTest()); - } - - private FlagEvalEvent nextEvent() { - final int i = cursor++; - // observeFullEvaluationData=true is required for this benchmark to mean anything: under the - // consent-off default the aggregator drops attrs and skips canonicalContextKey entirely, so - // every field-count profile would collapse to the same scalar-only cost. - return new FlagEvalEvent( - flagKeys[Math.floorMod(i, flagKeys.length)], - "variant-" + Math.floorMod(i, 4), - "alloc-" + Math.floorMod(i, flagKeys.length), - targetingKeys[Math.floorMod(i, targetingKeys.length)], - null, - 1_700_000_000_000L + i, - true, - attrs); - } - - private static String[] keys(final String prefix, final int count) { - final String[] out = new String[count]; - for (int i = 0; i < count; i++) { - out[i] = prefix + i; - } - return out; - } - - private static final class Profile { - private final int numFlags; - private final int numUsers; - private final int numFields; - - private Profile(final int numFlags, final int numUsers, final int numFields) { - this.numFlags = numFlags; - this.numUsers = numUsers; - this.numFields = numFields; - } - - private static Profile fromName(final String name) { - if ("typical/100flags_50users_10fields".equals(name)) { - return new Profile(100, 50, 10); - } - if ("stress/10flags_1000users_250fields".equals(name)) { - return new Profile(10, 1_000, 250); - } - if ("scale/2500flags_500users_20fields".equals(name)) { - return new Profile(2_500, 500, 20); - } - throw new IllegalArgumentException("unknown benchmark profile: " + name); - } - } -} 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/AgentlessFeatureFlagBackendApi.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java deleted file mode 100644 index 59919840ffe..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.datadog.featureflag; - -import datadog.communication.BackendApi; -import datadog.communication.HttpResponseException; -import datadog.communication.http.OkHttpUtils; -import datadog.communication.util.IOThrowingFunction; -import java.io.IOException; -import java.io.InputStream; -import java.net.ConnectException; -import java.util.function.Function; -import java.util.function.Supplier; -import javax.annotation.Nullable; -import okhttp3.RequestBody; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Sends Feature Flag events through the process-wide Agentless EVP route selector. */ -final class AgentlessFeatureFlagBackendApi implements BackendApi { - - private static final Logger LOGGER = - LoggerFactory.getLogger(AgentlessFeatureFlagBackendApi.class); - - private final FeatureFlagRouteSelector routeSelector; - private final Supplier proxyEndpointSupplier; - private final Function proxyApiFactory; - private final Supplier directApiSupplier; - private final String eventType; - private BackendApi proxyApi; - private FeatureFlagRouteSelector.LocalRoute proxyRoute; - private volatile BackendApi directApi; - private volatile boolean directApiCreationAttempted; - - AgentlessFeatureFlagBackendApi( - @Nullable final BackendApi directApi, - final Supplier proxyEndpointSupplier, - final Function proxyApiFactory, - final Supplier directApiSupplier, - final String eventType, - final FeatureFlagRouteSelector routeSelector) { - this.directApi = directApi; - this.proxyEndpointSupplier = proxyEndpointSupplier; - this.proxyApiFactory = proxyApiFactory; - this.directApiSupplier = directApiSupplier; - this.eventType = eventType; - this.routeSelector = routeSelector; - this.directApiCreationAttempted = directApi != null; - } - - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) - throws IOException { - final SelectedApi selected = selectApi(); - try { - return selected.api.post( - uri, requestBody, responseParser, requestListener, requestCompression); - } catch (final IOException exception) { - if (selected.localRoute == null) { - throw exception; - } - - final BackendApi fallbackApi = getOrCreateDirectApi(); - // Route selection governs future batches; replay permission applies only to this batch. - // DIRECT is intentionally terminal, even for a transient or ambiguous local failure. - routeSelector.localFailure(selected.localRoute, fallbackApi != null); - if (fallbackApi == null || !isSafeToReplayDirectly(exception)) { - throw exception; - } - return fallbackApi.post( - uri, requestBody, responseParser, requestListener, requestCompression); - } - } - - private SelectedApi selectApi() throws IOException { - if (routeSelector.tryBeginLocalRecovery()) { - String endpoint = null; - try { - endpoint = discoverProxyEndpoint(); - } finally { - routeSelector.localRecoveryFinished(endpoint); - } - } - - final FeatureFlagRouteSelector.LocalRoute localRoute = routeSelector.localRoute(); - if (localRoute != null) { - return new SelectedApi(getOrCreateProxyApi(localRoute), localRoute); - } - - if (routeSelector.current() == FeatureFlagRouteSelector.Route.DIRECT) { - final BackendApi selectedDirectApi = getOrCreateDirectApi(); - if (selectedDirectApi != null) { - return new SelectedApi(selectedDirectApi, null); - } - } - throw unavailableRoute(); - } - - @Nullable - private String discoverProxyEndpoint() { - try { - return proxyEndpointSupplier.get(); - } catch (final RuntimeException exception) { - LOGGER.debug("Could not discover the local Feature Flagging {} route", eventType, exception); - return null; - } - } - - private synchronized BackendApi getOrCreateProxyApi( - final FeatureFlagRouteSelector.LocalRoute localRoute) { - if (proxyRoute != localRoute) { - // Client construction uses the shared validated endpoint; it performs no discovery. - proxyApi = proxyApiFactory.apply(localRoute.endpoint); - proxyRoute = localRoute; - } - return proxyApi; - } - - @Nullable - private BackendApi getOrCreateDirectApi() { - if (directApiCreationAttempted) { - return directApi; - } - synchronized (this) { - if (!directApiCreationAttempted) { - directApi = directApiSupplier.get(); - directApiCreationAttempted = true; - } - return directApi; - } - } - - private IOException unavailableRoute() { - return new UnavailableRouteException(eventType); - } - - /** Expected drop while the shared selector waits for a usable route. */ - static final class UnavailableRouteException extends IOException { - private UnavailableRouteException(final String eventType) { - super("No Feature Flagging " + eventType + " delivery route is available"); - } - } - - private static boolean isSafeToReplayDirectly(final IOException exception) { - if (exception instanceof ConnectException) { - return true; - } - if (exception instanceof HttpResponseException) { - final int statusCode = ((HttpResponseException) exception).getStatusCode(); - return statusCode == 404 || statusCode == 405; - } - return false; - } - - private static final class SelectedApi { - private final BackendApi api; - private final FeatureFlagRouteSelector.LocalRoute localRoute; - - private SelectedApi( - final BackendApi api, @Nullable final FeatureFlagRouteSelector.LocalRoute localRoute) { - this.api = api; - this.localRoute = localRoute; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java deleted file mode 100644 index 83495545717..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.datadog.featureflag; - -import java.io.Closeable; - -public interface ConfigurationSourceService extends Closeable { - - void init(); - - void close(); -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java new file mode 100644 index 00000000000..ccc28f1e858 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java @@ -0,0 +1,104 @@ +package com.datadog.featureflag; + +import static com.datadog.featureflag.FeatureFlagEventType.EXPOSURE; +import static com.datadog.featureflag.FeatureFlagEventType.FLAG_EVALUATION; +import static datadog.communication.http.HttpRetryPolicy.Factory.NEVER_RETRY; +import static datadog.trace.api.intake.Intake.EVENT_PLATFORM; + +import datadog.communication.BackendApi; +import datadog.communication.BackendApiFactory; +import datadog.communication.HttpResponseException; +import java.io.IOException; +import java.net.ConnectException; +import javax.annotation.Nullable; +import okhttp3.MediaType; +import okhttp3.RequestBody; + +/** Posts Feature Flags events through the Datadog Agent event platform proxy. */ +class EventProxy { + private static final MediaType JSON = MediaType.parse("application/json"); + static final String EXPOSURES_ROUTE = "exposures"; + + private final BackendApiFactory backendApiFactory; + private final boolean directFallbackAvailable; + private volatile BackendApi exposures; + private volatile BackendApi flagEvaluations; + private volatile boolean resolved; + + /** + * @param backendApiFactory the factory of event platform proxy clients. + * @param directFallbackAvailable whether the SDK falls back to direct intake when the proxy is + * unavailable, in which case the proxy must not retry what the intake would accept. + */ + EventProxy(final BackendApiFactory backendApiFactory, final boolean directFallbackAvailable) { + this.backendApiFactory = backendApiFactory; + this.directFallbackAvailable = directFallbackAvailable; + } + + /** + * @return whether the Datadog Agent supports the event platform proxy. + */ + boolean isAvailable() { + resolve(); + return this.exposures != null; + } + + /** + * Posts a payload. + * + * @param route the event platform route. + * @param json the UTF-8 JSON payload. + * @return {@code true} if delivered, {@code false} if the proxy is definitively unavailable. + * @throws IOException if the payload could not be delivered. + */ + boolean post(final String route, final byte[] json) throws IOException { + resolve(); + final BackendApi api = EXPOSURES_ROUTE.equals(route) ? this.exposures : this.flagEvaluations; + if (api == null) { + return false; + } + try { + api.post(route, RequestBody.create(JSON, json), stream -> null, null, false); + return true; + } catch (final IOException e) { + if (isDefinitiveRejection(e)) { + return false; + } + throw e; + } + } + + private void resolve() { + if (this.resolved) { + return; + } + synchronized (this) { + if (this.resolved) { + return; + } + this.exposures = create(EXPOSURE); + this.flagEvaluations = create(FLAG_EVALUATION); + this.resolved = true; + } + } + + @Nullable + private BackendApi create(final FeatureFlagEventType eventType) { + return this.directFallbackAvailable + ? this.backendApiFactory.createEvpProxyApi( + EVENT_PLATFORM, eventType.responseCompressionEnabled(), NEVER_RETRY) + : this.backendApiFactory.createEvpProxyApi( + EVENT_PLATFORM, eventType.responseCompressionEnabled()); + } + + static boolean isDefinitiveRejection(final IOException exception) { + if (exception instanceof ConnectException) { + return true; + } + if (exception instanceof HttpResponseException) { + final int statusCode = ((HttpResponseException) exception).getStatusCode(); + return statusCode == 403 || statusCode == 404 || statusCode == 405; + } + return false; + } +} 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 7125d009a69..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java +++ /dev/null @@ -1,65 +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 final Integer serialId; - - public Value(final ExposureEvent event) { - this.variant = event.variant == null ? null : event.variant.key; - this.allocation = event.allocation == null ? null : event.allocation.key; - this.serialId = event.serial_id; - } - - @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) - && Objects.equals(serialId, value.serialId); - } - - @Override - public int hashCode() { - return Objects.hash(variant, allocation, serialId); - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java deleted file mode 100644 index 4c9fa80ea0d..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.datadog.featureflag; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway.ExposureListener; - -/** - * Defines an exposure writer responsible for sending exposure events to the EVP proxy. - * Implementations should use a background thread to perform these operations asynchronously. - */ -public interface ExposureWriter extends AutoCloseable, ExposureListener { - - void init(); - - void close(); -} 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 deleted file mode 100644 index 0584e5701ad..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ /dev/null @@ -1,316 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.api.telemetry.LogCollector.EXCLUDE_TELEMETRY; -import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_EXPOSURE_PROCESSOR; -import static datadog.trace.util.AgentThreadFactory.THREAD_JOIN_TIMOUT_MS; -import static datadog.trace.util.AgentThreadFactory.newAgentThread; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.common.queue.MessagePassingBlockingQueue; -import datadog.common.queue.Queues; -import datadog.communication.BackendApi; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.ExposuresRequest; -import datadog.trace.api.internal.VisibleForTesting; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ExposureWriterImpl implements ExposureWriter, FeatureFlagEventWriter { - - private static final Logger LOGGER = LoggerFactory.getLogger(ExposureWriterImpl.class); - private static final int DEFAULT_CAPACITY = 1 << 16; // 65536 elements - private static final int DEFAULT_FLUSH_INTERVAL_IN_SECONDS = 1; - private static final int FLUSH_THRESHOLD = 100; - private static final String EXPOSURES_ROUTE = "exposures"; - static final long SHUTDOWN_TIMEOUT_MILLIS = THREAD_JOIN_TIMOUT_MS; - - private final MessagePassingBlockingQueue queue; - private final ExposureSerializingHandler serializer; - private final Thread serializerThread; - private final Object lifecycleLock = new Object(); - private final AtomicBoolean closed = new AtomicBoolean(false); - private final long shutdownTimeoutMillis; - - ExposureWriterImpl( - final SharedCommunicationObjects sco, - final Config config, - final FeatureFlagRouteSelector routeSelector) { - this( - DEFAULT_CAPACITY, - DEFAULT_FLUSH_INTERVAL_IN_SECONDS, - SECONDS, - new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.EXPOSURE, routeSelector), - config); - } - - ExposureWriterImpl( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final SharedCommunicationObjects sco, - final Config config) { - this( - capacity, - flushInterval, - timeUnit, - new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.EXPOSURE), - config); - } - - ExposureWriterImpl( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final FeatureFlagBackendApiFactory backendApiFactory, - final Config config) { - this( - capacity, - flushInterval, - timeUnit, - backendApiFactory::create, - config, - SHUTDOWN_TIMEOUT_MILLIS); - } - - ExposureWriterImpl( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final Supplier backendApiSupplier, - final Config config, - final long shutdownTimeoutMillis) { - this( - Queues.mpscBlockingConsumerArrayQueue(capacity), - flushInterval, - timeUnit, - backendApiSupplier, - config, - shutdownTimeoutMillis); - } - - @VisibleForTesting - ExposureWriterImpl( - final MessagePassingBlockingQueue queue, - final long flushInterval, - final TimeUnit timeUnit, - final Supplier backendApiSupplier, - final Config config, - final long shutdownTimeoutMillis) { - this.queue = queue; - this.serializer = - new ExposureSerializingHandler( - backendApiSupplier, - queue, - flushInterval, - timeUnit, - FeatureFlagEvpContext.from(config), - this::close); - this.serializerThread = newAgentThread(FEATURE_FLAG_EXPOSURE_PROCESSOR, serializer); - this.shutdownTimeoutMillis = shutdownTimeoutMillis; - } - - @Override - public void init() { - synchronized (lifecycleLock) { - if (closed.get()) { - return; - } - FeatureFlaggingGateway.addExposureListener(this); - this.serializerThread.start(); - } - } - - @Override - public void close() { - final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(shutdownTimeoutMillis); - requestShutdown(); - awaitTermination(deadline); - } - - @Override - public void requestShutdown() { - synchronized (lifecycleLock) { - if (!closed.compareAndSet(false, true)) { - return; - } - // Stop accepting new dispatches without waiting for producers already inside accept(). - FeatureFlaggingGateway.removeExposureListener(this); - // Polling wakes within 100 ms. An interrupt can race with, or abort, the final POST. - serializer.requestShutdown(); - } - } - - @Override - public void awaitTermination(final long deadlineNanos) { - FeatureFlagEventWriter.awaitThread(serializerThread, deadlineNanos); - } - - @Override - public void accept(final ExposureEvent event) { - // Keep producers lock-free, like flag evaluations. Shutdown is best effort: an offer that - // passes this check can race past the final drain and be dropped. Stale gateway dispatches - // entering after shutdown starts are rejected without extending the shutdown wait. - if (!closed.get()) { - queue.offer(event); - } - } - - @VisibleForTesting - boolean isSerializerThreadAlive() { - return serializerThread.isAlive(); - } - - @VisibleForTesting - int queueSize() { - return queue.size(); - } - - private static class ExposureSerializingHandler implements Runnable { - private final MessagePassingBlockingQueue queue; - private final long ticksRequiredToFlush; - private long lastTicks; - - private final FeatureFlagEvpPublisher evpPublisher; - private final Map context; - private final ExposureCache cache; - - private final List buffer = new ArrayList<>(); - private final Runnable errorCallback; - private final AtomicBoolean shutdownRequested = new AtomicBoolean(false); - - ExposureSerializingHandler( - final Supplier backendApiSupplier, - final MessagePassingBlockingQueue queue, - final long flushInterval, - final TimeUnit timeUnit, - final Map context, - final Runnable errorCallback) { - this.queue = queue; - this.cache = new LRUExposureCache(queue.capacity()); - this.evpPublisher = new FeatureFlagEvpPublisher<>(backendApiSupplier, ExposuresRequest.class); - this.context = context; - - this.lastTicks = System.nanoTime(); - this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); - - this.errorCallback = errorCallback; - - LOGGER.debug("starting exposure serializer"); - } - - void requestShutdown() { - shutdownRequested.set(true); - } - - @Override - public void run() { - if (!evpPublisher.start()) { - errorCallback.run(); - LOGGER.warn("Feature Flagging exposure delivery is disabled"); - return; - } - try { - runDutyCycle(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - // Preserve an unrelated interrupt without passing it into OkHttp's final best-effort send. - final boolean wasInterrupted = Thread.interrupted(); - try { - drainAndFlush(); - } finally { - if (wasInterrupted) { - Thread.currentThread().interrupt(); - } - } - } - LOGGER.debug("exposure processor worker exited. submitting exposures stopped."); - } - - private void runDutyCycle() throws InterruptedException { - final Thread thread = Thread.currentThread(); - while (!thread.isInterrupted() && !shutdownRequested.get()) { - ExposureEvent event; - while ((event = queue.poll(100, TimeUnit.MILLISECONDS)) != null) { - if (addToBuffer(event)) { - consumeBatch(); - break; - } - } - flushIfNecessary(); - } - } - - private void consumeBatch() { - queue.drain(this::addToBuffer, queue.size()); - } - - private void drainAndFlush() { - ExposureEvent event; - while ((event = queue.poll()) != null) { - addToBuffer(event); - } - flush(); - } - - /** 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)) { - buffer.add(event); - return true; - } - return false; - } - - protected void flushIfNecessary() { - if (!buffer.isEmpty() && shouldFlush()) { - flush(); - } - } - - private void flush() { - if (buffer.isEmpty()) { - return; - } - final byte[] payload; - try { - final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); - payload = evpPublisher.serialize(exposures); - } catch (RuntimeException e) { - LOGGER.error(EXCLUDE_TELEMETRY, "Could not serialize exposures; dropping batch", e); - this.buffer.clear(); - return; - } - try { - evpPublisher.post(EXPOSURES_ROUTE, payload); - } catch (AgentlessFeatureFlagBackendApi.UnavailableRouteException ignored) { - // The shared selector reports unavailable/recovered transitions once for both writers. - } catch (Exception e) { - LOGGER.debug("Could not submit exposures", e); - } finally { - // Best-effort delivery must not retry an ambiguously accepted batch. A later definitive - // proxy rejection could otherwise replay the same exposures through direct intake. - this.buffer.clear(); - } - } - - private boolean shouldFlush() { - long nanoTime = System.nanoTime(); - long ticks = nanoTime - lastTicks; - if (ticks > ticksRequiredToFlush || buffer.size() >= FLUSH_THRESHOLD) { - lastTicks = nanoTime; - return true; - } - return false; - } - } -} 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 deleted file mode 100644 index d4f3c8c499a..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.communication.EvpProxy.JAVA_TRACING_LIBRARY; -import static datadog.communication.EvpProxy.ORIGIN_HEADER; -import static datadog.communication.EvpProxy.ORIGIN_VERSION_HEADER; -import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static java.util.Arrays.asList; -import static java.util.Collections.unmodifiableMap; - -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.communication.ddagent.TracerVersion; -import datadog.communication.http.HttpRetryPolicy; -import datadog.trace.api.Config; -import datadog.trace.api.intake.Intake; -import java.net.ConnectException; -import java.util.HashMap; -import java.util.Map; -import javax.annotation.Nullable; -import okhttp3.Response; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Selects the transport for Feature Flagging events. */ -final class FeatureFlagBackendApiFactory { - - private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagBackendApiFactory.class); - private static final Map REQUEST_HEADERS = requestHeaders(); - - /** Retries connection refusal only; automatic retries and redirects remain disabled. */ - static final HttpRetryPolicy.Factory PRE_SEND_RETRY = - new HttpRetryPolicy.Factory(3, 100, 2.0) { - @Override - public HttpRetryPolicy create() { - return new PreSendRetryPolicy(); - } - }; - - private static final class PreSendRetryPolicy extends HttpRetryPolicy { - private int attemptsLeft = 3; - - private PreSendRetryPolicy() { - super(3, 100, 2.0, false); - } - - @Override - public boolean shouldRetry(final Exception exception) { - if (exception instanceof ConnectException && attemptsLeft > 0) { - attemptsLeft--; - return true; - } - return false; - } - - @Override - public boolean shouldRetry(@Nullable final Response response) { - return false; - } - } - - private final Config config; - private final BackendApiFactory backendApiFactory; - private final FeatureFlagEventType eventType; - private final FeatureFlagRouteSelector routeSelector; - - FeatureFlagBackendApiFactory( - final Config config, - final SharedCommunicationObjects sharedCommunicationObjects, - final FeatureFlagEventType eventType) { - this(config, sharedCommunicationObjects, eventType, new FeatureFlagRouteSelector()); - } - - FeatureFlagBackendApiFactory( - final Config config, - final SharedCommunicationObjects sharedCommunicationObjects, - final FeatureFlagEventType eventType, - final FeatureFlagRouteSelector routeSelector) { - this( - config, - initializedBackendFactory(config, sharedCommunicationObjects), - eventType, - routeSelector); - } - - private static BackendApiFactory initializedBackendFactory( - final Config config, final SharedCommunicationObjects sharedCommunicationObjects) { - if (CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()) - && config.getApiKey() != null - && !config.getApiKey().isEmpty()) { - // Direct intake is constructed before Agent discovery. Initialize settings first so its - // cached HTTP client uses configured timeouts even when Feature Flagging starts first. - sharedCommunicationObjects.createRemaining(config); - } - return new BackendApiFactory(config, sharedCommunicationObjects, REQUEST_HEADERS, true); - } - - FeatureFlagBackendApiFactory( - final Config config, - final BackendApiFactory backendApiFactory, - final FeatureFlagEventType eventType) { - this(config, backendApiFactory, eventType, new FeatureFlagRouteSelector()); - } - - FeatureFlagBackendApiFactory( - final Config config, - final BackendApiFactory backendApiFactory, - final FeatureFlagEventType eventType, - final FeatureFlagRouteSelector routeSelector) { - this.config = config; - this.backendApiFactory = backendApiFactory; - this.eventType = eventType; - this.routeSelector = routeSelector; - } - - @Nullable - BackendApi create() { - final boolean agentless = - CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()); - if (!agentless) { - // Remote Config stays Agent-only. Fixed v2 intentionally replaces Java's previous - // discovered endpoint; only connection refusal retains a bounded retry. - return backendApiFactory.createEvpProxyApiForEndpoint( - Intake.EVENT_PLATFORM, - eventType.responseCompressionEnabled(), - PRE_SEND_RETRY, - V2_EVP_PROXY_ENDPOINT); - } - - final BackendApi directApi = createDirectApi(); - routeSelector.initialize(() -> discoverProxyEndpoint(false), directApi != null); - return new AgentlessFeatureFlagBackendApi( - directApi, - () -> discoverProxyEndpoint(true), - endpoint -> - backendApiFactory.createEvpProxyApiForEndpoint( - Intake.EVENT_PLATFORM, - eventType.responseCompressionEnabled(), - HttpRetryPolicy.Factory.NEVER_RETRY, - endpoint), - this::createDirectApi, - eventType.logName(), - routeSelector); - } - - @Nullable - private String discoverProxyEndpoint(final boolean forceDiscovery) { - try { - // A local route must preserve the identity headers that this sender emits on every batch. - return backendApiFactory.discoverEvpProxyEndpoint( - forceDiscovery, asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER)); - } catch (final RuntimeException exception) { - LOGGER.debug("Could not discover the local Feature Flagging route", exception); - return null; - } - } - - private static Map requestHeaders() { - final Map headers = new HashMap<>(2); - headers.put(ORIGIN_HEADER, JAVA_TRACING_LIBRARY); - headers.put(ORIGIN_VERSION_HEADER, TracerVersion.TRACER_VERSION); - return unmodifiableMap(headers); - } - - private boolean hasDirectCredentials() { - final String apiKey = config.getApiKey(); - return apiKey != null && !apiKey.isEmpty(); - } - - @Nullable - private BackendApi createDirectApi() { - if (!hasDirectCredentials()) { - return null; - } - try { - return backendApiFactory.createDirectIntakeApi( - Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled(), false); - } catch (final IllegalArgumentException exception) { - LOGGER.debug( - "Cannot configure direct Feature Flagging {} delivery", eventType.logName(), exception); - return null; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java index dee8b424fa3..8c7ce73d8b4 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java @@ -3,23 +3,17 @@ /** Defines event-specific transport behavior for Feature Flag delivery. */ enum FeatureFlagEventType { // Keep the established exposure transport behavior for compatibility. - EXPOSURE("exposure", true), + EXPOSURE(true), // Flag evaluation writers ignore successful response bodies, so gzip negotiation adds no value. - FLAG_EVALUATION("flag evaluation", false); + FLAG_EVALUATION(false); - private final String logName; private final boolean responseCompressionEnabled; - FeatureFlagEventType(final String logName, final boolean responseCompressionEnabled) { - this.logName = logName; + FeatureFlagEventType(final boolean responseCompressionEnabled) { this.responseCompressionEnabled = responseCompressionEnabled; } - String logName() { - return logName; - } - boolean responseCompressionEnabled() { return responseCompressionEnabled; } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventWriter.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventWriter.java deleted file mode 100644 index 1c7ad1219da..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventWriter.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.util.AgentThreadFactory.THREAD_JOIN_TIMOUT_MS; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static java.util.concurrent.TimeUnit.NANOSECONDS; - -/** Internal two-phase shutdown for writers sharing one subsystem termination budget. */ -interface FeatureFlagEventWriter extends AutoCloseable { - void requestShutdown(); - - void awaitTermination(long deadlineNanos); - - static long shutdownDeadline() { - return System.nanoTime() + MILLISECONDS.toNanos(THREAD_JOIN_TIMOUT_MS); - } - - static void awaitThread(Thread worker, long deadlineNanos) { - final long remaining = deadlineNanos - System.nanoTime(); - if (worker == Thread.currentThread() || remaining <= 0) { - return; - } - try { - // timedJoin preserves sub-millisecond budgets; never turn an expired budget into join(0). - NANOSECONDS.timedJoin(worker, remaining); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java deleted file mode 100644 index c964efa6c7f..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.datadog.featureflag; - -import datadog.trace.api.Config; -import java.util.HashMap; -import java.util.Map; - -final class FeatureFlagEvpContext { - - private FeatureFlagEvpContext() {} - - static Map from(final Config config) { - final Map context = new HashMap<>(4); - context.put("service", config.getServiceName() == null ? "unknown" : config.getServiceName()); - if (config.getEnv() != null) { - context.put("env", config.getEnv()); - } - if (config.getVersion() != null) { - context.put("version", config.getVersion()); - } - return context; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java deleted file mode 100644 index 8a024b52f43..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.trace.api.intake.Intake; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.util.function.Supplier; -import okhttp3.MediaType; -import okhttp3.RequestBody; - -final class FeatureFlagEvpPublisher { - - private static final MediaType JSON = MediaType.parse("application/json"); - - private final Supplier backendApiSupplier; - private final JsonAdapter jsonAdapter; - private BackendApi evp; - - FeatureFlagEvpPublisher(final BackendApiFactory backendApiFactory, final Class requestType) { - this(backendApiFactory, requestType, true); - } - - FeatureFlagEvpPublisher( - final BackendApiFactory backendApiFactory, - final Class requestType, - final boolean responseCompression) { - this( - () -> backendApiFactory.createBackendApi(Intake.EVENT_PLATFORM, responseCompression), - requestType); - } - - FeatureFlagEvpPublisher( - final Supplier backendApiSupplier, final Class requestType) { - this.backendApiSupplier = backendApiSupplier; - this.jsonAdapter = new Moshi.Builder().build().adapter(requestType); - } - - boolean start() { - if (evp == null) { - evp = backendApiSupplier.get(); - } - return evp != null; - } - - void post(final String route, final T request) throws IOException { - post(route, serialize(request)); - } - - byte[] serialize(final T request) { - return utf8Bytes(jsonAdapter.toJson(request)); - } - - void post(final String route, final byte[] json) throws IOException { - if (!start()) { - throw new IllegalStateException("EVP Proxy not available"); - } - final RequestBody requestBody = RequestBody.create(JSON, json); - evp.post(route, requestBody, stream -> null, null, false); - } - - static byte[] utf8Bytes(final String json) { - try { - return json.getBytes("UTF-8"); - } catch (UnsupportedEncodingException e) { - throw new AssertionError("UTF-8 must be available", e); - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagRouteSelector.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagRouteSelector.java deleted file mode 100644 index c18e724cbed..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagRouteSelector.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.datadog.featureflag; - -import java.util.concurrent.TimeUnit; -import java.util.function.LongSupplier; -import java.util.function.Supplier; -import javax.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Process-wide route state shared by all Feature Flagging event writers. */ -final class FeatureFlagRouteSelector { - - private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagRouteSelector.class); - - static final long DEFAULT_RECOVERY_INTERVAL_NANOS = TimeUnit.MINUTES.toNanos(1); - - enum Route { - UNINITIALIZED, - LOCAL, - DIRECT, - UNAVAILABLE - } - - private final LongSupplier nanoTime; - private final long recoveryIntervalNanos; - private volatile Route route = Route.UNINITIALIZED; - private volatile long nextLocalDiscoveryNanos; - private LocalRoute localRoute; - private boolean recoveryInProgress; - - /** A validated endpoint and its identity, replaced on every successful recovery. */ - static final class LocalRoute { - final String endpoint; - - private LocalRoute(final String endpoint) { - this.endpoint = endpoint; - } - } - - FeatureFlagRouteSelector() { - this(System::nanoTime, DEFAULT_RECOVERY_INTERVAL_NANOS); - } - - FeatureFlagRouteSelector(final LongSupplier nanoTime, final long recoveryIntervalNanos) { - this.nanoTime = nanoTime; - this.recoveryIntervalNanos = recoveryIntervalNanos; - } - - synchronized Route initialize( - final Supplier discoverEndpoint, final boolean directAvailable) { - if (route == Route.UNINITIALIZED) { - // Startup happens once across both writers. Later unavailable probes run outside this lock. - final String endpoint = discoverEndpoint.get(); - if (endpoint != null) { - localRoute = new LocalRoute(endpoint); - route = Route.LOCAL; - } else if (directAvailable) { - route = Route.DIRECT; - } else { - route = Route.UNAVAILABLE; - scheduleLocalDiscovery(); - logUnavailable(); - } - } - return route; - } - - Route current() { - return route; - } - - synchronized @Nullable LocalRoute localRoute() { - return route == Route.LOCAL ? localRoute : null; - } - - synchronized Route localFailure(final LocalRoute failedRoute, final boolean directAvailable) { - // An in-flight send on an old endpoint cannot invalidate a newly recovered route. - if (route == Route.LOCAL && localRoute == failedRoute) { - if (directAvailable) { - route = Route.DIRECT; - } else { - route = Route.UNAVAILABLE; - scheduleLocalDiscovery(); - logUnavailable(); - } - } - return route; - } - - synchronized boolean tryBeginLocalRecovery() { - if (route != Route.UNAVAILABLE - || recoveryInProgress - || nanoTime.getAsLong() - nextLocalDiscoveryNanos < 0) { - return false; - } - recoveryInProgress = true; - return true; - } - - synchronized void localRecoveryFinished(@Nullable final String endpoint) { - recoveryInProgress = false; - if (route == Route.UNAVAILABLE) { - if (endpoint != null) { - localRoute = new LocalRoute(endpoint); - route = Route.LOCAL; - LOGGER.info("Feature Flagging event delivery recovered through the local EVP proxy"); - } else { - scheduleLocalDiscovery(); - } - } - } - - private void scheduleLocalDiscovery() { - nextLocalDiscoveryNanos = nanoTime.getAsLong() + recoveryIntervalNanos; - } - - private static void logUnavailable() { - LOGGER.warn( - "Feature Flagging events are being dropped because no local EVP proxy or direct intake" - + " route is available. Make the Agent reachable, or configure DD_API_KEY and a valid DD_SITE."); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java new file mode 100644 index 00000000000..fdb03ee214e --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java @@ -0,0 +1,140 @@ +package com.datadog.featureflag; + +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; + +import datadog.communication.BackendApiFactory; +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.remoteconfig.ConfigurationPoller; +import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.telemetry.CoreMetricCollector; +import java.io.IOException; +import java.util.function.Consumer; +import javax.annotation.Nullable; + +/** The agent services exposed to the Feature Flags SDK instrumentation. */ +public final class FeatureFlagsBackend implements FeatureFlaggingGateway.Backend, AutoCloseable { + private static final String API_KEY = "api-key"; + private static final String SITE = "site"; + private static final String ENV = "env"; + private static final String SERVICE = "service"; + private static final String VERSION = "version"; + + private final SharedCommunicationObjects sco; + private final Config config; + private final EventProxy eventProxy; + private final SpanEnrichmentWriter spanEnrichmentWriter; + private final CoreMetricCollector metrics; + private RemoteConfigServiceImpl remoteConfig; + + public FeatureFlagsBackend(final SharedCommunicationObjects sco, final Config config) { + this( + sco, + config, + new EventProxy( + new BackendApiFactory(config, sco), + CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()) + && config.getApiKey() != null), + SpanEnrichmentWriter.getInstance(), + CoreMetricCollector.getInstance()); + } + + FeatureFlagsBackend( + final SharedCommunicationObjects sco, + final Config config, + final EventProxy eventProxy, + final SpanEnrichmentWriter spanEnrichmentWriter, + final CoreMetricCollector metrics) { + this.sco = sco; + this.config = config; + this.eventProxy = eventProxy; + this.spanEnrichmentWriter = spanEnrichmentWriter; + this.metrics = metrics; + } + + /** Registers the Remote Configuration product early when it is the configuration source. */ + public void start() { + if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals( + this.config.getFeatureFlaggingConfigurationSource()) + && isRemoteConfigAvailable()) { + remoteConfig().start(); + } + } + + @Nullable + @Override + public String setting(final String key) { + // Settings with agent defaults or derived values resolve through the agent configuration. + switch (key) { + case API_KEY: + return this.config.getApiKey(); + case SITE: + return this.config.getSite(); + case ENV: + return this.config.getEnv(); + case SERVICE: + return this.config.getServiceName(); + case VERSION: + return this.config.getVersion(); + default: + return this.config.configProvider().getString(key); + } + } + + @Override + public boolean isRemoteConfigAvailable() { + return this.config.isRemoteConfigEnabled(); + } + + @Override + public AutoCloseable subscribeRemoteConfig(final Consumer listener) { + return remoteConfig().subscribe(listener); + } + + private synchronized RemoteConfigServiceImpl remoteConfig() { + if (this.remoteConfig == null) { + final ConfigurationPoller poller = this.sco.configurationPoller(this.config); + if (poller == null) { + throw new IllegalStateException("Remote Configuration is not available"); + } + this.remoteConfig = new RemoteConfigServiceImpl(poller); + } + return this.remoteConfig; + } + + @Override + public boolean isEventProxyAvailable() { + return this.eventProxy.isAvailable(); + } + + @Override + public boolean postEvent(final String route, final byte[] json) throws IOException { + return this.eventProxy.post(route, json); + } + + @Override + public void count(final String metric, final long value, @Nullable final String reason) { + this.metrics.count(metric, value, reason == null ? null : "reason:" + reason); + } + + @Override + public void enrichSerialId( + final int serialId, final boolean doLog, @Nullable final String targetingKey) { + this.spanEnrichmentWriter.serialId(serialId, doLog, targetingKey); + } + + @Override + public void enrichRuntimeDefault(final String flagKey, @Nullable final Object defaultValue) { + this.spanEnrichmentWriter.runtimeDefault(flagKey, defaultValue); + } + + @Override + public synchronized void close() { + if (this.remoteConfig != null) { + this.remoteConfig.close(); + this.remoteConfig = null; + } + this.spanEnrichmentWriter.close(); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java deleted file mode 100644 index 531dbe82a2f..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java +++ /dev/null @@ -1,508 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.util.HashingUtils.addToHash; -import static datadog.trace.util.HashingUtils.hash; - -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; -import java.util.TreeMap; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -final class FlagEvaluationAggregator { - - // Design assumptions — document the scale we sized for - static final int EXPECTED_FLAG_COUNT = 2_500; - static final int EXPECTED_FULL_BUCKETS_PER_FLAG = 50; - static final int EXPECTED_USERS_PER_FLAG = 1_000; - static final int PER_FLAG_HEADROOM_MULTIPLIER = 10; - static final int EXPECTED_DEGRADED_BUCKETS_PER_FLAG = 10; - - // Derived sizing — show the math behind the bucket caps below - static final int FULL_BUCKET_SIZING_BASIS = - EXPECTED_FLAG_COUNT * EXPECTED_FULL_BUCKETS_PER_FLAG; // 125_000 - static final int PER_FLAG_BUCKET_SIZING_BASIS = - PER_FLAG_HEADROOM_MULTIPLIER * EXPECTED_USERS_PER_FLAG; // 10_000 - static final int DEGRADED_BUCKET_SIZING_BASIS = - EXPECTED_FLAG_COUNT * EXPECTED_DEGRADED_BUCKETS_PER_FLAG; // 25_000 - - // Enforced bucket caps - static final int GLOBAL_CAP = 131_072; // nearest power of two above FULL_BUCKET_SIZING_BASIS - static final int PER_FLAG_CAP = PER_FLAG_BUCKET_SIZING_BASIS; - static final int DEGRADED_CAP = 32_768; // nearest power of two above DEGRADED_BUCKET_SIZING_BASIS - - private static final byte CTX_TAG_STRING = 's'; - private static final byte CTX_TAG_BOOL = 'b'; - private static final byte CTX_TAG_INT = 'i'; - private static final byte CTX_TAG_LONG = 'l'; - private static final byte CTX_TAG_FLOAT = 'f'; - private static final byte CTX_TAG_DOUBLE = 'd'; - private static final byte CTX_TAG_OTHER = 'o'; - - final Map fullTier = new HashMap<>(); - final Map degradedTier = new HashMap<>(); - final Map perFlagCount = new HashMap<>(); - final AtomicLong droppedDegradedOverflow = new AtomicLong(0); - final AtomicInteger globalFullCount = new AtomicInteger(0); - // One shared key and map for each distinct feature set in the flush window, so buckets of many - // subjects do not each keep their own copy until the flush. - final Map featureSets = new HashMap<>(); - - void aggregate(final FlagEvalEvent event) { - final boolean isDefault = event.variant == null; - final boolean observeFullEvaluationData = event.observeFullEvaluationData; - // On the protected path the context is dropped on emit, so it must not fragment buckets or be - // stored — otherwise a high-cardinality field (request_id, timestamp) blows out PER_FLAG_CAP - // and spills every subsequent evaluation into the degraded tier. - final Map prunedAttrs = observeFullEvaluationData ? event.attrs : null; - final String ctxKey = observeFullEvaluationData ? canonicalContextKey(prunedAttrs) : ""; - // Features come from the flag configuration, not the evaluation context, so they split buckets - // in both tiers and whatever the consent: rows with different features must not merge. - final FeatureSet featureSet = featureSet(event.features); - final String featuresKey = featureSet.key; - final FullKey fullKey = buildFullKey(event, ctxKey, featuresKey); - - EvalBucket bucket = fullTier.get(fullKey); - if (bucket != null) { - bucket.merge(event.evalTimeMs, isDefault); - bucket.observeFullEvaluationData &= observeFullEvaluationData; - return; - } - - final int flagCount = perFlagCount.getOrDefault(event.flagKey, 0); - if (globalFullCount.get() < GLOBAL_CAP && flagCount < PER_FLAG_CAP) { - fullTier.put( - fullKey, - new EvalBucket( - event.flagKey, - event.variant, - event.allocationKey, - event.targetingKey, - event.errorMessage, - event.evalTimeMs, - isDefault, - prunedAttrs, - observeFullEvaluationData, - featureSet.features)); - globalFullCount.incrementAndGet(); - perFlagCount.put(event.flagKey, flagCount + 1); - return; - } - - final DegradedKey degradedKey = buildDegradedKey(event, featuresKey); - bucket = degradedTier.get(degradedKey); - if (bucket != null) { - bucket.merge(event.evalTimeMs, isDefault); - bucket.observeFullEvaluationData &= observeFullEvaluationData; - return; - } - - if (degradedTier.size() < DEGRADED_CAP) { - degradedTier.put( - degradedKey, - new EvalBucket( - event.flagKey, - event.variant, - event.allocationKey, - null, - event.errorMessage, - event.evalTimeMs, - isDefault, - null, - observeFullEvaluationData, - featureSet.features)); - return; - } - - droppedDegradedOverflow.incrementAndGet(); - } - - boolean isEmpty() { - return fullTier.isEmpty() && degradedTier.isEmpty(); - } - - int fullTierSize() { - return fullTier.size(); - } - - long degradedEvaluationCount() { - long count = 0; - for (final EvalBucket bucket : degradedTier.values()) { - count += bucket.count; - } - return count; - } - - int bucketCount() { - return fullTier.size() + degradedTier.size(); - } - - Iterable fullBuckets() { - return fullTier.values(); - } - - Iterable degradedBuckets() { - return degradedTier.values(); - } - - void clear() { - fullTier.clear(); - degradedTier.clear(); - featureSets.clear(); - perFlagCount.clear(); - globalFullCount.set(0); - } - - private FeatureSet featureSet(final Map features) { - if (features == null || features.isEmpty()) { - return FeatureSet.NONE; - } - final String key = canonicalContextKey(features); - FeatureSet featureSet = featureSets.get(key); - if (featureSet == null) { - featureSet = new FeatureSet(key, features); - featureSets.put(key, featureSet); - } - return featureSet; - } - - static final class FeatureSet { - static final FeatureSet NONE = new FeatureSet("", Collections.emptyMap()); - - final String key; - final Map features; - - FeatureSet(final String key, final Map features) { - this.key = key; - this.features = features; - } - } - - AggregatedState snapshot() { - return new AggregatedState( - new HashMap<>(fullTier), new HashMap<>(degradedTier), droppedDegradedOverflow.get()); - } - - void simulateFullTierAtCap() { - for (int i = globalFullCount.get(); i < GLOBAL_CAP; i++) { - final String key = "synthetic-full-" + i; - fullTier.put( - new FullKey(key, "on", "alloc", false, null, null, "", false, ""), - new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false, null)); - globalFullCount.incrementAndGet(); - perFlagCount.merge(key, 1, Integer::sum); - } - } - - void simulateDegradedTierAtCap() { - for (int i = degradedTier.size(); i < DEGRADED_CAP; i++) { - final String key = "synthetic-dg-" + i; - degradedTier.put( - new DegradedKey(key, "on", "alloc", false, null, ""), - new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false, null)); - } - } - - void addDegradedBucketForTest( - final String flagKey, - final String variant, - final String allocationKey, - final String errorMessage, - final long evalTimeMs) { - degradedTier.put( - new DegradedKey(flagKey, variant, allocationKey, variant == null, errorMessage, ""), - new EvalBucket( - flagKey, - variant, - allocationKey, - null, - errorMessage, - evalTimeMs, - variant == null, - null, - false, - null)); - } - - private static FullKey buildFullKey( - final FlagEvalEvent event, final String ctxKey, final String featuresKey) { - return new FullKey( - event.flagKey, - event.variant, - event.allocationKey, - event.variant == null, - event.errorMessage, - event.targetingKey, - ctxKey, - event.observeFullEvaluationData, - featuresKey); - } - - private static DegradedKey buildDegradedKey(final FlagEvalEvent event, final String featuresKey) { - return new DegradedKey( - event.flagKey, - event.variant, - event.allocationKey, - event.variant == null, - event.errorMessage, - featuresKey); - } - - static String canonicalContextKey(final Map prunedAttrs) { - if (prunedAttrs == null || prunedAttrs.isEmpty()) { - return ""; - } - final Map sorted = - (prunedAttrs instanceof TreeMap) ? prunedAttrs : new TreeMap<>(prunedAttrs); - final StringBuilder sb = new StringBuilder(); - for (final Map.Entry entry : sorted.entrySet()) { - appendLengthDelimited(sb, entry.getKey()); - appendContextValue(sb, entry.getValue()); - } - return sb.toString(); - } - - private static final String HEX_ZEROS = "00000000"; - - private static void appendLengthDelimited(final StringBuilder sb, final String s) { - // 8-char zero-padded hex length prefix, allocation-lean (no String.format on the hot path). - final String hexLength = Integer.toHexString(s.length()); - sb.append(HEX_ZEROS, 0, 8 - hexLength.length()); - sb.append(hexLength); - sb.append(s); - } - - private static void appendContextValue(final StringBuilder sb, final Object v) { - if (v instanceof Boolean) { - sb.append((char) CTX_TAG_BOOL); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Long) { - sb.append((char) CTX_TAG_LONG); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Integer) { - sb.append((char) CTX_TAG_INT); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Float) { - sb.append((char) CTX_TAG_FLOAT); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Double) { - sb.append((char) CTX_TAG_DOUBLE); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof String) { - sb.append((char) CTX_TAG_STRING); - appendLengthDelimited(sb, (String) v); - } else { - sb.append((char) CTX_TAG_OTHER); - appendLengthDelimited(sb, v == null ? "" : v.toString()); - } - } - - static class EvalBucket { - long count; - long firstEvalMs; - long lastEvalMs; - boolean runtimeDefaultUsed; - String flagKey; - String variant; - String allocationKey; - String targetingKey; - String errorMessage; - Map prunedAttrs; - // Identical for every evaluation in the bucket, because the features key is a bucket dimension. - Map features; - // Consent to emit raw PII. For full-tier buckets this is uniform (consent is a FullKey - // dimension) and the AND-fold on merge is defensive. For degraded-tier buckets consent is NOT - // a key dimension — mixed-consent events merge here — so the AND-fold produces false whenever - // any consent-off event lands in the bucket. That's benign because the degraded wire path - // drops the targeting key and context regardless of consent, so this field has no downstream - // effect for degraded rows. - boolean observeFullEvaluationData; - - EvalBucket( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final String errorMessage, - final long evalTimeMs, - final boolean runtimeDefaultUsed, - final Map prunedAttrs, - final boolean observeFullEvaluationData, - final Map features) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.targetingKey = targetingKey; - this.errorMessage = errorMessage; - this.firstEvalMs = evalTimeMs; - this.lastEvalMs = evalTimeMs; - this.count = 1; - this.runtimeDefaultUsed = runtimeDefaultUsed; - this.prunedAttrs = prunedAttrs; - this.observeFullEvaluationData = observeFullEvaluationData; - this.features = features; - } - - int prunedContextFieldCount() { - return prunedAttrs == null ? 0 : prunedAttrs.size(); - } - - void merge(final long evalTimeMs, final boolean isDefault) { - count++; - if (evalTimeMs < firstEvalMs) { - firstEvalMs = evalTimeMs; - } - if (evalTimeMs > lastEvalMs) { - lastEvalMs = evalTimeMs; - } - if (isDefault) { - runtimeDefaultUsed = true; - } - } - } - - static final class FullKey { - private final String flagKey; - private final String variant; - private final String allocationKey; - private final boolean runtimeDefaultUsed; - private final String errorMessage; - private final String targetingKey; - private final String contextKey; - // Part of the key so consent-on and consent-off evaluations never share a bucket. The - // serializer branches on this to hash the targeting key and drop the context, so events with - // different consent produce different wire rows and belong in different buckets. - private final boolean observeFullEvaluationData; - private final String featuresKey; - - FullKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage, - final String targetingKey, - final String contextKey, - final boolean observeFullEvaluationData, - final String featuresKey) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.runtimeDefaultUsed = runtimeDefaultUsed; - this.errorMessage = errorMessage; - this.targetingKey = targetingKey; - this.contextKey = contextKey; - this.observeFullEvaluationData = observeFullEvaluationData; - this.featuresKey = featuresKey; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (!(o instanceof FullKey)) { - return false; - } - final FullKey fullKey = (FullKey) o; - return runtimeDefaultUsed == fullKey.runtimeDefaultUsed - && observeFullEvaluationData == fullKey.observeFullEvaluationData - && Objects.equals(flagKey, fullKey.flagKey) - && Objects.equals(variant, fullKey.variant) - && Objects.equals(allocationKey, fullKey.allocationKey) - && Objects.equals(errorMessage, fullKey.errorMessage) - && Objects.equals(targetingKey, fullKey.targetingKey) - && Objects.equals(contextKey, fullKey.contextKey) - && Objects.equals(featuresKey, fullKey.featuresKey); - } - - @Override - public int hashCode() { - // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs - // on this hot bucket-lookup path. - int result = hash(flagKey, variant, allocationKey); - result = addToHash(result, runtimeDefaultUsed); - result = addToHash(result, errorMessage); - result = addToHash(result, targetingKey); - result = addToHash(result, contextKey); - result = addToHash(result, observeFullEvaluationData); - return addToHash(result, featuresKey); - } - } - - static final class DegradedKey { - // Unlike FullKey, consent is NOT a bucket dimension here: the wire serializer for degraded rows - // (FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket with isFullTier=false) drops the - // targeting key and context unconditionally, so two degraded buckets differing only in consent - // would emit byte-identical JSON with evaluation_count split — halving effective DEGRADED_CAP - // for zero wire fidelity. Mixed-consent events merge into one bucket; the AND-fold on - // EvalBucket.observeFullEvaluationData still runs but has no downstream effect for degraded - // rows. - private final String flagKey; - private final String variant; - private final String allocationKey; - private final boolean runtimeDefaultUsed; - private final String errorMessage; - private final String featuresKey; - - DegradedKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage, - final String featuresKey) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.runtimeDefaultUsed = runtimeDefaultUsed; - this.errorMessage = errorMessage; - this.featuresKey = featuresKey; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (!(o instanceof DegradedKey)) { - return false; - } - final DegradedKey that = (DegradedKey) o; - return runtimeDefaultUsed == that.runtimeDefaultUsed - && Objects.equals(flagKey, that.flagKey) - && Objects.equals(variant, that.variant) - && Objects.equals(allocationKey, that.allocationKey) - && Objects.equals(errorMessage, that.errorMessage) - && Objects.equals(featuresKey, that.featuresKey); - } - - @Override - public int hashCode() { - // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs - // on this hot bucket-lookup path. - int result = hash(flagKey, variant, allocationKey); - result = addToHash(result, runtimeDefaultUsed); - result = addToHash(result, errorMessage); - return addToHash(result, featuresKey); - } - } - - static class AggregatedState { - final Map fullTier; - final Map degradedTier; - final long droppedDegradedOverflow; - - AggregatedState( - final Map fullTier, - final Map degradedTier, - final long droppedDegradedOverflow) { - this.fullTier = fullTier; - this.degradedTier = degradedTier; - this.droppedDegradedOverflow = droppedDegradedOverflow; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java deleted file mode 100644 index e7e027cc2d2..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java +++ /dev/null @@ -1,301 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import java.io.ByteArrayOutputStream; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -final class FlagEvaluationPayloads { - - private static final byte[] PAYLOAD_SUFFIX = FeatureFlagEvpPublisher.utf8Bytes("]}"); - private static final byte[] JSON_COMMA = FeatureFlagEvpPublisher.utf8Bytes(","); - - /** - * Wire prefix identifying a privacy-preserving, hashed targeting key. Emitted for full-tier rows - * when {@code observeFullEvaluationData} is off. The suffix is the lower-case hex SHA-256 of the - * UTF-8 targeting key (see {@link ULeb128Encoder#hashTargetingKey}). This is a cross-SDK wire - * contract - keep it in sync with the other server SDKs and the UFC/EVP spec. - */ - private static final String HASHED_TARGETING_KEY_PREFIX = "sha256_"; - - private static final JsonAdapter EVENT_JSON_ADAPTER; - private static final JsonAdapter> CONTEXT_JSON_ADAPTER; - - static { - final Moshi moshi = new Moshi.Builder().build(); - EVENT_JSON_ADAPTER = moshi.adapter(FlagEvaluationEvent.class); - final Type contextType = Types.newParameterizedType(Map.class, String.class, String.class); - CONTEXT_JSON_ADAPTER = moshi.adapter(contextType); - } - - private FlagEvaluationPayloads() {} - - static class FlagEvaluationsRequest { - public final Map context; - public final List flagEvaluations; - - FlagEvaluationsRequest( - final Map context, final List flagEvaluations) { - this.context = context; - this.flagEvaluations = flagEvaluations; - } - } - - static EncodedPayloads buildPayloads( - final List events, - final Map context, - final int payloadSizeLimitBytes) { - final byte[] prefix = payloadPrefix(context); - EncodedPayloadBuilder current = new EncodedPayloadBuilder(prefix); - final List payloads = new ArrayList<>(); - long droppedPayloadLimit = 0; - long degradedPayloadLimit = 0; - - for (final FlagEvaluationEvent event : events) { - byte[] eventBytes = encodeEvent(event); - - if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { - payloads.add(current.toByteArray()); - current = new EncodedPayloadBuilder(prefix); - } - - if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { - current.add(eventBytes); - continue; - } - - final FlagEvaluationEvent degraded = event.withoutTargetingKeyAndContext(); - if (degraded != null) { - eventBytes = encodeEvent(degraded); - if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { - payloads.add(current.toByteArray()); - current = new EncodedPayloadBuilder(prefix); - } - if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { - current.add(eventBytes); - degradedPayloadLimit += event.evaluation_count; - continue; - } - } - - droppedPayloadLimit += event.evaluation_count; - } - - if (!current.isEmpty()) { - payloads.add(current.toByteArray()); - } - return new EncodedPayloads(payloads, droppedPayloadLimit, degradedPayloadLimit); - } - - private static byte[] payloadPrefix(final Map context) { - return FeatureFlagEvpPublisher.utf8Bytes( - "{\"context\":" + CONTEXT_JSON_ADAPTER.toJson(context) + ",\"flagEvaluations\":["); - } - - private static byte[] encodeEvent(final FlagEvaluationEvent event) { - return FeatureFlagEvpPublisher.utf8Bytes(EVENT_JSON_ADAPTER.toJson(event)); - } - - static final class EncodedPayloads { - final List bodies; - final long droppedPayloadLimit; - final long degradedPayloadLimit; - - private EncodedPayloads( - final List bodies, - final long droppedPayloadLimit, - final long degradedPayloadLimit) { - this.bodies = bodies; - this.droppedPayloadLimit = droppedPayloadLimit; - this.degradedPayloadLimit = degradedPayloadLimit; - } - } - - private static final class EncodedPayloadBuilder { - private final byte[] prefix; - private final List events = new ArrayList<>(); - private int eventBytes; - - private EncodedPayloadBuilder(final byte[] prefix) { - this.prefix = prefix; - } - - private boolean isEmpty() { - return events.isEmpty(); - } - - private boolean canAdd(final byte[] event, final int payloadSizeLimitBytes) { - return sizeWith(event) <= payloadSizeLimitBytes; - } - - private int sizeWith(final byte[] event) { - return prefix.length + PAYLOAD_SUFFIX.length + eventBytes + event.length + events.size(); - } - - private void add(final byte[] event) { - events.add(event); - eventBytes += event.length; - } - - private byte[] toByteArray() { - final int size = prefix.length + PAYLOAD_SUFFIX.length + eventBytes; - final ByteArrayOutputStream out = new ByteArrayOutputStream(size + events.size()); - out.write(prefix, 0, prefix.length); - for (int i = 0; i < events.size(); i++) { - if (i > 0) { - out.write(JSON_COMMA, 0, JSON_COMMA.length); - } - final byte[] event = events.get(i); - out.write(event, 0, event.length); - } - out.write(PAYLOAD_SUFFIX, 0, PAYLOAD_SUFFIX.length); - return out.toByteArray(); - } - } - - static class FlagEvaluationEvent { - public final long timestamp; - public final FlagKeyObject flag; - public final long first_evaluation; - public final long last_evaluation; - public final long evaluation_count; - public final KeyObject variant; - public final KeyObject allocation; - public final String targeting_key; - public final Boolean runtime_default_used; - public final EventContext context; - public final ErrorObject error; - public final Map features; - - FlagEvaluationEvent( - final long timestamp, - final String flagKey, - final long firstEvalMs, - final long lastEvalMs, - final long count, - final String variant, - final String allocation, - final String targetingKey, - final boolean runtimeDefaultUsed, - final String errorMessage, - final Map evaluationAttrs, - final Map features) { - this.timestamp = timestamp; - this.flag = new FlagKeyObject(flagKey); - this.first_evaluation = firstEvalMs; - this.last_evaluation = lastEvalMs; - this.evaluation_count = count; - this.variant = (variant != null && !variant.isEmpty()) ? new KeyObject(variant) : null; - this.allocation = - (allocation != null && !allocation.isEmpty()) ? new KeyObject(allocation) : null; - this.targeting_key = targetingKey; - this.runtime_default_used = runtimeDefaultUsed ? Boolean.TRUE : null; - this.context = - (evaluationAttrs != null && !evaluationAttrs.isEmpty()) - ? new EventContext(evaluationAttrs) - : null; - this.error = - (errorMessage != null && !errorMessage.isEmpty()) ? new ErrorObject(errorMessage) : null; - this.features = (features != null && !features.isEmpty()) ? features : null; - } - - static FlagEvaluationEvent fromBucket( - final FlagEvaluationAggregator.EvalBucket bucket, - final boolean isFullTier, - final boolean observeFullEvaluationData, - final long flushTimeMs) { - final boolean includeRawContext = isFullTier && observeFullEvaluationData; - return new FlagEvaluationEvent( - flushTimeMs, - bucket.flagKey, - bucket.firstEvalMs, - bucket.lastEvalMs, - bucket.count, - bucket.variant, - bucket.allocationKey, - resolveTargetingKey(bucket.targetingKey, isFullTier, observeFullEvaluationData), - bucket.runtimeDefaultUsed, - bucket.errorMessage, - includeRawContext ? bucket.prunedAttrs : null, - // Features come from the flag configuration, so both tiers keep them whatever the - // consent. - bucket.features); - } - - private static String resolveTargetingKey( - final String rawTargetingKey, - final boolean isFullTier, - final boolean observeFullEvaluationData) { - if (!isFullTier || rawTargetingKey == null) { - return null; - } - if (observeFullEvaluationData) { - return rawTargetingKey; - } - return HASHED_TARGETING_KEY_PREFIX + ULeb128Encoder.hashTargetingKey(rawTargetingKey); - } - - FlagEvaluationEvent withoutTargetingKeyAndContext() { - if (targeting_key == null && context == null) { - return null; - } - return new FlagEvaluationEvent( - timestamp, - flag.key, - first_evaluation, - last_evaluation, - evaluation_count, - keyOf(variant), - keyOf(allocation), - null, - Boolean.TRUE.equals(runtime_default_used), - messageOf(error), - null, - features); - } - } - - private static String keyOf(final KeyObject object) { - return object == null ? null : object.key; - } - - private static String messageOf(final ErrorObject object) { - return object == null ? null : object.message; - } - - static class KeyObject { - public final String key; - - KeyObject(final String key) { - this.key = key; - } - } - - static class FlagKeyObject { - public final String key; - - FlagKeyObject(final String key) { - this.key = key; - } - } - - static class ErrorObject { - public final String message; - - ErrorObject(final String message) { - this.message = message; - } - } - - static class EventContext { - public final Map evaluation; - - EventContext(final Map evaluation) { - this.evaluation = evaluation; - } - } -} 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 deleted file mode 100644 index c2a6231076c..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java +++ /dev/null @@ -1,614 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_EVALUATION_PROCESSOR; -import static datadog.trace.util.AgentThreadFactory.newAgentThread; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.common.queue.MessagePassingBlockingQueue; -import datadog.common.queue.Queues; -import datadog.communication.BackendApi; -import datadog.communication.EvpProxy; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.telemetry.CoreMetricCollector; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Supplier; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * EVP flagevaluation writer for Java. - * - *

Uses the same EVP publisher path as ExposureWriterImpl, with two-tier aggregation replacing - * the single-exposure buffer. Uses a local EVP proxy when available. Agentless mode can use direct - * intake when no compatible local route is available. - * - *

Two-tier aggregation contract: Full key: (flagKey, variant, allocationKey, runtimeDefault, - * errorMessage, targetingKey, canonical-context-key). Degraded key: (flagKey, variant, - * allocationKey, runtimeDefault, errorMessage) - no targetingKey/context. Canonical context key: - * sorted entries, type-tagged length-delimited encoding - NOT a hash (collision-safe, comparable - * string identity). Context pruning: deterministic (sort before cut), <=256 fields, string values - * <=256 chars; the pruned attributes are what gets aggregated and serialized. Caps: - * globalCap=131072, perFlagCap=10000, degradedCap=32768. Eval-time: min/max of - * firstEvalMs/lastEvalMs across events in the same bucket. Runtime default: absent variant means - * runtimeDefaultUsed=true. Flush interval: 10 seconds. Queue: bounded MessagePassingBlockingQueue - * (capacity 2^16), non-blocking offer; on overflow the event is dropped and the - * droppedQueueOverflow counter is incremented and surfaced on flush. Enqueue: lock-free. Producers - * contend only on the MPSC queue, never on a monitor, so evaluation threads do not serialize - * against each other. Shutdown: close() drains the queue and performs a final flush before the - * worker thread exits. Because enqueue is lock-free, a producer can still offer during shutdown; - * close() sweeps the queue once the worker has been joined, counting the observed remainder as - * closed drops. A producer paused before offer can resume after this sweep; shutdown is best-effort - * for events racing close, not a producer fence. - */ -public class FlagEvaluationWriterImpl implements FlagEvaluationWriter, FeatureFlagEventWriter { - - private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationWriterImpl.class); - - static final int DEFAULT_CAPACITY = 1 << 12; // 4096 elements, per cross-SDK RFC - static final int FLUSH_INTERVAL_SECONDS = 10; - - static final int FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES = EvpProxy.PAYLOAD_SIZE_LIMIT_BYTES; - static final String FLAG_EVALUATION_DROPPED_METRIC = "flagevaluation.rows.dropped"; - static final String FLAG_EVALUATION_DEGRADED_METRIC = "flagevaluation.rows.degraded"; - static final String FLAG_EVALUATION_SPLITS_METRIC = "flagevaluation.payload.splits"; - static final String FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC = "flagevaluation.context.truncated"; - static final String DROP_REASON_QUEUE_OVERFLOW = "queue_overflow"; - static final String DROP_REASON_CLOSED = "closed"; - static final String DROP_REASON_DEGRADED_CAP = "degraded_cap"; - static final String DROP_REASON_PAYLOAD_LIMIT = "payload_limit"; - static final String DEGRADED_REASON_CARDINALITY_CAP = "cardinality_cap"; - static final String DEGRADED_REASON_PAYLOAD_LIMIT = "payload_limit"; - private static final String FLAG_EVALUATION_ROUTE = "flagevaluation"; - private static final CoreMetricCollector CORE_METRICS = CoreMetricCollector.getInstance(); - - private final MessagePassingBlockingQueue queue; - private final FlagEvaluationSerializingHandler serializer; - private final Thread serializerThread; - private final Object lifecycleLock = new Object(); - private final AtomicBoolean closed = new AtomicBoolean(false); - - private static void countMetric(final String metricName, final long value, final String reason) { - if (value <= 0) { - return; - } - CORE_METRICS.count(metricName, value, reason == null ? null : "reason:" + reason); - } - - /** - * Observable counter for events dropped because the bounded hand-off queue was full when the hook - * tried to enqueue (backpressure). Incremented on the hook thread, surfaced on flush. - */ - private final AtomicLong droppedQueueOverflow = new AtomicLong(0); - - /** - * Per-reason-tag counters for evaluations whose context was truncated by copyPrunedContext. Keyed - * by the sorted comma-separated reason string (e.g. "max_key_length,max_value_length"). - * Incremented on the hook thread, drained and emitted on flush. - */ - private final ConcurrentHashMap contextTruncatedCounts = - new ConcurrentHashMap<>(); - - FlagEvaluationWriterImpl( - final SharedCommunicationObjects sco, - final Config config, - final FeatureFlagRouteSelector routeSelector) { - this( - DEFAULT_CAPACITY, - FLUSH_INTERVAL_SECONDS, - SECONDS, - new FeatureFlagBackendApiFactory( - config, sco, FeatureFlagEventType.FLAG_EVALUATION, routeSelector) - ::create, - config); - } - - FlagEvaluationWriterImpl( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final Supplier backendApiSupplier, - final Config config) { - this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); - this.serializer = - new FlagEvaluationSerializingHandler( - backendApiSupplier, - queue, - flushInterval, - timeUnit, - FeatureFlagEvpContext.from(config), - droppedQueueOverflow, - contextTruncatedCounts, - this::close, - FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - this.serializerThread = newAgentThread(FEATURE_FLAG_EVALUATION_PROCESSOR, serializer); - } - - @Override - public void start() { - synchronized (lifecycleLock) { - if (closed.get()) { - return; - } - // Register with the gateway so FlagEvalLoggingHook can route evaluations to this writer - FeatureFlaggingGateway.setFlagEvalWriter(this); - this.serializerThread.start(); - } - } - - /** Test seam: starts the worker thread WITHOUT registering with the global gateway. */ - void startForTest() { - synchronized (lifecycleLock) { - if (closed.get()) { - return; - } - this.serializerThread.start(); - } - } - - /** Test seam: current full-tier bucket count in the worker's aggregator. */ - int aggregatorFullTierSizeForTest() { - return serializer.aggregator.fullTierSize(); - } - - @Override - public void close() { - final long deadline = FeatureFlagEventWriter.shutdownDeadline(); - requestShutdown(); - awaitTermination(deadline); - } - - @Override - public void requestShutdown() { - synchronized (lifecycleLock) { - if (!closed.compareAndSet(false, true)) { - return; - } - // Disable and deregister from the gateway so no new events are enqueued. - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - FeatureFlaggingGateway.setFlagEvalWriter(null); - // Polling wakes within 100 ms; do not interrupt a current or final HTTP request. - serializer.requestShutdown(); - } - } - - @Override - public void awaitTermination(final long deadlineNanos) { - if (Thread.currentThread() == this.serializerThread) { - return; - } - FeatureFlagEventWriter.awaitThread(serializerThread, deadlineNanos); - // enqueue() is lock-free, so a producer that passed the closed check before close() ran can - // still land an event after the worker's final drain. Sweep the remainder so that loss is - // counted rather than silently stranded in a queue nobody polls again. Only safe once the - // worker is gone: the queue is single-consumer, so sweeping alongside a live worker would - // break that contract. If join() timed out the worker is still draining, so skip the sweep. - synchronized (lifecycleLock) { - if (!this.serializerThread.isAlive()) { - sweepAndCountResidualEvents(); - } - } - } - - /** - * Counts events left in the queue after the worker has exited. Must only be called when the - * serializer thread is not alive - the queue permits a single consumer. - */ - private void sweepAndCountResidualEvents() { - long residual = 0; - while (queue.poll() != null) { - residual++; - } - countMetric(FLAG_EVALUATION_DROPPED_METRIC, residual, DROP_REASON_CLOSED); - } - - @Override - public void enqueue(final FlagEvalEvent event) { - if (event == null) { - return; - } - if (isClosedOrEnqueueDisabled()) { - countClosedDrop(); - return; - } - // Deliberately lock-free: the hand-off queue is MPSC by design, so serializing producers on a - // monitor here would negate that and turn every evaluation in every application thread into - // contention on one lock. A producer that passed the check above can still offer after close() - // has started. The final drain and post-join sweep handle observed residue, but cannot account - // for an offer from a producer that resumes after both have finished. - // - // Safe publication of the event (including the context snapshot built by the hook) comes from - // the queue's own offer/poll ordering, not from any monitor held here. - // - // Non-blocking offer. Count overflow so loss is observable rather than silent; the count is - // surfaced on the next flush. The hook's pre-queue guard (see FlagEvalLoggingHook) samples the - // queue depth before doing any context-copy work, so a saturated queue costs an - // AtomicInteger.get(); the offer here still races with the worker and can legitimately fail. - if (!queue.offer(event)) { - droppedQueueOverflow.incrementAndGet(); - } - } - - /** - * Reports whether the async hand-off queue currently has room for another event. Producers - * consult this before performing any expensive context-copy work so a saturated queue is observed - * as an O(1) read rather than a full snapshot followed by a discarded offer. Best-effort only: - * the worker can drain (or a peer producer can fill) between this check and the subsequent - * enqueue, so callers must still tolerate offer failure. - */ - public boolean hasCapacityForEnqueue() { - return queue.size() < queue.capacity(); - } - - /** Counts one queue-overflow drop without offering an event. */ - public void countPreQueueOverflow() { - droppedQueueOverflow.incrementAndGet(); - } - - @Override - public void countContextTruncated(final String reason) { - contextTruncatedCounts.computeIfAbsent(reason, k -> new AtomicLong(0)).incrementAndGet(); - } - - private boolean isClosedOrEnqueueDisabled() { - return closed.get() || !FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled(); - } - - private void countClosedDrop() { - // Count the drop for any early exit from enqueue, whether closed=true or the gate was disabled - // by the surrounding subsystem. FeatureFlaggingSystem.stop() flips the gate before this - // writer's close() runs, so an in-flight enqueue could race that flip and see gate=false while - // closed=false. Counting on either condition keeps shutdown-loss observable. - countMetric(FLAG_EVALUATION_DROPPED_METRIC, 1, DROP_REASON_CLOSED); - } - - /** Returns the count of events dropped due to queue-overflow backpressure (observable). */ - long droppedQueueOverflow() { - return droppedQueueOverflow.get(); - } - - /** Test seam: returns one queued event without starting the worker. */ - FlagEvalEvent pollQueuedEventForTest() { - return queue.poll(); - } - - /** Test seam: flushes serializer state without starting the worker. */ - void flushForTest() { - serializer.flush(); - } - - // ---- Serializing handler (background thread logic) ---- - - static class FlagEvaluationSerializingHandler implements Runnable { - private final MessagePassingBlockingQueue queue; - private final long ticksRequiredToFlush; - - @SuppressFBWarnings( - value = "AT_NONATOMIC_64BIT_PRIMITIVE", - justification = "the field is confined to the single serializer thread") - private long lastTicks; - - private final FeatureFlagEvpPublisher - evpPublisher; - final Map context; - private final AtomicLong droppedQueueOverflow; - private final ConcurrentHashMap contextTruncatedCounts; - private final Runnable errorCallback; - private final int payloadSizeLimitBytes; - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - // Shutdown coordination: set by close(), drives a final drain+flush before the worker exits. - private final AtomicBoolean shutdownRequested = new AtomicBoolean(false); - private final CountDownLatch finalFlushDone = new CountDownLatch(1); - - FlagEvaluationSerializingHandler( - final Supplier backendApiSupplier, - final MessagePassingBlockingQueue queue, - final long flushInterval, - final TimeUnit timeUnit, - final Map context, - final AtomicLong droppedQueueOverflow, - final ConcurrentHashMap contextTruncatedCounts, - final Runnable errorCallback, - final int payloadSizeLimitBytes) { - this.queue = queue; - this.evpPublisher = - new FeatureFlagEvpPublisher<>( - backendApiSupplier, FlagEvaluationPayloads.FlagEvaluationsRequest.class); - this.context = context; - this.droppedQueueOverflow = droppedQueueOverflow; - this.contextTruncatedCounts = contextTruncatedCounts; - this.payloadSizeLimitBytes = payloadSizeLimitBytes; - this.lastTicks = System.nanoTime(); - this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); - this.errorCallback = errorCallback; - LOGGER.debug("starting flag evaluation serializer"); - } - - /** Signals the worker to drain the queue and perform a final flush before exiting. */ - void requestShutdown() { - shutdownRequested.set(true); - } - - @Override - public void run() { - if (!evpPublisher.start()) { - finalFlushDone.countDown(); - errorCallback.run(); - throw new IllegalArgumentException("EVP Proxy not available"); - } - try { - runDutyCycle(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - // On exit (interrupt or shutdown request), drain everything still buffered and flush it so - // queued events are not lost on shutdown. - // - // Normal close only signals shutdown. If an unrelated interrupt ends the duty cycle, - // clear it during the final socket I/O and restore it afterwards; OkHttp otherwise fails - // immediately without attempting the remaining batch. - final boolean wasInterrupted = Thread.interrupted(); - try { - drainAndFlush(); - } finally { - finalFlushDone.countDown(); - if (wasInterrupted) { - Thread.currentThread().interrupt(); - } - } - } - LOGGER.debug("flag evaluation processor worker exited."); - } - - private void runDutyCycle() throws InterruptedException { - final Thread thread = Thread.currentThread(); - while (!thread.isInterrupted() && !shutdownRequested.get()) { - final FlagEvalEvent event = queue.poll(100, TimeUnit.MILLISECONDS); - if (event != null) { - aggregateEvent(event); - } - flushIfNecessary(); - } - } - - void drainAndFlush() { - FlagEvalEvent event; - while ((event = queue.poll()) != null) { - aggregateEvent(event); - } - flush(); - } - - // ---- Aggregation logic ---- - - /** Routes an event into the full tier or degraded tier, or drops and counts on overflow. */ - void aggregateEvent(final FlagEvalEvent event) { - try { - aggregator.aggregate(event); - } catch (LinkageError | RuntimeException e) { - LOGGER.debug("Could not aggregate flag evaluation event", e); - } - } - - // ---- Flush logic ---- - - void flushIfNecessary() { - if (shouldFlush()) { - flush(); - } - } - - void flush() { - // Surface backpressure (queue-overflow) drops as an observable warning even when there is - // nothing else to flush. - final long qDrops = droppedQueueOverflow.getAndSet(0); - countMetric(FLAG_EVALUATION_DROPPED_METRIC, qDrops, DROP_REASON_QUEUE_OVERFLOW); - if (qDrops > 0) { - LOGGER.warn( - "flag evaluation queue full - dropped {} evaluation(s) under backpressure" - + " (best-effort telemetry)", - qDrops); - } - final long dgDrops = aggregator.droppedDegradedOverflow.getAndSet(0); - countMetric(FLAG_EVALUATION_DROPPED_METRIC, dgDrops, DROP_REASON_DEGRADED_CAP); - if (dgDrops > 0) { - LOGGER.warn( - "degraded aggregation tier full - dropped {} evaluation(s); raise degraded cap" - + " (best-effort telemetry)", - dgDrops); - } - - // Drain per-reason context-truncation counters and emit one metric per unique reason tag. - for (final Map.Entry entry : contextTruncatedCounts.entrySet()) { - final long count = entry.getValue().getAndSet(0); - if (count > 0) { - countMetric(FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, count, entry.getKey()); - } - } - - if (aggregator.isEmpty()) { - return; - } - try { - countMetric( - FLAG_EVALUATION_DEGRADED_METRIC, - aggregator.degradedEvaluationCount(), - DEGRADED_REASON_CARDINALITY_CAP); - final List events = buildEventList(); - if (events.isEmpty()) { - return; - } - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(events, context, payloadSizeLimitBytes); - countMetric( - FLAG_EVALUATION_DROPPED_METRIC, - payloads.droppedPayloadLimit, - DROP_REASON_PAYLOAD_LIMIT); - countMetric( - FLAG_EVALUATION_DEGRADED_METRIC, - payloads.degradedPayloadLimit, - DEGRADED_REASON_PAYLOAD_LIMIT); - if (payloads.bodies.size() > 1) { - countMetric(FLAG_EVALUATION_SPLITS_METRIC, payloads.bodies.size() - 1, null); - } - if (payloads.droppedPayloadLimit > 0) { - LOGGER.warn( - "flag evaluation payload too large - dropped {} evaluation(s)" - + " (best-effort telemetry)", - payloads.droppedPayloadLimit); - } - for (final byte[] payload : payloads.bodies) { - evpPublisher.post(FLAG_EVALUATION_ROUTE, payload); - } - } catch (AgentlessFeatureFlagBackendApi.UnavailableRouteException ignored) { - // The shared selector reports unavailable/recovered transitions once for both writers. - } catch (Exception e) { - LOGGER.error("Could not submit flag evaluations", e); - } finally { - // Best-effort: always clear the aggregator after a flush attempt. Retaining buckets across - // flushes on encode failure would let one unserializable value (for example a NaN Double a - // customer put in the context) permanently block every subsequent flush. - aggregator.clear(); - lastTicks = System.nanoTime(); - } - } - - private List buildEventList() { - final long flushTimeMs = System.currentTimeMillis(); - // Consent is read per bucket from the value each event snapshotted at evaluation time, not - // from the gateway here: CURRENT_CONFIG may have been overwritten by a later RC update since - // these evaluations happened, and reading it at flush would apply the wrong config's consent. - final List events = - new ArrayList<>(aggregator.bucketCount()); - for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.fullBuckets()) { - events.add( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, bucket.observeFullEvaluationData, flushTimeMs)); - } - for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.degradedBuckets()) { - events.add( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, false, bucket.observeFullEvaluationData, flushTimeMs)); - } - return events; - } - - private boolean shouldFlush() { - if (aggregator.isEmpty() && droppedQueueOverflow.get() == 0) { - return false; - } - final long nanoTime = System.nanoTime(); - final long ticks = nanoTime - lastTicks; - if (ticks > ticksRequiredToFlush) { - lastTicks = nanoTime; - return true; - } - return false; - } - } - - // ---- Test-seam inner class (package-private) ---- - - /** - * Test-accessible handler that exposes {@link #drainAndAggregate()} and {@link #flush()} without - * starting a real background thread. - */ - static class SerializingHandlerForTest extends FlagEvaluationSerializingHandler { - - SerializingHandlerForTest( - final Supplier backendApiSupplier, final Map context) { - this(backendApiSupplier, context, FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - } - - SerializingHandlerForTest( - final Supplier backendApiSupplier, - final Map context, - final int payloadSizeLimitBytes) { - super( - backendApiSupplier, - Queues.mpscBlockingConsumerArrayQueue(DEFAULT_CAPACITY), - Long.MAX_VALUE, // effectively never auto-flush - TimeUnit.NANOSECONDS, - context, - new AtomicLong(0), - new ConcurrentHashMap<>(), - () -> {}, - payloadSizeLimitBytes); - } - - private final List staged = new ArrayList<>(); - - /** Adds an event to the staged list (simulates hook enqueue). */ - void add(final FlagEvalEvent event) { - staged.add(event); - } - - /** Aggregates all staged events and returns the current aggregation state. */ - FlagEvaluationAggregator.AggregatedState drainAndAggregate() { - for (final FlagEvalEvent e : staged) { - aggregateEvent(e); - } - staged.clear(); - return aggregator.snapshot(); - } - - /** Simulates filling the full tier to GLOBAL_CAP by injecting synthetic distinct buckets. */ - void simulateFullTierAtCap() { - aggregator.simulateFullTierAtCap(); - } - - /** - * Simulates filling the degraded tier to DEGRADED_CAP by injecting synthetic distinct buckets. - */ - void simulateDegradedTierAtCap() { - aggregator.simulateDegradedTierAtCap(); - } - - void addDroppedDegradedOverflowForTest(final long count) { - aggregator.droppedDegradedOverflow.addAndGet(count); - } - - void addDegradedBucketForTest( - final String flagKey, - final String variant, - final String allocationKey, - final String errorMessage, - final long evalTimeMs) { - aggregator.addDegradedBucketForTest( - flagKey, variant, allocationKey, errorMessage, evalTimeMs); - } - - void clearAggregationForTest() { - aggregator.clear(); - } - - int fullTierSizeForTest() { - return aggregator.fullTierSize(); - } - } - - /** Factory method for test use - creates a SerializingHandlerForTest. */ - static SerializingHandlerForTest createHandlerForTest( - final Supplier backendApiSupplier, final Map context) { - return new SerializingHandlerForTest(backendApiSupplier, context); - } - - static SerializingHandlerForTest createHandlerForTest( - final Supplier backendApiSupplier, - final Map context, - final int payloadSizeLimitBytes) { - return new SerializingHandlerForTest(backendApiSupplier, context, payloadSizeLimitBytes); - } -} 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/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/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index e66a6231990..837e377dec4 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 @@ -1,44 +1,98 @@ package com.datadog.featureflag; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.remoteconfig.Capabilities; +import static datadog.remoteconfig.Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES; +import static datadog.remoteconfig.Product.FFE_FLAGS; + import datadog.remoteconfig.ConfigurationChangesTypedListener; 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.ufc.v1.ServerConfiguration; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -public class RemoteConfigServiceImpl - implements ConfigurationSourceService, ConfigurationChangesTypedListener { +/** + * Forwards the raw Feature Flags Remote Configuration documents to the SDK subscribers. The SDK + * owns the document format, so the agent does not parse it. + */ +public class RemoteConfigServiceImpl implements ConfigurationChangesTypedListener { + private static final Logger LOGGER = LoggerFactory.getLogger(RemoteConfigServiceImpl.class); private final ConfigurationPoller configurationPoller; + private final List> subscribers = new CopyOnWriteArrayList<>(); + private volatile byte[] current; + private boolean started; - public RemoteConfigServiceImpl(final SharedCommunicationObjects sco, final Config config) { - configurationPoller = sco.configurationPoller(config); + public RemoteConfigServiceImpl(final ConfigurationPoller configurationPoller) { + this.configurationPoller = configurationPoller; } - @Override - public void init() { - configurationPoller.addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - configurationPoller.addListener(Product.FFE_FLAGS, UniversalFlagConfigParser.INSTANCE, this); - configurationPoller.start(); + /** + * Registers the Feature Flags product. Registering at agent startup lets the first Remote + * Configuration poll ask for it. + */ + public synchronized void start() { + if (this.started) { + return; + } + this.started = true; + this.configurationPoller.addCapabilities(CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + // Listen as serialized event to prevent depending on the deserializer from agent + this.configurationPoller.addListener(FFE_FLAGS, content -> content, this); + this.configurationPoller.start(); } - @Override - public void close() { - configurationPoller.removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - configurationPoller.removeListeners(Product.FFE_FLAGS); - configurationPoller.stop(); + /** + * Subscribes to the configuration documents. + * + * @param subscriber the subscriber, receiving the current document if any. + * @return the handle to close to unsubscribe. + */ + public AutoCloseable subscribe(final Consumer subscriber) { + start(); + // Keep registration and replay atomic with notification snapshots. + synchronized (this.subscribers) { + this.subscribers.add(subscriber); + final byte[] document = this.current; + if (document != null) { + subscriber.accept(document); + } + } + return () -> this.subscribers.remove(subscriber); + } + + /** Unregisters the Feature Flags product and drops the subscribers. */ + public synchronized void close() { + if (!this.started) { + return; + } + this.started = false; + this.configurationPoller.removeCapabilities(CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + this.configurationPoller.removeListeners(FFE_FLAGS); + this.subscribers.clear(); + this.current = null; } @Override public void accept( final String configKey, - @Nullable final ServerConfiguration configuration, + @Nullable final byte[] content, final PollingRateHinter pollingRateHinter) { - FeatureFlaggingGateway.dispatch(configuration); + final Iterator> snapshot; + synchronized (this.subscribers) { + this.current = content; + snapshot = this.subscribers.iterator(); + } + while (snapshot.hasNext()) { + final Consumer subscriber = snapshot.next(); + try { + subscriber.accept(content); + } catch (final RuntimeException e) { + LOGGER.debug("Feature Flags configuration subscriber failed", e); + } + } } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java index f97259d9368..e9c1200868e 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java @@ -1,8 +1,6 @@ package com.datadog.featureflag; import datadog.trace.api.GlobalTracer; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.SpanEnrichmentEvent; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; import java.util.concurrent.atomic.AtomicBoolean; @@ -11,10 +9,9 @@ /** * Agent-side owner of APM feature-flag span enrichment. This is the WRITE tier of the - * capture-vs-write split: it listens on {@link FeatureFlaggingGateway} for {@link - * SpanEnrichmentEvent}s dispatched by the published {@code dd-openfeature} provider during flag - * evaluation, resolves the active local-root span, and accumulates per-trace state that a {@link - * SpanEnrichmentInterceptor} later flushes onto the root when the trace completes. + * capture-vs-write split: it receives the evaluations captured by the {@code dd-openfeature} SDK + * through its instrumentation, resolves the active local-root span, and accumulates per-trace state + * that a {@link SpanEnrichmentInterceptor} later flushes onto the root when the trace completes. * *

Process-wide singleton (restart-safe). Use {@link #getInstance()} for the agent wiring. * The tracer keeps trace interceptors for the life of the JVM and offers no removal API, so the @@ -22,18 +19,17 @@ * feature-flagging subsystem. A fresh writer per {@code start()} would build a second interceptor * at the same priority; the tracer would reject it and its state would never be read, silently * disabling enrichment after a restart. Reusing one instance avoids that: the single interceptor is - * registered exactly once and simply resumes when {@link #init()} re-subscribes the listener. + * registered exactly once and simply resumes on the next recorded evaluation. * *

Zero idle overhead when off. When the span-enrichment gate is off the provider adds no - * capture hook, so no seam events are dispatched, this listener never runs, and the interceptor is - * never registered — the tracer's write path is untouched. The interceptor is registered lazily on - * the first enrichment event that has an active span, so a service that enables the feature but - * never evaluates a flag on a traced request still pays nothing. + * capture hook, so no evaluation is recorded, this writer never runs, and the interceptor is never + * registered — the tracer's write path is untouched. The interceptor is registered lazily on the + * first enrichment event that has an active span, so a service that enables the feature but never + * evaluates a flag on a traced request still pays nothing. * *

All work is wrapped in try/catch — enrichment must NEVER break flag evaluation. */ -public final class SpanEnrichmentWriter - implements FeatureFlaggingGateway.SpanEnrichmentListener, AutoCloseable { +public final class SpanEnrichmentWriter implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentWriter.class); @@ -106,45 +102,31 @@ private SpanEnrichmentWriter() { this.interceptor = new SpanEnrichmentInterceptor(states); } - /** Starts listening for enrichment events. Safe to call again after {@link #close()}. */ - public void init() { - FeatureFlaggingGateway.addSpanEnrichmentListener(this); - } - /** - * Stops listening and drops any residual state. The interceptor stays registered with the tracer - * (it cannot be removed) but goes inert while the state is empty; a later {@link #init()} resumes - * enrichment on the same interceptor. + * Drops any residual state. The interceptor stays registered with the tracer (it cannot be + * removed) but goes inert while the state is empty; later evaluations resume enrichment on the + * same interceptor. */ + @Override public void close() { - FeatureFlaggingGateway.removeSpanEnrichmentListener(this); states.clear(); } - @Override - public void accept(final SpanEnrichmentEvent event) { - if (event == null) { - return; - } + /** + * Records an evaluation that resolved to a split with a serial id. + * + * @param serialId the split serial id. + * @param doLog whether the allocation logs exposures, to also record the subject. + * @param targetingKey the optional targeting key of the subject. + */ + public void serialId(final int serialId, final boolean doLog, final String targetingKey) { try { - final AgentSpan root = rootSpanResolver.activeLocalRoot(); - if (root == null) { - return; // no active span → nothing to enrich (and nothing to register the interceptor for) - } - if (!ensureInterceptorRegistered()) { - // The interceptor isn't registered (e.g. tracer absent), so nothing would ever flush this - // state — skip accumulating. A later event retries registration. - return; - } - final SpanEnrichmentAccumulator state = states.getOrCreate(root); - if (event.hasSerialId()) { - final int serialId = event.serialId(); + final SpanEnrichmentAccumulator state = activeRootState(); + if (state != null) { state.addSerialId(serialId); - if (event.doLog() && event.targetingKey() != null) { - state.addSubject(event.targetingKey(), serialId); + if (doLog && targetingKey != null) { + state.addSubject(targetingKey, serialId); } - } else if (event.flagKey() != null) { - state.addDefault(event.flagKey(), event.defaultValue()); } } catch (final Throwable t) { // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. @@ -152,6 +134,40 @@ public void accept(final SpanEnrichmentEvent event) { } } + /** + * Records an evaluation that resolved to its runtime default value. + * + * @param flagKey the flag key. + * @param defaultValue the native default value. + */ + public void runtimeDefault(final String flagKey, final Object defaultValue) { + if (flagKey == null) { + return; + } + try { + final SpanEnrichmentAccumulator state = activeRootState(); + if (state != null) { + state.addDefault(flagKey, defaultValue); + } + } catch (final Throwable t) { + log.debug("Span-enrichment accumulation failed", t); + } + } + + private SpanEnrichmentAccumulator activeRootState() { + final AgentSpan root = rootSpanResolver.activeLocalRoot(); + if (root == null) { + return null; // no active span → nothing to enrich (and nothing to register the interceptor + // for) + } + if (!ensureInterceptorRegistered()) { + // The interceptor isn't registered (e.g. tracer absent), so nothing would ever flush this + // state — skip accumulating. A later evaluation retries registration. + return null; + } + return states.getOrCreate(root); + } + /** * @return true once the interceptor is registered with the tracer. */ diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java deleted file mode 100644 index 27fd3f70c70..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java +++ /dev/null @@ -1,641 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.remoteconfig.ConfigurationDeserializer; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Feature; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ParsedSemver; -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 java.io.ByteArrayInputStream; -import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.time.Instant; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import okio.BufferedSource; -import okio.Okio; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -final class UniversalFlagConfigParser implements ConfigurationDeserializer { - - private static final Logger LOGGER = LoggerFactory.getLogger(UniversalFlagConfigParser.class); - - static final String INVALID_FLAG = "invalid_flag"; - static final String INVALID_SEMVER_COMPARAND = "invalid_semver_comparand"; - private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; - - /** - * Side-channel for tracking flags that failed semver comparand validation during parsing. Cleared - * and populated by {@link #parse(JsonReader)} so that {@link ServerConfiguration#invalidFlags} - * can be set after the Moshi adapter returns. - */ - static final ThreadLocal> INVALID_FLAGS_HOLDER = - ThreadLocal.withInitial(HashMap::new); - - static final UniversalFlagConfigParser INSTANCE = new UniversalFlagConfigParser(); - - private static final Moshi MOSHI = - new Moshi.Builder() - .add(Instant.class, new InstantAdapter()) - .add(ShardAdapter.FACTORY) - .add(AllocationAdapter.FACTORY) - .add(FlagMapAdapter.FACTORY) - .add(LenientBooleanAdapter.FACTORY) - .add(FeatureListAdapter.FACTORY) - .build(); - private static final JsonAdapter V1_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); - - private UniversalFlagConfigParser() {} - - @Override - public ServerConfiguration deserialize(final byte[] content) throws IOException { - try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { - final JsonReader reader = JsonReader.of(source); - final ServerConfiguration configuration = parse(reader); - requireEndOfDocument(reader); - return configuration; - } - } - - @Nullable - ServerConfiguration parse(final JsonReader reader) throws IOException { - INVALID_FLAGS_HOLDER.get().clear(); - final ServerConfiguration configuration = V1_ADAPTER.fromJson(reader); - if (configuration != null) { - final Map invalid = new HashMap<>(INVALID_FLAGS_HOLDER.get()); - if (!invalid.isEmpty()) { - configuration.invalidFlags = invalid; - } - } - INVALID_FLAGS_HOLDER.get().clear(); - return configuration; - } - - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } - - /** Converts a UFC uint32 wire value to its binary-compatible int representation. */ - private static int toUnsignedInt(@Nullable final Long value, final String fieldName) { - if (value == null) { - return 0; - } - if (value < 0 || value > MAX_UNSIGNED_INT) { - throw new InvalidFlagException("flag contains an invalid " + fieldName + " value"); - } - return value.intValue(); - } - - /** Validates the required nested UFC fields and SemVer comparands for a flag. */ - private static void validateFlag(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (final Allocation allocation : flag.allocations) { - if (allocation == null) { - continue; - } - validateConditionOperands(flagKey, allocation); - if (allocation.splits == null) { - continue; - } - for (final Split split : allocation.splits) { - if (split == null) { - continue; - } - if (split.shards == null) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" contains a split with missing shards"); - } - for (final Shard shard : split.shards) { - if (shard == null - || Integer.toUnsignedLong(shard.totalShards) == 0 - || shard.ranges == null) { - throw new InvalidFlagException("flag \"" + flagKey + "\" contains invalid shards"); - } - for (final ShardRange range : shard.ranges) { - if (range == null) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" contains an invalid shard range"); - } - } - } - } - } - validateAndCacheSemverComparands(flagKey, flag); - } - - private static void validateConditionOperands(final String flagKey, final Allocation allocation) { - if (allocation.rules == null) { - return; - } - for (final Rule rule : allocation.rules) { - if (rule == null || rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition == null || condition.operator == null) { - continue; - } - switch (condition.operator) { - case LT: - case LTE: - case GT: - case GTE: - if (!(condition.value instanceof Number)) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" has a non-numeric condition"); - } - break; - case ONE_OF: - case NOT_ONE_OF: - if (!(condition.value instanceof List)) { - throw new InvalidFlagException("flag \"" + flagKey + "\" has a non-list condition"); - } - break; - case IS_NULL: - if (!(condition.value instanceof Boolean)) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" has a non-boolean condition"); - } - break; - default: - break; - } - } - } - } - - /** - * Validates and caches SemVer comparands for all SEMVER_* conditions in a flag. Throws {@link - * InvalidSemverComparandException} if any condition has an invalid or non-string comparand value. - */ - private static void validateAndCacheSemverComparands(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (int allocIdx = 0; allocIdx < flag.allocations.size(); allocIdx++) { - final Allocation allocation = flag.allocations.get(allocIdx); - if (allocation.rules == null) { - continue; - } - for (final Rule rule : allocation.rules) { - if (rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition.operator == null) { - continue; - } - switch (condition.operator) { - case SEMVER_EQ: - case SEMVER_NEQ: - case SEMVER_LT: - case SEMVER_LTE: - case SEMVER_GT: - case SEMVER_GTE: - if (!(condition.value instanceof String)) { - throw new InvalidSemverComparandException( - "flag \"" - + flagKey - + "\" allocation " - + allocIdx - + " rule has condition with operator \"" - + condition.operator - + "\" that requires string value"); - } - final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); - if (parsed == null) { - throw new InvalidSemverComparandException( - "flag \"" - + flagKey - + "\" allocation " - + allocIdx - + " rule has condition with operator \"" - + condition.operator - + "\" and invalid semantic version \"" - + condition.value - + "\""); - } - condition.semverComparand = parsed; - break; - default: - // Non-semver operators are not validated here - break; - } - } - } - } - } - - /** Thrown when a flag has an invalid UFC shape. */ - static final class InvalidFlagException extends IllegalArgumentException { - InvalidFlagException(final String message) { - super(message); - } - } - - /** Thrown when a SEMVER_* condition has an invalid or non-string comparand value. */ - static final class InvalidSemverComparandException extends IllegalArgumentException { - InvalidSemverComparandException(final String message) { - super(message); - } - } - - /** - * Reads UFC uint32 shard fields through Long first, then preserves the bootstrap model's int ABI - * by storing the accepted value as its two's-complement bit pattern. - */ - static final class ShardAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || type != Shard.class) { - return null; - } - return new ShardAdapter(moshi.adapter(ShardJson.class)); - } - }; - - private final JsonAdapter delegate; - - ShardAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Nullable - @Override - public Shard fromJson(@Nonnull final JsonReader reader) throws IOException { - final ShardJson shard = delegate.fromJson(reader); - if (shard == null) { - return null; - } - final List ranges; - if (shard.ranges == null) { - ranges = null; - } else { - ranges = new ArrayList<>(); - for (final ShardRangeJson range : shard.ranges) { - ranges.add( - range == null - ? null - : new ShardRange( - toUnsignedInt(range.start, "range start"), - toUnsignedInt(range.end, "range end"))); - } - } - return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Shard value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class ShardJson { - String salt; - List ranges; - Long totalShards; - } - - static final class ShardRangeJson { - Long start; - Long end; - } - - static final class FlagMapAdapter extends JsonAdapter> { - - private static final Type FLAGS_TYPE = - Types.newParameterizedType(Map.class, String.class, Flag.class); - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { - return null; - } - return new FlagMapAdapter(moshi.adapter(Flag.class)); - } - }; - - private final JsonAdapter flagAdapter; - - FlagMapAdapter(final JsonAdapter flagAdapter) { - this.flagAdapter = flagAdapter; - } - - @Nullable - @Override - public Map fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - final Map flags = new HashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String flagKey = reader.nextName(); - final Object rawFlag = reader.readJsonValue(); - try { - final Flag flag = flagAdapter.fromJsonValue(rawFlag); - if (flag != null) { - validateFlag(flagKey, flag); - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException error) { - INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_FLAG); - if (error instanceof InvalidSemverComparandException) { - INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_SEMVER_COMPARAND); - } - LOGGER.warn( - "Dropping malformed FFE flag {} during remote config deserialization: {}", - flagKey, - error.toString()); - } - } - reader.endObject(); - return flags; - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - /** - * Reads a boxed Boolean tolerantly: JSON null and wrong-typed values (string, number, object, - * array) map to null rather than aborting the enclosing object's parse. Downstream reads must use - * Boolean.TRUE.equals(field) so null resolves to the privacy-preserving default. - * - *

Only applies to Boolean.class (not primitive boolean), so mandatory primitive-boolean fields - * (e.g. Flag.enabled) keep their strict parse. - */ - static final class LenientBooleanAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || type != Boolean.class) { - return null; - } - return new LenientBooleanAdapter(); - } - }; - - @Nullable - @Override - public Boolean fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.BOOLEAN) { - return reader.nextBoolean(); - } - // null and every wrong-typed value collapse to null so the caller falls back to its default - // rather than the enclosing config being rejected wholesale. - reader.skipValue(); - return null; - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Boolean value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - /** - * Reads a split's features. Features only feed exposure hooks, so a malformed list or entry is - * dropped instead of rejecting the flag: it must never change which variation a subject gets. - */ - static final class FeatureListAdapter extends JsonAdapter> { - - private static final Type FEATURES_TYPE = Types.newParameterizedType(List.class, Feature.class); - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, FEATURES_TYPE)) { - return null; - } - return new FeatureListAdapter(); - } - }; - - @Nullable - @Override - public List fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) { - reader.skipValue(); - return null; - } - final List features = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - final Feature feature = readFeature(reader); - if (feature != null) { - features.add(feature); - } - } - reader.endArray(); - return Collections.unmodifiableList(features); - } - - @Nullable - private static Feature readFeature(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - String key = null; - Object value = null; - List destinations = Collections.emptyList(); - reader.beginObject(); - while (reader.hasNext()) { - final String name = reader.nextName(); - if ("key".equals(name) && reader.peek() == JsonReader.Token.STRING) { - key = reader.nextString(); - } else if ("value".equals(name)) { - value = readScalar(reader); - } else if ("destinations".equals(name)) { - destinations = readDestinations(reader); - } else { - reader.skipValue(); - } - } - reader.endObject(); - if (key == null || key.isEmpty() || value == null || destinations.isEmpty()) { - return null; - } - return new Feature(key, value, destinations); - } - - /** Keeps every non-empty string, including destinations this SDK does not know yet. */ - private static List readDestinations(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) { - reader.skipValue(); - return Collections.emptyList(); - } - final List destinations = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - if (reader.peek() == JsonReader.Token.STRING) { - final String destination = reader.nextString(); - if (!destination.isEmpty()) { - destinations.add(destination); - } - } else { - reader.skipValue(); - } - } - reader.endArray(); - return Collections.unmodifiableList(destinations); - } - - @Nullable - private static Object readScalar(final JsonReader reader) throws IOException { - switch (reader.peek()) { - case STRING: - return reader.nextString(); - case NUMBER: - return reader.nextDouble(); - case BOOLEAN: - return reader.nextBoolean(); - default: - reader.skipValue(); - return null; - } - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final List value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class InstantAdapter extends JsonAdapter { - - @Nullable - @Override - public Instant fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - return parseInstant(reader.nextString()); - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Instant value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class AllocationAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, Allocation.class)) { - return null; - } - return new AllocationAdapter(moshi.adapter(AllocationJson.class)); - } - }; - - private final JsonAdapter delegate; - - AllocationAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Nullable - @Override - public Allocation fromJson(@Nonnull final JsonReader reader) throws IOException { - final AllocationJson allocation = delegate.fromJson(reader); - if (allocation == null) { - return null; - } - return Allocation.fromInstants( - allocation.key, - allocation.rules, - allocation.startAt, - allocation.endAt, - allocation.splits, - allocation.doLog); - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Allocation value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class AllocationJson { - String key; - List rules; - Instant startAt; - Instant endAt; - List splits; - Boolean doLog; - } - - @Nullable - private static Instant parseInstant(final String date) { - try { - return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); - } catch (Exception e) { - // ignore wrongly set dates - return null; - } - } -} 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/AgentlessFeatureFlagBackendApiTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java deleted file mode 100644 index 7ae0c3de5c3..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java +++ /dev/null @@ -1,608 +0,0 @@ -package com.datadog.featureflag; - -import static org.junit.jupiter.api.Assertions.assertEquals; -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 datadog.communication.BackendApi; -import datadog.communication.HttpResponseException; -import datadog.communication.http.OkHttpUtils; -import datadog.communication.util.IOThrowingFunction; -import java.io.IOException; -import java.io.InputStream; -import java.net.ConnectException; -import java.net.SocketException; -import java.net.SocketTimeoutException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.LongSupplier; -import java.util.function.Supplier; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import okhttp3.MediaType; -import okhttp3.RequestBody; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; - -class AgentlessFeatureFlagBackendApiTest { - - @Test - void recoveryErrorDoesNotPermanentlyBlockTheSiblingWriter() throws Exception { - final AtomicLong clock = new AtomicLong(); - final FeatureFlagRouteSelector selector = new FeatureFlagRouteSelector(clock::get, 10); - final RecordingBackendApi recovered = new RecordingBackendApi(); - final AgentlessFeatureFlagBackendApi exposures = - createApi( - null, - null, - () -> { - throw new LinkageError("probe failure"); - }, - () -> null, - "exposure", - selector); - final AgentlessFeatureFlagBackendApi evaluations = - createApi(null, null, () -> recovered, () -> null, "flag evaluation", selector); - - clock.set(10); - assertThrows( - LinkageError.class, - () -> exposures.post("exposures", requestBody("first"), stream -> null, null, false)); - clock.set(20); - evaluations.post("flagevaluation", requestBody("second"), stream -> null, null, false); - - assertEquals(1, recovered.calls); - } - - @ParameterizedTest - @ValueSource(ints = {404, 405}) - void replaysRejectedBatchDirectlyAndKeepsDirectRoute(final int statusCode) throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new HttpResponseException(statusCode, "rejected")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - createApi( - local, - null, - () -> local, - () -> { - directApiCreations.incrementAndGet(); - return direct; - }, - "flag evaluation"); - final RequestBody firstBody = requestBody("first"); - final RequestBody secondBody = requestBody("second"); - - assertEquals(0, directApiCreations.get()); - api.post("flagevaluation", firstBody, stream -> null, null, false); - api.post("flagevaluation", secondBody, stream -> null, null, false); - - assertEquals(1, directApiCreations.get()); - assertEquals(1, local.calls); - assertEquals(2, direct.calls); - assertSame(firstBody, local.requestBodies.get(0)); - assertSame(firstBody, direct.requestBodies.get(0)); - assertSame(secondBody, direct.requestBodies.get(1)); - } - - @ParameterizedTest - @MethodSource("featureFlagRoutes") - void fallsBackAfterConnectionRefusal(final String route, final String eventType) - throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new ConnectException("connection refused")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessFeatureFlagBackendApi api = - createApi(local, null, () -> local, () -> direct, eventType); - - api.post(route, requestBody(eventType), stream -> null, null, false); - - assertEquals(1, local.calls); - assertEquals(1, direct.calls); - } - - @Test - void keepsDirectRouteStickyAfterLocalFailure() throws Exception { - final AtomicLong clock = new AtomicLong(); - final RecordingBackendApi unavailableLocal = - new RecordingBackendApi(new ConnectException("connection refused")); - final RecordingBackendApi recoveredLocal = new RecordingBackendApi(); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessFeatureFlagBackendApi api = - createApi( - unavailableLocal, null, () -> recoveredLocal, () -> direct, "exposure", clock::get, 10); - - api.post("exposures", requestBody("first"), stream -> null, null, false); - clock.set(9); - api.post("exposures", requestBody("second"), stream -> null, null, false); - clock.set(10); - api.post("exposures", requestBody("third"), stream -> null, null, false); - - assertEquals(1, unavailableLocal.calls); - assertEquals(3, direct.calls); - assertEquals(0, recoveredLocal.calls); - } - - @ParameterizedTest - @ValueSource(ints = {403, 429, 500}) - void doesNotReplayAmbiguousHttpFailureButSwitchesFutureBatches(final int statusCode) - throws Exception { - assertNoSameBatchReplayButUsesDirectForNext(new HttpResponseException(statusCode, "ambiguous")); - } - - @Test - void doesNotReplayTimeoutButSwitchesFutureBatches() throws Exception { - assertNoSameBatchReplayButUsesDirectForNext(new SocketTimeoutException("timed out")); - } - - @Test - void doesNotReplayConnectionResetButSwitchesFutureBatches() throws Exception { - assertNoSameBatchReplayButUsesDirectForNext(new SocketException("connection reset")); - } - - @Test - void startsDirectAndNeverProbesLocal() throws Exception { - final AtomicLong clock = new AtomicLong(); - final RecordingBackendApi direct = new RecordingBackendApi(); - final RecordingBackendApi recoveredLocal = new RecordingBackendApi(); - final AtomicInteger proxyApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - createApi( - null, - direct, - () -> { - proxyApiCreations.incrementAndGet(); - return recoveredLocal; - }, - () -> direct, - "exposure", - clock::get, - 10); - - api.post("exposures", requestBody("first"), stream -> null, null, false); - clock.set(9); - api.post("exposures", requestBody("second"), stream -> null, null, false); - clock.set(10); - api.post("exposures", requestBody("third"), stream -> null, null, false); - - assertEquals(0, proxyApiCreations.get()); - assertEquals(3, direct.calls); - assertEquals(0, recoveredLocal.calls); - } - - @Test - void concurrentSendersDoNotBlockOnOrDuplicateARecoveryProbe() throws Exception { - final AtomicLong clock = new AtomicLong(10); - final RecordingBackendApi recoveredLocal = new RecordingBackendApi(); - final AtomicInteger proxyApiCreations = new AtomicInteger(); - final CountDownLatch probeStarted = new CountDownLatch(1); - final CountDownLatch releaseProbe = new CountDownLatch(1); - final AgentlessFeatureFlagBackendApi api = - createApi( - null, - null, - () -> { - proxyApiCreations.incrementAndGet(); - probeStarted.countDown(); - try { - assertTrue(releaseProbe.await(5, TimeUnit.SECONDS)); - } catch (final InterruptedException exception) { - Thread.currentThread().interrupt(); - throw new AssertionError(exception); - } - return recoveredLocal; - }, - () -> null, - "flag evaluation", - clock::get, - 10); - - // Construction schedules the first probe for t=20. - clock.set(20); - final CompletableFuture recoveringPost = - CompletableFuture.runAsync( - () -> { - try { - api.post("flagevaluation", requestBody("probe"), stream -> null, null, false); - } catch (final IOException exception) { - throw new AssertionError(exception); - } - }); - assertTrue(probeStarted.await(5, TimeUnit.SECONDS)); - - assertThrows( - IOException.class, - () -> api.post("flagevaluation", requestBody("parallel"), stream -> null, null, false)); - releaseProbe.countDown(); - recoveringPost.get(5, TimeUnit.SECONDS); - - assertEquals(1, proxyApiCreations.get()); - assertEquals(1, recoveredLocal.calls); - } - - @Test - void failedRecoveryIsStickyForAnotherCooldown() throws Exception { - final AtomicLong clock = new AtomicLong(); - final AtomicInteger proxyApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - createApi( - null, - null, - () -> { - proxyApiCreations.incrementAndGet(); - return null; - }, - () -> null, - "exposure", - clock::get, - 10); - - clock.set(10); - assertThrows( - IOException.class, - () -> api.post("exposures", requestBody("first"), stream -> null, null, false)); - assertThrows( - IOException.class, - () -> api.post("exposures", requestBody("second"), stream -> null, null, false)); - clock.set(20); - assertThrows( - IOException.class, - () -> api.post("exposures", requestBody("third"), stream -> null, null, false)); - - assertEquals(2, proxyApiCreations.get()); - } - - @Test - void doesNotRetryDirectApiCreationWhenFallbackIsUnavailable() { - final RecordingBackendApi local = - new RecordingBackendApi(new HttpResponseException(404, "rejected")); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - createApi( - local, - null, - () -> local, - () -> { - directApiCreations.incrementAndGet(); - return null; - }, - "exposure"); - - assertThrows( - HttpResponseException.class, - () -> api.post("exposures", requestBody("first"), stream -> null, null, false)); - assertThrows( - IOException.class, - () -> api.post("exposures", requestBody("second"), stream -> null, null, false)); - - assertEquals(1, local.calls); - assertEquals(1, directApiCreations.get()); - } - - @Test - void permitsUnavailableStartupSoLocalDeliveryCanRecover() { - final AgentlessFeatureFlagBackendApi api = - createApi(null, null, () -> null, () -> null, "flag evaluation"); - - assertThrows( - IOException.class, - () -> api.post("flagevaluation", requestBody("first"), stream -> null, null, false)); - } - - @Test - void propagatesDirectFailuresWithoutTryingToFallback() { - final SocketTimeoutException failure = new SocketTimeoutException("direct timeout"); - final RecordingBackendApi direct = new RecordingBackendApi(failure); - final AtomicInteger proxyApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - createApi( - null, - direct, - () -> { - proxyApiCreations.incrementAndGet(); - return null; - }, - () -> direct, - "exposure"); - - assertSame( - failure, - assertThrows( - SocketTimeoutException.class, - () -> api.post("exposures", requestBody("direct"), stream -> null, null, false))); - assertEquals(0, proxyApiCreations.get()); - assertEquals(1, direct.calls); - } - - @Test - void concurrentProxyFailuresOnlyTransitionTheMatchingRouteOnce() throws Exception { - final CountDownLatch localCallsStarted = new CountDownLatch(2); - final CountDownLatch releaseFailures = new CountDownLatch(1); - final BackendApi local = - new BackendApi() { - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) - throws IOException { - localCallsStarted.countDown(); - try { - assertTrue(releaseFailures.await(5, TimeUnit.SECONDS)); - } catch (final InterruptedException exception) { - Thread.currentThread().interrupt(); - throw new AssertionError(exception); - } - throw new HttpResponseException(404, "local route missing"); - } - }; - final AtomicInteger directCalls = new AtomicInteger(); - final BackendApi direct = - new BackendApi() { - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) { - directCalls.incrementAndGet(); - return null; - } - }; - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - createApi( - local, - null, - () -> local, - () -> { - directApiCreations.incrementAndGet(); - return direct; - }, - "exposure"); - - final CompletableFuture first = - CompletableFuture.runAsync(() -> postWithoutFailure(api, "first concurrent proxy request")); - final CompletableFuture second = - CompletableFuture.runAsync( - () -> postWithoutFailure(api, "second concurrent proxy request")); - assertTrue(localCallsStarted.await(5, TimeUnit.SECONDS)); - releaseFailures.countDown(); - first.get(5, TimeUnit.SECONDS); - second.get(5, TimeUnit.SECONDS); - - assertEquals(1, directApiCreations.get()); - assertEquals(2, directCalls.get()); - } - - @Test - void sharesRouteTransitionsAcrossExposureAndFlagEvaluationWriters() throws Exception { - final FeatureFlagRouteSelector routeSelector = new FeatureFlagRouteSelector(); - final RecordingBackendApi exposureLocal = - new RecordingBackendApi(new SocketTimeoutException("ambiguous timeout")); - final RecordingBackendApi exposureDirect = new RecordingBackendApi(); - final RecordingBackendApi evaluationLocal = new RecordingBackendApi(); - final RecordingBackendApi evaluationDirect = new RecordingBackendApi(); - final AgentlessFeatureFlagBackendApi exposureApi = - createApi( - exposureLocal, - exposureDirect, - () -> exposureLocal, - () -> exposureDirect, - "exposure", - routeSelector); - final AgentlessFeatureFlagBackendApi evaluationApi = - createApi( - evaluationLocal, - evaluationDirect, - () -> evaluationLocal, - () -> evaluationDirect, - "flag evaluation", - routeSelector); - - assertThrows( - SocketTimeoutException.class, - () -> exposureApi.post("exposures", requestBody("first"), stream -> null, null, false)); - evaluationApi.post("flagevaluation", requestBody("second"), stream -> null, null, false); - - assertEquals(1, exposureLocal.calls); - assertEquals(0, exposureDirect.calls); - assertEquals(0, evaluationLocal.calls); - assertEquals(1, evaluationDirect.calls); - } - - @Test - void failedRecoveryExceptionRemainsUnavailable() { - final AtomicLong clock = new AtomicLong(); - final FeatureFlagRouteSelector routeSelector = new FeatureFlagRouteSelector(clock::get, 10); - final AgentlessFeatureFlagBackendApi api = - createApi( - null, - null, - () -> { - throw new IllegalStateException("discovery failed"); - }, - () -> null, - "flag evaluation", - routeSelector); - clock.set(10); - assertThrows( - IOException.class, - () -> api.post("flagevaluation", requestBody("missing"), stream -> null, null, false)); - assertEquals(FeatureFlagRouteSelector.Route.UNAVAILABLE, routeSelector.current()); - } - - @Test - void sharedDirectRouteWithoutWriterClientIsUnavailable() { - final FeatureFlagRouteSelector routeSelector = new FeatureFlagRouteSelector(); - routeSelector.initialize(() -> null, true); - final AgentlessFeatureFlagBackendApi api = - createApi(null, null, () -> null, () -> null, "flag evaluation", routeSelector); - - assertThrows( - IOException.class, - () -> api.post("flagevaluation", requestBody("missing"), stream -> null, null, false)); - assertEquals(FeatureFlagRouteSelector.Route.DIRECT, routeSelector.current()); - } - - @Test - void workingDirectRouteDoesNotAttemptRecovery() throws Exception { - final AtomicLong clock = new AtomicLong(); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AtomicInteger proxyApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - createApi( - null, - direct, - () -> { - proxyApiCreations.incrementAndGet(); - throw new IllegalStateException("discovery failed"); - }, - () -> direct, - "exposure", - clock::get, - 10); - - clock.set(10); - api.post("exposures", requestBody("survives recovery failure"), stream -> null, null, false); - - assertEquals(1, direct.calls); - assertEquals(0, proxyApiCreations.get()); - } - - private static void assertNoSameBatchReplayButUsesDirectForNext(final IOException failure) - throws Exception { - final RecordingBackendApi local = new RecordingBackendApi(failure); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - createApi( - local, - null, - () -> local, - () -> { - directApiCreations.incrementAndGet(); - return direct; - }, - "flag evaluation"); - - assertThrows( - IOException.class, - () -> api.post("flagevaluation", requestBody("evaluation"), stream -> null, null, false)); - api.post("flagevaluation", requestBody("next"), stream -> null, null, false); - - assertEquals(1, local.calls); - assertEquals(1, direct.calls); - assertEquals(1, directApiCreations.get()); - } - - private static RequestBody requestBody(final String value) { - return RequestBody.create(MediaType.parse("application/json"), value); - } - - private static AgentlessFeatureFlagBackendApi createApi( - BackendApi local, - BackendApi direct, - Supplier discoverLocal, - Supplier createDirect, - String eventType) { - return createApi( - local, direct, discoverLocal, createDirect, eventType, new FeatureFlagRouteSelector()); - } - - private static AgentlessFeatureFlagBackendApi createApi( - BackendApi local, - BackendApi direct, - Supplier discoverLocal, - Supplier createDirect, - String eventType, - LongSupplier clock, - long interval) { - return createApi( - local, - direct, - discoverLocal, - createDirect, - eventType, - new FeatureFlagRouteSelector(clock, interval)); - } - - private static AgentlessFeatureFlagBackendApi createApi( - BackendApi local, - BackendApi direct, - Supplier discoverLocal, - Supplier createDirect, - String eventType, - FeatureFlagRouteSelector selector) { - final AtomicReference localApi = new AtomicReference<>(local); - selector.initialize(() -> local == null ? null : "local", direct != null); - return new AgentlessFeatureFlagBackendApi( - direct, - () -> { - localApi.set(discoverLocal.get()); - return localApi.get() == null ? null : "local"; - }, - endpoint -> localApi.get(), - createDirect, - eventType, - selector); - } - - private static void postWithoutFailure( - final AgentlessFeatureFlagBackendApi api, final String body) { - try { - api.post("exposures", requestBody(body), stream -> null, null, false); - } catch (final IOException exception) { - throw new AssertionError(exception); - } - } - - private static Stream featureFlagRoutes() { - return Stream.of( - Arguments.of("exposures", "exposure"), Arguments.of("flagevaluation", "flag evaluation")); - } - - private static final class RecordingBackendApi implements BackendApi { - private IOException failure; - private final List requestBodies = new ArrayList<>(); - private int calls; - - private RecordingBackendApi() { - this(null); - } - - private RecordingBackendApi(@Nullable final IOException failure) { - this.failure = failure; - } - - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) - throws IOException { - calls++; - requestBodies.add(requestBody); - if (failure != null) { - throw failure; - } - return null; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/EventProxyTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/EventProxyTest.java new file mode 100644 index 00000000000..c0492e04819 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/EventProxyTest.java @@ -0,0 +1,102 @@ +package com.datadog.featureflag; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.communication.BackendApi; +import datadog.communication.BackendApiFactory; +import datadog.communication.HttpResponseException; +import datadog.communication.http.HttpRetryPolicy; +import datadog.trace.api.intake.Intake; +import java.io.IOException; +import java.net.ConnectException; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class EventProxyTest { + private static final byte[] PAYLOAD = "{}".getBytes(UTF_8); + + @Test + void isUnavailableWithoutAgentProxySupport() throws IOException { + final EventProxy proxy = new EventProxy(mock(BackendApiFactory.class), false); + + assertFalse(proxy.isAvailable()); + assertFalse(proxy.post("exposures", PAYLOAD)); + } + + @Test + void postsEachRouteThroughItsOwnClient() throws IOException { + final BackendApiFactory factory = mock(BackendApiFactory.class); + final BackendApi exposures = mock(BackendApi.class); + final BackendApi flagEvaluations = mock(BackendApi.class); + when(factory.createEvpProxyApi(Intake.EVENT_PLATFORM, true)).thenReturn(exposures); + when(factory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(flagEvaluations); + final EventProxy proxy = new EventProxy(factory, false); + + assertTrue(proxy.isAvailable()); + assertTrue(proxy.post("exposures", PAYLOAD)); + assertTrue(proxy.post("flagevaluation", PAYLOAD)); + + verify(exposures).post(eq("exposures"), any(), any(), any(), eq(false)); + verify(flagEvaluations).post(eq("flagevaluation"), any(), any(), any(), eq(false)); + } + + @Test + void doesNotRetryWhenDirectFallbackIsAvailable() { + final BackendApiFactory factory = mock(BackendApiFactory.class); + final EventProxy proxy = new EventProxy(factory, true); + + proxy.isAvailable(); + + verify(factory) + .createEvpProxyApi(Intake.EVENT_PLATFORM, true, HttpRetryPolicy.Factory.NEVER_RETRY); + verify(factory) + .createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY); + } + + @Test + void reportsConnectionFailuresAsUnavailable() throws IOException { + final BackendApi api = proxyApi(new ConnectException("refused")); + + assertFalse(new EventProxy(factoryOf(api), false).post("exposures", PAYLOAD)); + } + + @TableTest({ + "Scenario | Status | Definitive", + "forbidden | 403 | true ", + "not found | 404 | true ", + "not allowed | 405 | true ", + "server error | 500 | false ", + "rate limited | 429 | false " + }) + void classifiesProxyRejections(final int status, final boolean definitive) throws IOException { + final BackendApi api = proxyApi(new HttpResponseException(status, "rejected")); + final EventProxy proxy = new EventProxy(factoryOf(api), false); + + if (definitive) { + assertFalse(proxy.post("exposures", PAYLOAD)); + } else { + assertThrows(HttpResponseException.class, () -> proxy.post("exposures", PAYLOAD)); + } + } + + private static BackendApi proxyApi(final IOException failure) throws IOException { + final BackendApi api = mock(BackendApi.class); + when(api.post(any(), any(), any(), any(), anyBoolean())).thenThrow(failure); + return api; + } + + private static BackendApiFactory factoryOf(final BackendApi api) { + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createEvpProxyApi(eq(Intake.EVENT_PLATFORM), anyBoolean())).thenReturn(api); + return factory; + } +} 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 deleted file mode 100644 index ca12d057578..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java +++ /dev/null @@ -1,882 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.communication.EvpProxy.ORIGIN_HEADER; -import static datadog.communication.EvpProxy.ORIGIN_VERSION_HEADER; -import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V4_EVP_PROXY_ENDPOINT; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static java.util.Arrays.asList; -import static java.util.Collections.singletonList; -import static java.util.Collections.singletonMap; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -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 static org.mockito.AdditionalAnswers.delegatesTo; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doAnswer; -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 static org.mockito.Mockito.when; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import datadog.common.queue.MessagePassingBlockingQueue; -import datadog.common.queue.Queues; -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.IntakeApi; -import datadog.communication.ddagent.DDAgentFeaturesDiscovery; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.communication.ddagent.TracerVersion; -import datadog.communication.http.HttpRetryPolicy; -import datadog.trace.agent.test.server.http.JavaTestHttpServer; -import datadog.trace.agent.test.server.http.JavaTestHttpServer.HandlerApi; -import datadog.trace.api.Config; -import datadog.trace.api.IdGenerationStrategy; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.Allocation; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.ExposuresRequest; -import datadog.trace.api.featureflag.exposure.Flag; -import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.exposure.Variant; -import datadog.trace.api.intake.Intake; -import datadog.trace.test.util.PollingConditions; -import java.io.ByteArrayInputStream; -import java.net.ConnectException; -import java.net.SocketTimeoutException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Queue; -import java.util.Random; -import java.util.Set; -import java.util.TreeSet; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.RequestBody; -import okio.Buffer; -import okio.Okio; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.tabletest.junit.TableTest; - -class ExposureWriterTests { - - private static final String EXPOSURES_ENDPOINT = "/evp_proxy/v2/api/v2/exposures"; - private static final String DIRECT_EXPOSURES_ENDPOINT = "/api/v2/exposures"; - private static final String API_KEY = "test-api-key"; - private static final double TIMEOUT_SECONDS = 5; - - private final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); - private Queue requests; - private Queue requestAttempts; - private Set failed; - private JavaTestHttpServer server; - private SharedCommunicationObjects sharedCommunicationObjects; - - @BeforeEach - void setUp() { - requests = new ConcurrentLinkedQueue<>(); - requestAttempts = new ConcurrentLinkedQueue<>(); - failed = Collections.newSetFromMap(new ConcurrentHashMap()); - JsonAdapter adapter = - new Moshi.Builder().build().adapter(ExposuresRequest.class); - server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> { - h.prefix(EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); - h.prefix( - DIRECT_EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); - })); - sharedCommunicationObjects = sharedCommunicationObjects(true); - } - - @AfterEach - void cleanup() { - if (server != null) { - server.close(); - } - } - - private void handleExposureRequest(HandlerApi api, JsonAdapter adapter) - throws Exception { - ExposuresRequest exposuresRequest = - adapter.fromJson( - Okio.buffer(Okio.source(new ByteArrayInputStream(api.getRequest().getBody())))); - String serviceName = exposuresRequest.context.get("service"); - requestAttempts.add(serviceName); - boolean failForever = "fail-forever".equals(serviceName); - boolean fail = serviceName.startsWith("fail") && (failed.add(serviceName) || failForever); - if (fail) { - api.getResponse().status(500).send("Boom!!!"); - } else { - requests.add(exposuresRequest); - api.getResponse().status(200).send("OK"); - } - } - - @TableTest({ - "service | env | version", - " | | ", - "'test-service' | 'test' | '23' ", - "'test-service' | | '23' ", - "'test-service' | 'test' | " - }) - void testExposureEventWrites(String service, String env, String version) throws Exception { - Config config = mockConfig(service, env, version); - List exposures = buildExposures(5); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - poll.eventually( - () -> { - assertFalse(requests.isEmpty()); - for (ExposuresRequest request : requests) { - assertContext(request.context, service, env, version); - } - assertExposures(allExposures(), exposures); - }); - } - } - - @Test - void testExposureRequestsCarrySdkIdentity() throws Exception { - // Exercise the production writer and factory on the fixed Remote Config EVP v2 route. - Config config = mockConfig("identity-service"); - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - writer.accept(buildExposure()); - - poll.eventually( - () -> { - assertNotNull(server.getLastRequest()); - assertEquals(EXPOSURES_ENDPOINT, server.getLastRequest().getPath()); - assertEquals("dd-trace-java", server.getLastRequest().getHeader("DD-EVP-ORIGIN")); - assertEquals( - TracerVersion.TRACER_VERSION, - server.getLastRequest().getHeader("DD-EVP-ORIGIN-VERSION")); - // The test resource must prevent a hardcoded loader fallback from passing unnoticed. - assertNotEquals("0.0.0", server.getLastRequest().getHeader("DD-EVP-ORIGIN-VERSION")); - assertNull(server.getLastRequest().getHeader("DD-API-KEY")); - }); - } finally { - sharedCommunicationObjects.agentHttpClient.connectionPool().evictAll(); - } - } - - @Test - void testAgentlessExposureEventWritesDirectlyWithApiKey() throws Exception { - Config config = mockConfig("test-service"); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getApiKey()).thenReturn(API_KEY); - BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - IntakeApi directApi = - new IntakeApi( - HttpUrl.get(server.getAddress()).resolve("/api/v2/"), - API_KEY, - "123", - HttpRetryPolicy.Factory.NEVER_RETRY, - new OkHttpClient.Builder().build(), - false); - when(backendApiFactory.createDirectIntakeApi( - eq(datadog.trace.api.intake.Intake.EVENT_PLATFORM), eq(true), eq(false))) - .thenReturn(directApi); - FeatureFlagBackendApiFactory exposureBackendApiFactory = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FeatureFlagEventType.EXPOSURE); - List exposures = buildExposures(5); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, exposureBackendApiFactory, config)) { - writer.init(); - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - poll.eventually( - () -> { - assertEquals(DIRECT_EXPOSURES_ENDPOINT, server.getLastRequest().getPath()); - assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); - assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); - assertExposures(allExposures(), exposures); - }); - } - } - - @Test - void testLruCache() throws Exception { - Config config = mockConfig("test-service"); - List exposures = buildExposures(6); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - // populating the cache - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - // all events are written - poll.eventually(() -> assertEquals(exposures.size(), allExposures().size())); - - // publishing duplicate events - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - // no events are written - MILLISECONDS.sleep(300); // wait until a flush happens - assertEquals(exposures.size(), allExposures().size()); - - // a new event is generated - writer.accept(buildExposure()); - - // oldest event is evicted and the new one is submitted - poll.eventually(() -> assertEquals(exposures.size() + 1, allExposures().size())); - } - } - - @Test - void testHighLoadScenario() throws Exception { - Config config = mockConfig("test-service"); - int exposuresPerThread = 100; - Random random = new Random(); - int threads = Runtime.getRuntime().availableProcessors(); - ExecutorService executor = Executors.newFixedThreadPool(threads); - List exposures = buildExposures(threads * exposuresPerThread); - CountDownLatch latch = new CountDownLatch(1); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl( - sharedCommunicationObjects, config, new FeatureFlagRouteSelector())) { - writer.init(); - List> futures = new ArrayList<>(); - for (int index = 0; index < exposures.size(); index += exposuresPerThread) { - List partition = - exposures.subList(index, Math.min(index + exposuresPerThread, exposures.size())); - futures.add( - executor.submit( - () -> { - latch.await(); - for (ExposureEvent exposure : partition) { - MILLISECONDS.sleep(random.nextInt(2)); - writer.accept(exposure); - } - return true; - })); - } - latch.countDown(); // start threads - - for (Future future : futures) { - assertTrue(future.get()); // wait for all threads to finish - } - poll.eventually(() -> assertExposures(allExposures(), exposures)); - } finally { - executor.shutdownNow(); - } - } - - @Test - void testQueueThresholdFlushesWithoutWaitingForTheInterval() throws Exception { - Config config = mockConfig("threshold-service"); - List exposures = buildExposures(101); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl( - 1 << 8, Long.MAX_VALUE, NANOSECONDS, sharedCommunicationObjects, config)) { - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - writer.init(); - - poll.eventually(() -> assertExposures(allExposures(), exposures)); - } - } - - @Test - void testCloseDrainsAndFinalFlushesExactlyOnceWithoutWaitingForTheInterval() throws Exception { - Config config = mockConfig("shutdown-service"); - List exposures = buildExposures(5); - ExposureWriterImpl writer = - new ExposureWriterImpl( - 1 << 4, Long.MAX_VALUE, NANOSECONDS, sharedCommunicationObjects, config); - - writer.init(); - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - writer.close(); - - assertFalse(writer.isSerializerThreadAlive()); - assertEquals(1, requests.size()); - assertExposures(allExposures(), exposures); - - // A repeated close and a stale listener invocation after close must neither replay the batch - // nor leave an event stranded in the queue. - writer.close(); - writer.accept(buildExposure()); - MILLISECONDS.sleep(200); - assertEquals(1, requests.size()); - assertEquals(0, writer.queueSize()); - } - - @Test - void testFinalFlushRunsWithoutTheInterruptFlagSet() throws Exception { - BackendApi backendApi = mock(BackendApi.class); - AtomicBoolean interruptedDuringPost = new AtomicBoolean(true); - when(backendApi.post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - invocation -> { - interruptedDuringPost.set(Thread.currentThread().isInterrupted()); - return null; - }); - ExposureWriterImpl writer = - new ExposureWriterImpl( - 1 << 4, - Long.MAX_VALUE, - NANOSECONDS, - () -> backendApi, - mockConfig("shutdown-service"), - ExposureWriterImpl.SHUTDOWN_TIMEOUT_MILLIS); - - writer.init(); - writer.accept(buildExposure()); - writer.close(); - - verify(backendApi, times(1)) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false)); - assertFalse( - interruptedDuringPost.get(), - "The final HTTP request must not run on an interrupted thread"); - assertFalse(writer.isSerializerThreadAlive()); - } - - @Test - void testCloseWaitIsBoundedWhenFinalPostDoesNotReturn() throws Exception { - BackendApi backendApi = mock(BackendApi.class); - CountDownLatch postStarted = new CountDownLatch(1); - CountDownLatch releasePost = new CountDownLatch(1); - when(backendApi.post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - invocation -> { - postStarted.countDown(); - releasePost.await(); - return null; - }); - ExposureWriterImpl writer = - new ExposureWriterImpl( - 1 << 4, - Long.MAX_VALUE, - NANOSECONDS, - () -> backendApi, - mockConfig("blocked-shutdown-service"), - 100); - - try { - writer.init(); - writer.accept(buildExposure()); - long start = System.nanoTime(); - - writer.close(); - - long elapsedMillis = NANOSECONDS.toMillis(System.nanoTime() - start); - assertTrue(postStarted.await(1, java.util.concurrent.TimeUnit.SECONDS)); - assertTrue(elapsedMillis < 2000, "close exceeded its configured bounded wait"); - assertTrue(writer.isSerializerThreadAlive()); - } finally { - releasePost.countDown(); - } - poll.eventually(() -> assertFalse(writer.isSerializerThreadAlive())); - } - - @Test - @SuppressWarnings("unchecked") - void testCloseDoesNotWaitForProducerAlreadyInsideAccept() throws Exception { - CountDownLatch offerStarted = new CountDownLatch(1); - CountDownLatch releaseOffer = new CountDownLatch(1); - MessagePassingBlockingQueue delegate = Queues.mpscBlockingConsumerArrayQueue(16); - MessagePassingBlockingQueue queue = - mock(MessagePassingBlockingQueue.class, delegatesTo(delegate)); - doAnswer( - invocation -> { - offerStarted.countDown(); - releaseOffer.await(); - return delegate.offer(invocation.getArgument(0)); - }) - .when(queue) - .offer(any(ExposureEvent.class)); - ExposureWriterImpl writer = - new ExposureWriterImpl( - queue, - Long.MAX_VALUE, - NANOSECONDS, - () -> mock(BackendApi.class), - mockConfig("paused-producer-service"), - ExposureWriterImpl.SHUTDOWN_TIMEOUT_MILLIS); - ExecutorService executor = Executors.newFixedThreadPool(2); - try { - writer.init(); - Future producer = executor.submit(() -> writer.accept(buildExposure())); - assertTrue(offerStarted.await(5, SECONDS)); - - // The producer has passed the closed check but cannot finish its offer. Shutdown must not - // wait for that producer, even though its event can miss the final best-effort drain. - Future closing = executor.submit(writer::close); - closing.get(2, SECONDS); - assertFalse(producer.isDone()); - assertFalse(writer.isSerializerThreadAlive()); - - releaseOffer.countDown(); - producer.get(5, SECONDS); - } finally { - releaseOffer.countDown(); - executor.shutdownNow(); - assertTrue(executor.awaitTermination(5, SECONDS)); - writer.close(); - } - } - - @Test - void testStaleGatewayDispatchCannotEnqueueAfterClose() throws Exception { - CountDownLatch dispatchSnapshotTaken = new CountDownLatch(1); - CountDownLatch releaseDispatch = new CountDownLatch(1); - FeatureFlaggingGateway.ExposureListener blocker = - ignored -> { - dispatchSnapshotTaken.countDown(); - try { - releaseDispatch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }; - FeatureFlaggingGateway.addExposureListener(blocker); - ExposureWriterImpl writer = - new ExposureWriterImpl( - 1 << 4, - Long.MAX_VALUE, - NANOSECONDS, - sharedCommunicationObjects, - mockConfig("stale-dispatch-service")); - Thread dispatcher = - new Thread(() -> FeatureFlaggingGateway.dispatch(buildExposure()), "exposure-dispatcher"); - - try { - writer.init(); - dispatcher.start(); - assertTrue(dispatchSnapshotTaken.await(5, java.util.concurrent.TimeUnit.SECONDS)); - - writer.close(); - releaseDispatch.countDown(); - dispatcher.join(5000); - - assertFalse(dispatcher.isAlive()); - assertFalse(writer.isSerializerThreadAlive()); - assertEquals(0, writer.queueSize()); - assertTrue(requests.isEmpty()); - } finally { - releaseDispatch.countDown(); - dispatcher.join(5000); - FeatureFlaggingGateway.removeExposureListener(blocker); - writer.close(); - } - } - - @Test - void testCloseBeforeInitPreventsLaterStartAndAccept() { - ExposureWriterImpl writer = - new ExposureWriterImpl( - sharedCommunicationObjects, - mockConfig("never-started-service"), - new FeatureFlagRouteSelector()); - - writer.close(); - writer.init(); - writer.accept(buildExposure()); - writer.close(); - - assertFalse(writer.isSerializerThreadAlive()); - assertEquals(0, writer.queueSize()); - assertTrue(requests.isEmpty()); - } - - @Test - void testMissingBackendClosesWriterFromSerializerThread() throws Exception { - CountDownLatch backendRequested = new CountDownLatch(1); - ExposureWriterImpl writer = - new ExposureWriterImpl( - 1 << 4, - Long.MAX_VALUE, - NANOSECONDS, - () -> { - backendRequested.countDown(); - return null; - }, - mockConfig("missing-backend-service"), - 100); - - writer.init(); - - assertTrue(backendRequested.await(5, java.util.concurrent.TimeUnit.SECONDS)); - poll.eventually(() -> assertFalse(writer.isSerializerThreadAlive())); - writer.accept(buildExposure()); - assertEquals(0, writer.queueSize()); - writer.close(); - } - - @Test - void testHttpFailureIsNotRetriedAtTransportLayer() throws Exception { - String serviceName = "fail-once"; - Config config = mockConfig(serviceName); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - writer.accept(buildExposure()); - - poll.eventually(() -> assertEquals(1, Collections.frequency(requestAttempts, serviceName))); - MILLISECONDS.sleep(500); - - assertEquals(1, Collections.frequency(requestAttempts, serviceName)); - assertNull(findRequest(serviceName), requests.toString()); - } - } - - @Test - void testSerializationFailureDoesNotPoisonFollowingExposures() throws Exception { - Config config = mockConfig("test-service"); - ExposureEvent validExposure = buildExposure(); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - writer.accept(buildExposure(singletonMap("invalid", (Object) Double.NaN))); - - MILLISECONDS.sleep(300); // wait for the invalid batch to be dropped - writer.accept(validExposure); - - poll.eventually(() -> assertExposures(allExposures(), singletonList(validExposure))); - } - } - - @Test - void testAmbiguousExposureBatchIsNotRetriedOrReplayedDirectly() throws Exception { - final Config config = mockConfig("test-service"); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getApiKey()).thenReturn(API_KEY); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - final BackendApi directApi = mock(BackendApi.class); - when(backendApiFactory.discoverEvpProxyEndpoint( - false, asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER))) - .thenReturn(V4_EVP_PROXY_ENDPOINT); - when(backendApiFactory.createEvpProxyApiForEndpoint( - Intake.EVENT_PLATFORM, - true, - HttpRetryPolicy.Factory.NEVER_RETRY, - V4_EVP_PROXY_ENDPOINT)) - .thenReturn(proxyApi); - when(backendApiFactory.createDirectIntakeApi(eq(Intake.EVENT_PLATFORM), eq(true), eq(false))) - .thenReturn(directApi); - when(proxyApi.post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))) - .thenThrow(new SocketTimeoutException("ambiguous timeout")) - .thenThrow(new ConnectException("definitive refusal")); - final FeatureFlagBackendApiFactory featureFlagBackendApiFactory = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FeatureFlagEventType.EXPOSURE); - final List exposures = buildExposures(2); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, featureFlagBackendApiFactory, config)) { - writer.init(); - writer.accept(exposures.get(0)); - - poll.eventually( - () -> - verify(proxyApi) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))); - MILLISECONDS.sleep(300); - verify(proxyApi, times(1)) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false)); - verify(directApi, never()) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false)); - - writer.accept(exposures.get(1)); - poll.eventually( - () -> - verify(directApi) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))); - - final ArgumentCaptor directBody = ArgumentCaptor.forClass(RequestBody.class); - verify(directApi).post(eq("exposures"), directBody.capture(), any(), any(), eq(false)); - final Buffer buffer = new Buffer(); - directBody.getValue().writeTo(buffer); - final ExposuresRequest directRequest = - new Moshi.Builder().build().adapter(ExposuresRequest.class).fromJson(buffer.readUtf8()); - assertNotNull(directRequest); - assertExposures(directRequest.exposures, singletonList(exposures.get(1))); - } - } - - @Test - void testAgentlessWriterWaitsForUnavailableProxyRecovery() throws Exception { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(false); - Config config = mockConfig("unavailable-service"); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl( - sharedCommunicationObjects, config, new FeatureFlagRouteSelector())) { - writer.init(); - poll.eventually(() -> assertTrue(writer.isSerializerThreadAlive())); - } - } - - @Test - void idleUnavailableWriterOnlyProbesForANewBatchAndStillCloses() throws Exception { - final Config config = mockConfig("idle-recovery"); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - final AtomicLong clock = new AtomicLong(); - final BackendApiFactory backend = mock(BackendApiFactory.class); - final BackendApi proxy = mock(BackendApi.class); - when(backend.discoverEvpProxyEndpoint(true, asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER))) - .thenReturn(V4_EVP_PROXY_ENDPOINT); - when(backend.createEvpProxyApiForEndpoint( - Intake.EVENT_PLATFORM, - true, - HttpRetryPolicy.Factory.NEVER_RETRY, - V4_EVP_PROXY_ENDPOINT)) - .thenReturn(proxy); - final FeatureFlagBackendApiFactory factory = - new FeatureFlagBackendApiFactory( - config, - backend, - FeatureFlagEventType.EXPOSURE, - new FeatureFlagRouteSelector(clock::get, 10)); - final ExposureWriterImpl writer = - new ExposureWriterImpl(16, 100, MILLISECONDS, factory, config); - try { - writer.init(); - poll.eventually( - () -> - verify(backend) - .discoverEvpProxyEndpoint(false, asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER))); - clock.set(10); - MILLISECONDS.sleep(300); - verify(backend, never()) - .discoverEvpProxyEndpoint(true, asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER)); - verify(proxy, never()).post(any(), any(), any(), any(), eq(false)); - writer.accept(buildExposure()); - poll.eventually(() -> verify(proxy).post(eq("exposures"), any(), any(), any(), eq(false))); - verify(backend).discoverEvpProxyEndpoint(true, asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER)); - } finally { - writer.close(); - } - assertFalse(writer.isSerializerThreadAlive()); - } - - private static Config mockConfig(String serviceName) { - return mockConfig(serviceName, "test", "0.0.0"); - } - - private static Config mockConfig(String serviceName, String env, String version) { - Config config = mock(Config.class); - when(config.getIdGenerationStrategy()).thenReturn(IdGenerationStrategy.fromName("RANDOM")); - when(config.getServiceName()).thenReturn(serviceName); - when(config.getEnv()).thenReturn(env); - when(config.getVersion()).thenReturn(version); - return config; - } - - private SharedCommunicationObjects sharedCommunicationObjects(boolean evpProxyAvailable) { - DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - when(discovery.supportsEvpProxy()).thenReturn(evpProxyAvailable); - if (evpProxyAvailable) { - when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); - } - - SharedCommunicationObjects sharedCommunicationObjects = new SharedCommunicationObjects(); - sharedCommunicationObjects.setFeaturesDiscovery(discovery); - sharedCommunicationObjects.agentUrl = HttpUrl.get(server.getAddress()); - sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); - return sharedCommunicationObjects; - } - - private static void assertContext( - Map context, String service, String env, String version) { - assertEquals(service == null ? "unknown" : service, context.get("service")); - assertOptionalContextValue(context, "env", env); - assertOptionalContextValue(context, "version", version); - } - - private static void assertOptionalContextValue( - Map context, String key, String value) { - if (value == null) { - assertFalse(context.containsKey(key)); - } else { - assertEquals(value, context.get(key)); - } - } - - private ExposuresRequest findRequest(String serviceName) { - for (ExposuresRequest request : requests) { - if (serviceName.equals(request.context.get("service"))) { - return request; - } - } - return null; - } - - private List allExposures() { - List exposures = new ArrayList<>(); - for (ExposuresRequest request : requests) { - exposures.addAll(request.exposures); - } - return exposures; - } - - private static void assertExposures( - List receivedExposures, List expectedExposures) { - assertEquals(expectedExposures.size(), receivedExposures.size()); - TreeSet received = new TreeSet<>(ExposureWriterTests::compare); - received.addAll(receivedExposures); - assertTrue(received.containsAll(expectedExposures)); - } - - private static int compare(ExposureEvent first, ExposureEvent second) { - if (first == second) { - return 0; - } - if (first == null) { - return -1; - } - if (second == null) { - return 1; - } - - int result = Long.compare(first.timestamp, second.timestamp); - if (result != 0) { - return result; - } - - result = compareNullableString(first.flag == null ? null : first.flag.key, second.flag); - if (result != 0) { - return result; - } - - result = - compareNullableString(first.variant == null ? null : first.variant.key, second.variant); - if (result != 0) { - return result; - } - - result = - compareNullableString( - first.allocation == null ? null : first.allocation.key, second.allocation); - if (result != 0) { - return result; - } - - result = compareNullableString(first.subject == null ? null : first.subject.id, second.subject); - if (result != 0) { - return result; - } - - Map.Entry firstEntry = firstEntry(first.subject); - Map.Entry secondEntry = firstEntry(second.subject); - result = - compareNullableString( - firstEntry == null ? null : firstEntry.getKey(), - secondEntry == null ? null : secondEntry.getKey()); - if (result != 0) { - return result; - } - return compareNullableString( - firstEntry == null ? null : String.valueOf(firstEntry.getValue()), - secondEntry == null ? null : String.valueOf(secondEntry.getValue())); - } - - private static int compareNullableString(String first, Flag second) { - return compareNullableString(first, second == null ? null : second.key); - } - - private static int compareNullableString(String first, Variant second) { - return compareNullableString(first, second == null ? null : second.key); - } - - private static int compareNullableString(String first, Allocation second) { - return compareNullableString(first, second == null ? null : second.key); - } - - private static int compareNullableString(String first, Subject second) { - return compareNullableString(first, second == null ? null : second.id); - } - - private static int compareNullableString(String first, String second) { - String firstValue = first == null ? "" : first; - String secondValue = second == null ? "" : second; - return firstValue.compareTo(secondValue); - } - - private static Map.Entry firstEntry(Subject subject) { - if (subject == null || subject.attributes == null) { - return null; - } - Iterator> iterator = subject.attributes.entrySet().iterator(); - return iterator.hasNext() ? iterator.next() : null; - } - - private static List buildExposures(int count) { - List exposures = new ArrayList<>(); - for (int index = 0; index < count; index++) { - exposures.add(buildExposure()); - } - return exposures; - } - - private static ExposureEvent buildExposure() { - String id = UUID.randomUUID().toString(); - return buildExposure(id, singletonMap("key_" + id, (Object) ("value_" + id))); - } - - private static ExposureEvent buildExposure(Map attributes) { - return buildExposure(UUID.randomUUID().toString(), attributes); - } - - private static ExposureEvent buildExposure(String id, Map attributes) { - return new ExposureEvent( - System.currentTimeMillis(), - new Allocation("Allocation_" + id), - new Flag("Flag_" + id), - new Variant("Variant_" + id), - new Subject("Subject_" + id, attributes)); - } -} 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 deleted file mode 100644 index 2121d05846e..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.datadog.featureflag; - -import static com.datadog.featureflag.FeatureFlagEventType.EXPOSURE; -import static com.datadog.featureflag.FeatureFlagEventType.FLAG_EVALUATION; -import static datadog.communication.EvpProxy.ORIGIN_HEADER; -import static datadog.communication.EvpProxy.ORIGIN_VERSION_HEADER; -import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; -import static java.util.Arrays.asList; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import datadog.trace.api.intake.Intake; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; - -class FeatureFlagBackendApiFactoryTest { - - @Test - void remoteConfigDoesNotEagerlyInitializeSharedTransport() { - final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); - final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); - - new FeatureFlagBackendApiFactory(config, sco, EXPOSURE); - - verifyNoInteractions(sco); - } - - @ParameterizedTest - @NullAndEmptySource - void agentlessWithoutCredentialsDoesNotEagerlyInitializeSharedTransport(String apiKey) { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, apiKey); - final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); - - new FeatureFlagBackendApiFactory(config, sco, EXPOSURE); - - verifyNoInteractions(sco); - } - - @Test - void remoteConfigUsesFixedV2WithoutDiscoveryOrDirectIntake() { - final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApiForEndpoint( - Intake.EVENT_PLATFORM, - false, - FeatureFlagBackendApiFactory.PRE_SEND_RETRY, - V2_EVP_PROXY_ENDPOINT)) - .thenReturn(proxyApi); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertSame(proxyApi, selected); - verify(backendApiFactory, never()) - .discoverEvpProxyEndpoint(false, asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER)); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessPrefersLocalRouteWithDirectFallbackReady() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.discoverEvpProxyEndpoint( - false, asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER))) - .thenReturn(V2_EVP_PROXY_ENDPOINT); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenReturn(mock(BackendApi.class)); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); - verify(backendApiFactory) - .discoverEvpProxyEndpoint(false, asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER)); - verify(backendApiFactory).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessUsesDirectIntakeWhenLocalRouteIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenReturn(mock(BackendApi.class)); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); - } - - @Test - void agentlessKeepsWriterAliveWhileEveryRouteIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); - - assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); - } - - @Test - void agentlessKeepsCompatibleLocalRouteWhenDirectUrlIsInvalid() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.discoverEvpProxyEndpoint( - false, asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER))) - .thenReturn(V2_EVP_PROXY_ENDPOINT); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenThrow(new IllegalArgumentException("invalid URL")); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); - } - - private static Config config(final String source, final String apiKey) { - final Config config = mock(Config.class); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(source); - when(config.getApiKey()).thenReturn(apiKey); - return config; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java deleted file mode 100644 index 6f034119cdf..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonList; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.trace.api.featureflag.exposure.Allocation; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.ExposuresRequest; -import datadog.trace.api.featureflag.exposure.Flag; -import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.exposure.Variant; -import datadog.trace.api.intake.Intake; -import java.nio.charset.StandardCharsets; -import okhttp3.RequestBody; -import org.junit.jupiter.api.Test; - -class FeatureFlagEvpPublisherTest { - - @Test - void defaultPublisherRequestsResponseCompression() { - final BackendApi backendApi = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(Intake.EVENT_PLATFORM, true)).thenReturn(backendApi); - - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(factory, TestRequest.class); - - publisher.start(); - - verify(factory).createBackendApi(Intake.EVENT_PLATFORM, true); - verifyNoMoreInteractions(factory); - } - - @Test - void responseCompressionCanBeDisabled() throws Exception { - final BackendApi backendApi = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(Intake.EVENT_PLATFORM, false)).thenReturn(backendApi); - - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(factory, TestRequest.class, false); - - publisher.post("flagevaluation", new TestRequest("value")); - - verify(factory).createBackendApi(Intake.EVENT_PLATFORM, false); - verify(backendApi) - .post(eq("flagevaluation"), any(RequestBody.class), any(), isNull(), eq(false)); - } - - @Test - void postThrowsWhenEvpBackendApiCannotBeCreated() { - final BackendApiFactory factory = mock(BackendApiFactory.class); - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(factory, TestRequest.class); - - assertFalse(publisher.start()); - assertThrows( - IllegalStateException.class, - () -> publisher.post("flagevaluation", FeatureFlagEvpPublisher.utf8Bytes("{}"))); - } - - @Test - void serializesSerialIdUnderTheIntakeWireKey() { - assertTrue(exposureJson(340132).contains("\"serial_id\":340132")); - } - - @Test - void serializesSerialIdZeroRatherThanOmittingIt() { - assertTrue(exposureJson(0).contains("\"serial_id\":0")); - } - - @Test - void omitsSerialIdKeyWhenAbsent() { - assertFalse(exposureJson(null).contains("serial_id")); - } - - @Test - void omitsSerialIdKeyForAnEventBuiltWithoutOne() { - final ExposureEvent event = - new ExposureEvent( - 1234L, - new Allocation("allocation"), - new Flag("flag"), - new Variant("variant"), - new Subject("subject", emptyMap())); - - assertFalse(exposureJsonOf(event).contains("serial_id")); - } - - private static String exposureJson(final Integer serialId) { - return exposureJsonOf( - new ExposureEvent( - 1234L, - new Allocation("allocation"), - new Flag("flag"), - new Variant("variant"), - new Subject("subject", emptyMap()), - serialId)); - } - - private static String exposureJsonOf(final ExposureEvent event) { - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(mock(BackendApiFactory.class), ExposuresRequest.class); - return new String( - publisher.serialize(new ExposuresRequest(emptyMap(), singletonList(event))), - StandardCharsets.UTF_8); - } - - static class TestRequest { - public final String value; - - TestRequest(final String value) { - this.value = value; - } - } - - @Test - void serializesExposureFeatures() { - final ExposureEvent event = - new ExposureEvent( - 1234L, - new Allocation("allocation"), - new Flag("flag"), - new Variant("variant"), - new Subject("subject", emptyMap()), - 7, - java.util.Collections.singletonMap("bandit.policy_id", "p2")); - - assertTrue(exposureJsonOf(event).contains("\"features\":{\"bandit.policy_id\":\"p2\"}")); - } - - @Test - void omitsFeaturesKeyWhenAbsent() { - assertFalse(exposureJson(7).contains("features")); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagRouteSelectorTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagRouteSelectorTest.java deleted file mode 100644 index 7fd88d12776..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagRouteSelectorTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.datadog.featureflag; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import java.util.concurrent.atomic.AtomicLong; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; - -class FeatureFlagRouteSelectorTest { - - @Test - void unavailableLoggingIsSharedAndOnlyRepeatsAfterRecovery() { - final Logger logger = (Logger) LoggerFactory.getLogger(FeatureFlagRouteSelector.class); - final ListAppender appender = new ListAppender<>(); - appender.start(); - logger.addAppender(appender); - try { - final AtomicLong clock = new AtomicLong(); - final FeatureFlagRouteSelector selector = new FeatureFlagRouteSelector(clock::get, 10); - selector.initialize(() -> null, false); - selector.initialize(() -> null, false); - for (int i = 1; i <= 3; i++) { - clock.set(i * 10); - assertTrue(selector.tryBeginLocalRecovery()); - selector.localRecoveryFinished(null); - } - assertEquals(1, appender.list.size()); - assertEquals(Level.WARN, appender.list.get(0).getLevel()); - assertTrue(appender.list.get(0).getFormattedMessage().contains("DD_API_KEY")); - clock.set(40); - assertTrue(selector.tryBeginLocalRecovery()); - selector.localRecoveryFinished("v4"); - assertEquals(2, appender.list.size()); - assertEquals(Level.INFO, appender.list.get(1).getLevel()); - selector.localFailure(selector.localRoute(), false); - assertEquals(3, appender.list.size()); - assertEquals(Level.WARN, appender.list.get(2).getLevel()); - } finally { - logger.detachAppender(appender); - appender.stop(); - } - } - - @Test - void directRouteIsTerminalAcrossInitializationAndRecoverySignals() { - final FeatureFlagRouteSelector routeSelector = new FeatureFlagRouteSelector(); - - assertEquals(FeatureFlagRouteSelector.Route.DIRECT, routeSelector.initialize(() -> null, true)); - assertEquals( - FeatureFlagRouteSelector.Route.DIRECT, routeSelector.initialize(() -> "v4", false)); - assertFalse(routeSelector.tryBeginLocalRecovery()); - - routeSelector.localRecoveryFinished("v4"); - - assertEquals(FeatureFlagRouteSelector.Route.DIRECT, routeSelector.current()); - } - - @Test - void anotherWriterCannotBypassTheRecoveryCooldown() { - final AtomicLong clock = new AtomicLong(); - final FeatureFlagRouteSelector routeSelector = new FeatureFlagRouteSelector(clock::get, 10); - - assertEquals( - FeatureFlagRouteSelector.Route.UNAVAILABLE, routeSelector.initialize(() -> null, false)); - assertEquals( - FeatureFlagRouteSelector.Route.UNAVAILABLE, - routeSelector.initialize( - () -> { - throw new AssertionError("duplicate discovery"); - }, - false)); - assertFalse(routeSelector.tryBeginLocalRecovery()); - clock.set(10); - assertTrue(routeSelector.tryBeginLocalRecovery()); - clock.set(100); - assertFalse(routeSelector.tryBeginLocalRecovery()); - routeSelector.localRecoveryFinished(null); - assertFalse(routeSelector.tryBeginLocalRecovery()); - clock.set(110); - assertTrue(routeSelector.tryBeginLocalRecovery()); - } - - @Test - void staleFailureCannotInvalidateANewGenerationOfTheSameEndpoint() { - final AtomicLong clock = new AtomicLong(); - final FeatureFlagRouteSelector selector = new FeatureFlagRouteSelector(clock::get, 10); - selector.initialize(() -> "v4", false); - final FeatureFlagRouteSelector.LocalRoute oldRoute = selector.localRoute(); - selector.localFailure(oldRoute, false); - clock.set(10); - assertTrue(selector.tryBeginLocalRecovery()); - selector.localRecoveryFinished("v4"); - assertNotSame(oldRoute, selector.localRoute()); - selector.localFailure(oldRoute, true); - assertEquals(FeatureFlagRouteSelector.Route.LOCAL, selector.current()); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagSendOnceTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagSendOnceTest.java deleted file mode 100644 index 27348f35421..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagSendOnceTest.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V4_EVP_PROXY_ENDPOINT; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import datadog.communication.BackendApi; -import datadog.communication.ddagent.DDAgentFeaturesDiscovery; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import java.io.IOException; -import java.util.concurrent.TimeUnit; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import okhttp3.mockwebserver.SocketPolicy; -import org.junit.jupiter.api.Timeout; -import org.tabletest.junit.TableTest; - -@Timeout(15) -class FeatureFlagSendOnceTest { - @TableTest({ - "Source | Route | Event ", - "remote_config | proxy | EXPOSURE ", - "remote_config | proxy | FLAG_EVALUATION", - "agentless | proxy | EXPOSURE ", - "agentless | proxy | FLAG_EVALUATION", - "agentless | direct | EXPOSURE ", - "agentless | direct | FLAG_EVALUATION" - }) - void productionFactoryDoesNotReplayAnAcceptedPostOnConnectionLoss( - String source, String route, FeatureFlagEventType event) throws Exception { - try (MockWebServer server = new MockWebServer()) { - server.start(); - server.enqueue(new MockResponse().setBody("{}")); - server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)); - server.enqueue(new MockResponse().setBody("{}")); - final OkHttpClient client = localClient(server); - try { - final BackendApi api = api(server, client, source, route, event); - post(api); - assertThrows(IOException.class, () -> post(api)); - assertEquals(2, server.getRequestCount()); - for (int i = 0; i < 2; i++) { - final RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); - assertEquals("POST", request.getMethod()); - if (route.equals("proxy")) { - assertNull(request.getHeader("DD-API-KEY")); - } else { - assertEquals("test-api-key", request.getHeader("DD-API-KEY")); - assertNull(request.getHeader("X-Datadog-EVP-Subdomain")); - } - } - } finally { - client.connectionPool().evictAll(); - client.dispatcher().executorService().shutdownNow(); - } - } - } - - @TableTest({ - "Source | Route ", - "remote_config | proxy ", - "agentless | proxy ", - "agentless | direct" - }) - void redirectDoesNotForwardTheBatchOrCredentials(String source, String route) throws Exception { - try (MockWebServer server = new MockWebServer(); - MockWebServer destination = new MockWebServer()) { - server.start(); - destination.start(); - server.enqueue( - new MockResponse() - .setResponseCode(307) - .addHeader("Location", destination.url("/redirected"))); - destination.enqueue(new MockResponse().setBody("{}")); - final OkHttpClient client = localClient(server); - try { - final BackendApi api = api(server, client, source, route, FeatureFlagEventType.EXPOSURE); - assertThrows(IOException.class, () -> post(api)); - assertEquals(1, server.getRequestCount()); - assertNull(destination.takeRequest(200, TimeUnit.MILLISECONDS)); - } finally { - client.connectionPool().evictAll(); - client.dispatcher().executorService().shutdownNow(); - } - } - } - - private static OkHttpClient localClient(MockWebServer server) { - return new OkHttpClient.Builder() - .addInterceptor( - chain -> { - final Request original = chain.request(); - final HttpUrl local = - original - .url() - .newBuilder() - .scheme("http") - .host(server.getHostName()) - .port(server.getPort()) - .build(); - return chain.proceed(original.newBuilder().url(local).build()); - }) - .build(); - } - - private static BackendApi api( - MockWebServer server, - OkHttpClient client, - String source, - String route, - FeatureFlagEventType event) { - final Config config = mock(Config.class); - when(config.getIdGenerationStrategy()).thenReturn(Config.get().getIdGenerationStrategy()); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(source); - when(config.getSite()).thenReturn("datadoghq.com"); - if (route.equals("direct")) { - when(config.getApiKey()).thenReturn("test-api-key"); - } - final DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - when(discovery.supportsEvpProxyHeaders(any())).thenReturn(true); - when(discovery.supportsEvpProxy()).thenReturn(route.equals("proxy")); - when(discovery.getEvpProxyEndpoint()) - .thenReturn(route.equals("proxy") ? V4_EVP_PROXY_ENDPOINT : null); - final SharedCommunicationObjects sco = - new SharedCommunicationObjects() { - @Override - public OkHttpClient getIntakeHttpClient() { - return client; - } - - @Override - public DDAgentFeaturesDiscovery featuresDiscovery(Config ignored) { - return discovery; - } - }; - sco.agentUrl = server.url("/"); - sco.agentHttpClient = client; - return new FeatureFlagBackendApiFactory(config, sco, event).create(); - } - - private static void post(BackendApi api) throws IOException { - api.post( - "events", - RequestBody.create(MediaType.parse("application/json"), "{}"), - stream -> null, - null, - false); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagTransportRegressionTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagTransportRegressionTest.java deleted file mode 100644 index 81d91f63701..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagTransportRegressionTest.java +++ /dev/null @@ -1,218 +0,0 @@ -package com.datadog.featureflag; - -import static com.datadog.featureflag.FeatureFlagEventType.EXPOSURE; -import static org.junit.jupiter.api.Assertions.assertAll; -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.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import datadog.communication.BackendApi; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.communication.ddagent.TracerVersion; -import datadog.trace.api.Config; -import datadog.trace.api.ProtocolVersion; -import datadog.trace.test.junit.utils.config.WithConfig; -import datadog.trace.test.util.DDJavaSpecification; -import java.io.IOException; -import java.net.ConnectException; -import java.net.SocketException; -import java.net.SocketTimeoutException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.tabletest.junit.TableTest; - -class FeatureFlagTransportRegressionTest extends DDJavaSpecification { - @ParameterizedTest - @EnumSource(FeatureFlagEventType.class) - @WithConfig(key = "trace.enabled", value = "false") - @WithConfig(key = "remote_config.enabled", value = "false") - @WithConfig(key = "telemetry.enabled", value = "false") - @WithConfig(key = "appsec.enabled", value = "false") - @WithConfig(key = "feature.flags.configuration.source", value = "agentless") - @WithConfig(key = "trace.agent.timeout", value = "2") - @WithConfig(key = "API_KEY", value = "test-api-key", env = true) - void agentlessInitializesDirectTimeoutsBeforeAgentDiscovery(FeatureFlagEventType eventType) - throws Exception { - try (MockWebServer agent = new MockWebServer()) { - agent.setDispatcher( - new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest request) { - return new MockResponse().setBody("{\"endpoints\":[\"v0.5/traces\"]}"); - } - }); - final SharedCommunicationObjects sco = new SharedCommunicationObjects(); - sco.agentUrl = agent.url("/"); - try { - assertNotNull(new FeatureFlagBackendApiFactory(Config.get(), sco, eventType).create()); - - final OkHttpClient directClient = sco.getIntakeHttpClient(); - assertAll( - () -> assertEquals(2000, sco.httpClientTimeout), - () -> assertEquals(2000, directClient.connectTimeoutMillis()), - () -> assertEquals(2000, directClient.readTimeoutMillis()), - () -> assertEquals(2000, directClient.writeTimeoutMillis())); - } finally { - if (sco.agentHttpClient != null) { - sco.agentHttpClient.connectionPool().evictAll(); - } - sco.getIntakeHttpClient().connectionPool().evictAll(); - } - } - } - - @TableTest({ - "Scenario | First Attempt | Attempts | Delivered", - "refused then accepted | refused | 2 | true ", - "refused throughout | refused-all | 4 | false ", - "forbidden | 403 | 1 | false ", - "throttled | 429 | 1 | false ", - "internal server error | 500 | 1 | false ", - "unavailable | 503 | 1 | false ", - "read timeout | timeout | 1 | false ", - "reset | reset | 1 | false ", - "broken pipe | broken-pipe | 1 | false " - }) - void remoteConfigRetriesOnlyProvenPreSendFailures( - String firstAttempt, int attempts, boolean delivered) throws Exception { - final List requests = new ArrayList<>(); - final SharedCommunicationObjects sco = new SharedCommunicationObjects(); - sco.agentUrl = HttpUrl.get("http://localhost:8126/"); - sco.agentHttpClient = - new OkHttpClient.Builder() - .addInterceptor( - chain -> { - final Request request = chain.request(); - requests.add(request); - if (firstAttempt.equals("refused-all")) { - throw new ConnectException("Connection refused"); - } - if (requests.size() == 1) { - switch (firstAttempt) { - case "refused": - throw new ConnectException("Connection refused"); - case "timeout": - throw new SocketTimeoutException("read timeout"); - case "reset": - throw new SocketException("Connection reset"); - case "broken-pipe": - throw new SocketException("Broken pipe"); - default: - return response(request, Integer.parseInt(firstAttempt), "{}"); - } - } - return response(request, 202, "{}"); - }) - .build(); - final BackendApi api = - new FeatureFlagBackendApiFactory(config("remote_config"), sco, EXPOSURE).create(); - - if (delivered) { - post(api); - } else { - assertThrows(IOException.class, () -> post(api)); - } - - assertEquals(attempts, requests.size()); - for (Request request : requests) { - assertEquals("/evp_proxy/v2/api/v2/exposures", request.url().encodedPath()); - assertNull(request.header("DD-API-KEY")); - } - } - - @TableTest({ - "Capabilities | Can Deliver", - "none | false ", - "origin | false ", - "version | false ", - "both | true " - }) - void agentlessIdentityRequiresBothForwardingCapabilities(String capabilities, boolean canDeliver) - throws Exception { - final String headers = - capabilities.equals("both") - ? "\"dd-evp-origin\",\"DD-EVP-ORIGIN-VERSION\"" - : capabilities.equals("origin") - ? "\"DD-EVP-ORIGIN\"" - : capabilities.equals("version") ? "\"DD-EVP-ORIGIN-VERSION\"" : ""; - try (MockWebServer server = new MockWebServer()) { - server.start(); - server.enqueue( - new MockResponse() - .setBody( - "{\"endpoints\":[\"v0.5/traces\",\"evp_proxy/v4/\"],\"evp_proxy_allowed_headers\":[" - + headers - + "]}")); - server.enqueue(new MockResponse().setResponseCode(202).setBody("{}")); - final SharedCommunicationObjects sco = new SharedCommunicationObjects(); - sco.agentUrl = server.url("/"); - sco.agentHttpClient = new OkHttpClient(); - try { - final BackendApi api = - new FeatureFlagBackendApiFactory(config("agentless"), sco, EXPOSURE).create(); - if (canDeliver) { - post(api); - } else { - assertThrows(IOException.class, () -> post(api)); - } - assertEquals(canDeliver ? 2 : 1, server.getRequestCount()); - assertEquals("/info", server.takeRequest(1, TimeUnit.SECONDS).getPath()); - if (canDeliver) { - final RecordedRequest event = server.takeRequest(1, TimeUnit.SECONDS); - assertEquals("/evp_proxy/v4/api/v2/exposures", event.getPath()); - assertNull(event.getHeader("DD-API-KEY")); - assertEquals("dd-trace-java", event.getHeader("DD-EVP-ORIGIN")); - assertEquals(TracerVersion.TRACER_VERSION, event.getHeader("DD-EVP-ORIGIN-VERSION")); - } - } finally { - sco.agentHttpClient.connectionPool().evictAll(); - sco.agentHttpClient.dispatcher().executorService().shutdownNow(); - } - } - } - - private static Config config(String source) { - final Config config = mock(Config.class); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(source); - when(config.getIdGenerationStrategy()).thenReturn(Config.get().getIdGenerationStrategy()); - when(config.getProtocolVersion()).thenReturn(ProtocolVersion.V0_5); - return config; - } - - private static Response response(Request request, int code, String body) { - return new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(code) - .message("test") - .body(ResponseBody.create(null, body)) - .build(); - } - - private static void post(BackendApi api) throws IOException { - api.post( - "exposures", - RequestBody.create(MediaType.parse("application/json"), "{}"), - stream -> null, - null, - false); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagWriterShutdownTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagWriterShutdownTest.java deleted file mode 100644 index 9d9b97185b2..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagWriterShutdownTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.datadog.featureflag; - -import static com.datadog.featureflag.FlagEvaluationTestSupport.cfg; -import static com.datadog.featureflag.FlagEvaluationTestSupport.simpleEvent; -import static java.util.Collections.emptyMap; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import datadog.communication.BackendApi; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -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.time.Duration; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.FutureTask; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -class FeatureFlagWriterShutdownTest { - @Test - void awaitThreadDoesNotJoinItself() { - assertTimeoutPreemptively( - Duration.ofSeconds(2), - () -> - FeatureFlagEventWriter.awaitThread( - Thread.currentThread(), System.nanoTime() + SECONDS.toNanos(30))); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void awaitThreadStopsWaitingWithoutInterruptingWorker(boolean interruptedCaller) - throws Exception { - final CountDownLatch releaseWorker = new CountDownLatch(1); - final FutureTask task = - new FutureTask<>( - () -> { - releaseWorker.await(); - return Thread.currentThread().isInterrupted(); - }); - final Thread worker = new Thread(task); - worker.start(); - try { - assertTimeoutPreemptively( - Duration.ofSeconds(2), - () -> { - try { - if (interruptedCaller) { - Thread.currentThread().interrupt(); - } - final long deadline = - System.nanoTime() + (interruptedCaller ? SECONDS.toNanos(30) : -1); - FeatureFlagEventWriter.awaitThread(worker, deadline); - assertEquals(interruptedCaller, Thread.currentThread().isInterrupted()); - } finally { - Thread.interrupted(); - } - }); - assertTrue(worker.isAlive(), "Stopping the wait must not terminate the worker"); - } finally { - releaseWorker.countDown(); - worker.join(5000); - } - assertFalse(worker.isAlive()); - assertFalse(task.get(5, SECONDS), "Waiting must not interrupt the worker"); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void closeDoesNotInterruptAnInFlightDelivery(boolean exposures) throws Exception { - final CountDownLatch postStarted = new CountDownLatch(1); - final CountDownLatch releasePost = new CountDownLatch(1); - final CountDownLatch postFinished = new CountDownLatch(1); - final CountDownLatch interrupted = new CountDownLatch(1); - final BackendApi api = mock(BackendApi.class); - when(api.post(any(), any(), any(), any(), anyBoolean())) - .thenAnswer( - invocation -> { - postStarted.countDown(); - try { - assertTrue(releasePost.await(5, SECONDS)); - } catch (InterruptedException failure) { - interrupted.countDown(); - } finally { - postFinished.countDown(); - } - return null; - }); - final AutoCloseable writer; - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - if (exposures) { - final ExposureWriterImpl exposureWriter = - new ExposureWriterImpl(16, 0, MILLISECONDS, () -> api, cfg(), 800); - writer = exposureWriter; - exposureWriter.init(); - exposureWriter.accept( - new ExposureEvent( - 1, - new Allocation("alloc"), - new Flag("flag"), - new Variant("on"), - new Subject("user", emptyMap()))); - } else { - final FlagEvaluationWriterImpl evaluationWriter = - new FlagEvaluationWriterImpl(16, 0, MILLISECONDS, () -> api, cfg()); - writer = evaluationWriter; - evaluationWriter.startForTest(); - evaluationWriter.enqueue(simpleEvent("flag", "on")); - } - final FutureTask close = - new FutureTask<>( - () -> { - writer.close(); - return null; - }); - final Thread closer = new Thread(close); - try { - assertTrue(postStarted.await(5, SECONDS)); - closer.start(); - close.get(5, SECONDS); - assertEquals(1, releasePost.getCount(), "Shutdown must finish before delivery is released"); - assertEquals(1, postFinished.getCount(), "The active delivery must still be pending"); - assertEquals(1, interrupted.getCount(), "Shutdown interrupted the active POST"); - } finally { - releasePost.countDown(); - closer.join(5000); - writer.close(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - assertFalse(closer.isAlive()); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java new file mode 100644 index 00000000000..c7a101ef2ff --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java @@ -0,0 +1,159 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +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 static org.mockito.Mockito.when; + +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.remoteconfig.ConfigurationPoller; +import datadog.remoteconfig.Product; +import datadog.trace.api.Config; +import datadog.trace.api.telemetry.CoreMetricCollector; +import datadog.trace.bootstrap.config.provider.ConfigProvider; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import java.util.Properties; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class FeatureFlagsBackendTest { + private final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); + private final Config config = mock(Config.class); + private final EventProxy eventProxy = mock(EventProxy.class); + private final AgentSpan root = rootSpan(); + // SpanEnrichmentWriter is final, so the test drives a real one bound to a mocked root span. + private final SpanEnrichmentWriter spanEnrichmentWriter = + new SpanEnrichmentWriter(() -> this.root); + private final CoreMetricCollector metrics = mock(CoreMetricCollector.class); + private final FeatureFlagsBackend backend = + new FeatureFlagsBackend( + this.sco, this.config, this.eventProxy, this.spanEnrichmentWriter, this.metrics); + + private static AgentSpan rootSpan() { + final AgentSpan root = mock(AgentSpan.class); + when(root.getLocalRootSpan()).thenReturn(root); + return root; + } + + @Test + void resolvesSettingsWithAgentDefaultsFromTheAgentConfiguration() { + when(this.config.getApiKey()).thenReturn("key"); + when(this.config.getSite()).thenReturn("datadoghq.eu"); + when(this.config.getEnv()).thenReturn("prod"); + when(this.config.getServiceName()).thenReturn("service"); + when(this.config.getVersion()).thenReturn("1.0"); + final Properties properties = new Properties(); + properties.setProperty("feature.flags.configuration.source", "agentless"); + when(this.config.configProvider()) + .thenReturn(ConfigProvider.withPropertiesOverride(properties)); + + assertEquals("key", this.backend.setting("api-key")); + assertEquals("datadoghq.eu", this.backend.setting("site")); + assertEquals("prod", this.backend.setting("env")); + assertEquals("service", this.backend.setting("service")); + assertEquals("1.0", this.backend.setting("version")); + assertEquals("agentless", this.backend.setting("feature.flags.configuration.source")); + } + + @TableTest({ + "Scenario | Source | RC enabled | Registers", + "remote config source | remote_config | true | true ", + "remote config disabled | remote_config | false | false ", + "agentless source | agentless | true | false " + }) + void startRegistersRemoteConfigOnlyWhenItIsTheSource( + final String source, final boolean rcEnabled, final boolean registers) { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + when(this.sco.configurationPoller(this.config)).thenReturn(poller); + when(this.config.getFeatureFlaggingConfigurationSource()).thenReturn(source); + when(this.config.isRemoteConfigEnabled()).thenReturn(rcEnabled); + + this.backend.start(); + + verify(poller, times(registers ? 1 : 0)).addCapabilities(anyLong()); + } + + @TableTest({ + "Scenario | Source | API key", + "agentless with API key | agentless | key ", + "agentless without API key | agentless | ", + "remote config | remote_config | key " + }) + void createsTheBackendFromTheAgentConfiguration(final String source, final String apiKey) { + when(this.config.getFeatureFlaggingConfigurationSource()).thenReturn(source); + when(this.config.getApiKey()).thenReturn(apiKey); + + final FeatureFlagsBackend created = new FeatureFlagsBackend(this.sco, this.config); + + assertEquals(apiKey, created.setting("api-key")); + } + + @Test + void closeWithoutRemoteConfigClearsSpanEnrichment() { + this.backend.enrichSerialId(1, false, null); + + this.backend.close(); + + verify(this.sco, never()).configurationPoller(any()); + assertTrue(this.spanEnrichmentWriter.states().isEmpty()); + } + + @Test + void sharesOneRemoteConfigServiceAcrossSubscriptions() { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + when(this.sco.configurationPoller(this.config)).thenReturn(poller); + + this.backend.subscribeRemoteConfig(content -> {}); + this.backend.subscribeRemoteConfig(content -> {}); + + verify(this.sco, times(1)).configurationPoller(this.config); + verify(poller, times(1)).addCapabilities(anyLong()); + } + + @Test + void failsToSubscribeWithoutRemoteConfig() { + assertThrows( + IllegalStateException.class, () -> this.backend.subscribeRemoteConfig(content -> {})); + } + + @Test + void closeUnsubscribesRemoteConfigAndClearsSpanEnrichment() { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + when(this.sco.configurationPoller(this.config)).thenReturn(poller); + this.backend.subscribeRemoteConfig(content -> {}); + this.backend.enrichSerialId(1, false, null); + + this.backend.close(); + + verify(poller).removeListeners(Product.FFE_FLAGS); + assertTrue(this.spanEnrichmentWriter.states().isEmpty()); + } + + @Test + void delegatesEventsMetricsAndSpanEnrichment() throws Exception { + final byte[] payload = "{}".getBytes(UTF_8); + when(this.eventProxy.isAvailable()).thenReturn(true); + when(this.eventProxy.post("exposures", payload)).thenReturn(true); + + assertTrue(this.backend.isEventProxyAvailable()); + assertTrue(this.backend.postEvent("exposures", payload)); + this.backend.count("flagevaluation.rows.dropped", 3, "closed"); + this.backend.count("flagevaluation.payload.splits", 1, null); + this.backend.enrichSerialId(42, true, "user"); + this.backend.enrichRuntimeDefault("flag", "default"); + + verify(this.metrics).count("flagevaluation.rows.dropped", 3, "reason:closed"); + verify(this.metrics).count("flagevaluation.payload.splits", 1, null); + final SpanEnrichmentAccumulator state = this.spanEnrichmentWriter.states().peek(this.root); + assertNotNull(state); + assertTrue(state.hasData()); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java deleted file mode 100644 index 6fb7692f8bb..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java +++ /dev/null @@ -1,509 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonMap; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class FlagEvaluationAggregatorTest { - - @Test - void identicalEventsAggregateIntoOneBucketWithCount2() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 1000L, emptyMap())); - aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 2000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(1, state.fullTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertEquals(2, bucket.count); - assertEquals(1000L, bucket.firstEvalMs); - assertEquals(2000L, bucket.lastEvalMs); - assertTrue(bucket.firstEvalMs <= bucket.lastEvalMs); - } - - @Test - void differentValueTypesProduceDifferentBuckets() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map attrsInt = new HashMap<>(); - attrsInt.put("score", 1); - final Map attrsStr = new HashMap<>(); - attrsStr.put("score", "1"); - - aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsInt)); - aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsStr)); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(2, state.fullTier.size()); - } - - @Test - void nulCharactersInKeyFieldsDoNotCollide() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.aggregate(event("a\0b", "c", "alloc1", "user-1", 1000L, emptyMap())); - aggregator.aggregate(event("a", "b\0c", "alloc1", "user-1", 1000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(2, state.fullTier.size()); - } - - @Test - void globalCapOverflowRoutesToDegradedTier() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.simulateFullTierAtCap(); - aggregator.aggregate(simpleEvent("extra-flag", "on")); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertTrue(state.degradedTier.size() > 0); - assertEquals(0, state.droppedDegradedOverflow); - } - - @Test - void degradedCapOverflowIncrementsDroppedCounter() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.simulateFullTierAtCap(); - aggregator.simulateDegradedTierAtCap(); - aggregator.aggregate(simpleEvent("drop-flag", "on")); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertTrue(state.droppedDegradedOverflow > 0); - } - - @Test - void perFlagCapOverflowRoutesToDegradedTierAndMergesSameDegradedKey() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); - - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, emptyMap())); - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(0, state.fullTier.size()); - assertEquals(1, state.degradedTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = - state.degradedTier.values().iterator().next(); - assertEquals(2, bucket.count); - assertEquals(1000L, bucket.firstEvalMs); - assertEquals(2000L, bucket.lastEvalMs); - } - - @Test - void mixedConsentDegradedEvaluationsMergeIntoOneBucket() { - // Mirror of mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets, but for the degraded - // tier: the wire serializer for degraded rows drops the targeting key and context regardless - // of consent, so two events differing only in consent emit byte-identical JSON. They must - // share a bucket, otherwise DEGRADED_CAP is effectively halved for zero wire fidelity gain. - // The AND-fold on EvalBucket.observeFullEvaluationData still runs but the value has no - // downstream effect for degraded rows. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); - - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, false, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(0, state.fullTier.size()); - assertEquals(1, state.degradedTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = - state.degradedTier.values().iterator().next(); - assertEquals(2, bucket.count); - // AND-fold collapses to consent-off; benign for degraded rows but a documented invariant. - assertFalse(bucket.observeFullEvaluationData); - } - - @Test - void absentVariantSetsRuntimeDefaultUsed() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.aggregate(event("flag-c", null, "alloc1", "user-1", 1000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(1, state.fullTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertTrue(bucket.runtimeDefaultUsed); - } - - @Test - void aggregatorStoresPrunedAttrsVerbatim() { - // Hot-path hook (DDEvaluator#copyPrunedContext) now delivers an already-pruned map. - // The aggregator no longer re-prunes; it stores what it is given. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map preprunedAttrs = new HashMap<>(); - for (int i = 0; i < 100; i++) { - preprunedAttrs.put("key" + i, "v" + i); - } - - aggregator.aggregate(event("flag-d", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertEquals(100, bucket.prunedContextFieldCount()); - assertEquals(100, bucket.prunedAttrs.size()); - } - - @Test - void emptyContextInputsProduceEmptyCanonicalKey() { - assertEquals("", FlagEvaluationAggregator.canonicalContextKey(null)); - assertEquals("", FlagEvaluationAggregator.canonicalContextKey(emptyMap())); - } - - @Test - void canonicalContextKeyEncodesSupportedValueTypes() { - final Map attrs = new HashMap<>(); - attrs.put("bool", true); - attrs.put("double", 1.5d); - attrs.put("float", 1.25f); - attrs.put("int", 1); - attrs.put("long", 2L); - attrs.put("null", null); - attrs.put("object", new StringBuilder("other")); - attrs.put("string", "value"); - - final String key = FlagEvaluationAggregator.canonicalContextKey(attrs); - - assertEquals(key, FlagEvaluationAggregator.canonicalContextKey(new HashMap<>(attrs))); - assertTrue(key.contains("bool")); - assertTrue(key.contains("string")); - assertTrue(key.contains("other")); - } - - @Test - void aggregatorStoresPrePrunedAttrsWithoutRePruning() { - // Value-length pruning moved to DDEvaluator#copyPrunedContext (hot path). The aggregator - // stores what it is given, so any long value present in the input remains present. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map preprunedAttrs = new HashMap<>(); - preprunedAttrs.put("short-val", "ok"); - - aggregator.aggregate(event("flag-e", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertTrue(bucket.prunedAttrs.containsKey("short-val")); - } - - @Test - void capSizingUsesNamedScaleConstants() { - assertEquals(125_000, FlagEvaluationAggregator.FULL_BUCKET_SIZING_BASIS); - assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_BUCKET_SIZING_BASIS); - assertEquals(25_000, FlagEvaluationAggregator.DEGRADED_BUCKET_SIZING_BASIS); - assertEquals(131_072, FlagEvaluationAggregator.GLOBAL_CAP); - assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_CAP); - assertEquals(32_768, FlagEvaluationAggregator.DEGRADED_CAP); - } - - @Test - void flagEvalEventDoesNotCarryReason() { - final boolean hasReasonField = - Arrays.stream( - datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent.class - .getDeclaredFields()) - .anyMatch(field -> field.getName().equals("reason")); - - assertFalse(hasReasonField); - } - - @Test - void evalBucketTracksBoundsDefaultStateAndNullContextFieldCount() { - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "bucket-flag", "on", "alloc1", "user-1", null, 1000L, false, null, false, null); - - assertEquals(0, bucket.prunedContextFieldCount()); - - bucket.merge(900L, true); - bucket.merge(1100L, false); - bucket.merge(1000L, false); - - assertEquals(4, bucket.count); - assertEquals(900L, bucket.firstEvalMs); - assertEquals(1100L, bucket.lastEvalMs); - assertTrue(bucket.runtimeDefaultUsed); - } - - @Test - void fullKeyEqualityUsesEveryDimension() { - final FlagEvaluationAggregator.FullKey base = - fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); - final FlagEvaluationAggregator.FullKey same = - fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); - - assertEquals(base, base); - assertEquals(base, same); - assertEquals(base.hashCode(), same.hashCode()); - assertNotEquals(base, null); - assertNotEquals(base, "not-a-key"); - assertNotEquals(base, fullKey("other", "on", "alloc", false, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "off", "alloc", false, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "other", false, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", true, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", false, "other", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "other", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "user", "other")); - } - - @Test - void degradedKeyEqualityUsesEveryDimension() { - final FlagEvaluationAggregator.DegradedKey base = - degradedKey("flag", "on", "alloc", false, "error"); - final FlagEvaluationAggregator.DegradedKey same = - degradedKey("flag", "on", "alloc", false, "error"); - - assertEquals(base, base); - assertEquals(base, same); - assertEquals(base.hashCode(), same.hashCode()); - assertNotEquals(base, null); - assertNotEquals(base, "not-a-key"); - assertNotEquals(base, degradedKey("other", "on", "alloc", false, "error")); - assertNotEquals(base, degradedKey("flag", "off", "alloc", false, "error")); - assertNotEquals(base, degradedKey("flag", "on", "other", false, "error")); - assertNotEquals(base, degradedKey("flag", "on", "alloc", true, "error")); - assertNotEquals(base, degradedKey("flag", "on", "alloc", false, "other")); - } - - @Test - void mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets() { - // Consent is part of FullKey: two evaluations that differ only in consent produce different - // wire rows (raw vs hashed targeting key, context vs no context) and belong in different - // buckets. Merging them would silently downgrade the consent-on row to the protected shape. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, false, emptyMap())); - - assertEquals(2, aggregator.fullTierSize()); - int onCount = 0; - int offCount = 0; - for (final FlagEvaluationAggregator.EvalBucket bucket : - aggregator.snapshot().fullTier.values()) { - if (bucket.observeFullEvaluationData) { - onCount++; - } else { - offCount++; - } - } - assertEquals(1, onCount); - assertEquals(1, offCount); - } - - @Test - void sameConsentEvaluationsForSameSubjectMergeIntoOneBucket() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, true, emptyMap())); - - final FlagEvaluationAggregator.EvalBucket bucket = - aggregator.snapshot().fullTier.values().iterator().next(); - assertEquals(2, bucket.count); - assertTrue(bucket.observeFullEvaluationData); - } - - @Test - void protectedPathCollapsesDifferingContextIntoOneBucket() { - // Same subject, different request-id contexts, consent off: the context is dropped on emit so - // it must not fragment full-tier buckets or the per-flag cap blows out under real traffic. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map ctx1 = new HashMap<>(); - ctx1.put("request_id", "req-1"); - final Map ctx2 = new HashMap<>(); - ctx2.put("request_id", "req-2"); - final Map ctx3 = new HashMap<>(); - ctx3.put("request_id", "req-3"); - - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, ctx1)); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, false, ctx2)); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 3000L, false, ctx3)); - - assertEquals(1, aggregator.fullTierSize()); - final FlagEvaluationAggregator.EvalBucket bucket = - aggregator.snapshot().fullTier.values().iterator().next(); - assertEquals(3, bucket.count); - assertFalse(bucket.observeFullEvaluationData); - assertEquals(0, bucket.prunedContextFieldCount()); - } - - @Test - void protectedPathSeparatesDifferentSubjects() { - // Different targeting keys must still fall into distinct buckets on the protected path — the - // (hashed) targeting key stays part of the aggregation identity. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, emptyMap())); - aggregator.aggregate(event("checkout", "on", "alloc1", "bob", 2000L, false, emptyMap())); - - assertEquals(2, aggregator.fullTierSize()); - } - - @Test - void fullPathStillSplitsBucketsOnDifferingContext() { - // Consent-on preserves the previous behaviour: distinct contexts remain distinct buckets so - // each raw context is emitted verbatim. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map ctx1 = new HashMap<>(); - ctx1.put("plan", "pro"); - ctx1.put("request_id", "req-1"); - final Map ctx2 = new HashMap<>(); - ctx2.put("plan", "pro"); - ctx2.put("request_id", "req-2"); - - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, true, ctx1)); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, true, ctx2)); - - assertEquals(2, aggregator.fullTierSize()); - } - - private static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map attrs) { - return new FlagEvalEvent( - flagKey, - variant, - allocationKey, - targetingKey, - null, - evalTimeMs, - observeFullEvaluationData, - attrs); - } - - private static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final Map attrs) { - return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); - } - - private static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { - return event(flagKey, variant, "alloc1", "user-1", 1000L, emptyMap()); - } - - private static FlagEvaluationAggregator.FullKey fullKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage, - final String targetingKey, - final String contextKey) { - return new FlagEvaluationAggregator.FullKey( - flagKey, - variant, - allocationKey, - runtimeDefaultUsed, - errorMessage, - targetingKey, - contextKey, - false, - ""); - } - - private static FlagEvaluationAggregator.DegradedKey degradedKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage) { - return new FlagEvaluationAggregator.DegradedKey( - flagKey, variant, allocationKey, runtimeDefaultUsed, errorMessage, ""); - } - - @Test - void featuresSplitBucketsInBothTiersWhateverTheConsent() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map p1 = singletonMap("bandit.policy_id", "p1"); - final Map p2 = singletonMap("bandit.policy_id", "p2"); - - aggregator.aggregate(featureEvent("user-1", 1000L, false, p1)); - aggregator.aggregate(featureEvent("user-1", 2000L, false, p2)); - aggregator.aggregate(featureEvent("user-1", 3000L, false, p1)); - - final FlagEvaluationAggregator.AggregatedState full = aggregator.snapshot(); - assertEquals(2, full.fullTier.size()); - for (final FlagEvaluationAggregator.EvalBucket bucket : full.fullTier.values()) { - assertEquals(p1.equals(bucket.features) ? 2 : 1, bucket.count); - } - - aggregator.simulateFullTierAtCap(); - aggregator.aggregate(featureEvent("user-2", 4000L, false, p1)); - aggregator.aggregate(featureEvent("user-3", 5000L, false, p2)); - aggregator.aggregate(featureEvent("user-4", 6000L, false, p1)); - - final FlagEvaluationAggregator.AggregatedState degraded = aggregator.snapshot(); - assertEquals(2, degraded.degradedTier.size()); - for (final FlagEvaluationAggregator.EvalBucket bucket : degraded.degradedTier.values()) { - assertEquals(p1.equals(bucket.features) ? 2 : 1, bucket.count); - } - } - - @Test - void bucketsShareOneCopyOfEachFeatureSetUntilTheWindowIsCleared() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.aggregate(featureEvent("user-1", 1000L, false, policy("p1"))); - aggregator.aggregate(featureEvent("user-2", 2000L, false, policy("p1"))); - aggregator.aggregate(featureEvent("user-3", 3000L, false, policy("p2"))); - - final List p1Buckets = new ArrayList<>(); - for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.fullTier.values()) { - if (policy("p1").equals(bucket.features)) { - p1Buckets.add(bucket); - } - } - assertEquals(2, p1Buckets.size()); - assertSame(p1Buckets.get(0).features, p1Buckets.get(1).features); - assertEquals(2, aggregator.featureSets.size()); - - aggregator.simulateFullTierAtCap(); - aggregator.aggregate(featureEvent("user-4", 4000L, false, policy("p1"))); - for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.degradedTier.values()) { - assertSame(p1Buckets.get(0).features, bucket.features); - } - - aggregator.clear(); - assertTrue(aggregator.featureSets.isEmpty()); - } - - private static Map policy(final String id) { - final Map features = new HashMap<>(); - features.put("bandit.policy_id", id); - return features; - } - - private static FlagEvalEvent featureEvent( - final String targetingKey, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map features) { - return new FlagEvalEvent( - "feature-flag", - "on", - "alloc1", - targetingKey, - null, - evalTimeMs, - observeFullEvaluationData, - emptyMap(), - features); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java deleted file mode 100644 index 75df3ac5269..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java +++ /dev/null @@ -1,496 +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 com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import java.lang.reflect.Type; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class FlagEvaluationPayloadsTest { - - private static final long EVAL_MS = 1_760_000_000_000L; - private static final Map CONTEXT = context(); - private static final JsonAdapter> JSON_MAP; - - static { - final Moshi moshi = new Moshi.Builder().build(); - final Type type = Types.newParameterizedType(Map.class, String.class, Object.class); - JSON_MAP = moshi.adapter(type); - } - - @Test - void fullTierPayloadUsesWorkerWireShape() throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event("my-flag", "on", "alloc-x", "user-1", 1, attrs)), - CONTEXT, - 1_000_000)); - - final Map ev = firstEvent(json); - assertObjectWithKey(ev.get("variant"), "on"); - assertObjectWithKey(ev.get("allocation"), "alloc-x"); - assertObjectWithKey(ev.get("flag"), "my-flag"); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - final Map evalAttrs = (Map) ctx.get("evaluation"); - assertNotNull(evalAttrs); - assertEquals("us-east-1", evalAttrs.get("region")); - assertFalse(ev.containsKey("reason")); - } - - @Test - void eventFromFullBucketUsesFlushTimeAndEvaluationBounds() throws Exception { - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "ts-flag", "on", "alloc1", "user-1", null, EVAL_MS, false, emptyMap(), true, null); - bucket.merge(EVAL_MS + 10, false); - final long flushTimeMs = EVAL_MS + 5_000; - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, true, flushTimeMs)), - CONTEXT, - 1_000_000)); - - final Map ev = firstEvent(json); - assertEquals((double) flushTimeMs, ((Number) ev.get("timestamp")).doubleValue()); - assertEquals((double) EVAL_MS, ((Number) ev.get("first_evaluation")).doubleValue()); - assertEquals((double) (EVAL_MS + 10), ((Number) ev.get("last_evaluation")).doubleValue()); - assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); - } - - @Test - void degradedTierEventOmitsTargetingKeyAndContext() throws Exception { - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "dg-flag", "on", "alloc1", null, null, EVAL_MS, false, null, false, null); - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, false, true, EVAL_MS)), - CONTEXT, - 1_000_000)); - - final Map ev = firstEvent(json); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void fullTierWithObserveFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "pii-flag", - "on", - "alloc1", - "jane.doe@datadoghq.com", - null, - EVAL_MS, - false, - attrs, - true, - null); - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, true, EVAL_MS)), - CONTEXT, - 1_000_000)); - - final Map ev = firstEvent(json); - assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - final Map evalAttrs = (Map) ctx.get("evaluation"); - assertNotNull(evalAttrs); - assertEquals("us-east-1", evalAttrs.get("region")); - } - - @Test - void fullTierWithObserveFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() - throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "pii-flag", - "on", - "alloc1", - "jane.doe@datadoghq.com", - null, - EVAL_MS, - false, - attrs, - false, - null); - - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, false, EVAL_MS)), - CONTEXT, - 1_000_000); - final String rawJson = - new String(payloads.bodies.get(0), java.nio.charset.StandardCharsets.UTF_8); - - // The raw wire bytes must carry the hashed key and must not leak the raw PII value or the - // per-event evaluation context — these are the exact properties system-tests asserts over the - // wire. (The batch envelope has its own top-level "context" field, so we guard on the nested - // "evaluation" key instead, which only appears inside a per-event context object.) - assertTrue( - rawJson.contains( - "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b")); - assertFalse(rawJson.contains("jane.doe@datadoghq.com")); - assertFalse(rawJson.contains("\"evaluation\":")); - - final Map ev = firstEvent(parse(payloads.bodies.get(0))); - assertEquals( - "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b", - ev.get("targeting_key")); - assertFalse(ev.containsKey("context")); - } - - @Test - void splitPayloadsByEncodedSize() throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("payload", repeat('x', 180)); - final java.util.ArrayList events = - new java.util.ArrayList<>(); - for (int i = 0; i < 4; i++) { - events.add(event("split-flag-" + i, "on", "alloc1", "user-" + i, 1, attrs)); - } - - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(events, CONTEXT, 1_100); - - assertTrue(payloads.bodies.size() > 1); - int eventCount = 0; - for (final byte[] body : payloads.bodies) { - assertTrue(body.length <= 1_100); - eventCount += eventCount(parse(body)); - } - assertEquals(4, eventCount); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void oversizedFullPayloadRowIsDegradedBeforeDrop() throws Exception { - final Map attrs = new HashMap<>(); - for (int i = 0; i < 4; i++) { - attrs.put("payload-" + i, repeat('x', 200)); - } - - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event("oversized-full", "on", "alloc1", "user-1", 2, attrs)), - CONTEXT, - 512); - - assertEquals(1, payloads.bodies.size()); - assertTrue(payloads.bodies.get(0).length <= 512); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(2, payloads.degradedPayloadLimit); - final Map ev = firstEvent(parse(payloads.bodies.get(0))); - assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void oversizedFullPayloadRowStartsNewPayloadWhenDegradedRowFitsByItself() throws Exception { - final FlagEvaluationPayloads.FlagEvaluationEvent first = - event("first-flag", "on", "alloc1", "user-1", 1, emptyMap()); - final Map attrs = new HashMap<>(); - attrs.put("payload", repeat('x', 400)); - final FlagEvaluationPayloads.FlagEvaluationEvent second = - event("second-flag", "on", "alloc1", "user-2", 3, attrs); - final FlagEvaluationPayloads.FlagEvaluationEvent degradedSecond = - second.withoutTargetingKeyAndContext(); - assertNotNull(degradedSecond); - - final int firstPayloadSize = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList(first), CONTEXT, 1_000_000) - .bodies - .get(0) - .length; - final int degradedPayloadSize = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList(degradedSecond), CONTEXT, 1_000_000) - .bodies - .get(0) - .length; - final int limit = Math.max(firstPayloadSize, degradedPayloadSize); - - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(Arrays.asList(first, second), CONTEXT, limit); - - assertEquals(2, payloads.bodies.size()); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(3, payloads.degradedPayloadLimit); - assertEquals(1, eventCount(parse(payloads.bodies.get(0)))); - final Map ev = firstEvent(parse(payloads.bodies.get(1))); - assertObjectWithKey(ev.get("flag"), "second-flag"); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void oversizedDegradedPayloadRowIsDropped() { - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event(repeat('f', 512), "on", "alloc1", null, 2, emptyMap())), - CONTEXT, - 128); - - assertTrue(payloads.bodies.isEmpty()); - assertEquals(2, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void oversizedFullPayloadRowIsDroppedWhenDegradedRowStillExceedsLimit() { - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event(repeat('f', 512), "on", "alloc1", "user-1", 2, emptyMap())), - CONTEXT, - 128); - - assertTrue(payloads.bodies.isEmpty()); - assertEquals(2, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void errorPayloadSerializesErrorObject() throws Exception { - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, - "err-flag", - EVAL_MS, - EVAL_MS, - 1, - null, - null, - null, - true, - "type mismatch", - null, - null)), - CONTEXT, - 1_000_000)); - - final Map ev = firstEvent(json); - final Map error = (Map) ev.get("error"); - assertNotNull(error); - assertEquals("type mismatch", error.get("message")); - assertEquals(Boolean.TRUE, ev.get("runtime_default_used")); - } - - @Test - void emptyEventListProducesNoPayloads() { - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(java.util.Collections.emptyList(), CONTEXT, 1_000_000); - - assertTrue(payloads.bodies.isEmpty()); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void requestDtoStoresContextAndEvents() { - final List events = - java.util.Collections.singletonList( - event("dto-flag", "on", "alloc1", "user-1", 1, emptyMap())); - - final FlagEvaluationPayloads.FlagEvaluationsRequest request = - new FlagEvaluationPayloads.FlagEvaluationsRequest(CONTEXT, events); - - assertEquals(CONTEXT, request.context); - assertEquals(events, request.flagEvaluations); - } - - @Test - void degradingEventWithMissingOptionalFieldsKeepsOptionalObjectsAbsent() { - final FlagEvaluationPayloads.FlagEvaluationEvent degraded = - new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, - "default-flag", - EVAL_MS, - EVAL_MS, - 1, - null, - null, - "user-1", - true, - null, - null, - null) - .withoutTargetingKeyAndContext(); - - assertNotNull(degraded); - assertNull(degraded.variant); - assertNull(degraded.allocation); - assertNull(degraded.targeting_key); - assertNull(degraded.context); - assertNull(degraded.error); - assertEquals(Boolean.TRUE, degraded.runtime_default_used); - } - - @Test - void emptyOptionalStringsAreTreatedAsAbsentWhenContextIsPresent() { - final Map attrs = new HashMap<>(); - attrs.put("tier", "gold"); - final FlagEvaluationPayloads.FlagEvaluationEvent event = - new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, "empty-optionals", EVAL_MS, EVAL_MS, 1, "", "", null, false, "", attrs, null); - - assertNull(event.variant); - assertNull(event.allocation); - assertNull(event.error); - assertNotNull(event.context); - - final FlagEvaluationPayloads.FlagEvaluationEvent degraded = - event.withoutTargetingKeyAndContext(); - assertNotNull(degraded); - assertNull(degraded.targeting_key); - assertNull(degraded.context); - } - - private static FlagEvaluationPayloads.FlagEvaluationEvent event( - final String flagKey, - final String variant, - final String allocation, - final String targetingKey, - final long count, - final Map attrs) { - return new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, - flagKey, - EVAL_MS, - EVAL_MS, - count, - variant, - allocation, - targetingKey, - false, - null, - attrs, - null); - } - - private static Map firstPayload( - final FlagEvaluationPayloads.EncodedPayloads payloads) throws Exception { - assertEquals(1, payloads.bodies.size()); - return parse(payloads.bodies.get(0)); - } - - private static Map parse(final byte[] body) throws Exception { - return JSON_MAP.fromJson(new String(body, java.nio.charset.StandardCharsets.UTF_8)); - } - - @SuppressWarnings("unchecked") - private static Map firstEvent(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events); - assertFalse(events.isEmpty()); - return (Map) events.get(0); - } - - @SuppressWarnings("unchecked") - private static int eventCount(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events); - return events.size(); - } - - private static void assertObjectWithKey(final Object object, final String expectedKey) { - assertTrue(object instanceof Map); - assertEquals(expectedKey, ((Map) object).get("key")); - } - - private static String repeat(final char c, final int count) { - final char[] chars = new char[count]; - java.util.Arrays.fill(chars, c); - return new String(chars); - } - - private static Map context() { - final Map context = new HashMap<>(); - context.put("service", "test-service"); - return context; - } - - @Test - void rowsCarryFeaturesInEveryTierAndWithoutConsent() throws Exception { - final Map features = new HashMap<>(); - features.put("bandit.policy_id", "p2"); - features.put("bandit.weight", 0.5); - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "feature-flag", "on", "alloc1", "user-1", null, EVAL_MS, false, null, false, features); - - final FlagEvaluationPayloads.FlagEvaluationEvent full = - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, true, false, EVAL_MS); - final FlagEvaluationPayloads.FlagEvaluationEvent degraded = - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, false, false, EVAL_MS); - - assertEquals(features, firstEvent(onlyPayload(full)).get("features")); - assertEquals(features, firstEvent(onlyPayload(degraded)).get("features")); - assertEquals(features, full.withoutTargetingKeyAndContext().features); - } - - @Test - void rowsWithoutFeaturesOmitTheField() throws Exception { - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "plain-flag", "on", "alloc1", null, null, EVAL_MS, false, null, false, emptyMap()); - - final FlagEvaluationPayloads.FlagEvaluationEvent row = - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, false, false, EVAL_MS); - - assertFalse(firstEvent(onlyPayload(row)).containsKey("features")); - } - - private static Map onlyPayload( - final FlagEvaluationPayloads.FlagEvaluationEvent row) throws Exception { - return firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList(row), CONTEXT, 1_000_000)); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java deleted file mode 100644 index 502f0e76c69..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java +++ /dev/null @@ -1,246 +0,0 @@ -package com.datadog.featureflag; - -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.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.intake.Intake; -import datadog.trace.api.telemetry.CoreMetricCollector; -import datadog.trace.api.telemetry.MetricCollector; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Supplier; -import okhttp3.RequestBody; -import okio.Buffer; - -final class FlagEvaluationTestSupport { - - static final long REALISTIC_EVAL_MS = 1_760_000_000_000L; - static final JsonAdapter> JSON_MAP; - - static { - final Moshi moshi = new Moshi.Builder().build(); - final Type type = Types.newParameterizedType(Map.class, String.class, Object.class); - JSON_MAP = moshi.adapter(type); - } - - private FlagEvaluationTestSupport() {} - - static Supplier backendApiSupplier(final BackendApiFactory factory) { - return () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false); - } - - static void clearCoreMetrics() { - CoreMetricCollector.getInstance().drain(); - } - - static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final Map attrs) { - return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); - } - - static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map attrs) { - return new FlagEvalEvent( - flagKey, - variant, - allocationKey, - targetingKey, - null, - evalTimeMs, - observeFullEvaluationData, - attrs); - } - - static FlagEvalEvent errorEvent( - final String flagKey, final String errorMessage, final long evalTimeMs) { - return new FlagEvalEvent( - flagKey, null, null, null, errorMessage, evalTimeMs, java.util.Collections.emptyMap()); - } - - static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { - return event(flagKey, variant, "alloc1", "user-1", 1000L, java.util.Collections.emptyMap()); - } - - static String repeat(final char c, final int count) { - final char[] chars = new char[count]; - Arrays.fill(chars, c); - return new String(chars); - } - - static TestWriterSetup buildTestWriter(final BackendApi mockEvp) { - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - - final Map context = new HashMap<>(); - context.put("service", "test-service"); - - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = - FlagEvaluationWriterImpl.createHandlerForTest(backendApiSupplier(factory), context); - - return new TestWriterSetup(handler, mockEvp, factory); - } - - static TestWriterSetup buildTestWriter( - final BackendApi mockEvp, final int payloadSizeLimitBytes) { - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - - final Map context = new HashMap<>(); - context.put("service", "test-service"); - - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = - FlagEvaluationWriterImpl.createHandlerForTest( - backendApiSupplier(factory), context, payloadSizeLimitBytes); - - return new TestWriterSetup(handler, mockEvp, factory); - } - - static CapturedJson flushAndCapture(final TestWriterSetup setup) throws Exception { - final List captured = flushAndCaptureAll(setup); - assertEquals(1, captured.size(), "Expected exactly one posted payload"); - return captured.get(0); - } - - static List flushAndCaptureAll(final TestWriterSetup setup) throws Exception { - final List captured = new ArrayList<>(); - when(setup.mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured.add(inv.getArgument(1)); - return null; - }); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - final List json = new ArrayList<>(); - for (final RequestBody body : captured) { - json.add(readJson(body)); - } - return json; - } - - static CapturedJson readJson(final RequestBody body) throws Exception { - assertNotNull(body, "RequestBody must have been posted"); - final Buffer buf = new Buffer(); - body.writeTo(buf); - final String raw = buf.readUtf8(); - return new CapturedJson(raw, JSON_MAP.fromJson(raw)); - } - - static Map flushAndCaptureJson(final TestWriterSetup setup) throws Exception { - return flushAndCapture(setup).parsed; - } - - static long metricSum( - final Collection metrics, - final String metricName, - final String tag) { - long sum = 0; - for (final MetricCollector.Metric metric : metrics) { - if (!metricName.equals(metric.metricName)) { - continue; - } - if (tag == null) { - if (!metric.tags.isEmpty()) { - continue; - } - } else if (!metric.tags.contains(tag)) { - continue; - } - sum += metric.value.longValue(); - } - return sum; - } - - static datadog.trace.api.Config cfg() { - final datadog.trace.api.Config config = mock(datadog.trace.api.Config.class); - when(config.getServiceName()).thenReturn("test-service"); - return config; - } - - static void assertObjectWithKey(final Object o, final String expectedKey, final String msg) { - assertTrue(o instanceof Map, msg + " (must be a JSON object, not a bare string)"); - assertEquals(expectedKey, ((Map) o).get("key"), msg); - } - - @SuppressWarnings("unchecked") - static Map firstEvent(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events, "flagEvaluations array must be present"); - assertFalse(events.isEmpty(), "flagEvaluations must not be empty"); - return (Map) events.get(0); - } - - @SuppressWarnings("unchecked") - static int eventCount(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events, "flagEvaluations array must be present"); - return events.size(); - } - - @SuppressWarnings("unchecked") - static Map eventForFlag(final Map batch, final String flagKey) { - final List events = (List) batch.get("flagEvaluations"); - for (final Object o : events) { - final Map ev = (Map) o; - final Map flag = (Map) ev.get("flag"); - if (flag != null && flagKey.equals(flag.get("key"))) { - return ev; - } - } - return null; - } - - static class TestWriterSetup { - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler; - final BackendApi mockEvp; - final BackendApiFactory factory; - - TestWriterSetup( - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler, - final BackendApi mockEvp, - final BackendApiFactory factory) { - this.handler = handler; - this.mockEvp = mockEvp; - this.factory = factory; - } - } - - static class CapturedJson { - final String raw; - final Map parsed; - - CapturedJson(final String raw, final Map parsed) { - this.raw = raw; - this.parsed = parsed; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java deleted file mode 100644 index f0bd4476dcf..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java +++ /dev/null @@ -1,936 +0,0 @@ -package com.datadog.featureflag; - -import static com.datadog.featureflag.FlagEvaluationTestSupport.JSON_MAP; -import static com.datadog.featureflag.FlagEvaluationTestSupport.backendApiSupplier; -import static com.datadog.featureflag.FlagEvaluationTestSupport.buildTestWriter; -import static com.datadog.featureflag.FlagEvaluationTestSupport.cfg; -import static com.datadog.featureflag.FlagEvaluationTestSupport.clearCoreMetrics; -import static com.datadog.featureflag.FlagEvaluationTestSupport.event; -import static com.datadog.featureflag.FlagEvaluationTestSupport.eventForFlag; -import static com.datadog.featureflag.FlagEvaluationTestSupport.flushAndCapture; -import static com.datadog.featureflag.FlagEvaluationTestSupport.flushAndCaptureJson; -import static com.datadog.featureflag.FlagEvaluationTestSupport.metricSum; -import static com.datadog.featureflag.FlagEvaluationTestSupport.repeat; -import static com.datadog.featureflag.FlagEvaluationTestSupport.simpleEvent; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -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.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import ch.qos.logback.classic.Logger; -import datadog.common.queue.MessagePassingBlockingQueue; -import datadog.common.queue.Queues; -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.IntakeApi; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.communication.http.HttpRetryPolicy; -import datadog.trace.agent.test.server.http.JavaTestHttpServer; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.intake.Intake; -import datadog.trace.api.telemetry.CoreMetricCollector; -import datadog.trace.api.telemetry.MetricCollector; -import datadog.trace.test.logging.TestLogCollector; -import datadog.trace.test.logging.TestLogbackAppender; -import datadog.trace.test.util.PollingConditions; -import java.io.IOException; -import java.lang.reflect.Field; -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.RequestBody; -import okio.Buffer; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; - -class FlagEvaluationWriterImplTest { - - @Test - void unavailableRouteDoesNotLogAnErrorForEachEvaluationFlush() { - final FeatureFlagRouteSelector selector = new FeatureFlagRouteSelector(); - selector.initialize(() -> null, false); - final BackendApi api = - new AgentlessFeatureFlagBackendApi( - null, () -> null, endpoint -> null, () -> null, "flag evaluation", selector); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(api); - final Logger logger = (Logger) LoggerFactory.getLogger(FlagEvaluationWriterImpl.class); - final TestLogbackAppender appender = new TestLogbackAppender(); - appender.setContext(logger.getLoggerContext()); - appender.start(); - logger.addAppender(appender); - TestLogCollector.enable(); - try { - logger.error("log-capture-positive-control"); - assertEquals(1, TestLogCollector.drainCapturedLogs().size()); - for (int i = 0; i < 3; i++) { - setup.handler.add(simpleEvent("flag", "on")); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - } - assertEquals( - 0, - TestLogCollector.drainCapturedLogs().stream() - .filter( - log -> - log.level.equals("ERROR") - && log.message.contains("Could not submit flag evaluations")) - .count()); - } finally { - TestLogCollector.disable(); - logger.detachAppender(appender); - appender.stop(); - } - } - - private static final String DIRECT_FLAG_EVALUATION_ENDPOINT = "/api/v2/flagevaluation"; - private static final String API_KEY = "test-api-key"; - private static final double TIMEOUT_SECONDS = 5; - - @BeforeEach - void clearCoreMetricsBefore() { - clearCoreMetrics(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @AfterEach - void clearCoreMetricsAfter() { - clearCoreMetrics(); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - // Reset the dispatched UFC state so observeFullEvaluationData can't leak into other tests. - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - - @Test - void degradedCapOverflowTelemetryIsEmittedOnFlush() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.addDroppedDegradedOverflowForTest(3); - setup.handler.flush(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 3, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_DEGRADED_CAP)); - } - - @Test - void startRegistersWriterAndCloseDeregistersIt() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.start(); - assertEquals(writer, FeatureFlaggingGateway.getFlagEvalWriter()); - - writer.close(); - writer.close(); - writer.start(); - - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } - - @Test - void unavailableBackendClosesWriterFromItsOwnWorker() throws Exception { - final AtomicReference worker = new AtomicReference<>(); - final CompletableFuture failure = new CompletableFuture<>(); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, - Long.MAX_VALUE, - TimeUnit.NANOSECONDS, - () -> { - worker.set(Thread.currentThread()); - worker.get().setUncaughtExceptionHandler((thread, error) -> failure.complete(error)); - return null; - }, - cfg()); - try { - writer.start(); - final Throwable error = failure.get(5, TimeUnit.SECONDS); - assertEquals(IllegalArgumentException.class, error.getClass()); - assertEquals("EVP Proxy not available", error.getMessage()); - worker.get().join(5000); - assertFalse(worker.get().isAlive()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - - writer.startForTest(); - assertEquals(Thread.State.TERMINATED, worker.get().getState()); - } finally { - writer.close(); - } - } - - @Test - void queueOverflowIncrementsObservableDropCounter() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl(2, 10L, TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); - - for (int i = 0; i < 100; i++) { - writer.enqueue(simpleEvent("of-flag", "on")); - } - - assertTrue(writer.droppedQueueOverflow() > 0); - final long queueDrops = writer.droppedQueueOverflow(); - writer.flushForTest(); - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - queueDrops, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_QUEUE_OVERFLOW)); - } - - @Test - void enqueueAfterCloseIsDroppedAndCounted() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.close(); - writer.enqueue(simpleEvent("closed-flag", "on")); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); - assertNull(writer.pollQueuedEventForTest()); - } - - @Test - void enqueueDisabledDropsAndCountsAsClosedDrop() { - // FeatureFlaggingSystem.stop() flips the gate before this writer's close() runs. Producers - // that race the gate flip must count the drop, otherwise shutdown loss stays invisible. - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - writer.enqueue(simpleEvent("disabled-flag", "on")); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); - assertNull(writer.pollQueuedEventForTest()); - } - - @Test - void closeSweepsAndCountsEventsLeftInTheQueue() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - // The worker is never started, so nothing drains these; close() must account for them rather - // than leave them silently stranded. Stands in for the narrow window where a lock-free - // producer offers after the worker's final drain. - writer.enqueue(simpleEvent("residual-flag-1", "on")); - writer.enqueue(simpleEvent("residual-flag-2", "on")); - writer.close(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 2, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); - assertNull(writer.pollQueuedEventForTest()); - } - - @Test - void enqueueIgnoresNullEvent() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.enqueue(null); - - assertNull(writer.pollQueuedEventForTest()); - assertEquals(0, writer.droppedQueueOverflow()); - } - - @Test - void enqueueDoesNotAggregateOnTheCallingThread() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.enqueue(simpleEvent("g2-flag", "on")); - writer.enqueue(simpleEvent("g2-flag", "on")); - - assertEquals(0, writer.aggregatorFullTierSizeForTest()); - assertEquals(0, writer.droppedQueueOverflow()); - } - - @Test - void handlerRunFailsFastWhenEvpProxyIsUnavailable() { - final BackendApiFactory factory = mock(BackendApiFactory.class); - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = - FlagEvaluationWriterImpl.createHandlerForTest(backendApiSupplier(factory), context()); - - assertThrows(IllegalArgumentException.class, handler::run); - } - - @Test - void flushIfNecessarySkipsEmptyStateAndWaitsForInterval() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.flushIfNecessary(); - setup.handler.add(simpleEvent("pending-flag", "on")); - setup.handler.drainAndAggregate(); - setup.handler.flushIfNecessary(); - - assertEquals(1, setup.handler.fullTierSizeForTest()); - } - - @Test - void flushIfNecessaryDoesNotReturnEarlyWhenOnlyQueueDropsArePending() { - final AtomicLong queueDrops = new AtomicLong(1); - final FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler handler = - new FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler( - () -> null, - Queues.mpscBlockingConsumerArrayQueue(16), - Long.MAX_VALUE, - TimeUnit.NANOSECONDS, - context(), - queueDrops, - new java.util.concurrent.ConcurrentHashMap<>(), - () -> {}, - FlagEvaluationWriterImpl.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - - handler.flushIfNecessary(); - - assertEquals(1, queueDrops.get()); - } - - @Test - @SuppressWarnings("unchecked") - void workerHandlesEmptyPolls() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final MessagePassingBlockingQueue queue = - mock(MessagePassingBlockingQueue.class); - when(queue.poll(100, TimeUnit.MILLISECONDS)) - .thenAnswer( - invocation -> { - Thread.currentThread().interrupt(); - return null; - }); - final FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler handler = - new FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler( - backendApiSupplier(factory), - queue, - Long.MAX_VALUE, - TimeUnit.NANOSECONDS, - context(), - new AtomicLong(0), - new java.util.concurrent.ConcurrentHashMap<>(), - () -> {}, - FlagEvaluationWriterImpl.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - - handler.run(); - - assertTrue(Thread.interrupted()); - } - - @Test - void degradedBucketsAreSerializedWithoutTargetingKeyOrContext() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.addDegradedBucketForTest("degraded-flag", "on", "alloc1", null, 1000L); - - final Map json = flushAndCaptureJson(setup); - - final Map ev = eventForFlag(json, "degraded-flag"); - assertNotNull(ev); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void testHandlerCanSimulateAndClearDegradedTierAtCap() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.simulateDegradedTierAtCap(); - setup.handler.clearAggregationForTest(); - setup.handler.add(simpleEvent("after-clear", "on")); - setup.handler.drainAndAggregate(); - - assertEquals(1, setup.handler.fullTierSizeForTest()); - } - - @Test - void payloadLimitDropsAreCountedOnFlush() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp, 128); - setup.handler.add(event(repeat('f', 512), "on", "alloc1", "user-1", 1000L, emptyMap())); - - setup.handler.drainAndAggregate(); - setup.handler.flush(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_PAYLOAD_LIMIT)); - } - - @Test - void finalFlushRunsWithoutTheInterruptFlagSet() throws Exception { - // close() signals shutdown without interrupting the worker. The final flush does socket I/O, - // and OkHttp fails fast on an interrupted thread. A mock publisher ignores the flag, so assert - // on it directly. - final java.util.concurrent.CountDownLatch posted = new java.util.concurrent.CountDownLatch(1); - final boolean[] interruptedDuringPost = {true}; - final BackendApi mockEvp = mock(BackendApi.class); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - interruptedDuringPost[0] = Thread.currentThread().isInterrupted(); - posted.countDown(); - return null; - }); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); - - writer.startForTest(); - writer.enqueue(simpleEvent("interrupt-flag", "on")); - writer.close(); - - assertTrue(posted.await(5, TimeUnit.SECONDS)); - assertFalse( - interruptedDuringPost[0], - "final flush must not run on an interrupted thread; OkHttp fails fast and the drained" - + " rows are lost without being counted"); - } - - @Test - void closeDrainsAndFinalFlushesQueuedEvents() throws Exception { - final java.util.concurrent.CountDownLatch posted = new java.util.concurrent.CountDownLatch(1); - final RequestBody[] captured = {null}; - final BackendApi mockEvp = mock(BackendApi.class); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured[0] = inv.getArgument(1); - posted.countDown(); - return null; - }); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); - - writer.startForTest(); - writer.enqueue(simpleEvent("shutdown-flag", "on")); - writer.close(); - - assertTrue(posted.await(5, TimeUnit.SECONDS)); - assertNotNull(captured[0]); - final Buffer buf = new Buffer(); - captured[0].writeTo(buf); - final Map json = JSON_MAP.fromJson(buf.readUtf8()); - assertNotNull(eventForFlag(json, "shutdown-flag")); - } - - @Test - void continuousTrafficFlushesWithoutWaitingForIdle() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 1 << 12, 1, TimeUnit.MILLISECONDS, backendApiSupplier(factory), cfg()); - - writer.startForTest(); - boolean posted = false; - try { - final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); - while (System.nanoTime() < deadline) { - writer.enqueue(simpleEvent("busy-flag", "on")); - try { - verify(mockEvp, atLeastOnce()) - .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - posted = true; - break; - } catch (AssertionError ignored) { - // Keep the worker busy until the deadline. - } - } - } finally { - writer.close(); - } - - assertTrue(posted); - } - - @Test - void flushPostsToFlagevaluationEndpoint() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.add(event("flag-f", "on", "alloc1", "user-1", 1000L, emptyMap())); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - - verify(setup.factory).createBackendApi(Intake.EVENT_PLATFORM, false); - verify(mockEvp).post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - } - - @Test - void splitPostFailureDoesNotRetryAlreadySentPayloads() throws Exception { - final int limit = 1_100; - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp, limit); - final AtomicInteger posts = new AtomicInteger(); - doAnswer( - invocation -> { - if (posts.incrementAndGet() == 2) { - throw new IOException("boom"); - } - return null; - }) - .when(mockEvp) - .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - - for (int i = 0; i < 4; i++) { - final Map attrs = new HashMap<>(); - attrs.put("payload", repeat('x', 180)); - setup.handler.add(event("split-failure-" + i, "on", "alloc1", "user-" + i, 1000L, attrs)); - } - - setup.handler.drainAndAggregate(); - setup.handler.flush(); - assertEquals(2, posts.get()); - setup.handler.flush(); - assertEquals(2, posts.get()); - } - - private static final String HASHED_JANE_DOE = - "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b"; - - @Test - void observeFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { - // Consent travels on the event (snapshotted by the hook at evaluation time); the writer honours - // it verbatim and never consults the gateway. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent(true)); - - final Map json = flushAndCapture(setup).parsed; - - final Map ev = eventForFlag(json, "pii-flag"); - assertNotNull(ev); - assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - final Map evalAttrs = (Map) ctx.get("evaluation"); - assertNotNull(evalAttrs); - assertEquals("us-east-1", evalAttrs.get("region")); - } - - @Test - void observeFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() throws Exception { - assertHashedTargetingKeyAndOmittedContext(piiEvent(false)); - } - - @Test - void flagEvalEventDefaultConsentHashesTargetingKeyAndOmitsContext() throws Exception { - // An event built without an explicit consent value defaults to the privacy-preserving false, so - // it must behave exactly like the explicit "false" case. This is the state the hook produces - // when no UFC has been dispatched (the gateway reports false). - assertHashedTargetingKeyAndOmittedContext(piiEventDefaultConsent()); - } - - @Test - void eventConsentFalseStaysHashedEvenWhenGatewayLaterReportsTrue() throws Exception { - // Regression guard: consent is decided by the value the event carried at evaluation time, never - // re-read from the gateway at flush. An event evaluated under consent=false must stay hashed - // even if a later RC update turns the gateway's consent on before the flush drains. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent(false)); - - // Flip the gateway's consent on before both aggregation and flush; the event's evaluation-time - // snapshot (false) must win at every downstream step, so neither may consult the gateway. - dispatchObserveFullEvaluationData(true); - setup.handler.drainAndAggregate(); - - final java.util.List captured = new java.util.ArrayList<>(); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured.add(inv.getArgument(1)); - return null; - }); - setup.handler.flush(); - - assertEquals(1, captured.size()); - final FlagEvaluationTestSupport.CapturedJson json = - FlagEvaluationTestSupport.readJson(captured.get(0)); - final Map ev = eventForFlag(json.parsed, "pii-flag"); - assertNotNull(ev); - assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); - assertFalse(ev.containsKey("context")); - assertTrue(json.raw.contains(HASHED_JANE_DOE)); - assertFalse(json.raw.contains("jane.doe@datadoghq.com")); - } - - @Test - void eventConsentTrueStaysRawEvenWhenGatewayLaterReportsFalse() throws Exception { - // Symmetric guard: an event evaluated under consent=true must stay raw even if a later RC - // update - // turns the gateway's consent off before aggregation and flush. Together with the false-stays- - // hashed test this pins that neither aggregation nor flush ever consults the gateway. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent(true)); - - dispatchObserveFullEvaluationData(false); - setup.handler.drainAndAggregate(); - - final java.util.List captured = new java.util.ArrayList<>(); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured.add(inv.getArgument(1)); - return null; - }); - setup.handler.flush(); - - assertEquals(1, captured.size()); - final Map ev = - eventForFlag(FlagEvaluationTestSupport.readJson(captured.get(0)).parsed, "pii-flag"); - assertNotNull(ev); - assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - assertNotNull(ctx.get("evaluation")); - } - - @Test - void consentOffPreservesErrorCodeSignalAndNeverLeaksPiiInErrorMessage() throws Exception { - // Upstream contract: the hook substitutes the ErrorCode name for the raw exception message - // under consent-off (see - // FlagEvalLoggingHookTest#errorMessageReplacedByErrorCodeUnderConsentOff). - // This wire-level guard pins that a properly-formed consent-off event (a) still surfaces the - // stable ErrorCode signal for operators and (b) never lets a PII-shaped string escape onto the - // wire. Mirrors the existing PII guards on the targeting_key axis. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add( - new FlagEvalEvent( - "err-flag", - null, - "alloc1", - "jane.doe@datadoghq.com", - "TYPE_MISMATCH", - 1000L, - false, - emptyMap())); - - final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); - - final Map ev = eventForFlag(captured.parsed, "err-flag"); - assertNotNull(ev); - final Map error = (Map) ev.get("error"); - assertNotNull(error, "error object must be present so operators keep the ErrorCode signal"); - assertEquals("TYPE_MISMATCH", error.get("message")); - assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); - assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); - assertFalse( - captured.raw.contains("For input string"), - "no exception-message-shaped text may reach the wire under consent-off"); - } - - @Test - void encodeFailureClearsAggregatorSoLaterFlushesRecover() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - // Moshi rejects non-finite JSON numbers. A NaN in the context poisons buildPayloads for this - // bucket. Before the fix, the aggregator kept the bucket and every later flush re-threw. - final Map poison = new HashMap<>(); - poison.put("bad-number", Double.NaN); - setup.handler.add(event("poison-flag", "on", "alloc1", "user-1", 1000L, true, poison)); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - verify(mockEvp, org.mockito.Mockito.never()) - .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - - // The bucket must not survive the failed flush. A follow-up healthy event flushes cleanly. - setup.handler.add(simpleEvent("healthy-flag", "on")); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - verify(mockEvp).post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - } - - @Test - void scoConstructorCreatesUsableWriter() { - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - new SharedCommunicationObjects(), cfg(), new FeatureFlagRouteSelector()); - writer.enqueue(simpleEvent("sco-flag", "on")); - assertNotNull(writer.pollQueuedEventForTest()); - writer.close(); - } - - @Test - void agentlessWritesFlagEvaluationsDirectlyWhenLocalProxyIsUnavailable() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.prefix( - DIRECT_FLAG_EVALUATION_ENDPOINT, - api -> api.getResponse().status(200).send("OK"))))) { - final Config config = cfg(); - when(config.getFeatureFlaggingConfigurationSource()) - .thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getApiKey()).thenReturn(API_KEY); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final OkHttpClient client = new OkHttpClient.Builder().build(); - try { - final IntakeApi directApi = - new IntakeApi( - HttpUrl.get(server.getAddress()).resolve("/api/v2/"), - API_KEY, - "123", - HttpRetryPolicy.Factory.NEVER_RETRY, - client, - false); - when(backendApiFactory.createDirectIntakeApi( - eq(Intake.EVENT_PLATFORM), eq(false), eq(false))) - .thenReturn(directApi); - final FeatureFlagBackendApiFactory featureFlagBackendApiFactory = - new FeatureFlagBackendApiFactory( - config, backendApiFactory, FeatureFlagEventType.FLAG_EVALUATION); - final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); - - try (FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, 1, TimeUnit.MILLISECONDS, featureFlagBackendApiFactory::create, config)) { - writer.startForTest(); - writer.enqueue(simpleEvent("direct-flag", "on")); - - poll.eventually( - () -> { - assertNotNull(server.getLastRequest()); - assertEquals(DIRECT_FLAG_EVALUATION_ENDPOINT, server.getLastRequest().getPath()); - assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); - assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); - assertTrue(server.getLastRequest().getBody().length > 0); - }); - } - } finally { - client.dispatcher().executorService().shutdownNow(); - client.connectionPool().evictAll(); - } - } - } - - @Test - void countContextTruncatedAccumulatesPerReason() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.countContextTruncated("field_count"); - writer.countContextTruncated("field_count"); - writer.countContextTruncated("field_length"); - writer.flushForTest(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 2, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, - "reason:field_count")); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, - "reason:field_length")); - } - - @Test - void hasCapacityForEnqueueReflectsQueueSaturationAndCountsPreQueueOverflow() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final int capacity = 2; - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - capacity, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - assertTrue(writer.hasCapacityForEnqueue()); - - // Saturate the hand-off queue without starting the worker so no drain can free slots. - for (int i = 0; i < capacity; i++) { - writer.enqueue(simpleEvent("cap-flag-" + i, "on")); - } - assertFalse(writer.hasCapacityForEnqueue()); - - // Simulate a pre-queue overflow account by the hook and surface it on flush. - writer.countPreQueueOverflow(); - writer.flushForTest(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_QUEUE_OVERFLOW)); - - writer.close(); - } - - private void assertHashedTargetingKeyAndOmittedContext(final FlagEvalEvent piiEvent) - throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent); - - final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); - - final Map ev = eventForFlag(captured.parsed, "pii-flag"); - assertNotNull(ev); - assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); - assertFalse(ev.containsKey("context")); - // The raw wire bytes must carry the hashed key and never leak the raw PII value or a per-event - // evaluation context (the batch envelope owns the top-level "context" key, so guard on the - // nested "evaluation" field instead). - assertTrue(captured.raw.contains(HASHED_JANE_DOE)); - assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); - assertFalse(captured.raw.contains("\"evaluation\":")); - } - - private static FlagEvalEvent piiEvent(final boolean observeFullEvaluationData) { - return event( - "pii-flag", - "on", - "alloc1", - "jane.doe@datadoghq.com", - 1000L, - observeFullEvaluationData, - piiAttrs()); - } - - private static FlagEvalEvent piiEventDefaultConsent() { - return event("pii-flag", "on", "alloc1", "jane.doe@datadoghq.com", 1000L, piiAttrs()); - } - - private static Map piiAttrs() { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - return attrs; - } - - private static void dispatchObserveFullEvaluationData(final boolean value) { - FeatureFlaggingGateway.dispatch( - new ServerConfiguration( - "2024-04-17T19:40:53.716Z", "SERVER", value, null, java.util.Collections.emptyMap())); - } - - private static Object lifecycleLock(final FlagEvaluationWriterImpl writer) throws Exception { - final Field field = FlagEvaluationWriterImpl.class.getDeclaredField("lifecycleLock"); - field.setAccessible(true); - return field.get(writer); - } - - private static void awaitThreadState(final Thread thread, final Thread.State state) - throws InterruptedException { - final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (thread.getState() != state && System.nanoTime() < deadline) { - Thread.sleep(10); - } - assertEquals(state, thread.getState()); - } - - private static Map context() { - final Map context = new HashMap<>(); - context.put("service", "test-service"); - return context; - } -} 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 fae2631ce2f..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java +++ /dev/null @@ -1,463 +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.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.squareup.moshi.JsonQualifier; -import com.squareup.moshi.Moshi; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import java.io.IOException; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import java.util.Collections; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -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() - + "}}{}")); - } - - @Test - void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag("no-allocations", ""), - booleanFlag( - "no-splits", ",\"allocations\":[{\"key\":\"no-splits\",\"rules\":[]}]"), - booleanFlag( - "null-split", - ",\"allocations\":[{\"key\":\"null-split\",\"rules\":[],\"splits\":[null]}]"), - booleanFlag("no-rules", allocation("no-rules", "")), - booleanFlag("no-conditions", allocation("no-conditions", "[{}]")), - booleanFlag( - "no-operator", allocation("no-operator", "[{\"conditions\":[{}]}]")), - booleanFlag( - "non-semver", - allocation( - "non-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"MATCHES\",\"value\":\"1\"}]}]")), - booleanFlag( - "valid-semver", - allocation( - "valid-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.2\"}]}]")), - booleanFlag( - "invalid-semver", - allocation( - "invalid-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.02\"}]}]")), - booleanFlag( - "non-string-semver", - allocation( - "non-string-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":1}]}]")), - "\"null-flag\":null"))); - - assertNotNull(configuration); - assertTrue(configuration.flags.containsKey("no-allocations")); - assertTrue(configuration.flags.containsKey("no-splits")); - assertTrue(configuration.flags.containsKey("null-split")); - assertTrue(configuration.flags.containsKey("no-rules")); - assertTrue(configuration.flags.containsKey("no-conditions")); - assertTrue(configuration.flags.containsKey("no-operator")); - assertTrue(configuration.flags.containsKey("non-semver")); - assertTrue(configuration.flags.containsKey("valid-semver")); - assertFalse(configuration.flags.containsKey("invalid-semver")); - assertFalse(configuration.flags.containsKey("non-string-semver")); - assertFalse(configuration.flags.containsKey("null-flag")); - assertEquals(2, configuration.invalidFlags.size()); - assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("invalid-semver")); - assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("non-string-semver")); - - assertNotNull( - configuration - .flags - .get("valid-semver") - .allocations - .get(0) - .rules - .get(0) - .conditions - .get(0) - .semverComparand); - } - - @Test - void dropsFlagWithMalformedAllocationsWithoutRejectingConfig() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag("malformed-allocations", ",\"allocations\":\"not-a-list\""), - booleanFlag("valid-sibling", "")))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("malformed-allocations")); - assertTrue(configuration.flags.containsKey("valid-sibling")); - assertEquals("invalid_flag", configuration.invalidFlags.get("malformed-allocations")); - } - - @Test - void dropsFlagWithMissingSplitShards() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag( - "missing-shards", - ",\"allocations\":[{\"key\":\"missing-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\"}]}]"), - booleanFlag("valid-sibling", "")))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("missing-shards")); - assertTrue(configuration.flags.containsKey("valid-sibling")); - assertEquals("invalid_flag", configuration.invalidFlags.get("missing-shards")); - } - - @Test - void dropsFlagsWithInvalidConditionOperandsAndShardBounds() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag( - "non-numeric-gt", - allocation( - "non-numeric-gt", - "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"GT\",\"value\":\"bad\"}]}]")), - booleanFlag( - "non-list-one-of", - allocation( - "non-list-one-of", - "[{\"conditions\":[{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":\"admin\"}]}]")), - booleanFlag( - "negative-shard-range", - ",\"allocations\":[{\"key\":\"negative-shard-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":100,\"ranges\":[{\"start\":-1,\"end\":1}]}]}]}]"), - booleanFlag("valid-sibling", "")))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("non-numeric-gt")); - assertFalse(configuration.flags.containsKey("non-list-one-of")); - assertFalse(configuration.flags.containsKey("negative-shard-range")); - assertTrue(configuration.flags.containsKey("valid-sibling")); - assertEquals("invalid_flag", configuration.invalidFlags.get("non-numeric-gt")); - assertEquals("invalid_flag", configuration.invalidFlags.get("non-list-one-of")); - assertEquals("invalid_flag", configuration.invalidFlags.get("negative-shard-range")); - } - - @Test - void dropsFlagsWithInvalidShardBoundsAndConditionOperands() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag( - "zero-total-shards", - ",\"allocations\":[{\"key\":\"zero-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":0,\"ranges\":[]}]}]}]"), - booleanFlag( - "unsigned-total-shards", - ",\"allocations\":[{\"key\":\"unsigned-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":2147483648,\"ranges\":[{\"start\":2147483648,\"end\":2147483649}]}]}]}]"), - booleanFlag( - "too-many-shards", - ",\"allocations\":[{\"key\":\"too-many-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":4294967296,\"ranges\":[]}]}]}]"), - booleanFlag( - "missing-ranges", - ",\"allocations\":[{\"key\":\"missing-ranges\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1}]}]}]"), - booleanFlag( - "null-shard", - ",\"allocations\":[{\"key\":\"null-shard\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[null]}]}]"), - booleanFlag( - "null-range", - ",\"allocations\":[{\"key\":\"null-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[null]}]}]}]"), - booleanFlag( - "negative-range-end", - ",\"allocations\":[{\"key\":\"negative-range-end\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[{\"start\":0,\"end\":-1}]}]}]}]"), - booleanFlag( - "non-boolean-is-null", - allocation( - "non-boolean-is-null", - "[{\"conditions\":[{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":\"false\"}]}]")), - booleanFlag( - "valid-condition-operands", - allocation( - "valid-condition-operands", - "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"LT\",\"value\":1},{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":[\"admin\"]},{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":true}]}]"))))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("zero-total-shards")); - assertTrue(configuration.flags.containsKey("unsigned-total-shards")); - assertEquals( - 2_147_483_648L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .totalShards)); - assertEquals( - 2_147_483_648L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .ranges - .get(0) - .start)); - assertEquals( - 2_147_483_649L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .ranges - .get(0) - .end)); - assertFalse(configuration.flags.containsKey("too-many-shards")); - assertFalse(configuration.flags.containsKey("missing-ranges")); - assertFalse(configuration.flags.containsKey("null-shard")); - assertFalse(configuration.flags.containsKey("null-range")); - assertFalse(configuration.flags.containsKey("negative-range-end")); - assertFalse(configuration.flags.containsKey("non-boolean-is-null")); - assertTrue(configuration.flags.containsKey("valid-condition-operands")); - } - - @Test - void shardAdapterFactoryRejectsQualifiedShard() { - assertNull( - UniversalFlagConfigParser.ShardAdapter.FACTORY.create( - Shard.class, - Collections.singleton(QualifiedShard.class.getAnnotation(ShardQualifier.class)), - new Moshi.Builder().build())); - } - - @JsonQualifier - @Retention(RetentionPolicy.RUNTIME) - @Target(ElementType.TYPE) - private @interface ShardQualifier {} - - @ShardQualifier - private static final class QualifiedShard {} - - @Test - void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { - assertNull(parse("{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":null}}")); - } - - @Test - void observeFullEvaluationDataDefaultsToFalseWhenAbsent() throws Exception { - // Absent → Moshi leaves the boxed field null; the read site's Boolean.TRUE.equals(...) then - // resolves to the privacy-preserving default (consent-off). Either null-or-false is the - // documented invariant; assert the field never reads as true. - final ServerConfiguration configuration = parse(wrap(emptyConfig())); - assertNotNull(configuration); - assertFalse(Boolean.TRUE.equals(configuration.observeFullEvaluationData)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void observeFullEvaluationDataParsesExplicitValue(final boolean value) throws Exception { - final ServerConfiguration configuration = - parse(wrap(configWithObserveFullEvaluationData(value))); - assertNotNull(configuration); - assertEquals(value, configuration.observeFullEvaluationData); - } - - @Test - void observeFullEvaluationDataExplicitNullDefaultsToFalseWithoutRejectingConfig() - throws Exception { - // An explicit null (or a wrong-typed value) for this field must not abort the whole UFC parse. - // A pod that starts after a malformed UFC has no last-known-good, so aborting would strand - // every flag on PROVIDER_NOT_READY (its default value). We fail closed on privacy (consent - // stays false) but preserve availability: flags parse and evaluate. - final ServerConfiguration configuration = - parse(wrap(configWithRawObserveFullEvaluationData("null"))); - - assertNotNull(configuration); - assertFalse( - configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); - assertNotNull(configuration.flags); - } - - @Test - void observeFullEvaluationDataWrongTypedStringDefaultsToFalse() throws Exception { - // Moshi tolerates a stringified boolean like "true" via nullSafe/boxed handling: it either - // parses as null or throws locally and leaves the field null. Either way, downstream reads - // via Boolean.TRUE.equals(...) treat it as consent-off. The rest of the config must parse. - final ServerConfiguration configuration = - parse(wrap(configWithRawObserveFullEvaluationData("\"true\""))); - - assertNotNull(configuration); - assertFalse( - configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); - assertNotNull(configuration.flags); - } - - @Test - void observeFullEvaluationDataWrongTypedNumberDefaultsToFalse() throws Exception { - final ServerConfiguration configuration = - parse(wrap(configWithRawObserveFullEvaluationData("1"))); - - assertNotNull(configuration); - assertFalse( - configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); - assertNotNull(configuration.flags); - } - - private static ServerConfiguration parse(final String json) throws Exception { - return JsonApiUfcResponseParser.INSTANCE.parse(json.getBytes(UTF_8)); - } - - private static String wrap(final String attributes) { - return "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" - + attributes - + "}}"; - } - - private static String configWithObserveFullEvaluationData(final boolean value) { - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"observeFullEvaluationData\":" - + value - + "," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{}" - + "}"; - } - - private static String configWithRawObserveFullEvaluationData(final String rawJsonValue) { - // Emit the field with a caller-controlled raw JSON value (null / "true" / 1 / ...) so we can - // assert the parser's tolerance of malformed shapes without going through configWith's - // boolean-typed helper. - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"observeFullEvaluationData\":" - + rawJsonValue - + "," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{}" - + "}"; - } - - private static String configWithFlags(final String... flags) { - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{" - + String.join(",", flags) - + "}}"; - } - - private static String booleanFlag(final String key, final String allocations) { - return "\"" - + key - + "\":{" - + "\"key\":\"" - + key - + "\",\"enabled\":true,\"variationType\":\"BOOLEAN\"," - + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":true}}" - + allocations - + "}"; - } - - private static String allocation(final String key, final String rules) { - return ",\"allocations\":[{\"key\":\"" - + key - + "\"" - + (rules.isEmpty() ? "" : ",\"rules\":" + rules) - + ",\"splits\":[]}]"; - } - - 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/LRUExposureCacheTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java deleted file mode 100644 index 7d43c7a8b51..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java +++ /dev/null @@ -1,311 +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 testSerialIdAppearingEmitsANewExposure() { - assertEquals(2, emissions(null, 7)); - } - - @Test - void testSerialIdDisappearingEmitsANewExposure() { - assertEquals(2, emissions(7, null)); - } - - @Test - void testSerialIdChangingEmitsANewExposure() { - assertEquals(2, emissions(7, 8)); - } - - @Test - void testSerialIdCycleEmitsAnExposurePerTransition() { - assertEquals(3, emissions(7, 8, 7)); - } - - @Test - void testUnchangedSerialIdIsDeduplicated() { - assertEquals(1, emissions(7, 7)); - } - - @Test - void testUnchangedAbsentSerialIdIsDeduplicated() { - assertEquals(1, emissions(null, null)); - } - - @Test - void testSerialIdZeroIsDistinguishedFromAbsent() { - assertEquals(2, emissions(null, 0)); - assertEquals(2, emissions(0, null)); - } - - @Test - void testSerialIdIsRetainedOnTheCachedValue() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation", 0); - - cache.add(event); - - assertEquals(Integer.valueOf(0), cache.get(new ExposureCache.Key(event)).serialId); - } - - @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 createEvent(flag, subject, variant, allocation, null); - } - - private static ExposureEvent createEvent( - String flag, String subject, String variant, String allocation, Integer serialId) { - return new ExposureEvent( - System.currentTimeMillis(), - new Allocation(allocation), - new Flag(flag), - new Variant(variant), - new Subject(subject, emptyMap()), - serialId); - } - - /** - * Number of exposures a run of same-flag, same-subject evaluations emits: the cache reports true - * only for the ones it does not suppress as duplicates. - */ - private static int emissions(final Integer... serialIds) { - LRUExposureCache cache = new LRUExposureCache(5); - int emitted = 0; - for (Integer serialId : serialIds) { - if (cache.add(createEvent("flag", "subject", "variant", "allocation", serialId))) { - emitted++; - } - } - return emitted; - } -} 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 f11816a9bc0..6bac29d44f9 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java @@ -1,540 +1,181 @@ package com.datadog.featureflag; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.emptySet; -import static java.util.Collections.singleton; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; 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 static org.mockito.Mockito.when; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.remoteconfig.Capabilities; import datadog.remoteconfig.ConfigurationDeserializer; 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.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.Feature; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.IOException; -import java.io.InputStream; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.time.Instant; -import java.util.Arrays; -import java.util.Date; +import java.util.ArrayList; import java.util.List; -import java.util.Map; -import okio.Buffer; -import org.junit.jupiter.api.AfterEach; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; 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.CsvSource; import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.tabletest.junit.TableTest; -@ExtendWith(MockitoExtension.class) class RemoteConfigServiceImplTest { - - @Mock private FeatureFlaggingGateway.ConfigListener listener; - @Captor private ArgumentCaptor deserializerCaptor; - - @AfterEach - void cleanup() { - FeatureFlaggingGateway.removeConfigListener(listener); - } + private static final byte[] DOCUMENT = "{\"flags\":{}}".getBytes(UTF_8); @Test - void testNewConfigReceived() throws Exception { + @SuppressWarnings("unchecked") + void subscriptionRegistersTheProductWithARawDeserializer() throws Exception { final ConfigurationPoller poller = mock(ConfigurationPoller.class); - final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); - when(sco.configurationPoller(any(Config.class))).thenReturn(poller); - FeatureFlaggingGateway.addConfigListener(listener); - final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(sco, Config.get()); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); - service.init(); + service.subscribe(content -> {}); + service.subscribe(content -> {}); verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).addListener(eq(Product.FFE_FLAGS), deserializerCaptor.capture(), eq(service)); - - final ServerConfiguration config = deserializer().deserialize(emptyConfig().getBytes(UTF_8)); - service.accept("test", config, mock(PollingRateHinter.class)); - - verify(listener).accept(any(ServerConfiguration.class)); - - service.close(); - - verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).removeListeners(Product.FFE_FLAGS); - } - - @Test - void skipsMalformedFlagAllocationsAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("malformed-allocations.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("malformed-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void parsesSplitSerialId() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("340132")); - - assertNotNull(config); - assertEquals(Integer.valueOf(340132), serialIdOf(config)); - } - - @Test - void parsesSplitSerialIdZero() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("0")); - - assertNotNull(config); - assertEquals(Integer.valueOf(0), serialIdOf(config)); - } - - @Test - void parsesAbsentSplitSerialIdAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId(null)); - - assertNotNull(config); - assertNull(serialIdOf(config)); - } - - @Test - void parsesNullSplitSerialIdAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("null")); - - assertNotNull(config); - assertNull(serialIdOf(config)); - } - - @Test - void skipsFlagWithUncoercibleSerialIdAndKeepsSiblingFlag() throws Exception { - final ServerConfiguration config = deserialize(configWithSiblingSerialIds("true")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("malformed-flag")); - assertEquals("invalid_flag", config.invalidFlags.get("malformed-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } - - /** - * Records how leniently the per-flag value reader coerces a serial id, so a future change to the - * parse path is visible here. The values come from the compiler-validated UFC, so the SDK adds no - * validation of its own; what matters is that a bad one never rejects the sibling flag. - */ - @ParameterizedTest - @CsvSource({"\"340132\", 340132", "1.5, 1", "-1, -1", "2147483648, 2147483647"}) - void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int expected) - throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId(wireValue)); - - assertNotNull(config); - assertEquals(Integer.valueOf(expected), serialIdOf(config)); - } - - @Test - void parsesSplitFeatures() throws Exception { - final ServerConfiguration config = - deserialize( - configWithFeatures( - "[{\"key\": \"holdout.key\", \"value\": \"q4-global\", \"destinations\": [\"HOOK\"]}," - + " {\"key\": \"holdout.weight\", \"value\": 0.5," - + " \"destinations\": [\"EXPOSURE\", \"EVALUATION\"]}," - + " {\"key\": \"holdout.should_include_in_holdout_analysis\", \"value\": true," - + " \"destinations\": [\"HOOK\", \"SOME_FUTURE_DESTINATION\"]}]")); - - final List features = featuresOf(config); - assertEquals(3, features.size()); - assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); - assertFeature(features.get(1), "holdout.weight", 0.5, "EXPOSURE", "EVALUATION"); - assertFeature( - features.get(2), - "holdout.should_include_in_holdout_analysis", - true, - "HOOK", - "SOME_FUTURE_DESTINATION"); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } - - @Test - void parsesAbsentSplitFeaturesAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithFeatures(null)); - - assertNull(featuresOf(config)); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } - - @Test - void dropsMalformedSplitFeaturesAndKeepsTheFlag() throws Exception { - final String hook = ", \"destinations\": [\"HOOK\"]"; - final ServerConfiguration config = - deserialize( - configWithFeatures( - "[{\"key\": \"object-value\", \"value\": {\"nested\": true}" - + hook - + "}," - + " {\"key\": \"array-value\", \"value\": [1]" - + hook - + "}," - + " {\"key\": \"null-value\", \"value\": null" - + hook - + "}," - + " {\"key\": \"\", \"value\": \"empty key\"" - + hook - + "}," - + " {\"key\": 3, \"value\": \"numeric key\"" - + hook - + "}," - + " {\"value\": \"missing key\"" - + hook - + "}," - + " {\"key\": \"missing value\"" - + hook - + "}," - + " {\"key\": \"no-destinations\", \"value\": \"dropped\"}," - + " {\"key\": \"empty-destinations\", \"value\": \"dropped\", \"destinations\": []}," - + " {\"key\": \"destinations-not-a-list\", \"value\": \"dropped\"," - + " \"destinations\": \"HOOK\"}," - + " {\"key\": \"only-invalid-destinations\", \"value\": \"dropped\"," - + " \"destinations\": [1, \"\", null, {\"name\": \"HOOK\"}]}," - + " \"not-an-object\"," - + " {\"key\": \"holdout.key\", \"value\": \"q4-global\"," - + " \"destinations\": [1, \"HOOK\", \"\"], \"destination\": \"HOOK\"}]")); - - assertTrue(config.flags.containsKey("valid-flag")); - final List features = featuresOf(config); - assertEquals(1, features.size()); - assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); - } - - @Test - void ignoresSplitFeaturesThatAreNotAList() throws Exception { - final ServerConfiguration config = - deserialize(configWithFeatures("{\"holdout.key\": \"q4-global\"}")); - - assertTrue(config.flags.containsKey("valid-flag")); - assertNull(featuresOf(config)); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } - - private static void assertFeature( - final Feature feature, final String key, final Object value, final String... destinations) { - assertEquals(key, feature.key); - assertEquals(value, feature.value); - assertEquals(Arrays.asList(destinations), feature.destinations); - } - - private static List featuresOf(final ServerConfiguration config) { - return config.flags.get("valid-flag").allocations.get(0).splits.get(0).features; - } - - /** Inserts the raw features JSON; null omits the key. */ - private static String configWithFeatures(final String featuresJson) throws IOException { - final String json = resource("split-features.json"); - return featuresJson == null - ? json.replace("\"features\": \"${features}\",", "") - : json.replace("\"${features}\"", featuresJson); - } - - private static Integer serialIdOf(final ServerConfiguration config) { - return config.flags.get("valid-flag").allocations.get(0).splits.get(0).serialId; - } - - private static String configWithSerialId(final String serialIdJson) throws IOException { - return withSerialId(resource("serial-id.json"), serialIdJson); - } - - /** A malformed serial id must bind to its own flag and leave the sibling flag intact. */ - private static String configWithSiblingSerialIds(final String malformedSerialIdJson) - throws IOException { - return withSerialId(resource("sibling-serial-ids.json"), malformedSerialIdJson); - } - - /** Inserts the raw wire value so the parser sees its original JSON type; null omits the key. */ - private static String withSerialId(final String json, final String serialIdJson) { - return serialIdJson == null - ? json.replace("\"serialId\": \"${serialId}\",", "") - : json.replace("\"${serialId}\"", serialIdJson); - } - - @Test - void ignoresUnknownTopLevelFields() throws Exception { - final ServerConfiguration config = deserialize(resource("unknown-top-level-fields.json")); - - assertNotNull(config); - assertEquals("2024-04-17T19:40:53.716Z", config.createdAt); - assertEquals("SERVER", config.format); - assertNotNull(config.environment); - assertEquals("Test", config.environment.name); - assertTrue(config.flags.isEmpty()); - } - - @Test - void parsesAllocationWindowDatesAsDateFieldsWithInstantAccessors() throws Exception { - final ServerConfiguration config = deserialize(resource("allocation-window-dates.json")); - - final Allocation allocation = config.flags.get("dated-flag").allocations.get(0); - assertEquals(Date.class, Allocation.class.getField("startAt").getType()); - assertEquals(Date.class, Allocation.class.getField("endAt").getType()); - assertEquals(Instant.parse("2023-01-01T00:00:00.123Z"), allocation.startAt.toInstant()); - assertEquals(Instant.parse("2023-01-02T00:00:00.987Z"), allocation.endAt.toInstant()); - assertEquals(Instant.parse("2023-01-01T00:00:00.123456Z"), allocation.startAtInstant()); - assertEquals(Instant.parse("2023-01-02T00:00:00.987654Z"), allocation.endAtInstant()); - } - - @Test - void rejectsTrailingJson() { - assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); - } - - @Test - void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("unknown-operator.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("operator-grease-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void flagMapAdapterFactoryOnlyCreatesFlagMapAdapterForFlagMapType() { - final Moshi moshi = moshi(); - final Type flagsType = Types.newParameterizedType(Map.class, String.class, Flag.class); - - final JsonAdapter adapter = - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(flagsType, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.FlagMapAdapter); - assertNull( - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(String.class, emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create( - flagsType, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void lenientBooleanAdapterFactoryOnlyCreatesAdapterForUnannotatedBoxedBoolean() { - final Moshi moshi = moshi(); - - final JsonAdapter adapter = - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - Boolean.class, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.LenientBooleanAdapter); - // Primitive boolean keeps Moshi's strict adapter so mandatory fields still reject bad values. - assertNull( - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - boolean.class, emptySet(), moshi)); - // A qualified Boolean belongs to whichever adapter declared the qualifier, not to this one. - assertNull( - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - Boolean.class, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void lenientBooleanAdapterIsReadOnly() { - final UniversalFlagConfigParser.LenientBooleanAdapter adapter = - new UniversalFlagConfigParser.LenientBooleanAdapter(); - - assertThrows( - UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), true)); - } - - @Test - void featureListAdapterFactoryOnlyCreatesAdapterForUnannotatedFeatureList() { - final Moshi moshi = moshi(); - final Type featuresType = Types.newParameterizedType(List.class, Feature.class); - - final JsonAdapter adapter = - UniversalFlagConfigParser.FeatureListAdapter.FACTORY.create( - featuresType, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.FeatureListAdapter); - assertNull( - UniversalFlagConfigParser.FeatureListAdapter.FACTORY.create( - Types.newParameterizedType(List.class, String.class), emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.FeatureListAdapter.FACTORY.create( - featuresType, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void featureListAdapterIsReadOnly() { - final UniversalFlagConfigParser.FeatureListAdapter adapter = - new UniversalFlagConfigParser.FeatureListAdapter(); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), emptyList())); - } - - @Test - void allowsNullFlagMap() throws Exception { - final ServerConfiguration config = deserialize(resource("null-flags.json")); - - assertNotNull(config); - assertNull(config.flags); - } - - @Test - void skipsNullFlagAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("null-flag.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("null-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void flagMapAdapterIsReadOnly() { - final UniversalFlagConfigParser.FlagMapAdapter adapter = - new UniversalFlagConfigParser.FlagMapAdapter(moshi().adapter(Flag.class)); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), emptyMap())); + final ArgumentCaptor> deserializer = + ArgumentCaptor.forClass(ConfigurationDeserializer.class); + verify(poller).addListener(eq(Product.FFE_FLAGS), deserializer.capture(), eq(service)); + verify(poller).start(); + assertArrayEquals(DOCUMENT, deserializer.getValue().deserialize(DOCUMENT)); + } + + @Test + void forwardsDocumentsAndRemovalsToAllSubscribers() { + final RemoteConfigServiceImpl service = + new RemoteConfigServiceImpl(mock(ConfigurationPoller.class)); + final List first = new ArrayList<>(); + final List second = new ArrayList<>(); + service.subscribe(first::add); + service.subscribe(second::add); + + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); + service.accept("key", null, mock(PollingRateHinter.class)); + + assertEquals(2, first.size()); + assertArrayEquals(DOCUMENT, first.get(0)); + assertNull(first.get(1)); + assertEquals(2, second.size()); + } + + @Test + void replaysTheCurrentDocumentToLateSubscribers() { + final RemoteConfigServiceImpl service = + new RemoteConfigServiceImpl(mock(ConfigurationPoller.class)); + service.subscribe(content -> {}); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); + + final List late = new ArrayList<>(); + service.subscribe(late::add); + + assertEquals(1, late.size()); + assertArrayEquals(DOCUMENT, late.get(0)); + } + + @Test + void concurrentRemovalIsDeliveredAfterTheInitialReplay() throws Exception { + final RemoteConfigServiceImpl service = + new RemoteConfigServiceImpl(mock(ConfigurationPoller.class)); + service.accept("key", DOCUMENT, null); + final List received = new CopyOnWriteArrayList<>(); + final CountDownLatch replayStarted = new CountDownLatch(1); + final CountDownLatch finishReplay = new CountDownLatch(1); + final CountDownLatch removalStarted = new CountDownLatch(1); + final ExecutorService executor = Executors.newFixedThreadPool(2); + try { + final Future subscription = + executor.submit( + () -> + service.subscribe( + content -> { + if (content != null) { + replayStarted.countDown(); + try { + assertTrue(finishReplay.await(5, SECONDS)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + received.add(content); + })); + assertTrue(replayStarted.await(5, SECONDS)); + final Future removal = + executor.submit( + () -> { + removalStarted.countDown(); + service.accept("key", null, null); + }); + assertTrue(removalStarted.await(5, SECONDS)); + assertThrows(TimeoutException.class, () -> removal.get(100, MILLISECONDS)); + + finishReplay.countDown(); + removal.get(5, SECONDS); + subscription.get(5, SECONDS).close(); + + assertEquals(2, received.size()); + assertArrayEquals(DOCUMENT, received.get(0)); + assertNull(received.get(1)); + } finally { + finishReplay.countDown(); + executor.shutdownNow(); + service.close(); + } } @Test - void allocationAdapterFactoryOnlyCreatesAllocationAdapterForAllocationType() { - final Moshi moshi = moshi(); + void startRegistersTheProductOnceBeforeAnySubscription() { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); - final JsonAdapter adapter = - UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( - Allocation.class, emptySet(), moshi); + service.start(); + service.start(); + service.subscribe(content -> {}); - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.AllocationAdapter); - assertNull( - UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( - String.class, emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( - Allocation.class, singleton(mock(Annotation.class)), moshi)); + verify(poller, times(1)).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller, times(1)).start(); } @Test - void allocationAdapterHandlesNullAndIsReadOnly() throws Exception { - final UniversalFlagConfigParser.AllocationAdapter adapter = - new UniversalFlagConfigParser.AllocationAdapter( - moshi().adapter(UniversalFlagConfigParser.AllocationJson.class)); - - assertNull(adapter.fromJson("null")); - assertThrows( - UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), null)); - } + void unsubscribedSubscribersStopReceivingDocuments() throws Exception { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); + final List received = new ArrayList<>(); + final AutoCloseable subscription = service.subscribe(received::add); - @TableTest({ - "scenario | value | expectedInstant ", - "utc second | '2023-01-01T00:00:00Z' | '2023-01-01T00:00:00Z' ", - "utc end of year | '2023-12-31T23:59:59Z' | '2023-12-31T23:59:59Z' ", - "leap day | '2024-02-29T12:00:00Z' | '2024-02-29T12:00:00Z' ", - "millisecond precision | '2023-01-01T00:00:00.000Z' | '2023-01-01T00:00:00Z' ", - "three fractional digits | '2023-06-15T14:30:45.123Z' | '2023-06-15T14:30:45.123Z' ", - "six fractional digits | '2023-06-15T14:30:45.123456Z' | '2023-06-15T14:30:45.123456Z' ", - "six fractional digits distinct | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235982Z' ", - "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123456789Z'", - "one fractional digit | '2023-06-15T14:30:45.1Z' | '2023-06-15T14:30:45.100Z' ", - "two fractional digits | '2023-06-15T14:30:45.12Z' | '2023-06-15T14:30:45.120Z' ", - "positive offset | '2023-01-01T01:00:00+01:00' | '2023-01-01T00:00:00Z' ", - "negative offset | '2023-01-01T00:00:00-05:00' | '2023-01-01T05:00:00Z' ", - "date only | '2023-01-01' | ", - "invalid | 'invalid-date' | ", - "empty string | '' | ", - "not a date | 'not-a-date' | ", - "slash date | '2023/01/01T00:00:00Z' | ", - "null | | " - }) - void testInstantParsing(final String value, final String expectedInstant) throws Exception { - final JsonReader reader = mock(JsonReader.class); - if (value == null) { - when(reader.peek()).thenReturn(JsonReader.Token.NULL); - when(reader.nextNull()).thenReturn(null); - } else { - when(reader.peek()).thenReturn(JsonReader.Token.STRING); - when(reader.nextString()).thenReturn(value); - } - final UniversalFlagConfigParser.InstantAdapter adapter = - new UniversalFlagConfigParser.InstantAdapter(); + subscription.close(); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); - final Instant parsed = adapter.fromJson(reader); - if (value == null) { - verify(reader).nextNull(); - } - if (expectedInstant == null) { - assertNull(parsed); - } else { - assertNotNull(parsed); - assertEquals(expectedInstant, parsed.toString()); - } + assertEquals(0, received.size()); + verify(poller, never()).removeListeners(any()); } @Test - void testParsingOnlyAdapter() { - final UniversalFlagConfigParser.InstantAdapter adapter = - new UniversalFlagConfigParser.InstantAdapter(); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), Instant.EPOCH)); - } - - @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)); - } - - private static Moshi moshi() { - return new Moshi.Builder() - .add(Instant.class, new UniversalFlagConfigParser.InstantAdapter()) - .add(UniversalFlagConfigParser.AllocationAdapter.FACTORY) - .build(); - } + void closeUnregistersTheProductAndDropsSubscribers() { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); + final List received = new ArrayList<>(); + service.subscribe(received::add); - private static String emptyConfig() throws IOException { - return resource("empty-config.json"); - } + service.close(); + service.close(); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); - private static String resource(final String name) throws IOException { - try (final InputStream stream = - RemoteConfigServiceImplTest.class.getResourceAsStream("/remote-config/" + name)) { - assertNotNull(stream, "Missing remote-config fixture: " + name); - return new Buffer().readFrom(stream).readUtf8(); - } + verify(poller, times(1)) + .removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller, times(1)).removeListeners(Product.FFE_FLAGS); + verify(poller, never()).stop(); + assertEquals(0, received.size()); } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SharedFeatureFlagRouteTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SharedFeatureFlagRouteTest.java deleted file mode 100644 index 60d39e6faf1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SharedFeatureFlagRouteTest.java +++ /dev/null @@ -1,207 +0,0 @@ -package com.datadog.featureflag; - -import static com.datadog.featureflag.FeatureFlagEventType.EXPOSURE; -import static com.datadog.featureflag.FeatureFlagEventType.FLAG_EVALUATION; -import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT; -import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V4_EVP_PROXY_ENDPOINT; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -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.Mockito.doAnswer; -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.communication.BackendApi; -import datadog.communication.HttpResponseException; -import datadog.communication.ddagent.DDAgentFeaturesDiscovery; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.junit.jupiter.api.Test; - -/** Exercises both production factories and HTTP clients against one shared discovery result. */ -class SharedFeatureFlagRouteTest { - private final AtomicLong clock = new AtomicLong(); - private final FeatureFlagRouteSelector selector = new FeatureFlagRouteSelector(clock::get, 10); - private final DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - private final Config config = mock(Config.class); - private final SharedCommunicationObjects sco = new SharedCommunicationObjects(); - private final List requests = new ArrayList<>(); - private int status = 200; - - SharedFeatureFlagRouteTest() { - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getIdGenerationStrategy()).thenReturn(Config.get().getIdGenerationStrategy()); - when(discovery.supportsEvpProxyHeaders(any())).thenReturn(true); - sco.setFeaturesDiscovery(discovery); - sco.agentUrl = HttpUrl.get("http://localhost:8126/agent-prefix/"); - sco.agentHttpClient = - new OkHttpClient.Builder() - .addInterceptor( - chain -> { - requests.add(chain.request()); - return new Response.Builder() - .request(chain.request()) - .protocol(Protocol.HTTP_1_1) - .code(status) - .message("test") - .body(ResponseBody.create(null, "{}")) - .build(); - }) - .build(); - } - - @Test - void bothWritersUseOneSuccessfulRecoveryWithoutRediscovery() throws Exception { - final BackendApi exposures = create(EXPOSURE); - final BackendApi evaluations = create(FLAG_EVALUATION); - // Only the first recovery can discover a route. A redundant sibling probe would lose it. - when(discovery.getEvpProxyEndpoint()).thenReturn(V4_EVP_PROXY_ENDPOINT).thenReturn(null); - clock.set(10); - - post(exposures, "exposures"); - post(evaluations, "flagevaluation"); - - assertEquals( - Arrays.asList( - "/agent-prefix/evp_proxy/v4/api/v2/exposures", - "/agent-prefix/evp_proxy/v4/api/v2/flagevaluation"), - paths()); - assertEquals("gzip", requests.get(0).header("Accept-Encoding")); - assertEquals("identity", requests.get(1).header("Accept-Encoding")); - verify(discovery).discover(); - verify(discovery).discoverIfOutdated(); - } - - @Test - void bothWritersReplaceTheirOldEndpointAfterRecovery() throws Exception { - when(discovery.getEvpProxyEndpoint()).thenReturn(V4_EVP_PROXY_ENDPOINT); - final BackendApi exposures = create(EXPOSURE); - final BackendApi evaluations = create(FLAG_EVALUATION); - post(evaluations, "flagevaluation"); - status = 404; - assertThrows(HttpResponseException.class, () -> post(exposures, "exposures")); - when(discovery.getEvpProxyEndpoint()).thenReturn(V2_EVP_PROXY_ENDPOINT); - status = 200; - clock.set(10); - - post(exposures, "exposures"); - post(evaluations, "flagevaluation"); - - assertEquals( - Arrays.asList( - "/agent-prefix/evp_proxy/v4/api/v2/flagevaluation", - "/agent-prefix/evp_proxy/v4/api/v2/exposures", - "/agent-prefix/evp_proxy/v2/api/v2/exposures", - "/agent-prefix/evp_proxy/v2/api/v2/flagevaluation"), - paths()); - verify(discovery, times(1)).discover(); - } - - private BackendApi create(FeatureFlagEventType eventType) { - return new FeatureFlagBackendApiFactory(config, sco, eventType, selector).create(); - } - - @Test - void concurrentInitializationSharesOneDiscovery() throws Exception { - final CountDownLatch started = new CountDownLatch(1); - final CountDownLatch release = new CountDownLatch(1); - when(discovery.getEvpProxyEndpoint()).thenReturn(V4_EVP_PROXY_ENDPOINT); - doAnswer( - invocation -> { - started.countDown(); - assertTrue(release.await(5, TimeUnit.SECONDS)); - return null; - }) - .when(discovery) - .discoverIfOutdated(); - final CompletableFuture exposures = - CompletableFuture.supplyAsync(() -> create(EXPOSURE)); - try { - assertTrue(started.await(5, TimeUnit.SECONDS)); - final CompletableFuture evaluations = - CompletableFuture.supplyAsync(() -> create(FLAG_EVALUATION)); - release.countDown(); - post(exposures.get(5, TimeUnit.SECONDS), "exposures"); - post(evaluations.get(5, TimeUnit.SECONDS), "flagevaluation"); - verify(discovery).discoverIfOutdated(); - assertEquals(2, requests.size()); - } finally { - release.countDown(); - } - } - - @Test - void slowRecoveryDoesNotPermitASecondProbeAfterTheCooldown() throws Exception { - final BackendApi exposures = create(EXPOSURE); - final BackendApi evaluations = create(FLAG_EVALUATION); - final CountDownLatch started = new CountDownLatch(1); - final CountDownLatch release = new CountDownLatch(1); - when(discovery.getEvpProxyEndpoint()).thenReturn(V4_EVP_PROXY_ENDPOINT); - doAnswer( - invocation -> { - started.countDown(); - assertTrue(release.await(5, TimeUnit.SECONDS)); - return null; - }) - .when(discovery) - .discover(); - clock.set(10); - final CompletableFuture recovery = - CompletableFuture.runAsync( - () -> { - try { - post(exposures, "exposures"); - } catch (Exception failure) { - throw new AssertionError(failure); - } - }); - try { - assertTrue(started.await(5, TimeUnit.SECONDS)); - clock.set(100); - assertThrows(IOException.class, () -> post(evaluations, "flagevaluation")); - } finally { - release.countDown(); - } - recovery.get(5, TimeUnit.SECONDS); - post(evaluations, "flagevaluation"); - verify(discovery).discover(); - assertEquals(2, requests.size()); - } - - private List paths() { - final List paths = new ArrayList<>(); - for (Request request : requests) { - paths.add(request.url().encodedPath()); - } - return paths; - } - - private static void post(BackendApi api, String path) throws Exception { - api.post( - path, - RequestBody.create(MediaType.parse("application/json"), "{}"), - stream -> null, - null, - false); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java index 662fe928658..4f76e732432 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java @@ -11,15 +11,14 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import datadog.trace.api.featureflag.SpanEnrichmentEvent; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import java.util.Collections; import org.junit.jupiter.api.Test; /** - * Write-tier listener suite: the agent-side {@link SpanEnrichmentWriter} translates {@link - * SpanEnrichmentEvent}s from the flag-eval seam into per-local-root accumulator state (resolving - * the active root through an injectable resolver so no static tracer is needed). + * Write-tier suite: the agent-side {@link SpanEnrichmentWriter} translates the evaluations recorded + * by the SDK into per-local-root accumulator state (resolving the active root through an injectable + * resolver so no static tracer is needed). */ class SpanEnrichmentWriterTest { @@ -37,7 +36,7 @@ private static SpanEnrichmentWriter writerFor(final AgentSpan root) { void serialIdWithDoLogAndTargetingKeyRecordsSerialAndSubject() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(42, true, "user-1")); + writer.serialId(42, true, "user-1"); final SpanEnrichmentAccumulator state = writer.states().peek(root); assertNotNull(state); @@ -49,7 +48,7 @@ void serialIdWithDoLogAndTargetingKeyRecordsSerialAndSubject() { void serialIdWithoutDoLogRecordsNoSubject() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(7, false, "user-1")); + writer.serialId(7, false, "user-1"); final SpanEnrichmentAccumulator state = writer.states().peek(root); assertTrue(state.serialIdsView().contains(7)); @@ -60,7 +59,7 @@ void serialIdWithoutDoLogRecordsNoSubject() { void serialIdWithNullTargetingKeyRecordsNoSubject() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(9, true, null)); + writer.serialId(9, true, null); final SpanEnrichmentAccumulator state = writer.states().peek(root); assertTrue(state.serialIdsView().contains(9)); @@ -71,7 +70,7 @@ void serialIdWithNullTargetingKeyRecordsNoSubject() { void runtimeDefaultRecordsDefault() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.runtimeDefault("flag", Collections.singletonMap("k", "v"))); + writer.runtimeDefault("flag", Collections.singletonMap("k", "v")); final SpanEnrichmentAccumulator state = writer.states().peek(root); assertNotNull(state); @@ -81,23 +80,15 @@ void runtimeDefaultRecordsDefault() { @Test void noActiveSpanAccumulatesNothing() { final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> null); - writer.accept(SpanEnrichmentEvent.serialId(1, true, "user-1")); + writer.serialId(1, true, "user-1"); assertTrue(writer.states().isEmpty(), "no active span => no accumulator state"); } - @Test - void nullEventIsANoOp() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(null); - assertTrue(writer.states().isEmpty()); - } - @Test void endToEndAccumulateThenFlush() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + writer.serialId(5, false, null); writer.interceptor().onTraceComplete(Collections.singletonList(root)); verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 @@ -109,7 +100,7 @@ void doesNotAccumulateWhenInterceptorCannotRegister() { final AgentSpan root = rootSpan(); // Registrar always rejects → nothing would ever flush the state, so we must not accumulate. final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> root, interceptor -> false); - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + writer.serialId(5, false, null); assertTrue( writer.states().isEmpty(), "no accumulation when the interceptor cannot be registered"); } @@ -151,12 +142,11 @@ void resolveLocalRootLogic() { } @Test - void initSubscribesAndCloseClearsState() { + void closeClearsState() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.init(); try { - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + writer.serialId(5, false, null); assertNotNull(writer.states().peek(root)); } finally { writer.close(); @@ -168,21 +158,21 @@ void initSubscribesAndCloseClearsState() { void runtimeDefaultWithNullFlagKeyIsIgnored() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.runtimeDefault(null, "v")); + writer.runtimeDefault(null, "v"); // No serial id and null flag key → nothing recorded (getOrCreate ran, but no data added). final SpanEnrichmentAccumulator state = writer.states().peek(root); assertTrue(state == null || !state.hasData()); } @Test - void acceptSwallowsResolverErrors() { + void recordingSwallowsResolverErrors() { final SpanEnrichmentWriter writer = new SpanEnrichmentWriter( () -> { throw new RuntimeException("resolver boom"); }); // Must not propagate — enrichment can never break flag evaluation. - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + writer.serialId(5, false, null); assertTrue(writer.states().isEmpty()); } @@ -196,7 +186,7 @@ void registrarErrorIsSwallowedAndNotLatched() { throw new RuntimeException("register boom"); }); // Registration throws → swallowed, not latched, and nothing accumulates (never flushable). - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + writer.serialId(5, false, null); assertTrue(writer.states().isEmpty()); } @@ -204,8 +194,8 @@ void registrarErrorIsSwallowedAndNotLatched() { void distinctEventsUnderSameRootShareAccumulator() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(100, false, null)); - writer.accept(SpanEnrichmentEvent.serialId(108, false, null)); + writer.serialId(100, false, null); + writer.serialId(108, false, null); assertEquals(1, writer.states().size(), "same root => one shared accumulator"); verify(root, never()).setTag(anyString(), anyString()); } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json deleted file mode 100644 index 26d0a90da2a..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "dated-flag": { - "key": "dated-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "dated-allocation", - "rules": [], - "startAt": "2023-01-01T01:00:00.123456+01:00", - "endAt": "2023-01-02T00:00:00.987654Z", - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json deleted file mode 100644 index 51b021e5de7..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": {} -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json deleted file mode 100644 index 93314b69e7d..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "malformed-flag": { - "key": "malformed-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "on": { - "key": "on", - "value": "on" - } - }, - "allocations": "this-is-not-a-list" - }, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json deleted file mode 100644 index a4f3a6c2502..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "null-flag": null, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json deleted file mode 100644 index a083526e553..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": null -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/serial-id.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/serial-id.json deleted file mode 100644 index 6b7dc926854..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/serial-id.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": "${serialId}", - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json deleted file mode 100644 index aa791903f66..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "malformed-flag": { - "key": "malformed-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "on": { - "key": "on", - "value": "on" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": "${serialId}", - "variationKey": "on", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": 7, - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json deleted file mode 100644 index d8ff27d02a0..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": 7, - "features": "${features}", - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json deleted file mode 100644 index a380463b1d5..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "operator-grease-flag": { - "key": "operator-grease-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "trap": { - "key": "trap", - "value": "trap" - } - }, - "allocations": [ - { - "key": "grease-allocation", - "rules": [ - { - "conditions": [ - { - "attribute": "country", - "operator": "not-a-real-operator", - "value": "anything" - } - ] - } - ], - "splits": [ - { - "variationKey": "trap", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json b/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json deleted file mode 100644 index fe23d4a46b1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "segments": { - "new-schema-key": { - "ignored": true - } - }, - "flags": {} -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 7e83730b139..5e49743d4af 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -164,7 +164,6 @@ include(":dd-java-agent:agent-aiguard") // Feature Flagging include( ":products:feature-flagging:feature-flagging-agent", - ":products:feature-flagging:feature-flagging-api", ":products:feature-flagging:feature-flagging-bootstrap", ":products:feature-flagging:feature-flagging-config", ":products:feature-flagging:feature-flagging-lib"