diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ee8c267c77d..30f2e2caf75 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -340,8 +340,10 @@ /dd-smoke-tests/kafka-3/ @DataDog/data-streams-monitoring @DataDog/apm-idm-java # @DataDog/feature-flagging-and-experimentation-sdk +/dd-openfeature/ @DataDog/feature-flagging-and-experimentation-sdk /dd-smoke-tests/openfeature/ @DataDog/feature-flagging-and-experimentation-sdk /products/feature-flagging/ @DataDog/feature-flagging-and-experimentation-sdk +/dd-java-agent/instrumentation/datadog/openfeature/ @DataDog/feature-flagging-and-experimentation-sdk # @DataDog/profiling-java /dd-java-agent/ddprof-lib/ @DataDog/profiling-java diff --git a/.github/workflows/run-system-tests.yaml b/.github/workflows/run-system-tests.yaml index 4b0ad663659..7b983f14e5e 100644 --- a/.github/workflows/run-system-tests.yaml +++ b/.github/workflows/run-system-tests.yaml @@ -49,8 +49,11 @@ 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 + # System tests install a dd-openfeature jar found next to the agent instead of the published one, + # as the agent supports only the dd-openfeature version built along with it. + cp workspace/dd-openfeature/build/libs/dd-openfeature-*.jar workspace/dd-java-agent/build/libs/ - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8717e5e3a3b..20639f95cf8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -355,7 +355,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 @@ -365,7 +365,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: @@ -482,11 +482,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 { diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b67fae9ceb..985b323d8c6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,8 @@ to `.classdata` to prevent unintended loading. See [`docs/how_to_work_with_gradl - **`agent-tooling/`** — Instrumentation framework. Key types: - `InstrumenterModule` — Base class for all instrumentation modules. Declares a target system - (Tracing, AppSec, IAST, Profiling, CiVisibility, USM, etc.) and one or more instrumentations. + (Tracing, AppSec, IAST, Profiling, CiVisibility, USM, FeatureFlags, etc.) and one or more + instrumentations. - `Instrumenter` — Type matching interface: `ForSingleType`, `ForKnownTypes`, `ForTypeHierarchy`, `ForBootstrap`. - `muzzle/` — Build-time and runtime safety checks. Verifies that expected types and methods @@ -229,9 +230,8 @@ Current products: - `metrics/` — StatsD client and monitoring abstraction. Provides `Monitoring` interface with counters, timers, and histograms for internal agent metrics collection. -- `feature-flagging/` — Server-side feature flag evaluation driven by remote configuration. - Implements the OpenFeature SDK, handles the Unified Feature Control (UFC) protocol, - and tracks flag exposure per user/session. +- `feature-flagging/` — Agent services for the [`dd-openfeature/`](#dd-openfeature) SDK: + remote configuration, event delivery through the Datadog Agent, and span enrichment. ### `communication/` @@ -279,6 +279,12 @@ that implements the `io.opentracing.Tracer` interface by wrapping the Datadog `C This is a pure library for manual instrumentation only — there is no auto-instrumentation or bytecode advice. +### `dd-openfeature/` + +Feature Flags OpenFeature provider. Publishes a standalone JAR artifact (`dd-openfeature.jar`, Java 11+) +that works without the agent. When the agent is attached, an instrumentation connects it to the +`products/feature-flagging/` agent services. + ### `dd-smoke-tests/` End-to-end smoke tests. Each boots a real application with the agent jar and verifies traces, spans, diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java index 1180c983990..48483cf917f 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java @@ -44,7 +44,6 @@ import datadog.trace.api.config.TraceInstrumentationConfig; import datadog.trace.api.config.TracerConfig; import datadog.trace.api.config.UsmConfig; -import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.api.gateway.SubscriptionService; import datadog.trace.api.git.EmbeddedGitInfoBuilder; @@ -194,7 +193,7 @@ public boolean isEnabledByDefault() { private static boolean distributedDebuggerEnabled = false; private static boolean agentlessLogSubmissionEnabled = false; private static boolean appLogsCollectionEnabled = false; - private static boolean featureFlaggingEnabled = false; + private static volatile boolean featureFlaggingEnabled = false; private static void safelySetContextClassLoader(ClassLoader classLoader) { try { @@ -283,7 +282,6 @@ public static void start( agentlessLogSubmissionEnabled = isFeatureEnabled(AgentFeature.AGENTLESS_LOG_SUBMISSION); appLogsCollectionEnabled = isFeatureEnabled(AgentFeature.APP_LOGS_COLLECTION); llmObsEnabled = isFeatureEnabled(AgentFeature.LLMOBS); - featureFlaggingEnabled = isFeatureFlaggingEnabled(); // setup writers when llmobs is enabled to accomodate apm and llmobs if (llmObsEnabled) { @@ -1275,6 +1273,7 @@ private static void maybeStartLLMObs(Instrumentation inst, Class scoClass, Ob } private static void maybeStartFeatureFlagging(final Class scoClass, final Object sco) { + featureFlaggingEnabled = Config.get().isFeatureFlaggingProviderEnabled(); if (featureFlaggingEnabled) { StaticEventLogger.begin("Feature Flagging"); @@ -1730,45 +1729,6 @@ private static boolean isFeatureEnabled(AgentFeature feature) { } } - private static boolean isFeatureFlaggingEnabled() { - final Boolean providerEnabled = - featureFlaggingBooleanSetting(FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED); - final String configurationSource = - featureFlaggingSetting(FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE); - final Boolean legacyProviderEnabled = - featureFlaggingBooleanSetting(FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); - - return FeatureFlaggingConfig.resolveConfiguration( - providerEnabled, configurationSource, legacyProviderEnabled) - .isEnabled(); - } - - @SuppressFBWarnings( - value = "NP_BOOLEAN_RETURN_NULL", - justification = "A null value preserves the distinction between absent and explicitly false") - private static Boolean featureFlaggingBooleanSetting(final String configKey) { - final String value = featureFlaggingSetting(configKey); - if (value == null) { - return null; - } - return Boolean.parseBoolean(value) || "1".equals(value); - } - - private static String featureFlaggingSetting(final String configKey) { - final String systemProperty = propertyNameToSystemPropertyName(configKey); - String value = SystemProperties.get(systemProperty); - if (value == null) { - value = getStableConfig(FLEET, configKey); - } - if (value == null) { - value = ddGetEnv(systemProperty); - } - if (value == null) { - value = getStableConfig(LOCAL, configKey); - } - return value; - } - /** * @see datadog.trace.api.ProductActivation#fromString(String) */ diff --git a/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java b/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java index d331759d2df..530ef4789ed 100644 --- a/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java +++ b/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java @@ -359,6 +359,9 @@ public static Set getEnabledSystems() { if (cfg.isLlmObsEnabled()) { enabledSystems.add(InstrumenterModule.TargetSystem.LLMOBS); } + if (cfg.isFeatureFlaggingEnabled()) { + enabledSystems.add(InstrumenterModule.TargetSystem.FEATURE_FLAGS); + } return enabledSystems; } diff --git a/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java b/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java index 6baeca12b62..ab2c4d78637 100644 --- a/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java +++ b/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java @@ -44,6 +44,7 @@ public abstract class InstrumenterModule implements Instrumenter { *
  • {@link TargetSystem#USM usm} *
  • {@link TargetSystem#CONTEXT_TRACKING context-tracking} *
  • {@link TargetSystem#RASP rasp} + *
  • {@link TargetSystem#FEATURE_FLAGS feature-flags} * */ public enum TargetSystem { @@ -56,6 +57,7 @@ public enum TargetSystem { LLMOBS, CONTEXT_TRACKING, RASP, + FEATURE_FLAGS, } private static final Logger log = LoggerFactory.getLogger(InstrumenterModule.class); diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle new file mode 100644 index 00000000000..9f1de1eb44a --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'dd-trace-java.module.instrumentation' + id 'idea' +} + +idea { + module { + jdkName = '11' + } +} + +// TODO Add a muzzle pass directive on com.datadoghq:dd-openfeature once the SDK is published. + +tracerJava { + addSourceSetFor(JavaVersion.VERSION_11) +} + +testJvmConstraints { + minJavaVersion = JavaVersion.VERSION_11 +} + +dependencies { + main_java11CompileOnly project(':dd-openfeature') + main_java11CompileOnly project(':products:feature-flagging:feature-flagging-bootstrap') + main_java11CompileOnly libs.spotbugs.annotations + + testImplementation project(':dd-openfeature') + testImplementation project(':products:feature-flagging:feature-flagging-bootstrap') + testImplementation project(':products:feature-flagging:feature-flagging-config') + testImplementation libs.bundles.mockito +} + +tasks.named("compileMain_java11Java", JavaCompile) { + configureCompiler(it, JavaVersion.VERSION_11) +} + +tasks.named("compileTestJava", JavaCompile) { + configureCompiler(it, JavaVersion.VERSION_11) +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile new file mode 100644 index 00000000000..5de2fc3c490 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile @@ -0,0 +1,130 @@ +# 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 :dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-connector:dependencies --write-locks +cafe.cryptography:curve25519-elisabeth:0.1.0=testRuntimeClasspath +cafe.cryptography:ed25519-elisabeth:0.1.0=testRuntimeClasspath +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.blogspot.mydailyjava:weak-lock-free:0.17=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq.okhttp3:okhttp:3.12.15=testCompileClasspath,testRuntimeClasspath +com.datadoghq.okio:okio:1.17.6=testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-instrument-java:0.0.5=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-javac-plugin-client:0.2.2=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:java-dogstatsd-client:4.4.5=testRuntimeClasspath +com.datadoghq:sketches-java:0.8.3=testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.jnr:jffi:1.3.15=testRuntimeClasspath +com.github.jnr:jnr-a64asm:1.0.0=testRuntimeClasspath +com.github.jnr:jnr-constants:0.10.4=testRuntimeClasspath +com.github.jnr:jnr-enxio:0.32.20=testRuntimeClasspath +com.github.jnr:jnr-ffi:2.2.19=testRuntimeClasspath +com.github.jnr:jnr-posix:3.1.22=testRuntimeClasspath +com.github.jnr:jnr-unixsocket:0.38.25=testRuntimeClasspath +com.github.jnr:jnr-x86asm:1.0.2=testRuntimeClasspath +com.github.spotbugs:spotbugs-annotations:4.10.4=compileClasspath,main_java11CompileClasspath,spotbugs +com.github.spotbugs:spotbugs:4.10.4=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.1.1=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath +com.google.auto.service:auto-service:1.1.1=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.1=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=annotationProcessor,compileClasspath,main_java11CompileClasspath,spotbugs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.14.0=spotbugs +com.google.errorprone:error_prone_annotations:2.18.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.47.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.48.0=spotbugs +com.google.guava:failureaccess:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:32.0.1-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=testCompileClasspath,testRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:2.8=annotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=testCompileClasspath,testRuntimeClasspath +com.google.re2j:re2j:1.8=testRuntimeClasspath +com.squareup.moshi:moshi:1.11.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:logging-interceptor:3.12.12=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:3.12.12=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=testCompileClasspath,testRuntimeClasspath +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.21.0=spotbugs +de.thetaphi:forbiddenapis:3.11=compileClasspath,testCompileClasspath,testRuntimeClasspath +dev.openfeature:sdk:1.22.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.66.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.66.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.66.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +io.sqreen:libsqreen:17.5.0=testRuntimeClasspath +javax.servlet:javax.servlet-api:3.1.0=testCompileClasspath,testRuntimeClasspath +jaxen:jaxen:2.0.6=spotbugs +junit:junit:4.13.2=testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.12=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.12=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna-platform:5.8.0=testRuntimeClasspath +net.java.dev.jna:jna:5.8.0=testRuntimeClasspath +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-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,testRuntimeClasspath +org.checkerframework:checker-qual:3.33.0=annotationProcessor,testAnnotationProcessor +org.codehaus.groovy:groovy-ant:3.0.23=codenarc +org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc +org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest-core:1.3=testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath +org.jspecify:jspecify:1.0.0=testCompileClasspath,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 +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-runner:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-suite-api:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-suite-commons:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:4.4.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-analysis:9.7.1=testRuntimeClasspath +org.ow2.asm:asm-commons:9.10.1=spotbugs,testRuntimeClasspath +org.ow2.asm:asm-tree:9.10.1=spotbugs,testRuntimeClasspath +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm-util:9.7.1=testRuntimeClasspath +org.ow2.asm:asm:9.10.1=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.30=buildTimeInstrumentationPlugin,compileClasspath,main_java11RuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=main_java11CompileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.snakeyaml:snakeyaml-engine:2.9=buildTimeInstrumentationPlugin,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,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 +org.tabletest:tabletest-parser:1.2.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=main_java11AnnotationProcessor,spotbugsPlugins diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java new file mode 100644 index 00000000000..dc909890988 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java @@ -0,0 +1,55 @@ +package datadog.trace.instrumentation.openfeature; + +import static datadog.trace.agent.tooling.InstrumenterModule.TargetSystem.FEATURE_FLAGS; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.isMethod; +import static net.bytebuddy.matcher.ElementMatchers.isStatic; +import static net.bytebuddy.matcher.ElementMatchers.takesNoArguments; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import java.util.Set; + +/** + * Connects the {@code dd-openfeature} SDK to the agent: Remote Configuration, the event platform + * proxy, health metrics and span enrichment. + */ +@SuppressWarnings("unused") +@AutoService(InstrumenterModule.class) +public class DDOpenFeatureInstrumentation extends InstrumenterModule + implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + public DDOpenFeatureInstrumentation() { + super("openfeature"); + } + + @Override + public boolean isApplicable(final Set enabledSystems) { + return enabledSystems.contains(FEATURE_FLAGS); + } + + @Override + public String instrumentedType() { + return "com.datadog.openfeature.internal.connector.Connectors"; + } + + @Override + public String[] helperClassNames() { + // Helpers are compiled in the java11 source set, out of reach of helper discovery. + return new String[] { + packageName + ".AgentConfigurationSource", + packageName + ".AgentEventProxy", + packageName + ".AgentHealthMetrics", + packageName + ".AgentSpanEnricher", + packageName + ".JavaAgentConnector", + }; + } + + @Override + public void methodAdvice(final MethodTransformer transformer) { + transformer.applyAdvice( + isMethod().and(isStatic()).and(named("detect")).and(takesNoArguments()), + packageName + ".DetectConnectorAdvice"); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java new file mode 100644 index 00000000000..d25d4475212 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java @@ -0,0 +1,19 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import java.util.function.Consumer; + +/** Receives the flag configuration from the agent Remote Configuration client. */ +public final class AgentConfigurationSource implements ConfigurationSource { + private final FeatureFlaggingGateway.Backend backend; + + AgentConfigurationSource(final FeatureFlaggingGateway.Backend backend) { + this.backend = backend; + } + + @Override + public AutoCloseable subscribe(final Consumer listener) { + return this.backend.subscribeRemoteConfig(listener); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java new file mode 100644 index 00000000000..5c30077065d --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java @@ -0,0 +1,22 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; +import com.datadog.openfeature.internal.connector.EventTransport; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import java.io.IOException; + +/** Posts events through the Datadog Agent event platform proxy. */ +public final class AgentEventProxy implements EventTransport { + private final FeatureFlaggingGateway.Backend backend; + + AgentEventProxy(final FeatureFlaggingGateway.Backend backend) { + this.backend = backend; + } + + @Override + public void post(final String route, final byte[] json) throws IOException { + if (!this.backend.postEvent(route, json)) { + throw new EventProxyUnavailableException("Datadog Agent event platform proxy unavailable"); + } + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java new file mode 100644 index 00000000000..abee16aeae1 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java @@ -0,0 +1,19 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.HealthMetrics; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import javax.annotation.Nullable; + +/** Reports the SDK health counters through the agent telemetry. */ +public final class AgentHealthMetrics implements HealthMetrics { + private final FeatureFlaggingGateway.Backend backend; + + AgentHealthMetrics(final FeatureFlaggingGateway.Backend backend) { + this.backend = backend; + } + + @Override + public void count(final String metric, final long value, @Nullable final String reason) { + this.backend.count(metric, value, reason); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java new file mode 100644 index 00000000000..5e0f87715cf --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java @@ -0,0 +1,25 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.SpanEnricher; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import javax.annotation.Nullable; + +/** Reflects flag evaluations onto the agent local root span. */ +public final class AgentSpanEnricher implements SpanEnricher { + private final FeatureFlaggingGateway.Backend backend; + + AgentSpanEnricher(final FeatureFlaggingGateway.Backend backend) { + this.backend = backend; + } + + @Override + public void serialId( + final int serialId, final boolean doLog, @Nullable final String targetingKey) { + this.backend.enrichSerialId(serialId, doLog, targetingKey); + } + + @Override + public void runtimeDefault(final String flagKey, @Nullable final Object defaultValue) { + this.backend.enrichRuntimeDefault(flagKey, defaultValue); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java new file mode 100644 index 00000000000..deb9d2d7bbe --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java @@ -0,0 +1,11 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.Connector; +import net.bytebuddy.asm.Advice; + +public class DetectConnectorAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void detect(@Advice.Return(readOnly = false) Connector connector) { + connector = JavaAgentConnector.connect(connector); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java new file mode 100644 index 00000000000..f4d463dab3a --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java @@ -0,0 +1,63 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import javax.annotation.Nullable; + +/** The connector backed by the agent Feature Flags backend. */ +public final class JavaAgentConnector implements Connector { + private final FeatureFlaggingGateway.Backend backend; + private final HealthMetrics healthMetrics; + private final SpanEnricher spanEnricher; + + JavaAgentConnector(final FeatureFlaggingGateway.Backend backend) { + this.backend = backend; + this.healthMetrics = new AgentHealthMetrics(backend); + this.spanEnricher = new AgentSpanEnricher(backend); + } + + /** + * Connects the SDK to the agent backend. + * + * @param fallback the connector to use if the agent backend is not started. + * @return the agent connector, or the fallback one. + */ + public static Connector connect(final Connector fallback) { + final FeatureFlaggingGateway.Backend backend = FeatureFlaggingGateway.backend(); + return backend == null ? fallback : new JavaAgentConnector(backend); + } + + @Nullable + @Override + public String setting(final String key) { + return this.backend.setting(key); + } + + @Nullable + @Override + public ConfigurationSource remoteConfiguration() { + return this.backend.isRemoteConfigAvailable() + ? new AgentConfigurationSource(this.backend) + : null; + } + + @Nullable + @Override + public EventTransport eventProxy() { + return this.backend.isEventProxyAvailable() ? new AgentEventProxy(this.backend) : null; + } + + @Override + public HealthMetrics healthMetrics() { + return this.healthMetrics; + } + + @Override + public SpanEnricher spanEnricher() { + return this.spanEnricher; + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java new file mode 100644 index 00000000000..b5091e326fd --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java @@ -0,0 +1,89 @@ +package datadog.trace.instrumentation.openfeature; + +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_ENABLED; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.Connector; +import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; +import datadog.trace.bootstrap.config.provider.ConfigProvider; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import org.tabletest.junit.TableTest; + +/** + * Guards against drift between the agent ({@link FeatureFlaggingConfig}) and the SDK ({@link + * Settings}) resolution of the Feature Flags product switch and configuration source. Both sides + * read the same raw settings, keyed by the agent setting names. + */ +class ConfigurationResolutionParityTest { + @TableTest({ + "Scenario | Enabled | Source | Legacy | Expected ", + "defaults | | | | agentless ", + "enabled | true | | | agentless ", + "enabled as number | 1 | | | agentless ", + "disabled | false | | | ", + "disabled as number | 0 | | | ", + "disabled wins over source | false | remote_config | | ", + "remote config source | | remote_config | | remote_config", + "agentless source | | agentless | | agentless ", + "source is normalized | | ' Remote_Config' | | remote_config", + "unsupported source | | offline | | ", + "legacy enabled | | | true | remote_config", + "legacy disabled | | | false | ", + "source wins over legacy | | agentless | true | agentless ", + "source wins over legacy disabled | | remote_config | false | remote_config", + "enabled with legacy disabled | true | | false | ", + "uppercase boolean | TRUE | | TRUE | remote_config", + "invalid boolean reads as false | maybe | | | ", + "invalid legacy reads as false | | | maybe | " + }) + void agentAndSdkResolveTheSameConfiguration( + final String enabled, final String source, final String legacy, final String expected) { + final Map settings = new HashMap<>(); + putIfNotNull(settings, FEATURE_FLAGS_ENABLED, enabled); + putIfNotNull(settings, FEATURE_FLAGS_CONFIGURATION_SOURCE, source); + putIfNotNull(settings, EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, legacy); + + assertEquals(expected, agentSource(settings), "agent resolution"); + assertEquals(expected, sdkSource(settings), "SDK resolution"); + } + + /** Resolves the source like the agent: {@code InstrumenterConfig} and {@code Config}. */ + private static String agentSource(final Map settings) { + final Properties properties = new Properties(); + properties.putAll(settings); + final ConfigProvider configProvider = ConfigProvider.withPropertiesOverride(properties); + final FeatureFlaggingConfig.Resolution resolution = + resolveConfiguration( + configProvider.getBoolean(FEATURE_FLAGS_ENABLED), + configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE), + configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED)); + return resolution.isEnabled() ? resolution.getSource() : null; + } + + /** + * Resolves the source like the SDK. Missing settings are reported as blank, so the SDK does not + * fall back to the system properties and environment of the test JVM. + */ + private static String sdkSource(final Map settings) { + final Connector connector = mock(Connector.class); + when(connector.setting(anyString())) + .thenAnswer(invocation -> settings.getOrDefault(invocation.getArgument(0), "")); + return Settings.load(connector).configurationSource(); + } + + private static void putIfNotNull( + final Map map, final String key, final String value) { + if (value != null) { + map.put(key, value); + } + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java new file mode 100644 index 00000000000..0c7dab1cfb8 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java @@ -0,0 +1,26 @@ +package datadog.trace.instrumentation.openfeature; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; + +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.Connectors; +import datadog.trace.agent.test.AbstractInstrumentationTest; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.test.junit.utils.config.WithConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +@WithConfig(key = "feature.flags.enabled", value = "false") +class DDOpenFeatureInstrumentationDisabledForkedTest extends AbstractInstrumentationTest { + @AfterEach + void unregisterBackend() { + FeatureFlaggingGateway.register(null); + } + + @Test + void doesNotInstrumentWhenFeatureFlagsIsDisabled() { + FeatureFlaggingGateway.register(mock(FeatureFlaggingGateway.Backend.class)); + assertSame(Connector.NONE, Connectors.detect()); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java new file mode 100644 index 00000000000..10c46a4e4ac --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java @@ -0,0 +1,103 @@ +package datadog.trace.instrumentation.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.Connectors; +import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; +import com.datadog.openfeature.internal.connector.EventTransport; +import datadog.trace.agent.test.AbstractInstrumentationTest; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +// Bootstrap types are not allowed in test class signatures, so the backend is a local variable. +class DDOpenFeatureInstrumentationTest extends AbstractInstrumentationTest { + @AfterEach + void unregisterBackend() { + FeatureFlaggingGateway.register(null); + } + + @Test + void detectsTheAgentConnector() { + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + assertInstanceOf(JavaAgentConnector.class, Connectors.detect()); + } + + @Test + void keepsTheDefaultConnectorWithoutBackend() { + assertSame(Connector.NONE, Connectors.detect()); + } + + @Test + void delegatesSettingsAndHealthMetrics() { + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + when(backend.setting("site")).thenReturn("datadoghq.eu"); + final Connector connector = Connectors.detect(); + + assertEquals("datadoghq.eu", connector.setting("site")); + connector.healthMetrics().count("metric", 2, "reason"); + verify(backend).count("metric", 2, "reason"); + } + + @Test + void exposesRemoteConfigurationOnlyWhenAvailable() throws Exception { + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + final Connector connector = Connectors.detect(); + assertNull(connector.remoteConfiguration()); + + when(backend.isRemoteConfigAvailable()).thenReturn(true); + final AutoCloseable subscription = mock(AutoCloseable.class); + when(backend.subscribeRemoteConfig(any())).thenReturn(subscription); + final ConfigurationSource source = connector.remoteConfiguration(); + final Consumer listener = content -> {}; + + assertSame(subscription, source.subscribe(listener)); + verify(backend).subscribeRemoteConfig(listener); + } + + @Test + void postsThroughTheEventProxyOnlyWhenAvailable() throws Exception { + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + final Connector connector = Connectors.detect(); + assertNull(connector.eventProxy()); + + when(backend.isEventProxyAvailable()).thenReturn(true); + final EventTransport proxy = connector.eventProxy(); + final byte[] payload = "{}".getBytes(UTF_8); + when(backend.postEvent("exposures", payload)).thenReturn(true); + proxy.post("exposures", payload); + verify(backend).postEvent("exposures", payload); + + when(backend.postEvent("exposures", payload)).thenReturn(false); + assertThrows(EventProxyUnavailableException.class, () -> proxy.post("exposures", payload)); + } + + @Test + void delegatesSpanEnrichment() { + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + final Connector connector = Connectors.detect(); + + connector.spanEnricher().serialId(42, true, "user"); + connector.spanEnricher().runtimeDefault("flag", "default"); + + verify(backend).enrichSerialId(42, true, "user"); + verify(backend).enrichRuntimeDefault("flag", "default"); + } +} diff --git a/dd-openfeature/README.md b/dd-openfeature/README.md new file mode 100644 index 00000000000..26f604ba2e0 --- /dev/null +++ b/dd-openfeature/README.md @@ -0,0 +1,78 @@ +# Datadog OpenFeature provider + +`com.datadoghq:dd-openfeature` is the Datadog Feature Flags SDK for the [OpenFeature](https://openfeature.dev/) Java API. +It evaluates flags locally from the Universal Flag Configuration (UFC) and reports exposures and evaluation counts to Datadog. + +The SDK works on its own. When the Datadog Java agent (`dd-java-agent`) is attached, the SDK also connects to it to use Remote Configuration, the Datadog Agent event proxy, agent telemetry, and APM span enrichment. + +## Requirements + +* Java 11 or later +* A Datadog [API key](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys) and [site](https://docs.datadoghq.com/getting_started/site/), unless events go through a Datadog Agent +* Optionally, `dd-java-agent` 1.67.0 or later + +## Setup + +Add the dependency. It brings the OpenFeature SDK, the OpenTelemetry API and `jackson-core`: + +```xml + + com.datadoghq + dd-openfeature + ${dd-openfeature.version} + +``` + +Register the provider: + +```java +import com.datadog.openfeature.Provider; +import dev.openfeature.sdk.OpenFeatureAPI; + +OpenFeatureAPI.getInstance().setProviderAndWait(new Provider()); +Client client = OpenFeatureAPI.getInstance().getClient(); +``` + +`new Provider(new Provider.Options().initTimeout(10, SECONDS))` changes how long initialization waits for the first flag configuration (30 seconds by default). + +## Configuration + +Settings are read from the Datadog Java agent configuration when attached, then from `dd.`-prefixed system properties, then from environment variables. + +| Environment variable | Default | Description | +|---|---|---| +| `DD_FEATURE_FLAGS_ENABLED` | `true` | Enables Feature Flags. `false` makes the provider fail initialization, so evaluations return defaults. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE` | `agentless` | `agentless` polls the configuration from the Datadog CDN. `remote_config` receives it through Remote Configuration and requires `dd-java-agent`. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL` | | Custom configuration endpoint. A URL without a path gets the default path. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS` | `30` | CDN poll interval. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS` | `5` | CDN request timeout. | +| `DD_FLAGGING_EVALUATION_COUNTS_ENABLED` | `true` | Sends aggregated flag evaluation counts. | +| `DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED` | `false` | Adds flag evaluations to the local root span. Requires `dd-java-agent`. | +| `DD_API_KEY`, `DD_SITE`, `DD_ENV`, `DD_SERVICE`, `DD_VERSION` | | Credentials and event context. | +| `DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED` | | Deprecated: `true` selects `remote_config`, `false` disables Feature Flags. An explicit source wins. | + +## Features by setup + +| Feature | SDK only | SDK + `dd-java-agent` | +|---|---|---| +| Configuration, `agentless` source | CDN | CDN | +| Configuration, `remote_config` source | Not supported | Remote Configuration | +| Exposures and evaluation counts | Direct intake, requires `DD_API_KEY` | Datadog Agent event proxy, with direct intake fallback in `agentless` mode | +| `feature_flag.evaluations` OpenTelemetry metric | Yes | Yes | +| Health metrics | No | Agent telemetry | +| Span enrichment | No | Yes | + +## Migrating from earlier versions + +* The provider moved to `com.datadog.openfeature.Provider`. `datadog.trace.api.openfeature.Provider` is kept as a deprecated alias. +* The SDK no longer requires `dd-java-agent` and no longer links against agent classes. +* `dd-openfeature` 1.66.0 and earlier require the agent classes this release removes. Upgrade `dd-openfeature` together with `dd-java-agent` 1.67.0 or later. + +## Design notes for contributors + +The SDK does not use any dd-trace-java internal module. Its parts that the agent can provide are behind the `com.datadog.openfeature.internal.connector` SPI: + +* `Connectors.detect()` returns `Connector.NONE` without the agent. +* The `:dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-connector` instrumentation replaces its result with a connector delegating to the agent backend (`products/feature-flagging/feature-flagging-lib`) through the bootstrap `FeatureFlaggingGateway`. +* The SPI is the only contract crossing SDK and agent versions. It must evolve in a backward compatible way, and the instrumentation references to it are checked by muzzle. +* UFC parsing, evaluation, event payloads and batching stay in the SDK. The agent forwards raw Remote Configuration documents and event payloads. diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts new file mode 100644 index 00000000000..a0a715a005b --- /dev/null +++ b/dd-openfeature/build.gradle.kts @@ -0,0 +1,73 @@ +import datadog.gradle.configureCompiler +import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension + +plugins { + `java-library` + idea + id("dd-trace-java.module.distributable.api") + id("dd-trace-java.version-file") + id("dd-trace-java.jmh-conventions") +} + +description = "Datadog OpenFeature provider SDK." + +// Set both JAR and Maven artifact name +val openFeatureArtifactId = "dd-openfeature" +base { + archivesName.set(openFeatureArtifactId) +} + +publishing { + publications.withType().configureEach { + artifactId = openFeatureArtifactId + } +} + +configure { + minJavaVersion.set(JavaVersion.VERSION_11) +} + +idea { + module { + jdkName = "11" + } +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(11) + } +} + +dependencies { + api("dev.openfeature:sdk:1.22.0") + api("io.opentelemetry:opentelemetry-api:1.66.0") + api(libs.jackson.core) + + testImplementation(libs.bundles.junit5) + testImplementation(libs.bundles.mockito) +} + +jmh { + jmhVersion = libs.versions.jmh.get() +} + +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 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/dd-openfeature/gradle.lockfile b/dd-openfeature/gradle.lockfile new file mode 100644 index 00000000000..55a26499a91 --- /dev/null +++ b/dd-openfeature/gradle.lockfile @@ -0,0 +1,81 @@ +# 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 :dd-openfeature:dependencies --write-locks +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.spotbugs:spotbugs-annotations:4.10.4=compileClasspath,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,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.14.0=spotbugs +com.google.errorprone:error_prone_annotations:2.48.0=spotbugs +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-io:commons-io:2.21.0=spotbugs +de.thetaphi:forbiddenapis:3.11=compileClasspath +dev.openfeature:sdk:1.22.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.66.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.66.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.66.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy-agent:1.18.12=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.12=testCompileClasspath,testRuntimeClasspath +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-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,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 +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt +org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt +org.jacoco:org.jacoco.core:0.8.15=jacocoAnt +org.jacoco:org.jacoco.report:0.8.15=jacocoAnt +org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:4.4.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=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.10.1=jacocoAnt,spotbugs +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-junit:1.2.2=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java similarity index 85% rename from products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java rename to dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java index e292424e53e..34412974120 100644 --- a/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java @@ -1,11 +1,10 @@ -package datadog.trace.api.openfeature; +package com.datadog.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 com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.FlagValueType; import dev.openfeature.sdk.HookContext; @@ -30,7 +29,6 @@ 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; @@ -38,9 +36,8 @@ * 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. + *

    This is the counterpart to FlagEvaluationHotPathBenchmark, which measures the writer queue and + * the worker-thread aggregation. * *

    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 @@ -49,8 +46,7 @@ * *

    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}. + *

    Run: {@code ./gradlew :dd-openfeature:jmh -Pjmh.includes=FlagEvalHookHotPathBenchmark}. */ @State(Scope.Benchmark) @Warmup(iterations = 3, time = 2, timeUnit = SECONDS) @@ -94,13 +90,8 @@ public void setUp() { 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); + final FlagEvaluationWriter writer = new NoOpWriter(); + hook = new FlagEvalLoggingHook<>(() -> writer); } /** Total inline cost under consent-on: scalar extraction, bounded context copy, and enqueue. */ @@ -193,11 +184,5 @@ 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-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java similarity index 80% rename from products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java rename to dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java index bbec5d65229..b862c1388f9 100644 --- a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java @@ -1,13 +1,10 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.flagevaluation; 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 com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.HealthMetrics; import de.thetaphi.forbiddenapis.SuppressForbidden; import java.util.HashMap; import java.util.Map; @@ -27,17 +24,17 @@ import org.openjdk.jmh.infra.Blackhole; /** - * Producer-contention benchmark for {@link FlagEvaluationWriterImpl#enqueue}. + * Producer-contention benchmark for {@link FlagEvaluationPipeline#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 groups rather than {@code @Threads}: the hand-off queue ({@code + * EventQueue}) is 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 @@ -46,8 +43,8 @@ * 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}. + *

    Run: {@code ./gradlew :dd-openfeature:jmh + * -Pjmh.includes=FlagEvaluationEnqueueContentionBenchmark}. */ @State(Scope.Benchmark) @Warmup(iterations = 3, time = 2, timeUnit = SECONDS) @@ -67,7 +64,7 @@ public class FlagEvaluationEnqueueContentionBenchmark { private Map attrs; private String[] flagKeys; private String[] targetingKeys; - private FlagEvaluationWriterImpl writer; + private FlagEvaluationPipeline writer; /** Per-producer cursor: a shared counter would add its own cache-line contention. */ @State(Scope.Thread) @@ -77,10 +74,6 @@ public static class ProducerCursor { @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"); @@ -88,16 +81,15 @@ public void setUp() { 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( + new FlagEvaluationPipeline( 1 << 20, Long.MAX_VALUE, NANOSECONDS, - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), - config); + (route, json) -> {}, + new HashMap<>(), + new RuntimeServices(HealthMetrics.NOOP)); } /** diff --git a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java similarity index 82% rename from products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java rename to dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java index fbd467b88d2..6a1f6154b2f 100644 --- a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java @@ -1,12 +1,11 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.flagevaluation; 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 com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; import java.util.HashMap; import java.util.Map; import org.openjdk.jmh.annotations.Benchmark; @@ -31,16 +30,15 @@ * 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. + * consent-on. That cost is measured separately by FlagEvalHookHotPathBenchmark in the root package, + * 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}. + *

    Run: {@code ./gradlew :dd-openfeature:jmh -Pjmh.includes=FlagEvaluationHotPathBenchmark}. */ @State(Scope.Benchmark) @Warmup(iterations = 3, time = 2, timeUnit = SECONDS) @@ -61,8 +59,8 @@ public class FlagEvaluationHotPathBenchmark { private String[] flagKeys; private String[] targetingKeys; private int cursor; - private FlagEvaluationWriterImpl writer; - private FlagEvaluationWriterImpl.SerializingHandlerForTest handler; + private FlagEvaluationPipeline writer; + private FlagEvaluationPipeline.SerializingHandlerForTest handler; @Setup(Level.Iteration) public void setUp() { @@ -76,22 +74,16 @@ public void setUp() { targetingKeys = keys("bench-user-", p.numUsers); cursor = 0; - final Config config = Config.get(); - final BackendApiFactory factory = new BackendApiFactory(config, null); + final EventTransport transport = (route, json) -> {}; + final RuntimeServices services = new RuntimeServices(HealthMetrics.NOOP); final Map ddContext = new HashMap<>(); ddContext.put("service", "bench-service"); - handler = - FlagEvaluationWriterImpl.createHandlerForTest( - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), ddContext); + handler = new FlagEvaluationPipeline.SerializingHandlerForTest(transport, ddContext, services); // 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); + new FlagEvaluationPipeline( + 1 << 20, Long.MAX_VALUE, NANOSECONDS, transport, ddContext, services); } /** diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java new file mode 100644 index 00000000000..08af9e7e571 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -0,0 +1,572 @@ +package com.datadog.openfeature; + +import static java.util.Arrays.asList; + +import com.datadog.openfeature.internal.FeatureFlagsRuntime; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.evaluation.ConfigurationStore; +import com.datadog.openfeature.internal.evaluation.EvaluationResult; +import com.datadog.openfeature.internal.evaluation.FlagEvaluator; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.exposure.Subject; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.ValueType; +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.Structure; +import dev.openfeature.sdk.Value; +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.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +class DDEvaluator implements Evaluator, Consumer { + + private static final Set> SUPPORTED_RESOLUTION_TYPES = + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); + + /** + * 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). Emitted only when the span-enrichment gate is on. + static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; + static final String METADATA_DO_LOG = "__dd_do_log"; + + // 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"; + + private final Runnable configCallback; + private final Connector connector; + private final Settings settings; + private final FlagEvaluator core; + private final ConfigurationStore configuration = new ConfigurationStore(); + private volatile FeatureFlagsRuntime runtime; + + DDEvaluator(final Runnable configCallback, final Connector connector, final Settings settings) { + this.configCallback = configCallback; + this.connector = connector; + this.settings = settings; + // When span enrichment is off, the __dd_* span-enrichment metadata is not attached to + // evaluations, so an enabled provider pays nothing extra. + this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled()); + } + + @Override + public synchronized boolean initialize( + final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { + if (this.runtime != null) { + return hasConfiguration(); + } + final FeatureFlagsRuntime acquired = FeatureFlagsRuntime.acquire(this.connector, this.settings); + this.runtime = acquired; + acquired.addConfigurationListener(this); + return this.configuration.await(timeout, unit) || hasConfiguration(); + } + + @Override + public boolean hasConfiguration() { + return this.configuration.get() != null; + } + + @Override + public synchronized void shutdown() { + final FeatureFlagsRuntime released = this.runtime; + this.runtime = null; + if (released != null) { + released.removeConfigurationListener(this); + FeatureFlagsRuntime.release(); + } + } + + /** + * @return the runtime, or {@code null} if the evaluator is not initialized. + */ + FeatureFlagsRuntime runtime() { + return this.runtime; + } + + @Override + public void accept(final ServerConfiguration config) { + this.configuration.set(config); + if (config != null) { + this.configuration.markReady(); + this.configCallback.run(); + } else if (this.configuration.wasReady()) { + this.configCallback.run(); + } + } + + @Override + @SuppressWarnings("unchecked") + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + final com.datadog.openfeature.internal.evaluation.EvaluationContext adapted = + context == null + ? null + : new com.datadog.openfeature.internal.evaluation.EvaluationContext() { + @Override + public String getTargetingKey() { + return context.getTargetingKey(); + } + + @Override + public boolean hasAttribute(final String name) { + return context.keySet().contains(name); + } + + @Override + public Object attribute(final String name) { + return context.convertValue(context.getValue(name)); + } + }; + final EvaluationResult result = + core.evaluate(configuration.get(), (Class) coreType(target), key, defaultValue, adapted); + final ImmutableMetadata.ImmutableMetadataBuilder metadata = ImmutableMetadata.builder(); + for (final Map.Entry entry : result.getFlagMetadata().values().entrySet()) { + final Object value = entry.getValue(); + if (value instanceof Boolean) metadata.addBoolean(entry.getKey(), (Boolean) value); + else if (value instanceof Integer) metadata.addInteger(entry.getKey(), (Integer) value); + else if (value instanceof Long) metadata.addLong(entry.getKey(), (Long) value); + else metadata.addString(entry.getKey(), (String) value); + } + final T value = + result.getValue() == defaultValue ? defaultValue : mapValue(target, result.getValue()); + final ProviderEvaluation evaluation = + ProviderEvaluation.builder() + .value(value) + .reason(result.getReason()) + .variant(result.getVariant()) + .errorCode( + result.getErrorCode() == null + ? null + : ErrorCode.valueOf(result.getErrorCode().name())) + .errorMessage(result.getErrorMessage()) + .flagMetadata(metadata.build()) + .build(); + if (result.isLogExposure()) { + dispatchExposure(key, evaluation, result.getSerialId(), context); + } + return evaluation; + } + + private static Class coreType(final Class target) { + return target == Value.class ? Object.class : target; + } + + static boolean isAllocationActive(final Allocation allocation, final Instant now) { + return FlagEvaluator.isAllocationActive(allocation, now); + } + + static boolean isTypeCompatible(final Class target, final ValueType type) { + return FlagEvaluator.isTypeCompatible(coreType(target), type); + } + + @SuppressWarnings("unchecked") + static T mapValue(final Class target, final Object value) { + if (target == Value.class) { + return value == null ? null : (T) Value.objectToValue(value); + } + return FlagEvaluator.mapValue(target, value); + } + + private void dispatchExposure( + final String flag, + final ProviderEvaluation evaluation, + final Integer serialId, + final EvaluationContext context) { + final String allocationKey = allocationKey(evaluation); + final String variantKey = evaluation.getVariant(); + if (allocationKey == null || variantKey == null) { + return; + } + final ExposureEvent event = + new ExposureEvent( + System.currentTimeMillis(), + new com.datadog.openfeature.internal.exposure.Allocation(allocationKey), + new com.datadog.openfeature.internal.exposure.Flag(flag), + new com.datadog.openfeature.internal.exposure.Variant(variantKey), + new Subject(context.getTargetingKey(), flattenContext(context)), + serialId); + recordExposure(event); + } + + /** + * Records an exposure to the runtime, if initialized. + * + * @param event the exposure to record. + */ + void recordExposure(final ExposureEvent event) { + final FeatureFlagsRuntime current = this.runtime; + if (current != null) { + current.recordExposure(event); + } + } + + private static String allocationKey(final ProviderEvaluation resolution) { + final ImmutableMetadata meta = resolution.getFlagMetadata(); + return meta == null ? null : meta.getString("allocationKey"); + } + + static AbstractMap flattenContext(final EvaluationContext context) { + 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); + } + } + + 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/Evaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java similarity index 91% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java index f4c9cacffdb..10176fe09a2 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ProviderEvaluation; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java similarity index 82% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java index 322f11ac9e1..5baab2df44a 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java @@ -1,8 +1,7 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; +import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; @@ -28,34 +27,14 @@ *

    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. + *

    The pipeline is resolved lazily on each call, so the hook is always safe to register - if the + * pipeline is absent (killswitch off or not yet started) it is a no-op. */ class FlagEvalLoggingHook implements Hook { - /** - * 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. - */ + /** Pipeline resolver, returning {@code null} when evaluation counts are not delivered. */ 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; } @@ -76,10 +55,6 @@ public void finallyAfter( if (details == null) { return; } - if (!FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()) { - return; - } - final FlagEvaluationWriter w = writerSupplier.get(); if (w == null) { return; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java similarity index 54% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java index eba3b7ba4b5..a108e0fd582 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java @@ -1,7 +1,8 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import dev.openfeature.sdk.ErrorCode; import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; @@ -31,39 +32,98 @@ class FlagEvalMetrics implements Closeable { AttributeKey.stringKey("feature_flag.result.allocation_key"); private volatile LongCounter counter; + private volatile OpenTelemetry telemetry; + private volatile boolean closed; + private volatile Boolean legacyAgentBridge; + private volatile boolean getOrNoopUnavailable; + private final boolean suppliedCounter; FlagEvalMetrics() { + suppliedCounter = false; + } + + private LongCounter resolveCounter() { + if (closed || suppliedCounter) { + return counter; + } try { - Meter meter = GlobalOpenTelemetry.get().getMeterProvider().meterBuilder(METER_NAME).build(); + final OpenTelemetry current = currentOpenTelemetry(); + if (current == telemetry) { + return counter; + } + return initializeCounter(current); + } catch (LinkageError e) { + log.debug("OpenTelemetry API is not available for evaluation metrics", e); + shutdown(); + } catch (Exception e) { + log.error("Failed to initialize flag evaluation metrics", e); + } + return null; + } + + private OpenTelemetry currentOpenTelemetry() { + if (!getOrNoopUnavailable) { + try { + OpenTelemetry current = GlobalOpenTelemetry.getOrNoop(); + if (current == OpenTelemetry.noop() && hasLegacyAgentBridge()) { + // Older OTel agents bridge get(), but predate getOrNoop(). Only use get() when the + // agent has injected its API bridge. Mere agent-JAR presence is not sufficient. + current = GlobalOpenTelemetry.get(); + } + return current; + } catch (NoSuchMethodError e) { + // getOrNoop() was added in OpenTelemetry API 1.57 + getOrNoopUnavailable = true; + } + } + return GlobalOpenTelemetry.get(); + } + + private boolean hasLegacyAgentBridge() { + Boolean available = legacyAgentBridge; + if (available == null) { + try { + Class.forName( + "io.opentelemetry.javaagent.instrumentation.opentelemetryapi.ApplicationOpenTelemetry", + false, + GlobalOpenTelemetry.class.getClassLoader()); + available = true; + } catch (ClassNotFoundException | LinkageError | SecurityException ignored) { + available = false; + } + legacyAgentBridge = available; + } + return available; + } + + private synchronized LongCounter initializeCounter(final OpenTelemetry current) { + if (closed) { + return null; + } + if (current != telemetry) { + Meter meter = current.getMeterProvider().meterBuilder(METER_NAME).build(); counter = meter .counterBuilder(METRIC_NAME) .setUnit(METRIC_UNIT) .setDescription(METRIC_DESC) .build(); + telemetry = current; 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; } + return counter; } /** Package-private constructor for testing with a mock counter. */ FlagEvalMetrics(LongCounter counter) { + this.suppliedCounter = true; this.counter = counter; } void record( String flagKey, String variant, String reason, ErrorCode errorCode, String allocationKey) { - LongCounter c = counter; + LongCounter c = resolveCounter(); if (c == null) { return; } @@ -93,7 +153,9 @@ public void close() { shutdown(); } - void shutdown() { + synchronized void shutdown() { + closed = true; counter = null; + telemetry = null; } } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java similarity index 96% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java index a3e0dbfc83c..d63cbe56d8c 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.FlagEvaluationDetails; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java similarity index 82% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java index 17009d9933a..62f1ab751e5 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -1,8 +1,12 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static java.util.concurrent.TimeUnit.SECONDS; -import de.thetaphi.forbiddenapis.SuppressForbidden; +import com.datadog.openfeature.internal.FeatureFlagsRuntime; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.Connectors; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.EventProvider; @@ -15,7 +19,6 @@ 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; @@ -24,15 +27,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** The Datadog OpenFeature provider. */ 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 Connector connector; + private final Settings settings; private final AtomicReference initializationState = new AtomicReference<>(InitializationState.NOT_STARTED); private final FlagEvalMetrics flagEvalMetrics; @@ -43,10 +48,16 @@ public class Provider extends EventProvider implements Metadata { // once so gate-off evaluation allocates nothing on this hot path. private final List providerHooks; + /** Creates a provider waiting up to 30 seconds for the initial flag configuration. */ public Provider() { this(DEFAULT_OPTIONS, null); } + /** + * Creates a provider. + * + * @param options the provider options. + */ public Provider(final Options options) { this(options, null); } @@ -57,7 +68,7 @@ public Provider(final Options options) { /** * @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test - * seam); when null, the gate is read via {@link SpanEnrichmentGate}. + * seam); when null, the gate is read from the settings. */ Provider( final Options options, @@ -65,6 +76,8 @@ public Provider(final Options options) { final Boolean spanEnrichmentEnabledOverride) { this.options = options; this.evaluator = evaluator; + this.connector = Connectors.detect(); + this.settings = Settings.load(this.connector); FlagEvalMetrics metrics = null; FlagEvalMetricsHook hook = null; try { @@ -82,8 +95,9 @@ public Provider(final Options options) { final boolean spanEnrichmentEnabled = spanEnrichmentEnabledOverride != null ? spanEnrichmentEnabledOverride - : SpanEnrichmentGate.isEnabled(); - this.spanEnrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; + : this.settings.isSpanEnrichmentEnabled(); + this.spanEnrichmentHook = + spanEnrichmentEnabled ? new SpanEnrichmentHook(this.connector.spanEnricher()) : null; // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) // allocates nothing, including when the gate is off. @@ -91,16 +105,9 @@ public Provider(final Options options) { 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. - } + // EVP flagevaluation hook: always registered; no-op when the pipeline is absent (killswitch + // off or provider not initialized). The pipeline is resolved lazily on each call. + hooks.add(new FlagEvalLoggingHook<>(this::flagEvaluationPipeline)); if (spanEnrichmentHook != null) { hooks.add(spanEnrichmentHook); } @@ -139,7 +146,8 @@ public void initialize(final EvaluationContext context) throws Exception { throw e; } catch (final Throwable e) { markInitializationError(); - throw new FatalError("Failed to initialize provider, is the tracer configured?", e); + throw new FatalError( + "Failed to initialize Datadog Feature Flags provider: " + e.getMessage(), e); } } @@ -212,13 +220,20 @@ private void markInitializationError() { } } - private Evaluator buildEvaluator() throws Exception { + private Evaluator buildEvaluator() { if (evaluator != null) { return evaluator; } - final Class evaluatorClass = loadEvaluatorClass(); - final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); - return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); + return new DDEvaluator(this::onConfigurationChange, this.connector, this.settings); + } + + private FlagEvaluationWriter flagEvaluationPipeline() { + final Evaluator current = this.evaluator; + if (!(current instanceof DDEvaluator)) { + return null; + } + final FeatureFlagsRuntime runtime = ((DDEvaluator) current).runtime(); + return runtime == null ? null : runtime.evaluations(); } @Override @@ -226,18 +241,12 @@ public List getProviderHooks() { return providerHooks; } - Hook buildFlagEvalLoggingHook() { - return FlagEvalLoggingHook.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(); } @@ -288,11 +297,6 @@ public ProviderEvaluation getObjectEvaluation( return evaluator.evaluate(Value.class, key, defaultValue, ctx); } - @SuppressForbidden // Class#forName(String) used to lazy-load the evaluator implementation - protected Class loadEvaluatorClass() throws ClassNotFoundException { - return Class.forName(EVALUATOR_IMPL); - } - private enum InitializationState { NOT_STARTED, INITIALIZING, @@ -301,11 +305,19 @@ private enum InitializationState { ERROR } + /** The provider options. */ public static class Options { private long timeout; private TimeUnit unit; + /** + * Sets how long provider initialization waits for the initial flag configuration. + * + * @param timeout the timeout. + * @param unit the timeout unit. + * @return these options. + */ public Options initTimeout(final long timeout, final TimeUnit unit) { this.timeout = timeout; this.unit = unit; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java similarity index 86% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java index 40e998a6546..525ba9badf9 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java @@ -1,7 +1,6 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.SpanEnrichmentEvent; +import com.datadog.openfeature.internal.connector.SpanEnricher; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; @@ -20,9 +19,9 @@ /** * OpenFeature {@code finally} hook that captures feature-flag evaluation metadata for APM span * enrichment. Registered from {@link Provider#getProviderHooks()} only when the gate is on. For - * each relevant evaluation it dispatches a {@link SpanEnrichmentEvent} onto {@link - * FeatureFlaggingGateway}; the agent-side write tier resolves the active local-root span and - * accumulates the per-trace state that is flushed onto the root when the trace completes. + * each relevant evaluation it records it to the {@link SpanEnricher}; the Datadog Java agent + * 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): * @@ -43,6 +42,12 @@ class SpanEnrichmentHook implements Hook { static final String METADATA_SERIAL_ID = DDEvaluator.METADATA_SPLIT_SERIAL_ID; static final String METADATA_DO_LOG = DDEvaluator.METADATA_DO_LOG; + private final SpanEnricher enricher; + + SpanEnrichmentHook(final SpanEnricher enricher) { + this.enricher = enricher; + } + @Override public void finallyAfter( final HookContext ctx, @@ -56,14 +61,11 @@ public void finallyAfter( final Integer serialId = metadata != null ? metadata.getInteger(METADATA_SERIAL_ID) : null; if (serialId != null) { final boolean doLog = Boolean.TRUE.equals(metadata.getBoolean(METADATA_DO_LOG)); - FeatureFlaggingGateway.dispatch( - SpanEnrichmentEvent.serialId(serialId, doLog, targetingKey(ctx))); + this.enricher.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()))); + // Value to a native Java type here so the connector carries only JDK types. + this.enricher.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. diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java new file mode 100644 index 00000000000..cadbbd0d8b2 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java @@ -0,0 +1,18 @@ +package com.datadog.openfeature.internal; + +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.util.function.Consumer; + +/** Delivers flag configurations to the runtime. */ +public interface ConfigurationService extends AutoCloseable { + /** + * Starts delivering configurations. Implementations may deliver the first configuration before + * returning. + * + * @param listener the listener receiving configurations, or {@code null} when removed. + */ + void start(Consumer listener); + + @Override + void close(); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java new file mode 100644 index 00000000000..2bea9fb33e9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java @@ -0,0 +1,107 @@ +package com.datadog.openfeature.internal; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; +import java.util.function.Consumer; + +/** + * A bounded, lock-free, multiple-producer single-consumer queue. Producers never block; the + * consumer polls with a timeout by parking, as producers do not signal it. + * + * @param the element type. + */ +public final class EventQueue { + private static final long PARK_NANOS = TimeUnit.MILLISECONDS.toNanos(10); + + private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); + private final AtomicInteger size = new AtomicInteger(); + private final int capacity; + + public EventQueue(final int capacity) { + this.capacity = capacity; + } + + /** + * Offers an element without blocking. + * + * @param element the element to offer. + * @return {@code true} if the element was queued, {@code false} if the queue is full. + */ + public boolean offer(final E element) { + if (this.size.incrementAndGet() > this.capacity) { + this.size.decrementAndGet(); + return false; + } + this.queue.offer(element); + return true; + } + + /** + * @return the head element, or {@code null} if the queue is empty. + */ + public E poll() { + final E element = this.queue.poll(); + if (element != null) { + this.size.decrementAndGet(); + } + return element; + } + + /** + * Polls the head element, waiting up to the given timeout for one to be available. + * + * @param timeout the maximum time to wait. + * @param unit the timeout unit. + * @return the head element, or {@code null} if none was available in time. + * @throws InterruptedException if the consumer thread is interrupted while waiting. + */ + public E poll(final long timeout, final TimeUnit unit) throws InterruptedException { + final long deadline = System.nanoTime() + unit.toNanos(timeout); + while (true) { + final E element = poll(); + if (element != null) { + return element; + } + final long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + return null; + } + LockSupport.parkNanos(this, Math.min(remaining, PARK_NANOS)); + if (Thread.interrupted()) { + throw new InterruptedException(); + } + } + } + + /** + * Drains up to {@code limit} elements to the consumer. + * + * @param consumer the consumer of the drained elements. + * @param limit the maximum number of elements to drain. + */ + public void drain(final Consumer consumer, final int limit) { + for (int i = 0; i < limit; i++) { + final E element = poll(); + if (element == null) { + return; + } + consumer.accept(element); + } + } + + /** + * @return the current number of queued elements. + */ + public int size() { + return this.size.get(); + } + + /** + * @return the queue capacity. + */ + public int capacity() { + return this.capacity; + } +} 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 new file mode 100644 index 00000000000..57896c89f7f --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -0,0 +1,228 @@ +package com.datadog.openfeature.internal; + +import static com.datadog.openfeature.internal.config.Settings.CONFIGURATION_SOURCE_REMOTE_CONFIG; + +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.delivery.CdnConfigurationSource; +import com.datadog.openfeature.internal.delivery.DirectIntakeTransport; +import com.datadog.openfeature.internal.delivery.FallbackTransport; +import com.datadog.openfeature.internal.delivery.RemoteConfigurationService; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.exposure.ExposurePipeline; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The Feature Flags runtime: configuration delivery and event pipelines. A single runtime is shared + * by all the providers of a class loader, and stopped when the last one releases it. + */ +public final class FeatureFlagsRuntime { + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagsRuntime.class); + + private static final Object SHARED_LOCK = new Object(); + private static FeatureFlagsRuntime shared; + private static int references; + + private final ConfigurationService source; + @Nullable private final ExposurePipeline exposures; + @Nullable private final FlagEvaluationPipeline evaluations; + private final List> listeners = new CopyOnWriteArrayList<>(); + private volatile ServerConfiguration configuration; + + FeatureFlagsRuntime( + final ConfigurationService source, + @Nullable final ExposurePipeline exposures, + @Nullable final FlagEvaluationPipeline evaluations) { + this.source = source; + this.exposures = exposures; + this.evaluations = evaluations; + } + + /** + * Acquires the shared runtime, starting it on first acquisition. Each acquisition must be paired + * with a {@link #release()}. + * + * @param connector the connector to the Datadog Java agent. + * @param settings the SDK settings. + * @return the shared runtime. + * @throws IllegalStateException if Feature Flags is disabled or misconfigured. + */ + public static FeatureFlagsRuntime acquire(final Connector connector, final Settings settings) { + synchronized (SHARED_LOCK) { + if (shared == null) { + final FeatureFlagsRuntime created = create(connector, settings); + created.start(); + shared = created; + } + references++; + return shared; + } + } + + /** Releases the shared runtime, stopping it on last release. */ + public static void release() { + final FeatureFlagsRuntime stopped; + synchronized (SHARED_LOCK) { + if (references == 0 || --references > 0) { + return; + } + stopped = shared; + shared = null; + } + stopped.stop(); + } + + static FeatureFlagsRuntime create(final Connector connector, final Settings settings) { + final String sourceName = settings.configurationSource(); + if (sourceName == null) { + throw new IllegalStateException("Feature Flags is disabled"); + } + final boolean remoteConfig = CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(sourceName); + final RuntimeServices services = new RuntimeServices(connector.healthMetrics()); + final ConfigurationService source; + final EventTransport transport; + if (remoteConfig) { + // TODO Support Remote Configuration without the Datadog Java agent. + final ConfigurationSource remoteConfiguration = connector.remoteConfiguration(); + if (remoteConfiguration == null) { + throw new IllegalStateException( + "Feature Flags Remote Configuration source requires the Datadog Java agent with" + + " Remote Configuration enabled"); + } + source = new RemoteConfigurationService(remoteConfiguration); + // Remote Configuration never falls back to direct intake. + transport = connector.eventProxy(); + } else { + source = new CdnConfigurationSource(settings, services); + final EventTransport proxy = connector.eventProxy(); + final EventTransport direct = DirectIntakeTransport.create(settings); + transport = + proxy == null ? direct : direct == null ? proxy : new FallbackTransport(proxy, direct); + } + if (transport == null) { + LOGGER.warn( + "Feature Flags event delivery is disabled: set DD_API_KEY or attach the Datadog Java agent"); + return new FeatureFlagsRuntime(source, null, null); + } + final Map context = eventContext(settings); + final ExposurePipeline exposures = + new ExposurePipeline( + ExposurePipeline.DEFAULT_CAPACITY, + ExposurePipeline.DEFAULT_FLUSH_INTERVAL_IN_SECONDS, + TimeUnit.SECONDS, + transport, + context, + services); + final FlagEvaluationPipeline evaluations = + settings.isEvaluationCountsEnabled() + ? new FlagEvaluationPipeline( + FlagEvaluationPipeline.DEFAULT_CAPACITY, + FlagEvaluationPipeline.FLUSH_INTERVAL_SECONDS, + TimeUnit.SECONDS, + transport, + context, + services) + : null; + return new FeatureFlagsRuntime(source, exposures, evaluations); + } + + private static Map eventContext(final Settings settings) { + final Map context = new HashMap<>(); + putIfNotNull(context, "service", settings.service()); + putIfNotNull(context, "env", settings.env()); + putIfNotNull(context, "version", settings.version()); + return context; + } + + private static void putIfNotNull( + final Map map, final String key, @Nullable final String value) { + if (value != null) { + map.put(key, value); + } + } + + void start() { + try { + if (this.exposures != null) { + this.exposures.start(); + } + if (this.evaluations != null) { + this.evaluations.start(); + } + this.source.start(this::onConfiguration); + } catch (final RuntimeException | Error e) { + stop(); + throw e; + } + } + + void stop() { + this.source.close(); + if (this.evaluations != null) { + this.evaluations.close(); + } + if (this.exposures != null) { + this.exposures.close(); + } + } + + private void onConfiguration(@Nullable final ServerConfiguration configuration) { + this.configuration = configuration; + for (final Consumer listener : this.listeners) { + listener.accept(configuration); + } + } + + /** + * Adds a configuration listener, receiving the current configuration if any. + * + * @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); + } + } + + /** + * Removes a configuration listener. + * + * @param listener the listener to remove. + */ + public void removeConfigurationListener(final Consumer listener) { + this.listeners.remove(listener); + } + + /** + * Records an exposure, if exposure delivery is enabled. + * + * @param exposure the exposure to record. + */ + public void recordExposure(final ExposureEvent exposure) { + if (this.exposures != null) { + this.exposures.accept(exposure); + } + } + + /** + * @return the flag evaluation pipeline, or {@code null} if evaluation counts are disabled. + */ + @Nullable + public FlagEvaluationPipeline evaluations() { + return this.evaluations; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/JsonWriting.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/JsonWriting.java new file mode 100644 index 00000000000..807f76ab428 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/JsonWriting.java @@ -0,0 +1,96 @@ +package com.datadog.openfeature.internal; + +import com.fasterxml.jackson.core.JsonEncoding; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Collection; +import java.util.Map; +import javax.annotation.Nullable; + +/** JSON serialization helpers for event payloads. */ +public final class JsonWriting { + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + + private JsonWriting() {} + + /** + * Serializes a JSON document. + * + * @param writer the document writer. + * @return the UTF-8 encoded document. + */ + public static byte[] write(final Writer writer) { + final ByteArrayOutputStream output = new ByteArrayOutputStream(256); + try (JsonGenerator generator = JSON_FACTORY.createGenerator(output, JsonEncoding.UTF8)) { + writer.write(generator); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + return output.toByteArray(); + } + + /** + * Writes an arbitrary value: maps, collections, strings, numbers, booleans and {@code null}. + * Other values are written with their string representation. + * + * @param generator the generator to write to. + * @param value the value to write. + * @throws IOException if the value could not be written. + */ + public static void writeValue(final JsonGenerator generator, @Nullable final Object value) + throws IOException { + if (value == null) { + generator.writeNull(); + } else if (value instanceof String) { + generator.writeString((String) value); + } else if (value instanceof Boolean) { + generator.writeBoolean((Boolean) value); + } else if (value instanceof Integer || value instanceof Long || value instanceof Short) { + generator.writeNumber(((Number) value).longValue()); + } else if (value instanceof Number) { + generator.writeNumber(((Number) value).doubleValue()); + } else if (value instanceof Map) { + generator.writeStartObject(); + for (final Map.Entry entry : ((Map) value).entrySet()) { + generator.writeFieldName(String.valueOf(entry.getKey())); + writeValue(generator, entry.getValue()); + } + generator.writeEndObject(); + } else if (value instanceof Collection) { + generator.writeStartArray(); + for (final Object element : (Collection) value) { + writeValue(generator, element); + } + generator.writeEndArray(); + } else { + generator.writeString(value.toString()); + } + } + + /** + * Writes a {@code {"key": ...}} object field, skipped when the key is {@code null}. + * + * @param generator the generator to write to. + * @param fieldName the field name. + * @param key the key value. + * @throws IOException if the field could not be written. + */ + public static void writeKeyObject( + final JsonGenerator generator, final String fieldName, @Nullable final String key) + throws IOException { + if (key != null) { + generator.writeObjectFieldStart(fieldName); + generator.writeStringField("key", key); + generator.writeEndObject(); + } + } + + /** Writes a JSON document. */ + @FunctionalInterface + public interface Writer { + void write(JsonGenerator generator) throws IOException; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java new file mode 100644 index 00000000000..49cd87d9dcc --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java @@ -0,0 +1,40 @@ +package com.datadog.openfeature.internal; + +import com.datadog.openfeature.internal.connector.HealthMetrics; +import javax.annotation.Nullable; + +/** Thread creation and health metrics shared by the runtime components. */ +public final class RuntimeServices { + private final HealthMetrics healthMetrics; + + public RuntimeServices(final HealthMetrics healthMetrics) { + this.healthMetrics = healthMetrics; + } + + /** + * Creates a daemon thread. + * + * @param role the thread role, used to name the thread. + * @param task the thread task. + * @return the created thread, not started yet. + */ + public Thread newThread(final String role, final Runnable task) { + final Thread thread = new Thread(task, "dd-openfeature-" + role); + thread.setDaemon(true); + thread.setContextClassLoader(null); + return thread; + } + + /** + * Increments a health counter, skipping non-positive values. + * + * @param name the metric name. + * @param value the increment. + * @param reason the optional {@code reason} tag value. + */ + public void countMetric(final String name, final long value, @Nullable final String reason) { + if (value > 0) { + this.healthMetrics.count(name, value, reason); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java new file mode 100644 index 00000000000..b4ea3613ea9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java @@ -0,0 +1,31 @@ +package com.datadog.openfeature.internal.config; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** The version of this SDK, as written by the build. */ +public final class SdkVersion { + /** The SDK version, or {@code unknown} if the version file is missing. */ + public static final String VERSION = readVersion(); + + private SdkVersion() {} + + private static String readVersion() { + final InputStream stream = SdkVersion.class.getResourceAsStream("/dd-openfeature.version"); + if (stream == null) { + return "unknown"; + } + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + final String line = reader.readLine(); + // Strip the git hash suffix. + final int hash = line == null ? -1 : line.indexOf('~'); + return line == null ? "unknown" : hash < 0 ? line.trim() : line.substring(0, hash); + } catch (final IOException e) { + return "unknown"; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java new file mode 100644 index 00000000000..a175b5d43bf --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -0,0 +1,249 @@ +package com.datadog.openfeature.internal.config; + +import com.datadog.openfeature.internal.connector.Connector; +import de.thetaphi.forbiddenapis.SuppressForbidden; +import java.util.Locale; +import java.util.function.UnaryOperator; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Resolves the SDK settings. A setting is looked up from the {@link Connector} first, then from the + * {@code dd.}-prefixed system property, then from the {@code DD_}-prefixed environment variable. + */ +public final class Settings { + private static final Logger LOGGER = LoggerFactory.getLogger(Settings.class); + + public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; + public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; + + static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; + static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = "feature.flags.configuration.source"; + static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = + "experimental.flagging.provider.enabled"; + static final String AGENTLESS_BASE_URL = "feature.flags.configuration.source.agentless.base.url"; + static final String AGENTLESS_POLL_INTERVAL_SECONDS = + "feature.flags.configuration.source.agentless.poll.interval.seconds"; + static final String AGENTLESS_REQUEST_TIMEOUT_SECONDS = + "feature.flags.configuration.source.agentless.request.timeout.seconds"; + static final String SPAN_ENRICHMENT_ENABLED = + "experimental.flagging.provider.span.enrichment.enabled"; + static final String EVALUATION_COUNTS_ENABLED = "flagging.evaluation.counts.enabled"; + static final String API_KEY = "api-key"; + static final String SITE = "site"; + static final String ENV = "env"; + static final String SERVICE = "service"; + static final String VERSION = "version"; + + static final String DEFAULT_SITE = "datadoghq.com"; + static final int DEFAULT_POLL_INTERVAL_SECONDS = 30; + static final int DEFAULT_REQUEST_TIMEOUT_SECONDS = 5; + + private final Connector connector; + private final UnaryOperator systemProperties; + private final UnaryOperator environment; + + Settings( + final Connector connector, + final UnaryOperator systemProperties, + final UnaryOperator environment) { + this.connector = connector; + this.systemProperties = systemProperties; + this.environment = environment; + } + + /** + * Creates the settings backed by the given connector, system properties and environment. + * + * @param connector the connector to look settings up from first. + * @return the settings. + */ + // Allowing VM properties and environment variable access + // as the SDK does not ship the agent configuration helpers + @SuppressForbidden + public static Settings load(final Connector connector) { + return new Settings(connector, System::getProperty, System::getenv); + } + + /** + * @return whether Feature Flags is enabled. + */ + public boolean isEnabled() { + return resolveSource() != null; + } + + /** + * Resolves the configuration source, with the same semantics as the Datadog Java agent: an + * explicit {@code DD_FEATURE_FLAGS_ENABLED=false} disables the product, an explicit source wins + * over the legacy {@code DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED} toggle, and {@code agentless} + * is the default. + * + * @return the configuration source, or {@code null} if Feature Flags is disabled or the source is + * not supported. + */ + @Nullable + public String configurationSource() { + return resolveSource(); + } + + @Nullable + private String resolveSource() { + final Boolean enabled = getBoolean(FEATURE_FLAGS_ENABLED); + final String source = normalize(get(FEATURE_FLAGS_CONFIGURATION_SOURCE)); + if (Boolean.FALSE.equals(enabled)) { + return null; + } + if (source != null) { + if (CONFIGURATION_SOURCE_AGENTLESS.equals(source) + || CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(source)) { + return source; + } + LOGGER.warn("Unsupported Feature Flags configuration source: {}", source); + return null; + } + final Boolean legacyEnabled = getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); + if (legacyEnabled != null) { + return legacyEnabled ? CONFIGURATION_SOURCE_REMOTE_CONFIG : null; + } + return CONFIGURATION_SOURCE_AGENTLESS; + } + + /** + * @return the custom CDN base URL, or {@code null} to use the Datadog managed one. + */ + @Nullable + public String agentlessBaseUrl() { + final String url = get(AGENTLESS_BASE_URL); + return url == null || url.trim().isEmpty() ? null : url.trim(); + } + + /** + * @return the CDN poll interval, in seconds. + */ + public int agentlessPollIntervalSeconds() { + return getPositiveInt(AGENTLESS_POLL_INTERVAL_SECONDS, DEFAULT_POLL_INTERVAL_SECONDS); + } + + /** + * @return the CDN request timeout, in seconds. + */ + public int agentlessRequestTimeoutSeconds() { + return getPositiveInt(AGENTLESS_REQUEST_TIMEOUT_SECONDS, DEFAULT_REQUEST_TIMEOUT_SECONDS); + } + + /** + * @return whether span enrichment is enabled (off by default). + */ + public boolean isSpanEnrichmentEnabled() { + return Boolean.TRUE.equals(getBoolean(SPAN_ENRICHMENT_ENABLED)); + } + + /** + * @return whether flag evaluation counts are sent to the event platform (on by default). + */ + public boolean isEvaluationCountsEnabled() { + return !Boolean.FALSE.equals(getBoolean(EVALUATION_COUNTS_ENABLED)); + } + + /** + * @return the Datadog API key, or {@code null} if not set. + */ + @Nullable + public String apiKey() { + return nonBlank(get(API_KEY)); + } + + /** + * @return the Datadog site. + */ + public String site() { + final String site = nonBlank(get(SITE)); + return site == null ? DEFAULT_SITE : site; + } + + /** + * @return the environment, or {@code null} if not set. + */ + @Nullable + public String env() { + return nonBlank(get(ENV)); + } + + /** + * @return the service name, or {@code null} if not set. + */ + @Nullable + public String service() { + return nonBlank(get(SERVICE)); + } + + /** + * @return the application version, or {@code null} if not set. + */ + @Nullable + public String version() { + return nonBlank(get(VERSION)); + } + + @Nullable + String get(final String key) { + String value = this.connector.setting(key); + if (value == null) { + value = this.systemProperties.apply("dd." + key); + } + if (value == null) { + value = this.environment.apply(environmentVariableName(key)); + } + return value; + } + + @Nullable + private Boolean getBoolean(final String key) { + final String value = normalize(get(key)); + if (value == null) { + return null; + } + // Same parsing as the agent: invalid values are read as false. + if ("true".equals(value) || "1".equals(value)) { + return Boolean.TRUE; + } + if (!"false".equals(value) && !"0".equals(value)) { + LOGGER.warn("Invalid boolean value for {}: {}", key, value); + } + return Boolean.FALSE; + } + + private int getPositiveInt(final String key, final int defaultValue) { + final String value = nonBlank(get(key)); + if (value == null) { + return defaultValue; + } + try { + final int parsed = Integer.parseInt(value); + return parsed > 0 ? parsed : defaultValue; + } catch (final NumberFormatException e) { + LOGGER.warn("Invalid value for {}: {}", key, value); + return defaultValue; + } + } + + static String environmentVariableName(final String key) { + return "DD_" + key.replace('.', '_').replace('-', '_').toUpperCase(Locale.ROOT); + } + + @Nullable + private static String normalize(@Nullable final String value) { + final String trimmed = nonBlank(value); + return trimmed == null ? null : trimmed.toLowerCase(Locale.ROOT); + } + + @Nullable + private static String nonBlank(@Nullable final String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java new file mode 100644 index 00000000000..21307031fe7 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java @@ -0,0 +1,15 @@ +package com.datadog.openfeature.internal.connector; + +import java.util.function.Consumer; + +/** A source of Universal Flag Configuration (UFC) documents. */ +public interface ConfigurationSource { + /** + * Subscribes to configuration updates. + * + * @param listener the listener receiving raw UFC JSON documents, or {@code null} when the + * configuration is removed. + * @return the handle to close to unsubscribe. + */ + AutoCloseable subscribe(Consumer listener); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java new file mode 100644 index 00000000000..d67c36f9fb7 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java @@ -0,0 +1,41 @@ +package com.datadog.openfeature.internal.connector; + +import javax.annotation.Nullable; + +/** Components supplied by the Datadog Java agent when attached, or no-op ones otherwise. */ +public interface Connector { + /** The connector used when no Datadog Java agent is attached. */ + Connector NONE = new NoConnector(); + + /** + * Looks up a setting from the agent configuration. + * + * @param key the setting key, using the {@code dd.} system property notation without the prefix. + * @return the setting value, or {@code null} to fall back to system properties and environment. + */ + @Nullable + String setting(String key); + + /** + * @return the Remote Configuration source, or {@code null} if not available. + */ + @Nullable + ConfigurationSource remoteConfiguration(); + + /** + * @return the transport through the Datadog Agent event platform proxy, or {@code null} if not + * available. + */ + @Nullable + EventTransport eventProxy(); + + /** + * @return the health metrics sink. + */ + HealthMetrics healthMetrics(); + + /** + * @return the span enricher. + */ + SpanEnricher spanEnricher(); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java new file mode 100644 index 00000000000..cf9be77a214 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java @@ -0,0 +1,16 @@ +package com.datadog.openfeature.internal.connector; + +/** Resolves the {@link Connector} to use. */ +public final class Connectors { + private Connectors() {} + + /** + * Detects the connector to use. The Datadog Java agent instruments this method to return its own + * connector. + * + * @return the connector to use. + */ + public static Connector detect() { + return Connector.NONE; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java new file mode 100644 index 00000000000..c85887214fd --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java @@ -0,0 +1,13 @@ +package com.datadog.openfeature.internal.connector; + +import java.io.IOException; + +/** + * Thrown by an event proxy {@link EventTransport} when the proxy definitively rejects the payload + * as unavailable, so the SDK can fall back to direct intake. + */ +public final class EventProxyUnavailableException extends IOException { + public EventProxyUnavailableException(final String message) { + super(message); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java new file mode 100644 index 00000000000..0bdfb355590 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java @@ -0,0 +1,16 @@ +package com.datadog.openfeature.internal.connector; + +import java.io.IOException; + +/** Delivers serialized product events to the Datadog event platform. */ +@FunctionalInterface +public interface EventTransport { + /** + * Posts a JSON payload. + * + * @param route the event platform route, like {@code exposures} or {@code flagevaluation}. + * @param json the UTF-8 JSON payload. + * @throws IOException if the payload could not be delivered. + */ + void post(String route, byte[] json) throws IOException; +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java new file mode 100644 index 00000000000..fcb0e99f558 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java @@ -0,0 +1,19 @@ +package com.datadog.openfeature.internal.connector; + +import javax.annotation.Nullable; + +/** Records SDK health counters. */ +@FunctionalInterface +public interface HealthMetrics { + /** The sink discarding all counters. */ + HealthMetrics NOOP = (metric, value, reason) -> {}; + + /** + * Increments a counter. + * + * @param metric the metric name. + * @param value the increment. + * @param reason the optional {@code reason} tag value. + */ + void count(String metric, long value, @Nullable String reason); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java new file mode 100644 index 00000000000..1f48b8b05ff --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java @@ -0,0 +1,33 @@ +package com.datadog.openfeature.internal.connector; + +import javax.annotation.Nullable; + +final class NoConnector implements Connector { + @Nullable + @Override + public String setting(final String key) { + return null; + } + + @Nullable + @Override + public ConfigurationSource remoteConfiguration() { + return null; + } + + @Nullable + @Override + public EventTransport eventProxy() { + return null; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java new file mode 100644 index 00000000000..2d744b891f2 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java @@ -0,0 +1,34 @@ +package com.datadog.openfeature.internal.connector; + +import javax.annotation.Nullable; + +/** Reflects flag evaluations onto the active local root span. */ +public interface SpanEnricher { + /** The enricher discarding all evaluations. */ + SpanEnricher NOOP = + new SpanEnricher() { + @Override + public void serialId( + final int serialId, final boolean doLog, @Nullable final String targetingKey) {} + + @Override + public void runtimeDefault(final String flagKey, @Nullable final Object defaultValue) {} + }; + + /** + * 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. + */ + void serialId(int serialId, boolean doLog, @Nullable String targetingKey); + + /** + * Records an evaluation that resolved to its runtime default value. + * + * @param flagKey the flag key. + * @param defaultValue the default value, unwrapped to a native Java type (map, list or scalar). + */ + void runtimeDefault(String flagKey, @Nullable Object defaultValue); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java new file mode 100644 index 00000000000..98469feaeb0 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java @@ -0,0 +1,8 @@ +/** + * Extension points the Datadog Java agent fills in through instrumentation. + * + *

    These types are not customer API. They form the contract between this SDK and the agent + * instrumentation, which can run a different version than the SDK, so they must evolve in a + * backward compatible way. + */ +package com.datadog.openfeature.internal.connector; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java new file mode 100644 index 00000000000..ae14f2bb224 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java @@ -0,0 +1,342 @@ +package com.datadog.openfeature.internal.delivery; + +import com.datadog.openfeature.internal.ConfigurationService; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +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.function.Consumer; +import java.util.function.DoubleSupplier; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Polls the flag configuration from the Datadog managed CDN, or a custom HTTP endpoint. */ +public final class CdnConfigurationSource implements ConfigurationService { + private static final Logger LOGGER = LoggerFactory.getLogger(CdnConfigurationSource.class); + + static final String UFC_RULES_BASED_SERVER_PATH = + "/api/v2/feature-flagging/config/rules-based/server"; + static final int MAX_ATTEMPTS = 3; + 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 static final long WARNING_INTERVAL_NANOS = TimeUnit.MINUTES.toNanos(5); + + private final URI endpoint; + @Nullable private final String apiKey; + private final long pollIntervalMillis; + private final Duration requestTimeout; + private final HttpClient httpClient; + private final ScheduledExecutorService executor; + private final RetrySleeper retrySleeper; + private final DoubleSupplier jitter; + private final Object lifecycleLock = new Object(); + private final AtomicBoolean polling = new AtomicBoolean(); + private volatile Consumer listener; + private volatile boolean closed; + private volatile boolean started; + private volatile ScheduledFuture scheduledPoll; + private volatile Thread pollingThread; + private volatile String etag; + private long lastWarningNanos; + + public CdnConfigurationSource(final Settings settings, final RuntimeServices services) { + this( + endpoint(settings), + isManagedEndpoint(settings) ? settings.apiKey() : null, + TimeUnit.SECONDS.toMillis(settings.agentlessPollIntervalSeconds()), + Duration.ofSeconds(settings.agentlessRequestTimeoutSeconds()), + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(settings.agentlessRequestTimeoutSeconds())) + .followRedirects(HttpClient.Redirect.NEVER) + .build(), + Executors.newSingleThreadScheduledExecutor( + task -> services.newThread("configuration", task)), + TimeUnit.MILLISECONDS::sleep, + () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)); + } + + CdnConfigurationSource( + final URI endpoint, + @Nullable final String apiKey, + final long pollIntervalMillis, + final Duration requestTimeout, + final HttpClient httpClient, + final ScheduledExecutorService executor, + final RetrySleeper retrySleeper, + final DoubleSupplier jitter) { + this.endpoint = endpoint; + this.apiKey = apiKey; + this.pollIntervalMillis = pollIntervalMillis; + this.requestTimeout = requestTimeout; + this.httpClient = httpClient; + this.executor = executor; + this.retrySleeper = retrySleeper; + this.jitter = jitter; + } + + @Override + public void start(final Consumer listener) { + synchronized (this.lifecycleLock) { + if (this.closed || this.started) { + return; + } + this.started = true; + this.listener = listener; + } + // Complete the first poll cycle on the calling thread so provider initialization observes a + // successful retry before it checks whether configuration is ready. + pollOnceSafely(); + synchronized (this.lifecycleLock) { + if (!this.closed) { + this.scheduledPoll = + this.executor.scheduleWithFixedDelay( + this::pollOnceSafely, + this.pollIntervalMillis, + this.pollIntervalMillis, + TimeUnit.MILLISECONDS); + } + } + } + + @Override + public void close() { + final ScheduledFuture poll; + synchronized (this.lifecycleLock) { + if (this.closed) { + return; + } + this.closed = true; + poll = this.scheduledPoll; + this.scheduledPoll = null; + } + if (poll != null) { + poll.cancel(true); + } + final Thread activePollingThread = this.pollingThread; + if (activePollingThread != null) { + activePollingThread.interrupt(); + } + this.executor.shutdownNow(); + } + + /** Test seam: sets the configuration listener without starting the polling. */ + void setListenerForTest(final Consumer listener) { + this.listener = listener; + } + + /** + * Polls the endpoint once. + * + * @return {@code true} if the configuration was applied or is unchanged, {@code false} otherwise. + */ + boolean pollOnce() { + if (this.closed || !this.polling.compareAndSet(false, true)) { + return false; + } + this.pollingThread = Thread.currentThread(); + try { + return fetchAndApply(); + } finally { + this.pollingThread = null; + this.polling.set(false); + } + } + + private void pollOnceSafely() { + try { + pollOnce(); + } catch (final RuntimeException e) { + LOGGER.debug("Unexpected error while polling the Feature Flags configuration", e); + } + } + + private boolean fetchAndApply() { + final HttpResponse response; + try { + response = fetchWithRetries(); + } catch (final IOException e) { + if (!this.closed) { + warn("Feature Flags configuration request failed after {} attempts", MAX_ATTEMPTS, e); + } + return false; + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + if (this.closed) { + return false; + } + final int status = response.statusCode(); + if (isRetryableStatus(status)) { + warn( + "Feature Flags configuration endpoint failed after {} attempts with HTTP {}", + MAX_ATTEMPTS, + status); + return false; + } + synchronized (this.lifecycleLock) { + return !this.closed && apply(response); + } + } + + private boolean apply(final HttpResponse response) { + final int status = response.statusCode(); + if (status == HttpURLConnection.HTTP_NOT_MODIFIED) { + return true; + } + if (status == HttpURLConnection.HTTP_UNAUTHORIZED + || status == HttpURLConnection.HTTP_FORBIDDEN) { + warn( + "Feature Flags configuration endpoint returned HTTP {}; verify endpoint authentication", + status); + return false; + } + if (status != HttpURLConnection.HTTP_OK || response.body() == null) { + return false; + } + final ServerConfiguration configuration; + try { + configuration = UniversalFlagConfigParser.parseJsonApi(Gzip.decode(response)); + } catch (final IOException | RuntimeException e) { + LOGGER.debug("Feature Flags configuration endpoint returned a malformed payload", e); + return false; + } + if (configuration == null) { + return false; + } + this.listener.accept(configuration); + final String nextEtag = response.headers().firstValue("ETag").orElse(null); + this.etag = nextEtag == null || nextEtag.trim().isEmpty() ? null : nextEtag; + return true; + } + + private HttpResponse fetchWithRetries() throws IOException, InterruptedException { + final HttpRequest request = buildRequest(); + int attempt = 0; + while (true) { + attempt++; + try { + final HttpResponse response = + this.httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (!isRetryableStatus(response.statusCode()) || attempt == MAX_ATTEMPTS || this.closed) { + return response; + } + } catch (final IOException e) { + if (attempt == MAX_ATTEMPTS || this.closed || Thread.currentThread().isInterrupted()) { + throw e; + } + } + this.retrySleeper.sleep( + retryDelayMillis(this.pollIntervalMillis, attempt, this.jitter.getAsDouble())); + } + } + + private HttpRequest buildRequest() { + final HttpRequest.Builder request = + HttpRequest.newBuilder(this.endpoint) + .timeout(this.requestTimeout) + .header("Accept-Encoding", "gzip"); + Headers.addMetadata(request); + if (this.apiKey != null) { + request.header("DD-API-KEY", this.apiKey); + } + final String currentEtag = this.etag; + if (currentEtag != null) { + request.header("If-None-Match", currentEtag); + } + return request.GET().build(); + } + + private synchronized void warn(final String message, final Object... arguments) { + final long now = System.nanoTime(); + if (this.lastWarningNanos == 0 || now - this.lastWarningNanos >= WARNING_INTERVAL_NANOS) { + this.lastWarningNanos = now; + LOGGER.warn(message, arguments); + } else { + LOGGER.debug(message, arguments); + } + } + + static boolean isRetryableStatus(final int status) { + return status == HttpURLConnection.HTTP_CLIENT_TIMEOUT + || status == 429 + || (status >= 500 && status <= 599); + } + + static URI endpoint(final Settings settings) { + final String configuredBaseUrl = settings.agentlessBaseUrl(); + if (configuredBaseUrl == null) { + final String env = settings.env(); + return URI.create( + "https://" + + managedHost(settings) + + UFC_RULES_BASED_SERVER_PATH + + (env == null ? "" : "?dd_env=" + URLEncoder.encode(env, StandardCharsets.UTF_8))); + } + final URI parsed; + try { + parsed = URI.create(configuredBaseUrl); + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid Feature Flags configuration source URL: " + configuredBaseUrl, e); + } + if (parsed.getScheme() == null || parsed.getHost() == null) { + throw new IllegalArgumentException( + "Invalid Feature Flags configuration source URL: " + configuredBaseUrl); + } + final String path = parsed.getRawPath(); + if (path == null || path.isEmpty() || "/".equals(path)) { + return parsed.resolve(UFC_RULES_BASED_SERVER_PATH); + } + return parsed; + } + + private static boolean isManagedEndpoint(final Settings settings) { + return settings.agentlessBaseUrl() == null; + } + + private static String managedHost(final Settings settings) { + return "ufc-server.ff-cdn." + settings.site(); + } + + 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 Flags 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)); + } + + @FunctionalInterface + interface RetrySleeper { + void sleep(long delayMillis) throws InterruptedException; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java new file mode 100644 index 00000000000..3d63e5e295c --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java @@ -0,0 +1,103 @@ +package com.datadog.openfeature.internal.delivery; + +import static java.net.http.HttpClient.Redirect.NEVER; +import static java.net.http.HttpRequest.BodyPublishers.ofByteArray; + +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.EventTransport; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; + +/** Posts events directly to the Datadog event platform intake, authenticated with an API key. */ +public final class DirectIntakeTransport implements EventTransport { + static final int MAX_RETRIES = 5; + private static final long INITIAL_RETRY_DELAY_MILLIS = 100; + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); + + private final URI intake; + private final String apiKey; + private final String traceId; + private final HttpClient httpClient; + private final CdnConfigurationSource.RetrySleeper retrySleeper; + + DirectIntakeTransport( + final URI intake, + final String apiKey, + final HttpClient httpClient, + final CdnConfigurationSource.RetrySleeper retrySleeper) { + this.intake = intake; + this.apiKey = apiKey; + // Intake requests are attributed to a stable, random trace id. + this.traceId = Long.toUnsignedString(ThreadLocalRandom.current().nextLong() >>> 1); + this.httpClient = httpClient; + this.retrySleeper = retrySleeper; + } + + /** + * Creates a direct intake transport. + * + * @param settings the SDK settings. + * @return the transport, or {@code null} if no API key is configured. + */ + @Nullable + public static DirectIntakeTransport create(final Settings settings) { + final String apiKey = settings.apiKey(); + if (apiKey == null) { + return null; + } + return new DirectIntakeTransport( + URI.create("https://event-platform-intake." + settings.site() + "/api/v2/"), + apiKey, + HttpClient.newBuilder().connectTimeout(REQUEST_TIMEOUT).followRedirects(NEVER).build(), + TimeUnit.MILLISECONDS::sleep); + } + + @Override + public void post(final String route, final byte[] json) throws IOException { + final HttpRequest.Builder builder = + HttpRequest.newBuilder(this.intake.resolve(route)) + .timeout(REQUEST_TIMEOUT) + .header("Content-Type", "application/json") + .header("dd-api-key", this.apiKey) + .header("x-datadog-trace-id", this.traceId) + .header("x-datadog-parent-id", this.traceId); + Headers.addMetadata(builder); + final HttpRequest request = builder.POST(ofByteArray(json)).build(); + IOException failure = null; + long delayMillis = INITIAL_RETRY_DELAY_MILLIS; + for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + if (attempt > 0) { + this.retrySleeper.sleep(delayMillis); + delayMillis *= 2; + } + final int status = + this.httpClient.send(request, HttpResponse.BodyHandlers.discarding()).statusCode(); + if (status >= 200 && status < 300) { + return; + } + failure = new IOException("Request to " + route + " returned error response " + status); + if (!isRetryableStatus(status)) { + break; + } + } catch (final IOException e) { + failure = e; + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Request to " + route + " was interrupted", e); + } + } + throw failure; + } + + static boolean isRetryableStatus(final int status) { + return status == 429 || status >= 500; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java new file mode 100644 index 00000000000..fe26bb6d31f --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java @@ -0,0 +1,39 @@ +package com.datadog.openfeature.internal.delivery; + +import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; +import com.datadog.openfeature.internal.connector.EventTransport; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Posts through the event proxy, switching to direct intake for good once the proxy is definitively + * unavailable. Ambiguous proxy failures are not replayed through direct intake. + */ +public final class FallbackTransport implements EventTransport { + private static final Logger LOGGER = LoggerFactory.getLogger(FallbackTransport.class); + + private final EventTransport proxy; + private final EventTransport direct; + private volatile boolean proxyUnavailable; + + public FallbackTransport(final EventTransport proxy, final EventTransport direct) { + this.proxy = proxy; + this.direct = direct; + } + + @Override + public void post(final String route, final byte[] json) throws IOException { + if (!this.proxyUnavailable) { + try { + this.proxy.post(route, json); + return; + } catch (final EventProxyUnavailableException e) { + LOGGER.debug( + "Switching Feature Flags event delivery from the event proxy to direct intake"); + this.proxyUnavailable = true; + } + } + this.direct.post(route, json); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Gzip.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Gzip.java new file mode 100644 index 00000000000..0525ebd397d --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Gzip.java @@ -0,0 +1,34 @@ +package com.datadog.openfeature.internal.delivery; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.http.HttpResponse; +import java.util.zip.GZIPInputStream; + +/** Gzip decoding, as the JDK HTTP client does not handle content encoding. */ +final class Gzip { + private Gzip() {} + + /** + * Decodes a response body according to its {@code Content-Encoding} header. + * + * @param response the response to decode the body of. + * @return the decoded body. + * @throws IOException if the body is not valid gzip content. + */ + static byte[] decode(final HttpResponse response) throws IOException { + final boolean gzipped = + response + .headers() + .firstValue("Content-Encoding") + .map("gzip"::equalsIgnoreCase) + .orElse(false); + if (!gzipped) { + return response.body(); + } + try (InputStream input = new GZIPInputStream(new ByteArrayInputStream(response.body()))) { + return input.readAllBytes(); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java new file mode 100644 index 00000000000..e61ab4735a0 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java @@ -0,0 +1,27 @@ +package com.datadog.openfeature.internal.delivery; + +import com.datadog.openfeature.internal.config.SdkVersion; +import java.net.http.HttpRequest; + +/** Common Datadog request headers. */ +final class Headers { + private static final String JAVA_VERSION = System.getProperty("java.version", "unknown"); + private static final String JAVA_VM_NAME = System.getProperty("java.vm.name", "unknown"); + private static final String JAVA_VM_VENDOR = System.getProperty("java.vm.vendor", "unknown"); + + private Headers() {} + + /** + * Adds the language and SDK metadata headers. + * + * @param request the request to add the headers to. + */ + static void addMetadata(final HttpRequest.Builder request) { + request + .header("Datadog-Meta-Lang", "java") + .header("Datadog-Meta-Lang-Version", JAVA_VERSION) + .header("Datadog-Meta-Lang-Interpreter", JAVA_VM_NAME) + .header("Datadog-Meta-Lang-Interpreter-Vendor", JAVA_VM_VENDOR) + .header("Datadog-Meta-Tracer-Version", SdkVersion.VERSION); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java new file mode 100644 index 00000000000..04c0722e9a8 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java @@ -0,0 +1,57 @@ +package com.datadog.openfeature.internal.delivery; + +import com.datadog.openfeature.internal.ConfigurationService; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; +import java.io.IOException; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Receives the flag configuration from Remote Configuration, through the Datadog Java agent. */ +public final class RemoteConfigurationService implements ConfigurationService { + private static final Logger LOGGER = LoggerFactory.getLogger(RemoteConfigurationService.class); + + private final ConfigurationSource source; + private AutoCloseable subscription; + + public RemoteConfigurationService(final ConfigurationSource source) { + this.source = source; + } + + @Override + public synchronized void start(final Consumer listener) { + if (this.subscription != null) { + return; + } + this.subscription = + this.source.subscribe( + content -> { + if (content == null) { + listener.accept(null); + return; + } + try { + final ServerConfiguration configuration = UniversalFlagConfigParser.parse(content); + if (configuration != null) { + listener.accept(configuration); + } + } catch (final IOException | RuntimeException e) { + LOGGER.debug("Remote Configuration returned a malformed flag configuration", e); + } + }); + } + + @Override + public synchronized void close() { + if (this.subscription != null) { + try { + this.subscription.close(); + } catch (final Exception e) { + LOGGER.debug("Failed to unsubscribe from Remote Configuration", e); + } + this.subscription = null; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java new file mode 100644 index 00000000000..391912dd226 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java @@ -0,0 +1,32 @@ +package com.datadog.openfeature.internal.evaluation; + +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** Atomically publishes one validated snapshot for synchronous evaluation. */ +public final class ConfigurationStore { + private final AtomicReference current = new AtomicReference<>(); + private final CountDownLatch ready = new CountDownLatch(1); + + public ServerConfiguration get() { + return current.get(); + } + + public void set(ServerConfiguration configuration) { + current.set(configuration); + } + + public void markReady() { + ready.countDown(); + } + + public boolean wasReady() { + return ready.getCount() == 0; + } + + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return ready.await(timeout, unit); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java new file mode 100644 index 00000000000..0f4112b2f3d --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java @@ -0,0 +1,10 @@ +package com.datadog.openfeature.internal.evaluation; + +/** Lazy application context; adapters retain their public SDK's value-conversion rules. */ +public interface EvaluationContext { + String getTargetingKey(); + + boolean hasAttribute(String name); + + Object attribute(String name); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java new file mode 100644 index 00000000000..b1b17d91501 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java @@ -0,0 +1,50 @@ +package com.datadog.openfeature.internal.evaluation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Immutable evaluation metadata, independent of the public OpenFeature SDK. */ +public final class EvaluationMetadata { + private final Map values; + + private EvaluationMetadata(final Map values) { + this.values = Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } + + public Map values() { + return values; + } + + public static EvaluationMetadataBuilder builder() { + return new EvaluationMetadataBuilder(); + } + + public static final class EvaluationMetadataBuilder { + private final Map values = new LinkedHashMap<>(); + + public EvaluationMetadataBuilder addString(String key, String value) { + values.put(key, value); + return this; + } + + public EvaluationMetadataBuilder addBoolean(String key, boolean value) { + values.put(key, value); + return this; + } + + public EvaluationMetadataBuilder addInteger(String key, int value) { + values.put(key, value); + return this; + } + + public EvaluationMetadataBuilder addLong(String key, long value) { + values.put(key, value); + return this; + } + + public EvaluationMetadata build() { + return new EvaluationMetadata(values); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java new file mode 100644 index 00000000000..e83edfd9355 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java @@ -0,0 +1,137 @@ +package com.datadog.openfeature.internal.evaluation; + +/** A resolved value plus product metadata. Transport and SDK adapters consume this result. */ +public final class EvaluationResult { + public enum ErrorCode { + PROVIDER_NOT_READY, + INVALID_CONTEXT, + FLAG_NOT_FOUND, + TARGETING_KEY_MISSING, + TYPE_MISMATCH, + PARSE_ERROR, + GENERAL + } + + public enum Reason { + DISABLED, + DEFAULT, + ERROR, + TARGETING_MATCH, + SPLIT, + STATIC + } + + private final T value; + private final String reason; + private final String variant; + private final ErrorCode errorCode; + private final String errorMessage; + private final EvaluationMetadata metadata; + private final boolean logExposure; + private final Integer serialId; + + private EvaluationResult(final Builder builder) { + value = builder.value; + reason = builder.reason; + variant = builder.variant; + errorCode = builder.errorCode; + errorMessage = builder.errorMessage; + metadata = builder.metadata; + logExposure = builder.logExposure; + serialId = builder.serialId; + } + + public T getValue() { + return value; + } + + public String getReason() { + return reason; + } + + public String getVariant() { + return variant; + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public EvaluationMetadata getFlagMetadata() { + return metadata; + } + + public boolean isLogExposure() { + return logExposure; + } + + /** + * @return the serial id of the resolved split, or {@code null} if absent. + */ + public Integer getSerialId() { + return serialId; + } + + public static Builder builder() { + return new Builder<>(); + } + + public static final class Builder { + private T value; + private String reason; + private String variant; + private ErrorCode errorCode; + private String errorMessage; + private EvaluationMetadata metadata; + private boolean logExposure; + private Integer serialId; + + public Builder value(T value) { + this.value = value; + return this; + } + + public Builder reason(String reason) { + this.reason = reason; + return this; + } + + public Builder variant(String variant) { + this.variant = variant; + return this; + } + + public Builder errorCode(ErrorCode errorCode) { + this.errorCode = errorCode; + return this; + } + + public Builder errorMessage(String errorMessage) { + this.errorMessage = errorMessage; + return this; + } + + public Builder flagMetadata(EvaluationMetadata metadata) { + this.metadata = metadata; + return this; + } + + public Builder serialId(Integer serialId) { + this.serialId = serialId; + return this; + } + + public Builder logExposure(boolean logExposure) { + this.logExposure = logExposure; + return this; + } + + public EvaluationResult build() { + return new EvaluationResult<>(this); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java new file mode 100644 index 00000000000..ac0c16675b5 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -0,0 +1,547 @@ +package com.datadog.openfeature.internal.evaluation; + +import static java.util.Arrays.asList; + +import com.datadog.openfeature.internal.evaluation.EvaluationResult.ErrorCode; +import com.datadog.openfeature.internal.evaluation.EvaluationResult.Reason; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ConditionConfiguration; +import com.datadog.openfeature.internal.ufc.ConditionOperator; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ParsedSemver; +import com.datadog.openfeature.internal.ufc.Rule; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Shard; +import com.datadog.openfeature.internal.ufc.ShardRange; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Local evaluation over a single configuration snapshot. No transport or OpenFeature dependency. + */ +public final class FlagEvaluator { + private static final Set> SUPPORTED_RESOLUTION_TYPES = + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Object.class)); + private static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = + "observe_full_evaluation_data"; + private static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; + private static final String METADATA_DO_LOG = "__dd_do_log"; + private final boolean spanEnrichmentEnabled; + + public FlagEvaluator(final boolean spanEnrichmentEnabled) { + this.spanEnrichmentEnabled = spanEnrichmentEnabled; + } + + public EvaluationResult evaluate( + final ServerConfiguration config, + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + // Snapshot the config once and thread observeFullEvaluationData through every + // EvaluationResult returned, so the hook's consent decision is pinned to this evaluation's + // config and cannot drift on a concurrent Remote Config swap. + // 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 EvaluationResult.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 EvaluationResult.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 EvaluationMetadata consentMetadata(final boolean observeFullEvaluationData) { + return EvaluationMetadata.builder() + .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .build(); + } + + private static EvaluationResult 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 EvaluationResult.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 EvaluationResult.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(); + } + + public static boolean isAllocationActive(final Allocation allocation, final Instant now) { + final Instant startDate = allocation.startAt; + if (startDate != null && now.isBefore(startDate)) { + return false; + } + + final Instant endDate = allocation.endAt; + 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) { + final long assignedShard = getShard(shard.salt, targetingKey, shard.totalShards); + for (final ShardRange range : shard.ranges) { + if (assignedShard >= range.start && assignedShard < range.end) { + return true; + } + } + return false; + } + + private static 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 EvaluationResult 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 EvaluationMetadata.EvaluationMetadataBuilder metadataBuilder = + EvaluationMetadata.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 — + // only when span enrichment is on, so a provider without enrichment pays nothing extra. + // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always + // present (when enrichment is on) so the span-enrichment hook can decide whether to record the + // subject. + if (spanEnrichmentEnabled) { + if (split.serialId != null) { + metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); + } + metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); + } + final EvaluationResult result = + EvaluationResult.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()) + .logExposure(Boolean.TRUE.equals(allocation.doLog)) + .serialId(split.serialId) + .build(); + return result; + } + + private static Object resolveAttribute(final String name, final EvaluationContext context) { + // Special handling for "id" attribute: if not explicitly provided, use targeting key + if ("id".equals(name) && !context.hasAttribute(name)) { + return context.getTargetingKey(); + } + return context.attribute(name); + } + + public 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 == Object.class; + default: + return true; // Unknown types pass through — mapValue errors caught as GENERAL + } + } + + @SuppressWarnings("unchecked") + public 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; + } + + private static Double parseDouble(final Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return Double.parseDouble(String.valueOf(value)); + } + + @FunctionalInterface + private interface NumberComparator { + boolean compare(double a, double b); + } + + @FunctionalInterface + private interface SemverComparator { + boolean compare(int ordering); + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java similarity index 70% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java index 53df4b4b512..a4d40e9e3c3 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.exposure; +package com.datadog.openfeature.internal.exposure; public class Allocation { public final String key; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java similarity index 94% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java index 7125d009a69..0ce6e6c31d9 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java @@ -1,6 +1,5 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.exposure; -import datadog.trace.api.featureflag.exposure.ExposureEvent; import java.util.Objects; public interface ExposureCache { diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java similarity index 70% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java index 4101b767760..a49811908ba 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.exposure; +package com.datadog.openfeature.internal.exposure; public class ExposureEvent { // milliseconds since epoch as given by System.currentTimeMillis() @@ -7,18 +7,8 @@ public class ExposureEvent { public final Flag flag; public final Variant variant; public final Subject subject; - public final Integer serial_id; - 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, diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java similarity index 51% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java index fcd50e5dc34..c0385845ce2 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java @@ -1,81 +1,48 @@ -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.newAgentThread; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.common.queue.MessagePassingBlockingQueue; -import datadog.common.queue.Queues; -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; +package com.datadog.openfeature.internal.exposure; + +import com.datadog.openfeature.internal.EventQueue; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class ExposureWriterImpl implements ExposureWriter { +/** Deduplicates and batches exposure events to the event platform {@code exposures} route. */ +public class ExposurePipeline implements Consumer, AutoCloseable { - 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 Logger LOGGER = LoggerFactory.getLogger(ExposurePipeline.class); + public static final int DEFAULT_CAPACITY = 1 << 16; // 65536 elements + public static final int DEFAULT_FLUSH_INTERVAL_IN_SECONDS = 1; private static final int FLUSH_THRESHOLD = 100; private static final String EXPOSURES_ROUTE = "exposures"; - private final MessagePassingBlockingQueue queue; + private final EventQueue queue; private final Thread serializerThread; - public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config config) { - this(DEFAULT_CAPACITY, DEFAULT_FLUSH_INTERVAL_IN_SECONDS, SECONDS, sco, 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( + public ExposurePipeline( final int capacity, final long flushInterval, final TimeUnit timeUnit, - final FeatureFlagBackendApiFactory backendApiFactory, - final Config config) { - this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this.queue = new EventQueue<>(capacity); final ExposureSerializingHandler serializer = - new ExposureSerializingHandler( - backendApiFactory, - queue, - flushInterval, - timeUnit, - FeatureFlagEvpContext.from(config), - this::close); - this.serializerThread = newAgentThread(FEATURE_FLAG_EXPOSURE_PROCESSOR, serializer); + new ExposureSerializingHandler(transport, queue, flushInterval, timeUnit, context); + this.serializerThread = services.newThread("exposures", serializer); } - @Override - public void init() { - FeatureFlaggingGateway.addExposureListener(this); + /** Starts the background serializing thread. */ + public void start() { this.serializerThread.start(); } @Override public void close() { - FeatureFlaggingGateway.removeExposureListener(this); if (this.serializerThread.isAlive()) { this.serializerThread.interrupt(); } @@ -86,56 +53,44 @@ public void accept(final ExposureEvent event) { 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 EventQueue queue; private final long ticksRequiredToFlush; private long lastTicks; - private final FeatureFlagEvpPublisher evpPublisher; + private final EventTransport transport; private final Map context; private final ExposureCache cache; private final List buffer = new ArrayList<>(); - private final Runnable errorCallback; ExposureSerializingHandler( - final FeatureFlagBackendApiFactory backendApiFactory, - final MessagePassingBlockingQueue queue, + final EventTransport transport, + final EventQueue queue, final long flushInterval, final TimeUnit timeUnit, - final Map context, - final Runnable errorCallback) { + final Map context) { this.queue = queue; this.cache = new LRUExposureCache(queue.capacity()); - this.evpPublisher = - new FeatureFlagEvpPublisher<>(backendApiFactory::create, ExposuresRequest.class); + this.transport = transport; this.context = context; this.lastTicks = System.nanoTime(); this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); - this.errorCallback = errorCallback; - LOGGER.debug("starting exposure serializer"); } @Override public void run() { - if (!evpPublisher.start()) { - errorCallback.run(); - LOGGER.warn("Feature Flagging exposure delivery is disabled"); - return; - } try { runDutyCycle(); } catch (InterruptedException e) { @@ -179,19 +134,18 @@ protected void flushIfNecessary() { final byte[] payload; try { final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); - payload = evpPublisher.serialize(exposures); + payload = exposures.serialize(); } catch (RuntimeException e) { - LOGGER.error(EXCLUDE_TELEMETRY, "Could not serialize exposures; dropping batch", e); + LOGGER.error("Could not serialize exposures; dropping batch", e); this.buffer.clear(); return; } try { - evpPublisher.post(EXPOSURES_ROUTE, payload); + transport.post(EXPOSURES_ROUTE, payload); } 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. + // Best-effort delivery must not retry an ambiguously accepted batch. this.buffer.clear(); } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java new file mode 100644 index 00000000000..c1522dc210e --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java @@ -0,0 +1,53 @@ +package com.datadog.openfeature.internal.exposure; + +import static com.datadog.openfeature.internal.JsonWriting.writeKeyObject; +import static com.datadog.openfeature.internal.JsonWriting.writeValue; + +import com.datadog.openfeature.internal.JsonWriting; +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; + } + + /** + * @return the UTF-8 JSON payload. + */ + public byte[] serialize() { + return JsonWriting.write( + generator -> { + generator.writeStartObject(); + generator.writeFieldName("context"); + writeValue(generator, this.context); + generator.writeArrayFieldStart("exposures"); + for (final ExposureEvent exposure : this.exposures) { + generator.writeStartObject(); + generator.writeNumberField("timestamp", exposure.timestamp); + writeKeyObject(generator, "allocation", exposure.allocation.key); + writeKeyObject(generator, "flag", exposure.flag.key); + writeKeyObject(generator, "variant", exposure.variant.key); + generator.writeObjectFieldStart("subject"); + if (exposure.subject.id != null) { + generator.writeStringField("id", exposure.subject.id); + } + if (exposure.subject.attributes != null) { + generator.writeFieldName("attributes"); + writeValue(generator, exposure.subject.attributes); + } + generator.writeEndObject(); + if (exposure.serial_id != null) { + generator.writeNumberField("serial_id", exposure.serial_id); + } + generator.writeEndObject(); + } + generator.writeEndArray(); + generator.writeEndObject(); + }); + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java similarity index 68% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java index c13b123ad6c..0085cfe4d8a 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.exposure; +package com.datadog.openfeature.internal.exposure; public class Flag { public final String key; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java similarity index 70% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java index e21752cc529..8a83e6df745 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java @@ -1,7 +1,6 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.exposure; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.core.util.LRUCache; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -13,7 +12,13 @@ public class LRUExposureCache implements ExposureCache { private final Map cache; public LRUExposureCache(final int capacity) { - cache = new LRUCache<>(capacity); + cache = + new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(final Map.Entry eldest) { + return super.size() > capacity; + } + }; } @Override diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java similarity index 83% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java index ebd381e7069..2ba7cfab153 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.exposure; +package com.datadog.openfeature.internal.exposure; import java.util.Map; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java similarity index 69% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java index dfc4cbe38c7..ce5ddb2ae01 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.exposure; +package com.datadog.openfeature.internal.exposure; public class Variant { public final String key; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java similarity index 98% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java index f5f44b94d7b..f31ea9a3da1 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.flagevaluation; +package com.datadog.openfeature.internal.flagevaluation; import java.util.Collections; import java.util.Map; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java similarity index 96% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java index 5d0eeaa0e82..ed35250b7b9 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java @@ -1,9 +1,5 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.flagevaluation; -import static datadog.trace.util.HashingUtils.addToHash; -import static datadog.trace.util.HashingUtils.hash; - -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -12,6 +8,19 @@ import java.util.concurrent.atomic.AtomicLong; final class FlagEvaluationAggregator { + private static int hash(Object first, Object second, Object third) { + return 31 * 31 * java.util.Objects.hashCode(first) + + 31 * java.util.Objects.hashCode(second) + + java.util.Objects.hashCode(third); + } + + private static int addToHash(int hash, Object value) { + return 31 * hash + java.util.Objects.hashCode(value); + } + + private static int addToHash(int hash, boolean value) { + return 31 * hash + Boolean.hashCode(value); + } // Design assumptions — document the scale we sized for static final int EXPECTED_FLAG_COUNT = 2_500; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java similarity index 69% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java index 3a8734fd516..90ced6c3ac4 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java @@ -1,50 +1,44 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.flagevaluation; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; +import static com.datadog.openfeature.internal.JsonWriting.writeKeyObject; +import static com.datadog.openfeature.internal.JsonWriting.writeValue; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.datadog.openfeature.internal.JsonWriting; import java.io.ByteArrayOutputStream; -import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; 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(","); + private static final byte[] PAYLOAD_SUFFIX = "]}".getBytes(UTF_8); + private static final byte[] JSON_COMMA = ",".getBytes(UTF_8); /** * 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. + * UTF-8 targeting key (see {@link #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); - } + // Per-thread SHA-256 instance as hashing runs for every full-tier row without consent. + private static final ThreadLocal SHA_256 = + ThreadLocal.withInitial( + () -> { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + }); 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, @@ -92,12 +86,62 @@ static EncodedPayloads buildPayloads( } private static byte[] payloadPrefix(final Map context) { - return FeatureFlagEvpPublisher.utf8Bytes( - "{\"context\":" + CONTEXT_JSON_ADAPTER.toJson(context) + ",\"flagEvaluations\":["); + final byte[] contextJson = JsonWriting.write(generator -> writeValue(generator, context)); + return ("{\"context\":" + new String(contextJson, UTF_8) + ",\"flagEvaluations\":[") + .getBytes(UTF_8); } - private static byte[] encodeEvent(final FlagEvaluationEvent event) { - return FeatureFlagEvpPublisher.utf8Bytes(EVENT_JSON_ADAPTER.toJson(event)); + static byte[] encodeEvent(final FlagEvaluationEvent event) { + return JsonWriting.write( + generator -> { + generator.writeStartObject(); + generator.writeNumberField("timestamp", event.timestamp); + writeKeyObject(generator, "flag", event.flag.key); + generator.writeNumberField("first_evaluation", event.first_evaluation); + generator.writeNumberField("last_evaluation", event.last_evaluation); + generator.writeNumberField("evaluation_count", event.evaluation_count); + writeKeyObject(generator, "variant", keyOf(event.variant)); + writeKeyObject(generator, "allocation", keyOf(event.allocation)); + if (event.targeting_key != null) { + generator.writeStringField("targeting_key", event.targeting_key); + } + if (event.runtime_default_used != null) { + generator.writeBooleanField("runtime_default_used", event.runtime_default_used); + } + if (event.context != null) { + generator.writeObjectFieldStart("context"); + generator.writeFieldName("evaluation"); + writeValue(generator, event.context.evaluation); + generator.writeEndObject(); + } + if (event.error != null) { + generator.writeObjectFieldStart("error"); + generator.writeStringField("message", event.error.message); + generator.writeEndObject(); + } + generator.writeEndObject(); + }); + } + + /** + * Hashes a targeting key. + * + * @param value the targeting key. + * @return the lower-case hex SHA-256 of the UTF-8 targeting key. + */ + static String hashTargetingKey(final String value) { + final MessageDigest digest = SHA_256.get(); + digest.reset(); + final byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hex = new StringBuilder(hash.length * 2); + for (final byte b : hash) { + final int v = b & 0xFF; + if (v < 0x10) { + hex.append('0'); + } + hex.append(Integer.toHexString(v)); + } + return hex.toString(); } static final class EncodedPayloads { @@ -230,7 +274,7 @@ private static String resolveTargetingKey( if (observeFullEvaluationData) { return rawTargetingKey; } - return HASHED_TARGETING_KEY_PREFIX + ULeb128Encoder.hashTargetingKey(rawTargetingKey); + return HASHED_TARGETING_KEY_PREFIX + hashTargetingKey(rawTargetingKey); } FlagEvaluationEvent withoutTargetingKeyAndContext() { diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java similarity index 73% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java index 2da8ffe0256..8888f6331ba 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java @@ -1,19 +1,8 @@ -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; +package com.datadog.openfeature.internal.flagevaluation; + +import com.datadog.openfeature.internal.EventQueue; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.List; @@ -23,16 +12,14 @@ 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. + *

    Uses the same event transport as the exposure pipeline, with two-tier aggregation replacing + * the single-exposure buffer. * *

    Two-tier aggregation contract: Full key: (flagKey, variant, allocationKey, runtimeDefault, * errorMessage, targetingKey, canonical-context-key). Degraded key: (flagKey, variant, @@ -42,23 +29,23 @@ * <=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 any remainder as a closed drop - * so shutdown loss is observable rather than silent. + * runtimeDefaultUsed=true. Flush interval: 10 seconds. Queue: bounded lock-free queue (capacity + * 2^12), 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 any remainder as a closed drop so shutdown loss is + * observable rather than silent. */ -public class FlagEvaluationWriterImpl implements FlagEvaluationWriter { +public class FlagEvaluationPipeline implements FlagEvaluationWriter, AutoCloseable { - private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationWriterImpl.class); + private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationPipeline.class); - static final int DEFAULT_CAPACITY = 1 << 12; // 4096 elements, per cross-SDK RFC - static final int FLUSH_INTERVAL_SECONDS = 10; + public static final int DEFAULT_CAPACITY = 1 << 12; // 4096 elements, per cross-SDK RFC + public static final int FLUSH_INTERVAL_SECONDS = 10; - static final int FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES = EvpProxy.PAYLOAD_SIZE_LIMIT_BYTES; + static final int FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES = 5 * 1024 * 1024; 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"; @@ -70,19 +57,16 @@ public class FlagEvaluationWriterImpl implements FlagEvaluationWriter { 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 RuntimeServices services; - private final MessagePassingBlockingQueue queue; + private final EventQueue 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); + private void countMetric(final String metricName, final long value, final String reason) { + services.countMetric(metricName, value, reason); } /** @@ -99,50 +83,31 @@ private static void countMetric(final String metricName, final long value, final private final ConcurrentHashMap contextTruncatedCounts = new ConcurrentHashMap<>(); - public FlagEvaluationWriterImpl(final SharedCommunicationObjects sco, final Config config) { - this( - DEFAULT_CAPACITY, - FLUSH_INTERVAL_SECONDS, - SECONDS, - new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.FLAG_EVALUATION)::create, - config); - } - - FlagEvaluationWriterImpl( + public FlagEvaluationPipeline( final int capacity, final long flushInterval, final TimeUnit timeUnit, - final Supplier backendApiSupplier, - final Config config) { - this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this.services = services; + this.queue = new EventQueue<>(capacity); this.serializer = new FlagEvaluationSerializingHandler( - backendApiSupplier, + transport, queue, flushInterval, timeUnit, - FeatureFlagEvpContext.from(config), + context, droppedQueueOverflow, contextTruncatedCounts, - this::close, - FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - this.serializerThread = newAgentThread(FEATURE_FLAG_EVALUATION_PROCESSOR, serializer); + FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, + services); + this.serializerThread = services.newThread("evaluations", serializer); } - @Override + /** Starts the background serializing thread. */ 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; @@ -163,9 +128,6 @@ public void close() { if (!closed.compareAndSet(false, true)) { return; } - // Disable and deregister from the gateway so no new events are enqueued. - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - FeatureFlaggingGateway.setFlagEvalWriter(null); workerRunning = this.serializerThread.isAlive(); if (workerRunning) { // Ask the worker to drain the queue and final-flush, then interrupt to wake it from poll(). @@ -211,7 +173,7 @@ public void enqueue(final FlagEvalEvent event) { if (event == null) { return; } - if (isClosedOrEnqueueDisabled()) { + if (closed.get()) { countClosedDrop(); return; } @@ -233,18 +195,12 @@ public void enqueue(final FlagEvalEvent event) { } } - /** - * 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. - */ + @Override public boolean hasCapacityForEnqueue() { return queue.size() < queue.capacity(); } - /** Counts one queue-overflow drop without offering an event. */ + @Override public void countPreQueueOverflow() { droppedQueueOverflow.incrementAndGet(); } @@ -254,15 +210,7 @@ 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); } @@ -284,7 +232,13 @@ void flushForTest() { // ---- Serializing handler (background thread logic) ---- static class FlagEvaluationSerializingHandler implements Runnable { - private final MessagePassingBlockingQueue queue; + private final RuntimeServices services; + + private void countMetric(String name, long value, String reason) { + services.countMetric(name, value, reason); + } + + private final EventQueue queue; private final long ticksRequiredToFlush; @SuppressFBWarnings( @@ -292,12 +246,10 @@ static class FlagEvaluationSerializingHandler implements Runnable { justification = "the field is confined to the single serializer thread") private long lastTicks; - private final FeatureFlagEvpPublisher - evpPublisher; + private final EventTransport transport; final Map context; private final AtomicLong droppedQueueOverflow; private final ConcurrentHashMap contextTruncatedCounts; - private final Runnable errorCallback; private final int payloadSizeLimitBytes; final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); @@ -306,26 +258,24 @@ static class FlagEvaluationSerializingHandler implements Runnable { private final CountDownLatch finalFlushDone = new CountDownLatch(1); FlagEvaluationSerializingHandler( - final Supplier backendApiSupplier, - final MessagePassingBlockingQueue queue, + final EventTransport transport, + final EventQueue queue, final long flushInterval, final TimeUnit timeUnit, final Map context, final AtomicLong droppedQueueOverflow, final ConcurrentHashMap contextTruncatedCounts, - final Runnable errorCallback, - final int payloadSizeLimitBytes) { + final int payloadSizeLimitBytes, + final RuntimeServices services) { + this.services = services; this.queue = queue; - this.evpPublisher = - new FeatureFlagEvpPublisher<>( - backendApiSupplier, FlagEvaluationPayloads.FlagEvaluationsRequest.class); + this.transport = transport; 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"); } @@ -336,11 +286,6 @@ void requestShutdown() { @Override public void run() { - if (!evpPublisher.start()) { - finalFlushDone.countDown(); - errorCallback.run(); - throw new IllegalArgumentException("EVP Proxy not available"); - } try { runDutyCycle(); } catch (InterruptedException e) { @@ -350,7 +295,7 @@ public void run() { // queued events are not lost on shutdown. // // close() interrupts this thread to break it out of poll(). The final flush does socket - // I/O, and OkHttp fails fast on a thread whose interrupt flag is set, so clear the flag + // I/O, which fails fast on a thread whose interrupt flag is set, so clear the flag // for the duration of the drain and restore it afterwards. Without this the flush that // close() exists to guarantee throws IOException, the aggregated rows are discarded by // the clear() in flush(), and the loss is invisible to the drop counters because those @@ -466,7 +411,7 @@ void flush() { payloads.droppedPayloadLimit); } for (final byte[] payload : payloads.bodies) { - evpPublisher.post(FLAG_EVALUATION_ROUTE, payload); + transport.post(FLAG_EVALUATION_ROUTE, payload); } } catch (Exception e) { LOGGER.error("Could not submit flag evaluations", e); @@ -522,24 +467,27 @@ private boolean shouldFlush() { static class SerializingHandlerForTest extends FlagEvaluationSerializingHandler { SerializingHandlerForTest( - final Supplier backendApiSupplier, final Map context) { - this(backendApiSupplier, context, FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this(transport, context, FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, services); } SerializingHandlerForTest( - final Supplier backendApiSupplier, + final EventTransport transport, final Map context, - final int payloadSizeLimitBytes) { + final int payloadSizeLimitBytes, + final RuntimeServices services) { super( - backendApiSupplier, - Queues.mpscBlockingConsumerArrayQueue(DEFAULT_CAPACITY), + transport, + new EventQueue<>(DEFAULT_CAPACITY), Long.MAX_VALUE, // effectively never auto-flush TimeUnit.NANOSECONDS, context, new AtomicLong(0), new ConcurrentHashMap<>(), - () -> {}, - payloadSizeLimitBytes); + payloadSizeLimitBytes, + services); } private final List staged = new ArrayList<>(); @@ -592,17 +540,4 @@ 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/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java new file mode 100644 index 00000000000..5733ef94d34 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java @@ -0,0 +1,33 @@ +package com.datadog.openfeature.internal.flagevaluation; + +/** Records flag evaluation events from the evaluation thread, without blocking. */ +public interface FlagEvaluationWriter { + /** + * 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. + * + * @return {@code true} if the queue currently has room for another event. + */ + boolean hasCapacityForEnqueue(); + + /** Counts one queue-overflow drop without offering an event. */ + void countPreQueueOverflow(); + + /** + * Counts one evaluation whose context was truncated. + * + * @param reason the sorted, comma-separated set of the caps that fired. + */ + void countContextTruncated(String reason); + + /** + * Non-blocking enqueue of a flag evaluation event. Drops the event, and counts the drop, if the + * queue is full. + * + * @param event the event to enqueue. + */ + void enqueue(FlagEvalEvent event); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java new file mode 100644 index 00000000000..b37a676571a --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java @@ -0,0 +1,28 @@ +package com.datadog.openfeature.internal.ufc; + +import java.time.Instant; +import java.util.List; + +public final class Allocation { + public final String key; + public final List rules; + public final Instant startAt; + public final Instant endAt; + public final List splits; + public final Boolean doLog; + + public Allocation( + final String key, + final List rules, + final Instant startAt, + final Instant endAt, + final List splits, + final Boolean doLog) { + this.key = key; + this.rules = rules; + this.startAt = startAt; + this.endAt = endAt; + this.splits = splits; + this.doLog = doLog; + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java similarity index 85% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java index ae693674acc..c5d79cd67e1 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java @@ -1,6 +1,6 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; -public class ConditionConfiguration { +public final class ConditionConfiguration { public final ConditionOperator operator; public final String attribute; public final Object value; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java similarity index 81% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java index 1e864da2b6a..fc3d3bab683 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; public enum ConditionOperator { LT, diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java similarity index 54% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java index 86d04c7d8ef..664ae3fa07e 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java @@ -1,6 +1,6 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; -public class Environment { +public final class Environment { public final String name; public Environment(String name) { diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java similarity index 89% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java index 529e3145344..8ff4dd83989 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java @@ -1,9 +1,9 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import java.util.List; import java.util.Map; -public class Flag { +public final class Flag { public final String key; public final boolean enabled; public final ValueType variationType; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java similarity index 99% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java index 03ef7590fa1..c4f99828516 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import java.util.Arrays; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java similarity index 71% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java index ed0fe60f366..819ebeeaf23 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java @@ -1,8 +1,8 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import java.util.List; -public class Rule { +public final class Rule { public final List conditions; public Rule(final List conditions) { diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java similarity index 63% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java index 60f39fc666f..96aa6288154 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java @@ -1,17 +1,15 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import java.util.Collections; import java.util.Map; -public class ServerConfiguration { +public final 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. + + /** PII consent; {@code null} must be read as the privacy-preserving {@code false}. */ public final Boolean observeFullEvaluationData; + public final Environment environment; public final Map flags; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java new file mode 100644 index 00000000000..9f9faf0f483 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java @@ -0,0 +1,16 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; + +/** A UFC shard. {@code totalShards} holds an unsigned 32-bit wire value. */ +public final class Shard { + public final String salt; + public final List ranges; + public final long totalShards; + + public Shard(final String salt, final List ranges, final long totalShards) { + this.salt = salt; + this.ranges = ranges; + this.totalShards = totalShards; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java new file mode 100644 index 00000000000..f48c9bfac45 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java @@ -0,0 +1,12 @@ +package com.datadog.openfeature.internal.ufc; + +/** A UFC shard range. {@code start} and {@code end} hold unsigned 32-bit wire values. */ +public final class ShardRange { + public final long start; + public final long end; + + public ShardRange(final long start, final long 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/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java similarity index 60% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java index 37f6909eeea..c4852517adc 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java @@ -1,15 +1,14 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import java.util.List; import java.util.Map; -public class Split { +public final 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. + + /** Absent in some UFC shapes. Surfaced as {@code __dd_split_serial_id} for span enrichment. */ public final Integer serialId; public Split( diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java new file mode 100644 index 00000000000..fb67e8c19ac --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -0,0 +1,568 @@ +package com.datadog.openfeature.internal.ufc; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import java.io.IOException; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parses Universal Flag Configuration (UFC) documents. + * + *

    The document is first read as a generic JSON tree (objects, arrays, strings, {@link Double} + * numbers, booleans and nulls), then mapped onto the UFC model. Malformed flags are dropped + * individually and reported through {@link ServerConfiguration#invalidFlags}; a malformed envelope + * fails the whole document. + */ +public final class UniversalFlagConfigParser { + 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 String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; + private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + + private static final JsonFactory JSON_FACTORY = + new JsonFactory().enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); + + private UniversalFlagConfigParser() {} + + /** + * Parses a raw UFC document, as delivered by Remote Configuration. + * + * @param content the UFC JSON document. + * @return the parsed configuration, or {@code null} for a JSON {@code null} document. + * @throws IOException if the document is not valid JSON or does not match the UFC envelope. + */ + @Nullable + public static ServerConfiguration parse(final byte[] content) throws IOException { + return toServerConfiguration(readDocument(content)); + } + + /** + * Parses a JSON:API response wrapping a UFC document, as delivered by the CDN. + * + * @param content the JSON:API response body. + * @return the parsed configuration, or {@code null} if the response holds no UFC document. + * @throws IOException if the response is not valid JSON or does not match the UFC envelope. + */ + @Nullable + public static ServerConfiguration parseJsonApi(final byte[] content) throws IOException { + final Object response = readDocument(content); + if (!(response instanceof Map)) { + return null; + } + final Object data = ((Map) response).get("data"); + if (!(data instanceof Map)) { + return null; + } + final Map dataObject = (Map) data; + if (!UNIVERSAL_FLAG_CONFIGURATION_TYPE.equals(dataObject.get("type"))) { + return null; + } + final ServerConfiguration configuration = toServerConfiguration(dataObject.get("attributes")); + return configuration != null && configuration.flags != null ? configuration : null; + } + + private static Object readDocument(final byte[] content) throws IOException { + try (JsonParser parser = JSON_FACTORY.createParser(content)) { + if (parser.nextToken() == null) { + throw new JsonParseException(parser, "Empty document"); + } + final Object document = readValue(parser); + if (parser.nextToken() != null) { + throw new JsonParseException(parser, "Unexpected content after the document"); + } + return document; + } + } + + private static Object readValue(final JsonParser parser) throws IOException { + final JsonToken token = parser.currentToken(); + switch (token) { + case START_OBJECT: + final Map object = new LinkedHashMap<>(); + while (parser.nextToken() == JsonToken.FIELD_NAME) { + final String name = parser.currentName(); + parser.nextToken(); + object.put(name, readValue(parser)); + } + return object; + case START_ARRAY: + final List array = new ArrayList<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + array.add(readValue(parser)); + } + return array; + case VALUE_STRING: + return parser.getText(); + case VALUE_NUMBER_INT: + case VALUE_NUMBER_FLOAT: + return parser.getDoubleValue(); + case VALUE_TRUE: + return Boolean.TRUE; + case VALUE_FALSE: + return Boolean.FALSE; + case VALUE_NULL: + return null; + default: + throw new JsonParseException(parser, "Unexpected token " + token); + } + } + + @Nullable + private static ServerConfiguration toServerConfiguration(@Nullable final Object value) + throws IOException { + if (value == null) { + return null; + } + try { + final Map json = asObject(value, "configuration"); + final Map invalidFlags = new HashMap<>(); + final ServerConfiguration configuration = + new ServerConfiguration( + asString(json.get("createdAt"), "createdAt"), + asString(json.get("format"), "format"), + lenientBoolean(json.get("observeFullEvaluationData")), + toEnvironment(json.get("environment")), + toFlags(json.get("flags"), invalidFlags)); + if (!invalidFlags.isEmpty()) { + configuration.invalidFlags = invalidFlags; + } + return configuration; + } catch (final IllegalArgumentException e) { + throw new IOException("Invalid flag configuration: " + e.getMessage(), e); + } + } + + @Nullable + private static Environment toEnvironment(@Nullable final Object value) { + if (value == null) { + return null; + } + return new Environment(asString(asObject(value, "environment").get("name"), "name")); + } + + @Nullable + private static Map toFlags( + @Nullable final Object value, final Map invalidFlags) { + if (value == null) { + return null; + } + final Map flags = new HashMap<>(); + for (final Map.Entry entry : asObject(value, "flags").entrySet()) { + final String flagKey = (String) entry.getKey(); + try { + final Flag flag = toFlag(entry.getValue()); + if (flag != null) { + validateFlag(flagKey, flag); + flags.put(flagKey, flag); + } + } catch (final IllegalArgumentException error) { + invalidFlags.put( + flagKey, + error instanceof InvalidSemverComparandException + ? INVALID_SEMVER_COMPARAND + : INVALID_FLAG); + LOGGER.warn("Dropping malformed FFE flag {}: {}", flagKey, error.toString()); + } + } + return flags; + } + + @Nullable + private static Flag toFlag(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "flag"); + return new Flag( + asString(json.get("key"), "key"), + strictBoolean(json.get("enabled"), "enabled"), + asEnum(json.get("variationType"), ValueType.class, "variationType"), + toVariants(json.get("variations")), + toList(json.get("allocations"), "allocations", UniversalFlagConfigParser::toAllocation)); + } + + @Nullable + private static Map toVariants(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map variants = new HashMap<>(); + for (final Map.Entry entry : asObject(value, "variations").entrySet()) { + variants.put((String) entry.getKey(), toVariant(entry.getValue())); + } + return variants; + } + + @Nullable + private static Variant toVariant(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "variant"); + return new Variant(asString(json.get("key"), "key"), json.get("value")); + } + + @Nullable + private static Allocation toAllocation(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "allocation"); + return new Allocation( + asString(json.get("key"), "key"), + toList(json.get("rules"), "rules", UniversalFlagConfigParser::toRule), + toInstant(json.get("startAt")), + toInstant(json.get("endAt")), + toList(json.get("splits"), "splits", UniversalFlagConfigParser::toSplit), + lenientBoolean(json.get("doLog"))); + } + + @Nullable + private static Rule toRule(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "rule"); + return new Rule( + toList(json.get("conditions"), "conditions", UniversalFlagConfigParser::toCondition)); + } + + @Nullable + private static ConditionConfiguration toCondition(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "condition"); + return new ConditionConfiguration( + asEnum(json.get("operator"), ConditionOperator.class, "operator"), + asString(json.get("attribute"), "attribute"), + json.get("value")); + } + + @Nullable + private static Split toSplit(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "split"); + final Object serialId = json.get("serialId"); + return new Split( + toList(json.get("shards"), "shards", UniversalFlagConfigParser::toShard), + asString(json.get("variationKey"), "variationKey"), + toStringMap(json.get("extraLogging")), + serialId == null ? null : asLenientInt(serialId, "serialId")); + } + + @Nullable + private static Shard toShard(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "shard"); + return new Shard( + asString(json.get("salt"), "salt"), + toList(json.get("ranges"), "ranges", UniversalFlagConfigParser::toShardRange), + asUnsignedInt(json.get("totalShards"), "totalShards")); + } + + @Nullable + private static ShardRange toShardRange(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "range"); + return new ShardRange( + asUnsignedInt(json.get("start"), "range start"), + asUnsignedInt(json.get("end"), "range end")); + } + + @Nullable + private static Map toStringMap(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map map = new HashMap<>(); + for (final Map.Entry entry : asObject(value, "extraLogging").entrySet()) { + map.put((String) entry.getKey(), asString(entry.getValue(), (String) entry.getKey())); + } + return map; + } + + @Nullable + private static List toList( + @Nullable final Object value, final String name, final ElementMapper mapper) { + if (value == null) { + return null; + } + if (!(value instanceof List)) { + throw new IllegalArgumentException(name + " is not an array"); + } + final List json = (List) value; + final List list = new ArrayList<>(json.size()); + for (final Object element : json) { + list.add(mapper.map(element)); + } + return list; + } + + private static Map asObject(final Object value, final String name) { + if (!(value instanceof Map)) { + throw new IllegalArgumentException(name + " is not an object"); + } + return (Map) value; + } + + @Nullable + private static String asString(@Nullable final Object value, final String name) { + if (value == null || value instanceof String) { + return (String) value; + } + throw new IllegalArgumentException(name + " is not a string"); + } + + private static boolean strictBoolean(@Nullable final Object value, final String name) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + throw new IllegalArgumentException(name + " is not a boolean"); + } + + /** Maps wrong-typed values to {@code null} so callers fall back to their default. */ + @Nullable + private static Boolean lenientBoolean(@Nullable final Object value) { + return value instanceof Boolean ? (Boolean) value : null; + } + + @Nullable + private static > E asEnum( + @Nullable final Object value, final Class type, final String name) { + final String constant = asString(value, name); + if (constant == null) { + return null; + } + try { + return Enum.valueOf(type, constant); + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException(name + " has an unknown value " + constant); + } + } + + /** + * Coerces a number, or a numeric string, to an int, truncating fractions and saturating out of + * range values. + */ + private static int asLenientInt(final Object value, final String name) { + if (value instanceof Double) { + return (int) (double) (Double) value; + } + if (value instanceof String) { + try { + return (int) Double.parseDouble((String) value); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException(name + " is not a number"); + } + } + throw new IllegalArgumentException(name + " is not a number"); + } + + private static long asUnsignedInt(@Nullable final Object value, final String name) { + if (value == null) { + return 0; + } + return asIntegral(value, 0, MAX_UNSIGNED_INT, name); + } + + private static long asIntegral( + final Object value, final long min, final long max, final String name) { + if (!(value instanceof Double)) { + throw new IllegalArgumentException(name + " is not a number"); + } + final double number = (Double) value; + if (number != Math.rint(number) || number < min || number > max) { + throw new IllegalArgumentException("flag contains an invalid " + name + " value"); + } + return (long) number; + } + + /** Ignores wrongly set dates. */ + @Nullable + private static Instant toInstant(@Nullable final Object value) { + if (!(value instanceof String)) { + return null; + } + try { + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse((String) value, Instant::from); + } catch (final RuntimeException e) { + return null; + } + } + + /** 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 IllegalArgumentException( + "flag \"" + flagKey + "\" contains a split with missing shards"); + } + for (final Shard shard : split.shards) { + if (shard == null || 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"); + } + } + } + } + } + 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 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; + } + } + } + } + + /** + * Validates and caches SemVer comparands for all SEMVER_* conditions in a flag. + * + * @throws InvalidSemverComparandException if any condition has an invalid or non-string comparand + * value. + */ + private static void validateAndCacheSemverComparands(final String flagKey, final Flag flag) { + for (int allocIdx = 0; allocIdx < flag.allocations.size(); allocIdx++) { + final Allocation allocation = flag.allocations.get(allocIdx); + if (allocation == null || allocation.rules == null) { + continue; + } + 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 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; + } + } + } + } + } + + @FunctionalInterface + private interface ElementMapper { + @Nullable + T map(@Nullable Object value); + } + + /** 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); + } + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java similarity index 62% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java index 6f0a973a379..4b260059ae9 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; public enum ValueType { BOOLEAN, diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java similarity index 68% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java index 26e30aa1aad..4d9246c5965 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java @@ -1,6 +1,6 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; -public class Variant { +public final class Variant { public final String key; public final Object value; 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..1710ce62dbe --- /dev/null +++ b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -0,0 +1,48 @@ +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 initTimeout(final long timeout, final TimeUnit unit) { + super.initTimeout(timeout, unit); + return this; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java new file mode 100644 index 00000000000..e8fc19c1fcb --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java @@ -0,0 +1,205 @@ +package com.datadog.openfeature; + +import static java.util.Collections.emptyMap; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; +import static org.mockito.Mockito.mock; + +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Evaluates the cross-SDK canonical fixtures from the {@code ffe-system-test-data} submodule, + * parsed with the production UFC parser. + */ +class CanonicalFixtureTest { + private static final String CANONICAL_FIXTURE_PATH = + "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; + + @Test + void canonicalFixturesArePresent() throws IOException { + assertThat(canonicalTestCases().size(), greaterThan(0)); + } + + @MethodSource("canonicalTestCases") + @ParameterizedTest(name = "{0}") + void evaluateCanonicalFixture(final FixtureCase testCase) throws IOException { + final DDEvaluator evaluator = + new DDEvaluator(mock(Runnable.class), Connector.NONE, TestSettings.of()); + evaluator.accept( + UniversalFlagConfigParser.parse( + Files.readAllBytes(fixtureRoot().resolve("ufc-config.json")))); + + final Class targetType = targetType(testCase.variationType); + final Object defaultValue = DDEvaluator.mapValue(targetType, testCase.defaultValue); + final Object expectedValue = DDEvaluator.mapValue(targetType, testCase.resultValue); + final ProviderEvaluation details = + evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); + + assertThat(details.getValue(), equalTo(expectedValue)); + assertThat(details.getReason(), equalTo(testCase.resultReason)); + if (testCase.resultVariant != null) { + assertThat(details.getVariant(), equalTo(testCase.resultVariant)); + } + if (testCase.resultErrorCode != null) { + assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.resultErrorCode))); + } + if (testCase.resultAllocationKey != null) { + assertThat( + details.getFlagMetadata().getString("allocationKey"), + equalTo(testCase.resultAllocationKey)); + } + } + + @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); + } + + @SuppressWarnings("unchecked") + static List canonicalTestCases() throws IOException { + final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); + final List result = new ArrayList<>(); + try (Stream paths = Files.list(evaluationCases)) { + final List files = + paths + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted() + .collect(Collectors.toList()); + for (final Path file : files) { + final List testCases = (List) JsonReading.read(Files.readAllBytes(file)); + for (int index = 0; index < testCases.size(); index++) { + result.add( + new FixtureCase( + file.getFileName().toString(), + index, + (Map) testCases.get(index))); + } + } + } + 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 EvaluationContext context(final FixtureCase testCase) { + final MutableContext context = + new MutableContext(Value.objectToValue(testCase.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); + } + } + + /** Normalizes the integral numbers read as Long to Double, as the UFC parser does. */ + private static Object normalize(final Object value) { + if (value instanceof Long) { + return ((Long) value).doubleValue(); + } + if (value instanceof Map) { + final Map map = new java.util.LinkedHashMap<>(); + ((Map) value).forEach((k, v) -> map.put((String) k, normalize(v))); + return map; + } + if (value instanceof List) { + final List list = new ArrayList<>(); + ((List) value).forEach(v -> list.add(normalize(v))); + return list; + } + return value; + } + + static final class FixtureCase { + final String fileName; + final int index; + final Map attributes; + final Object defaultValue; + final String flag; + final String targetingKey; + final String variationType; + final Object resultValue; + final String resultReason; + final String resultErrorCode; + final String resultVariant; + final String resultAllocationKey; + + @SuppressWarnings("unchecked") + FixtureCase(final String fileName, final int index, final Map json) { + this.fileName = fileName; + this.index = index; + final Object attributes = normalize(json.get("attributes")); + this.attributes = attributes == null ? emptyMap() : (Map) attributes; + this.defaultValue = normalize(json.get("defaultValue")); + this.flag = (String) json.get("flag"); + this.targetingKey = (String) json.get("targetingKey"); + this.variationType = (String) json.get("variationType"); + final Map result = (Map) json.get("result"); + this.resultValue = normalize(result.get("value")); + this.resultReason = (String) result.get("reason"); + this.resultErrorCode = (String) result.get("errorCode"); + this.resultVariant = (String) result.get("variant"); + final Map metadata = (Map) result.get("flagMetadata"); + final Object allocationKey = metadata == null ? null : metadata.get("allocationKey"); + this.resultAllocationKey = allocationKey == null ? null : String.valueOf(allocationKey); + } + + @Override + public String toString() { + return this.fileName + "[" + this.index + "] flag=" + this.flag; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java new file mode 100644 index 00000000000..923648f8a79 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java @@ -0,0 +1,67 @@ +package com.datadog.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 com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.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}, enabled through its settings. */ +class DDEvaluatorSpanEnrichmentTest { + + @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)); + } + + @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), + Connector.NONE, + TestSettings.of("experimental.flagging.provider.span.enrichment.enabled", "true")); + 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/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java similarity index 58% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java index 940c84998d4..507d1bca061 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static dev.openfeature.sdk.Reason.ERROR; import static java.util.Arrays.asList; @@ -10,61 +10,45 @@ 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 static org.mockito.Mockito.when; + +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ConditionConfiguration; +import com.datadog.openfeature.internal.ufc.ConditionOperator; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ParsedSemver; +import com.datadog.openfeature.internal.ufc.Rule; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Shard; +import com.datadog.openfeature.internal.ufc.ShardRange; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; -import java.io.IOException; -import java.lang.reflect.Type; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.time.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; @@ -72,40 +56,25 @@ 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; + private static DDEvaluator newEvaluator(final Runnable configCallback) { + return new DDEvaluator(configCallback, Connector.NONE, TestSettings.of()); + } - @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); - } + /** Creates an evaluator whose runtime polls a local CDN that has no configuration. */ + private static DDEvaluator newInitializableEvaluator( + final Runnable configCallback, final LocalHttpServer cdn) { + return new DDEvaluator( + configCallback, + Connector.NONE, + TestSettings.of( + "feature.flags.configuration.source.agentless.base.url", + cdn.uri("/").toString(), + "flagging.evaluation.counts.enabled", + "false")); } + private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + private static Arguments[] valueMappingTestCases() { return new Arguments[] { // String mappings @@ -165,7 +134,7 @@ public void testValueMapping(final Class target, final Object value, final Ob @Test public void testEvaluateNoConfig() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); assertThat(details.getValue(), equalTo(23)); @@ -176,40 +145,44 @@ public void testEvaluateNoConfig() { @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(); + try (LocalHttpServer cdn = new LocalHttpServer()) { + final DDEvaluator evaluator = newInitializableEvaluator(configCallback, cdn); + 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))); + try (LocalHttpServer cdn = new LocalHttpServer()) { + final DDEvaluator evaluator = newInitializableEvaluator(mock(Runnable.class), cdn); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + final Future initialized = + executor.submit(() -> evaluator.initialize(5, SECONDS, mock(EvaluationContext.class))); - evaluator.accept(null); - assertThat(initialized.isDone(), equalTo(false)); + 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(); + evaluator.accept(new ServerConfiguration("", "", false, null, emptyMap())); + assertThat(initialized.get(5, 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 DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", false, null, emptyMap())); final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, null); assertThat(details.getValue(), equalTo(23)); assertThat(details.getReason(), equalTo(ERROR.name())); @@ -221,7 +194,7 @@ 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)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", false, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); @@ -242,12 +215,12 @@ 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. + // unsigned 32-bit semantics. final Shard shard = new Shard( "salt", - singletonList(new ShardRange((int) 3_699_531_192L, (int) 3_699_531_193L)), - (int) MAX_UNSIGNED_INT); + singletonList(new ShardRange(3_699_531_192L, 3_699_531_193L)), + 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); @@ -255,7 +228,7 @@ public void testEvaluateUnsignedShardRange() { flags.put( "target", new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", false, null, flags)); final EvaluationContext context = @@ -354,7 +327,7 @@ public void observeFullEvaluationDataStampedFalseOnDefault() { 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)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", true, null, new HashMap<>())); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); @@ -369,7 +342,7 @@ public void observeFullEvaluationDataStampedOnFlagNotFoundError() { @Test public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); @@ -380,12 +353,12 @@ public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { @Test public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { - // The field is boxed so Moshi tolerates a malformed consent value in the UFC JSON without + // The field is boxed so the parser 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)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", null, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); @@ -415,75 +388,6 @@ public void exposureOmitsSerialIdWhenTheSplitHasNone() { assertNull(exposureFor(null).serial_id); } - @Test - public void legacyExposureApiDispatchesAnExposureWithoutSerialId() { - final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true); - try { - assertNull(exposureFor(7).serial_id); - } finally { - DDEvaluator.USE_LEGACY_EXPOSURE_API.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()); - assertFalse(DDEvaluator.USE_LEGACY_EXPOSURE_API.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 and returns the single * dispatched exposure. Span enrichment is off here, as it is by default, so this also pins that @@ -491,19 +395,25 @@ public void probeKeepsSplitSupportWhenOnlyTheEventConstructorIsMissing() { */ private static ExposureEvent exposureFor(final Integer serialId) { final List dispatched = new ArrayList<>(); - final FeatureFlaggingGateway.ExposureListener listener = dispatched::add; - FeatureFlaggingGateway.addExposureListener(listener); - try { - 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); - evaluateFlag( - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), true); - } finally { - FeatureFlaggingGateway.removeExposureListener(listener); - } + final DDEvaluator evaluator = + new DDEvaluator(mock(Runnable.class), Connector.NONE, TestSettings.of()) { + @Override + void recordExposure(final ExposureEvent event) { + dispatched.add(event); + } + }; + 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))); + evaluator.accept(new ServerConfiguration("", "", true, null, flags)); + evaluator.evaluate( + Integer.class, "target", 23, new MutableContext("target").setTargetingKey("user-1")); assertEquals(1, dispatched.size()); return dispatched.get(0); } @@ -544,7 +454,7 @@ 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)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); @@ -605,32 +515,19 @@ private static ProviderEvaluation evaluateWithNumericRuleOnId( flags.put( "num-rule", new Flag("num-rule", true, ValueType.INTEGER, emptyMap(), singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(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); + new Allocation("allocation", emptyList(), startAt, endAt, emptyList(), true); assertThat( DDEvaluator.isAllocationActive(allocation, startAt.minusNanos(1_000)), equalTo(false)); @@ -649,8 +546,7 @@ private static Flag semverFlag(final ConditionOperator operator, final String co 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); + new Allocation("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)); @@ -720,7 +616,7 @@ public void testEvaluateSemverCondition( final boolean wantMatch) { final Map flags = new HashMap<>(); flags.put("test-flag", semverFlag(operator, comparand)); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", null, null, flags)); final ProviderEvaluation details = @@ -739,7 +635,7 @@ public void testEvaluateSemverCondition( 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)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", null, null, flags)); final ProviderEvaluation details = @@ -758,7 +654,7 @@ public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { 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)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(config); final ProviderEvaluation details = @@ -776,7 +672,7 @@ public void testEvaluateMalformedFlagReturnsParseError() { 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)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(config); final ProviderEvaluation details = @@ -980,208 +876,99 @@ public void testCopyPrunedContextMultipleReasonsAreSortedAndDeduplicated() { assertThat(result.truncatedReason, equalTo("max_key_length,max_value_length")); } - @Test - public void testCanonicalFixturesArePresent() throws IOException { - assertThat(canonicalTestCases().size(), greaterThan(0)); + private static Arguments[] typeCompatibilityTestCases() { + return new Arguments[] { + Arguments.of(Boolean.class, ValueType.BOOLEAN, true), + Arguments.of(String.class, ValueType.BOOLEAN, false), + Arguments.of(String.class, ValueType.STRING, true), + Arguments.of(Boolean.class, ValueType.STRING, false), + Arguments.of(Integer.class, ValueType.INTEGER, true), + Arguments.of(String.class, ValueType.INTEGER, false), + Arguments.of(Double.class, ValueType.NUMERIC, true), + Arguments.of(String.class, ValueType.NUMERIC, false), + Arguments.of(Value.class, ValueType.JSON, true), + Arguments.of(String.class, ValueType.JSON, false), + Arguments.of(String.class, null, true), + }; } - @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")))); - } + @ParameterizedTest + @MethodSource("typeCompatibilityTestCases") + public void testTypeCompatibility( + final Class target, final ValueType variationType, final boolean expected) { + assertThat(DDEvaluator.isTypeCompatible(target, variationType), equalTo(expected)); } - @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); - } + @Test + public void testContextCopyDetectsContainerCycles() { + final List cyclicList = new ArrayList<>(); + final Value listValue = new Value(cyclicList); + cyclicList.add(listValue); + final EvaluationContext listContext = mock(EvaluationContext.class); + when(listContext.keySet()).thenReturn(java.util.Collections.singleton("list")); + when(listContext.getValue("list")).thenReturn(listValue); - 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(); - } - } + final DDEvaluator.CopyResult listResult = DDEvaluator.copyPrunedContext(listContext); + assertThat(listResult.truncatedReason, equalTo("cycle")); - /** - * 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; - } + final dev.openfeature.sdk.MutableStructure cyclicStructure = + new dev.openfeature.sdk.MutableStructure(); + final Value structureValue = new Value(cyclicStructure); + cyclicStructure.add("self", structureValue); + final EvaluationContext structureContext = mock(EvaluationContext.class); + when(structureContext.keySet()).thenReturn(java.util.Collections.singleton("structure")); + when(structureContext.getValue("structure")).thenReturn(structureValue); + + final DDEvaluator.CopyResult structureResult = DDEvaluator.copyPrunedContext(structureContext); + assertThat(structureResult.truncatedReason, equalTo("cycle")); } - 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); - } - } - } + @Test + public void testCopyPrunedContextHandlesEmptyAndScalarValues() { + assertThat(DDEvaluator.copyPrunedContext(null).attrs, equalTo(emptyMap())); + assertThat(DDEvaluator.copyPrunedContext(new MutableContext()).attrs, equalTo(emptyMap())); - assertThat(result.size(), greaterThan(0)); - return result; - } + final EvaluationContext nullContext = mock(EvaluationContext.class); + when(nullContext.keySet()) + .thenReturn(new java.util.LinkedHashSet<>(asList("java-null", "openfeature-null"))); + when(nullContext.getValue("java-null")).thenReturn(null); + when(nullContext.getValue("openfeature-null")).thenReturn(new Value()); + final Map snapshot = DDEvaluator.snapshotValues(nullContext); + assertNull(snapshot.get("java-null")); + assertThat(snapshot.get("openfeature-null").isNull(), equalTo(true)); + final DDEvaluator.CopyResult nullResult = DDEvaluator.copyPrunedContext(nullContext); + assertThat(nullResult.attrs, hasEntry("java-null", null)); + assertThat(nullResult.attrs, hasEntry("openfeature-null", null)); - 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"); + final MutableContext scalarContext = new MutableContext(); + scalarContext.add("boolean", true); + scalarContext.add("number", 42); + scalarContext.add("instant", Instant.parse("2026-08-20T00:00:00Z")); + final DDEvaluator.CopyResult scalarResult = DDEvaluator.copyPrunedContext(scalarContext); + assertThat(scalarResult.attrs, hasEntry("boolean", true)); + assertThat(scalarResult.attrs, hasEntry("number", 42)); + assertThat(scalarResult.attrs, hasEntry("instant", "2026-08-20T00:00:00Z")); } - private static String read(final Path path) throws IOException { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } + @Test + public void testFlattenValuesHandlesJavaNullAndContainerCycles() { + final Map values = new HashMap<>(); + values.put("null", null); - 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; - } + final List cyclicList = new ArrayList<>(); + final Value listValue = new Value(cyclicList); + cyclicList.add(listValue); + values.put("list", listValue); - 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); - } - } + final dev.openfeature.sdk.MutableStructure cyclicStructure = + new dev.openfeature.sdk.MutableStructure(); + final Value structureValue = new Value(cyclicStructure); + cyclicStructure.add("self", structureValue); + values.put("structure", structureValue); - private static Object mapFixtureValue(final Class targetType, final Object value) { - return DDEvaluator.mapValue(targetType, value); + final Map flattened = DDEvaluator.flattenValues(values); + assertThat(flattened, hasEntry("null", null)); + assertThat(flattened.size(), equalTo(1)); } private static Map mapOf(final Object... props) { @@ -1194,251 +981,4 @@ private static Map mapOf(final Object... props) { } return result; } - - private static final class FixtureCase { - Map attributes = emptyMap(); - Object defaultValue; - String flag; - FixtureResult result; - String targetingKey; - String variationType; - transient String fileName; - transient int index; - - @Override - public String toString() { - return fileName + "[" + index + "] flag=" + flag; - } - } - - private static final class FixtureResult { - Object value; - String reason; - String errorCode; - String variant; - Map flagMetadata = emptyMap(); - } - - /** - * 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/FlagEvalLoggingHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java similarity index 88% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java index 3b7910d4712..32eb936c932 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -7,17 +7,17 @@ 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.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.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; 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 com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.FlagValueType; @@ -33,8 +33,6 @@ 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; /** @@ -43,53 +41,24 @@ */ 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() {} - }; + /** Creates a pipeline mock that captures the enqueued event for assertion. */ + private static FlagEvaluationWriter capturingWriter(final AtomicReference ref) { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + doAnswer( + invocation -> { + ref.set(invocation.getArgument(0)); + return null; + }) + .when(writer) + .enqueue(any()); + return writer; } private static FlagEvalLoggingHook hookWithWriter(final FlagEvaluationWriter writer) { - return new FlagEvalLoggingHook<>(writer); + return new FlagEvalLoggingHook<>(() -> writer); } private static FlagEvaluationDetails details( @@ -370,13 +339,10 @@ void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { hook.finallyAfter(null, det, Collections.emptyMap()); - // Capacity check gates the enqueue; exactly one enqueue call; no start/close. + // Capacity check gates the enqueue; exactly one enqueue call; nothing else. 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(); + verifyNoMoreInteractions(writer); } @Test @@ -457,24 +423,6 @@ void countContextTruncatedNotCalledWhenNoTruncation() { 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 @@ -665,17 +613,6 @@ void protectedPathSkipsEvaluationContextCapture() { 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. @@ -708,13 +645,4 @@ private static ImmutableMetadata consentOffMetadata() { .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/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java similarity index 99% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java index 322a06d2de7..cb56826fbb0 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java new file mode 100644 index 00000000000..80d212a10f8 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java @@ -0,0 +1,52 @@ +package com.datadog.openfeature; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +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 io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.LongCounterBuilder; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.metrics.MeterBuilder; +import io.opentelemetry.api.metrics.MeterProvider; +import org.junit.jupiter.api.Test; + +class FlagEvalMetricsLateSdkForkedTest { + @Test + void evaluationBeforeSdkRegistrationDoesNotClaimTheGlobalProvider() { + GlobalOpenTelemetry.resetForTest(); + final FlagEvalMetrics metrics = new FlagEvalMetrics(); + metrics.record("before", "on", "STATIC", null, null); + assertFalse(GlobalOpenTelemetry.isSet()); + + final OpenTelemetry telemetry = mock(OpenTelemetry.class); + final MeterProvider provider = mock(MeterProvider.class); + final MeterBuilder meterBuilder = mock(MeterBuilder.class); + final Meter meter = mock(Meter.class); + final LongCounterBuilder counterBuilder = mock(LongCounterBuilder.class); + final LongCounter counter = mock(LongCounter.class); + when(telemetry.getMeterProvider()).thenReturn(provider); + when(provider.meterBuilder("ddtrace.openfeature")).thenReturn(meterBuilder); + when(meterBuilder.build()).thenReturn(meter); + when(meter.counterBuilder("feature_flag.evaluations")).thenReturn(counterBuilder); + when(counterBuilder.setUnit("{evaluation}")).thenReturn(counterBuilder); + when(counterBuilder.setDescription("Number of feature flag evaluations")) + .thenReturn(counterBuilder); + when(counterBuilder.build()).thenReturn(counter); + try { + assertDoesNotThrow(() -> GlobalOpenTelemetry.set(telemetry)); + metrics.record("after", "on", "STATIC", null, null); + verify(counter).add(eq(1L), any(Attributes.class)); + } finally { + metrics.close(); + GlobalOpenTelemetry.resetForTest(); + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java similarity index 99% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java index 214fadc7274..61e0ab4dd4b 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java similarity index 65% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java index d7adf645e74..4008aac4974 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java @@ -1,6 +1,8 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; -import static datadog.trace.api.openfeature.Provider.METADATA; +import static com.datadog.openfeature.Provider.METADATA; +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; @@ -16,11 +18,21 @@ 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 com.datadog.openfeature.Provider.Options; +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; @@ -28,7 +40,6 @@ 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; @@ -37,12 +48,17 @@ import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import java.io.IOException; import java.lang.reflect.Field; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.junit.jupiter.api.AfterEach; @@ -57,19 +73,53 @@ public class ProviderTest { private static final long EVENT_TIMEOUT_SECONDS = 10; private ExecutorService executor; + private LocalHttpServer cdn; + private DDEvaluator evaluator; @BeforeEach - public void setup() { - executor = Executors.newSingleThreadExecutor(); + public void setup() throws IOException { + this.executor = Executors.newSingleThreadExecutor(); + this.cdn = new LocalHttpServer(); } @AfterEach public void tearDown() { - executor.shutdownNow(); + this.executor.shutdownNow(); OpenFeatureAPI.getInstance().shutdown(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + this.cdn.close(); + } + + /** + * Creates a provider backed by a real evaluator, whose runtime polls a local CDN that has no + * configuration. Configurations are then dispatched with {@link #dispatch}. + */ + private Provider newProvider(final Options options) { + return newProvider(options, Connector.NONE); + } + + private Provider newProvider(final Options options, final Connector connector) { + final Provider[] providerRef = new Provider[1]; + this.evaluator = + new DDEvaluator( + () -> providerRef[0].onConfigurationChange(), + connector, + TestSettings.of( + "feature.flags.configuration.source.agentless.base.url", + this.cdn.uri("/").toString())); + providerRef[0] = new Provider(options, this.evaluator); + return providerRef[0]; + } + + private Provider newProvider() { + return newProvider(new Options().initTimeout(30, SECONDS)); + } + + private void dispatch(final ServerConfiguration configuration) { + this.evaluator.accept(configuration); + } + + private static ServerConfiguration emptyConfiguration() { + return new ServerConfiguration("", "", false, null, emptyMap()); } @Test @@ -77,12 +127,12 @@ public void testSetProvider() throws Exception { final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); final CompletableFuture readyEvent = new CompletableFuture<>(); api.onProviderReady(readyEvent::complete); - api.setProvider(new Provider()); + api.setProvider(newProvider()); final Client client = api.getClient(); assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + dispatch(emptyConfiguration()); readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); assertThat(client.getProviderState(), equalTo(ProviderState.READY)); } @@ -90,12 +140,13 @@ public void testSetProvider() throws Exception { @Test public void testSetProviderAndWait() throws Exception { final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final Future provider = executor.submit(() -> api.setProviderAndWait(new Provider())); + final Provider newProvider = newProvider(); + final Future provider = this.executor.submit(() -> api.setProviderAndWait(newProvider)); final Client client = api.getClient(); assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + dispatch(emptyConfiguration()); provider.get(EVENT_TIMEOUT_SECONDS, SECONDS); assertThat(client.getProviderState(), equalTo(ProviderState.READY)); } @@ -110,12 +161,12 @@ public void testSetProviderAndWaitTimeoutRecoversWhenConfigurationArrives() thro assertThrows( ProviderNotReadyError.class, - () -> api.setProviderAndWait(new Provider(new Options().initTimeout(10, MILLISECONDS)))); + () -> api.setProviderAndWait(newProvider(new Options().initTimeout(10, MILLISECONDS)))); assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); assertFalse(readyEvent.isDone()); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + dispatch(emptyConfiguration()); final EventDetails eventDetails = readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); assertThat(client.getProviderState(), equalTo(ProviderState.READY)); @@ -263,8 +314,11 @@ public ProviderEvaluation evaluate( @Test public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throws Exception { final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - api.setProviderAndWait(new Provider()); + final Provider provider = newProvider(); + this.executor.submit(() -> api.setProviderAndWait(provider)); + awaitInitialization(); + dispatch(emptyConfiguration()); + awaitReady(api); final Client client = api.getClient(); assertThat(client.getProviderState(), equalTo(ProviderState.READY)); @@ -277,7 +331,7 @@ public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throw client.on(ProviderEvent.PROVIDER_ERROR, errorEventHandler); client.on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, configChangedEventHandler); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + dispatch(null); final EventDetails eventDetails = errorEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); assertThat(eventDetails.getProviderName(), equalTo(METADATA)); @@ -287,11 +341,11 @@ public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throw assertThat(evalDetails.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + dispatch(emptyConfiguration()); readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + dispatch(emptyConfiguration()); configChangedEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); verify(errorEventHandler, times(1)).accept(any()); verify(readyEventHandler, times(1)).accept(any()); @@ -299,24 +353,34 @@ public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throw } @Test - public void testFailureToLoadInternalApi() { - @SuppressWarnings("unchecked") - final Consumer consumer = mock(Consumer.class); + public void testDisabledProductFailsInitialization() { + final Provider[] providerRef = new Provider[1]; + final DDEvaluator disabled = + new DDEvaluator( + () -> providerRef[0].onConfigurationChange(), + Connector.NONE, + TestSettings.of("feature.flags.enabled", "false")); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), disabled); + + final FatalError error = + assertThrows( + FatalError.class, + () -> OpenFeatureAPI.getInstance().setProviderAndWait(providerRef[0])); + assertThat(error.getCause().getMessage(), equalTo("Feature Flags is disabled")); + } - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.onProviderError(consumer); + @Test + public void testRemoteConfigurationWithoutAgentFailsInitialization() { + final Provider[] providerRef = new Provider[1]; + final DDEvaluator remoteConfig = + new DDEvaluator( + () -> providerRef[0].onConfigurationChange(), + Connector.NONE, + TestSettings.of("feature.flags.configuration.source", "remote_config")); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), remoteConfig); assertThrows( - FatalError.class, - () -> - api.setProviderAndWait( - new Provider() { - @Override - protected Class loadEvaluatorClass() throws ClassNotFoundException { - throw new ClassNotFoundException( - "Class " + FeatureFlaggingGateway.class.getName() + " not found"); - } - })); + FatalError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(providerRef[0])); } @Test @@ -330,59 +394,43 @@ public void testGetProviderHooksReturnsFlagEvalMetricsHook() { assertThat(hooks.get(1) instanceof FlagEvalLoggingHook, 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(1)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, 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 BlockingQueue posted = new LinkedBlockingQueue<>(); + final Provider provider = + newProvider( + new Options().initTimeout(10, SECONDS), + new ProxyConnector( + (route, json) -> { + if ("flagevaluation".equals(route)) { + posted.add(json); + } + })); final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); + this.executor.submit(() -> api.setProviderAndWait(provider)); + awaitInitialization(); + dispatch(loggedFlagConfiguration()); + awaitReady(api); final MutableContext context = new MutableContext("user-1"); context.add("region", "us-east-1"); final FlagEvaluationDetails details = api.getClient().getStringDetails("logged-flag", "default", context); + // Shutting down the provider flushes the evaluation pipeline. + api.shutdown(); 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")); + final byte[] payload = posted.poll(EVENT_TIMEOUT_SECONDS, SECONDS); + assertTrue(payload != null, "flag evaluation payload must be posted on shutdown"); + final Map event = firstFlagEvaluation(payload); + assertThat(((Map) event.get("flag")).get("key"), equalTo("logged-flag")); + assertThat(((Map) event.get("variant")).get("key"), equalTo("variant-1")); + assertThat(((Map) event.get("allocation")).get("key"), equalTo("allocation-1")); + assertThat(event.get("targeting_key"), equalTo("user-1")); + assertThat(event.get("evaluation_count"), equalTo(1L)); + assertThat( + ((Map) ((Map) event.get("context")).get("evaluation")).get("region"), + equalTo("us-east-1")); } @Test @@ -437,7 +485,6 @@ private static Arguments[] 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); @@ -480,29 +527,74 @@ private static Consumer completingHandler( 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; - } + /** Waits for the evaluator to start waiting for its initial configuration. */ + private void awaitInitialization() throws InterruptedException { + final long deadline = System.nanoTime() + SECONDS.toNanos(EVENT_TIMEOUT_SECONDS); + while (this.evaluator.runtime() == null && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertTrue(this.evaluator.runtime() != null, "provider initialization must start"); + } - @Override - public void countPreQueueOverflow() {} + private static void awaitReady(final OpenFeatureAPI api) throws InterruptedException { + final long deadline = System.nanoTime() + SECONDS.toNanos(EVENT_TIMEOUT_SECONDS); + while (api.getClient().getProviderState() != ProviderState.READY + && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertThat(api.getClient().getProviderState(), equalTo(ProviderState.READY)); + } - @Override - public void countContextTruncated(final String reason) {} + private static ServerConfiguration loggedFlagConfiguration() { + final Map variations = new HashMap<>(); + variations.put("variant-1", new Variant("variant-1", "value")); + final Split split = new Split(java.util.Collections.emptyList(), "variant-1", emptyMap(), null); + final Allocation allocation = + new Allocation("allocation-1", null, null, null, singletonList(split), Boolean.TRUE); + final Map flags = new HashMap<>(); + flags.put( + "logged-flag", + new Flag("logged-flag", true, ValueType.STRING, variations, singletonList(allocation))); + return new ServerConfiguration("", "", true, null, flags); + } - @Override - public void start() {} + @SuppressWarnings("unchecked") + private static Map firstFlagEvaluation(final byte[] payload) { + final Map request = JsonReading.readObject(payload); + return (Map) ((List) request.get("flagEvaluations")).get(0); + } - @Override - public void close() {} - }; + /** A connector only providing an event platform proxy. */ + private static final class ProxyConnector implements Connector { + private final EventTransport proxy; + + ProxyConnector(final EventTransport proxy) { + this.proxy = proxy; + } + + @Override + public String setting(final String key) { + return null; + } + + @Override + public ConfigurationSource remoteConfiguration() { + return null; + } + + @Override + public EventTransport eventProxy() { + return this.proxy; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java similarity index 77% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java index 2ce0931e116..e9f261e9ad3 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -6,8 +6,7 @@ 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 com.datadog.openfeature.internal.connector.SpanEnricher; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.FlagValueType; import dev.openfeature.sdk.Hook; @@ -22,30 +21,30 @@ 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. + * Capture-side unit suite for APM feature-flag span enrichment. The hook records evaluations to the + * {@link SpanEnricher} and the agent does the accumulation, so these tests assert the recorded + * events (not span tags) 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); - } + private final SpanEnricher enricher = + new SpanEnricher() { + @Override + public void serialId(final int serialId, final boolean doLog, final String targetingKey) { + SpanEnrichmentHookTest.this.captured.add( + new SpanEnrichmentEvent(true, serialId, doLog, targetingKey, null, null)); + } + + @Override + public void runtimeDefault(final String flagKey, final Object defaultValue) { + SpanEnrichmentHookTest.this.captured.add( + new SpanEnrichmentEvent(false, 0, false, null, flagKey, defaultValue)); + } + }; // ---- helpers ---- @@ -85,7 +84,7 @@ private static HookContext ctx(final String flagKey, final String target @Test void serialIdWithDoLogDispatchesSerialAndSubject() { - new SpanEnrichmentHook() + new SpanEnrichmentHook(this.enricher) .finallyAfter( ctx("flag", "user-1"), details("flag", "on", "v", metadata(42, true)), @@ -101,7 +100,7 @@ void serialIdWithDoLogDispatchesSerialAndSubject() { @Test void serialIdWithoutDoLogStillDispatchesSerialButNotDoLog() { - new SpanEnrichmentHook() + new SpanEnrichmentHook(this.enricher) .finallyAfter( ctx("flag", "user-1"), details("flag", "on", "v", metadata(7, false)), @@ -123,7 +122,7 @@ void wrongTypedSerialIdDispatchesNothing() { .addString(SpanEnrichmentHook.METADATA_SERIAL_ID, "not-a-number") .addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, true) .build(); - new SpanEnrichmentHook() + new SpanEnrichmentHook(this.enricher) .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"); @@ -134,7 +133,7 @@ void wrongTypedSerialIdDispatchesNothing() { @Test void missingVariantDispatchesRuntimeDefaultWithNativeMap() { final Map objectValue = Collections.singletonMap("k", "val"); - new SpanEnrichmentHook() + new SpanEnrichmentHook(this.enricher) .finallyAfter( ctx("obj-flag", "user-1"), details("obj-flag", null, objectValue, ImmutableMetadata.builder().build()), @@ -154,7 +153,7 @@ void missingVariantUnwrapsOpenFeatureValueStructureToNativeMap() { inner.put("enabled", new Value(true)); final Value structureDefault = new Value(new ImmutableStructure(inner)); - new SpanEnrichmentHook() + new SpanEnrichmentHook(this.enricher) .finallyAfter( ctx("struct-flag", "user-1"), details("struct-flag", null, structureDefault, ImmutableMetadata.builder().build()), @@ -197,7 +196,7 @@ void unwrapPassesNativeValuesThrough() { @Test void nullDetailsDispatchesNothing() { - new SpanEnrichmentHook().finallyAfter(null, null, null); + new SpanEnrichmentHook(this.enricher).finallyAfter(null, null, null); assertTrue(captured.isEmpty()); } @@ -238,4 +237,53 @@ void getProviderHooksReturnsSameInstanceEachCall() { gateOn.getProviderHooks() == gateOn.getProviderHooks(), "gate on => getProviderHooks allocates nothing (same instance)"); } + + /** A recorded span enrichment. */ + private static 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; + + 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; + } + + boolean hasSerialId() { + return this.serialIdPresent; + } + + int serialId() { + return this.serialId; + } + + boolean doLog() { + return this.doLog; + } + + String targetingKey() { + return this.targetingKey; + } + + String flagKey() { + return this.flagKey; + } + + Object defaultValue() { + return this.defaultValue; + } + } } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java new file mode 100644 index 00000000000..a030740cefc --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java @@ -0,0 +1,129 @@ +package com.datadog.openfeature.internal; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +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 java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class EventQueueTest { + @Test + void rejectsOffersBeyondCapacity() { + final EventQueue queue = new EventQueue<>(2); + + assertTrue(queue.offer("a")); + assertTrue(queue.offer("b")); + assertFalse(queue.offer("c")); + + assertEquals(2, queue.size()); + assertEquals(2, queue.capacity()); + assertEquals("a", queue.poll()); + assertTrue(queue.offer("c")); + } + + @Test + void pollsInFifoOrderAndReturnsNullWhenEmpty() { + final EventQueue queue = new EventQueue<>(4); + queue.offer(1); + queue.offer(2); + + assertEquals(1, queue.poll()); + assertEquals(2, queue.poll()); + assertNull(queue.poll()); + assertEquals(0, queue.size()); + } + + @Test + void timedPollWaitsForAnElement() throws Exception { + final EventQueue queue = new EventQueue<>(1); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + executor.submit( + () -> { + MILLISECONDS.sleep(50); + return queue.offer("late"); + }); + + assertEquals("late", queue.poll(5, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + } + + @Test + void timedPollTimesOut() throws Exception { + final EventQueue queue = new EventQueue<>(1); + final long start = System.nanoTime(); + + assertNull(queue.poll(30, MILLISECONDS)); + + assertTrue(System.nanoTime() - start >= MILLISECONDS.toNanos(30)); + } + + @Test + void timedPollIsInterruptible() { + final EventQueue queue = new EventQueue<>(1); + Thread.currentThread().interrupt(); + + assertThrows(InterruptedException.class, () -> queue.poll(5, TimeUnit.SECONDS)); + assertFalse(Thread.currentThread().isInterrupted()); + } + + @Test + void drainsUpToTheLimit() { + final EventQueue queue = new EventQueue<>(8); + for (int i = 0; i < 5; i++) { + queue.offer(i); + } + final List drained = new ArrayList<>(); + + queue.drain(drained::add, 3); + + assertEquals(List.of(0, 1, 2), drained); + assertEquals(2, queue.size()); + queue.drain(drained::add, 10); + assertEquals(List.of(0, 1, 2, 3, 4), drained); + } + + @Test + void concurrentProducersNeverExceedCapacity() throws Exception { + final int capacity = 1000; + final EventQueue queue = new EventQueue<>(capacity); + final int producers = 8; + final ExecutorService executor = Executors.newFixedThreadPool(producers); + final CountDownLatch start = new CountDownLatch(1); + final AtomicInteger accepted = new AtomicInteger(); + try { + for (int p = 0; p < producers; p++) { + executor.submit( + () -> { + start.await(); + for (int i = 0; i < 500; i++) { + if (queue.offer(i)) { + accepted.incrementAndGet(); + } + } + return null; + }); + } + start.countDown(); + executor.shutdown(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + + assertEquals(capacity, accepted.get()); + assertEquals(capacity, queue.size()); + } +} 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 new file mode 100644 index 00000000000..bc8545cd387 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -0,0 +1,238 @@ +package com.datadog.openfeature.internal; + +import static java.util.Collections.emptyMap; +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.assertSame; +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.verify; + +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +class FeatureFlagsRuntimeTest { + private static final byte[] EMPTY_UFC = + "{\"environment\":{\"name\":\"Test\"},\"flags\":{}}".getBytes(StandardCharsets.UTF_8); + + @Test + void disabledProductIsRejected() { + final IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> + FeatureFlagsRuntime.create( + Connector.NONE, TestSettings.of("feature.flags.enabled", "false"))); + + assertEquals("Feature Flags is disabled", error.getMessage()); + } + + @Test + void remoteConfigurationRequiresTheJavaAgent() { + final IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> + FeatureFlagsRuntime.create( + Connector.NONE, + TestSettings.of("feature.flags.configuration.source", "remote_config"))); + + assertTrue(error.getMessage().contains("requires the Datadog Java agent")); + } + + @Test + void agentlessWithoutApiKeyOrProxyDisablesEventDelivery() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create(Connector.NONE, TestSettings.of()); + + assertNull(runtime.evaluations()); + } + + @Test + void evaluationCountsKillSwitchDisablesTheEvaluationPipeline() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(null, (route, json) -> {}), + TestSettings.of("flagging.evaluation.counts.enabled", "false")); + + assertNull(runtime.evaluations()); + } + + @Test + void eventProxyEnablesBothPipelines() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create(new TestConnector(null, (route, json) -> {}), TestSettings.of()); + + assertNotNull(runtime.evaluations()); + } + + @Test + void remoteConfigurationDeliversParsedConfigurationsAndReplaysToLateListeners() { + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + final List early = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener(early::add); + runtime.start(); + try { + source.listener.accept(EMPTY_UFC); + source.listener.accept("{not json".getBytes(StandardCharsets.UTF_8)); + final List late = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener(late::add); + + assertEquals(1, early.size()); + assertEquals("Test", early.get(0).environment.name); + assertEquals(1, late.size()); + assertSame(early.get(0), late.get(0)); + + source.listener.accept(null); + assertEquals(2, early.size()); + assertNull(early.get(1)); + } finally { + runtime.stop(); + } + assertTrue(source.closed); + } + + @Test + void removedListenersAreNotNotified() { + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + @SuppressWarnings("unchecked") + final Consumer listener = mock(Consumer.class); + runtime.addConfigurationListener(listener); + runtime.removeConfigurationListener(listener); + runtime.start(); + try { + source.listener.accept(EMPTY_UFC); + } finally { + runtime.stop(); + } + + verify(listener, org.mockito.Mockito.never()).accept(org.mockito.ArgumentMatchers.any()); + } + + @Test + void sharedRuntimeIsReferenceCounted() { + final TestConfigurationSource source = new TestConfigurationSource(); + final Connector connector = new TestConnector(source, null); + final FeatureFlagsRuntime first = + FeatureFlagsRuntime.acquire( + connector, TestSettings.of("feature.flags.configuration.source", "remote_config")); + final FeatureFlagsRuntime second = FeatureFlagsRuntime.acquire(connector, TestSettings.of()); + try { + assertSame(first, second); + FeatureFlagsRuntime.release(); + assertTrue(!source.closed); + } finally { + FeatureFlagsRuntime.release(); + } + assertTrue(source.closed); + // Extra releases are ignored. + FeatureFlagsRuntime.release(); + } + + @Test + void failedStartDoesNotCacheTheSharedRuntime() { + final ConfigurationSource failing = + listener -> { + throw new IllegalStateException("subscription failed"); + }; + assertThrows( + IllegalStateException.class, + () -> + FeatureFlagsRuntime.acquire( + new TestConnector(failing, null), + TestSettings.of("feature.flags.configuration.source", "remote_config"))); + + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.acquire( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + try { + assertNotNull(runtime); + assertNotNull(source.listener, "a later acquisition starts a new runtime"); + } finally { + FeatureFlagsRuntime.release(); + } + } + + @Test + void recordsExposuresOnlyWhenDeliveryIsEnabled() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create(Connector.NONE, TestSettings.of()); + // Must not throw without an exposure pipeline. + runtime.recordExposure( + new com.datadog.openfeature.internal.exposure.ExposureEvent( + 1L, + new com.datadog.openfeature.internal.exposure.Allocation("a"), + new com.datadog.openfeature.internal.exposure.Flag("f"), + new com.datadog.openfeature.internal.exposure.Variant("v"), + new com.datadog.openfeature.internal.exposure.Subject("s", emptyMap()), + null)); + } + + private static final class TestConfigurationSource implements ConfigurationSource { + volatile Consumer listener; + volatile boolean closed; + + @Override + public AutoCloseable subscribe(final Consumer listener) { + this.listener = listener; + return () -> this.closed = true; + } + } + + private static final class TestConnector implements Connector { + private final ConfigurationSource remoteConfiguration; + private final EventTransport eventProxy; + + TestConnector(final ConfigurationSource remoteConfiguration, final EventTransport eventProxy) { + this.remoteConfiguration = remoteConfiguration; + this.eventProxy = eventProxy; + } + + @Override + public String setting(final String key) { + return null; + } + + @Override + public ConfigurationSource remoteConfiguration() { + return this.remoteConfiguration; + } + + @Override + public EventTransport eventProxy() { + return this.eventProxy; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/JsonReading.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/JsonReading.java new file mode 100644 index 00000000000..82e888e5b0a --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/JsonReading.java @@ -0,0 +1,101 @@ +package com.datadog.openfeature.internal; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Reads JSON documents as generic trees for test assertions. */ +public final class JsonReading { + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + + private JsonReading() {} + + /** Reads a JSON document. Integral numbers are read as {@link Long}, others as {@link Double}. */ + public static Object read(final byte[] json) { + try (JsonParser parser = JSON_FACTORY.createParser(json)) { + parser.nextToken(); + return readValue(parser); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + } + + public static Object read(final String json) { + return read(json.getBytes(UTF_8)); + } + + @SuppressWarnings("unchecked") + public static Map readObject(final byte[] json) { + return (Map) read(json); + } + + @SuppressWarnings("unchecked") + public static Map readObject(final String json) { + return (Map) read(json); + } + + /** Reads a JSON object with all numbers as {@link Double}, like Moshi's generic adapters. */ + @SuppressWarnings("unchecked") + public static Map readObjectWithDoubles(final byte[] json) { + return (Map) toDoubles(read(json)); + } + + private static Object toDoubles(final Object value) { + if (value instanceof Long) { + return ((Long) value).doubleValue(); + } + if (value instanceof Map) { + final Map map = new LinkedHashMap<>(); + ((Map) value).forEach((k, v) -> map.put((String) k, toDoubles(v))); + return map; + } + if (value instanceof List) { + final List list = new ArrayList<>(); + ((List) value).forEach(v -> list.add(toDoubles(v))); + return list; + } + return value; + } + + private static Object readValue(final JsonParser parser) throws IOException { + final JsonToken token = parser.currentToken(); + switch (token) { + case START_OBJECT: + final Map object = new LinkedHashMap<>(); + while (parser.nextToken() == JsonToken.FIELD_NAME) { + final String name = parser.currentName(); + parser.nextToken(); + object.put(name, readValue(parser)); + } + return object; + case START_ARRAY: + final List array = new ArrayList<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + array.add(readValue(parser)); + } + return array; + case VALUE_STRING: + return parser.getText(); + case VALUE_NUMBER_INT: + return parser.getLongValue(); + case VALUE_NUMBER_FLOAT: + return parser.getDoubleValue(); + case VALUE_TRUE: + return Boolean.TRUE; + case VALUE_FALSE: + return Boolean.FALSE; + case VALUE_NULL: + return null; + default: + throw new IOException("Unexpected token " + token); + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/LocalHttpServer.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/LocalHttpServer.java new file mode 100644 index 00000000000..c2d2e9dbdc6 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/LocalHttpServer.java @@ -0,0 +1,163 @@ +package com.datadog.openfeature.internal; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.zip.GZIPOutputStream; + +/** A local HTTP server replaying scripted responses and recording the received requests. */ +public final class LocalHttpServer implements AutoCloseable { + private final HttpServer server; + private final ConcurrentLinkedQueue responses = new ConcurrentLinkedQueue<>(); + private final List requests = new CopyOnWriteArrayList<>(); + private final BlockingQueue received = new LinkedBlockingQueue<>(); + private volatile Response fallback = new Response(404, null, Map.of(), false); + + public LocalHttpServer() throws IOException { + this.server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + this.server.createContext("/", this::handle); + this.server.start(); + } + + /** + * @param path the request path, with an optional query. + * @return the URI of the given path on this server. + */ + public URI uri(final String path) { + return URI.create( + "http://" + + this.server.getAddress().getHostString() + + ":" + + this.server.getAddress().getPort() + + path); + } + + /** Queues a response. */ + public LocalHttpServer enqueue(final int status, final String body, final String... headers) { + this.responses.add(response(status, body, false, headers)); + return this; + } + + /** Queues a gzip encoded response. */ + public LocalHttpServer enqueueGzip(final int status, final String body, final String... headers) { + this.responses.add(response(status, body, true, headers)); + return this; + } + + /** Sets the response returned once queued responses are exhausted. */ + public LocalHttpServer otherwise(final int status, final String body, final String... headers) { + this.fallback = response(status, body, false, headers); + return this; + } + + public List requests() { + return this.requests; + } + + public BlockingQueue received() { + return this.received; + } + + @Override + public void close() { + this.server.stop(0); + } + + private static Response response( + final int status, final String body, final boolean gzip, final String... headers) { + final Map headerMap = new java.util.HashMap<>(); + for (int i = 0; i < headers.length; i += 2) { + headerMap.put(headers[i], headers[i + 1]); + } + return new Response(status, body, headerMap, gzip); + } + + private void handle(final HttpExchange exchange) throws IOException { + final Request request = + new Request( + exchange.getRequestMethod(), + exchange.getRequestURI(), + exchange.getRequestHeaders(), + exchange.getRequestBody().readAllBytes()); + this.requests.add(request); + this.received.add(request); + Response response = this.responses.poll(); + if (response == null) { + response = this.fallback; + } + for (final Map.Entry header : response.headers.entrySet()) { + exchange.getResponseHeaders().add(header.getKey(), header.getValue()); + } + byte[] body = response.body == null ? new byte[0] : response.body.getBytes(UTF_8); + if (response.gzip) { + exchange.getResponseHeaders().add("Content-Encoding", "gzip"); + final ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(compressed)) { + gzip.write(body); + } + body = compressed.toByteArray(); + } + exchange.sendResponseHeaders(response.status, body.length == 0 ? -1 : body.length); + if (body.length > 0) { + try (OutputStream output = exchange.getResponseBody()) { + output.write(body); + } + } + exchange.close(); + } + + /** A received request. */ + public static final class Request { + public final String method; + public final URI uri; + public final Headers headers; + public final byte[] body; + + Request(final String method, final URI uri, final Headers headers, final byte[] body) { + this.method = method; + this.uri = uri; + this.headers = headers; + this.body = body; + } + + public String header(final String name) { + return this.headers.getFirst(name); + } + + public String bodyAsString() { + return new String(this.body, UTF_8); + } + } + + private static final class Response { + final int status; + final String body; + final Map headers; + final boolean gzip; + + Response( + final int status, + final String body, + final Map headers, + final boolean gzip) { + this.status = status; + this.body = body; + this.headers = headers; + this.gzip = gzip; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java new file mode 100644 index 00000000000..c2774b4c9a9 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java @@ -0,0 +1,168 @@ +package com.datadog.openfeature.internal.config; + +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 com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class SettingsTest { + @Test + void looksUpConnectorThenSystemPropertiesThenEnvironment() { + final Map connector = new HashMap<>(); + final Map properties = new HashMap<>(); + final Map environment = new HashMap<>(); + final Settings settings = + new Settings(new SettingConnector(connector), properties::get, environment::get); + + assertNull(settings.env()); + environment.put("DD_ENV", "from-env"); + assertEquals("from-env", settings.env()); + properties.put("dd.env", "from-property"); + assertEquals("from-property", settings.env()); + connector.put("env", "from-agent"); + assertEquals("from-agent", settings.env()); + } + + @TableTest({ + "scenario | key | variable ", + "dotted key | 'feature.flags.enabled' | 'DD_FEATURE_FLAGS_ENABLED' ", + "dashed key | 'api-key' | 'DD_API_KEY' ", + "deeply nested key | 'flagging.evaluation.counts.enabled' | 'DD_FLAGGING_EVALUATION_COUNTS_ENABLED'" + }) + void mapsKeysToEnvironmentVariables(final String key, final String variable) { + assertEquals(variable, Settings.environmentVariableName(key)); + } + + @TableTest({ + "scenario | enabled | source | legacy | resolved ", + "default | | | | 'agentless' ", + "explicit agentless | | 'agentless' | | 'agentless' ", + "explicit remote config | | 'remote_config' | | 'remote_config'", + "normalized source | | ' Remote_Config ' | | 'remote_config'", + "unsupported source | | 'offline' | | ", + "blank source | | ' ' | | 'agentless' ", + "disabled wins over source | 'false' | 'remote_config' | | ", + "enabled keeps default | 'true' | | | 'agentless' ", + "legacy enabled selects rc | | | 'true' | 'remote_config'", + "legacy disabled disables | | | 'false' | ", + "explicit source wins over legacy | | 'agentless' | 'false' | 'agentless' ", + "invalid enabled reads as false | 'maybe' | | | ", + "numeric disabled | '0' | | | " + }) + void resolvesConfigurationSource( + final String enabled, final String source, final String legacy, final String resolved) { + final Map properties = new HashMap<>(); + putIfNotNull(properties, "dd.feature.flags.enabled", enabled); + putIfNotNull(properties, "dd.feature.flags.configuration.source", source); + putIfNotNull(properties, "dd.experimental.flagging.provider.enabled", legacy); + final Settings settings = new Settings(Connector.NONE, properties::get, name -> null); + + assertEquals(resolved, settings.configurationSource()); + assertEquals(resolved != null, settings.isEnabled()); + } + + @Test + void appliesDefaults() { + final Settings settings = TestSettings.of(); + + assertEquals("datadoghq.com", settings.site()); + assertNull(settings.apiKey()); + assertNull(settings.service()); + assertNull(settings.version()); + assertNull(settings.agentlessBaseUrl()); + assertEquals(30, settings.agentlessPollIntervalSeconds()); + assertEquals(5, settings.agentlessRequestTimeoutSeconds()); + assertFalse(settings.isSpanEnrichmentEnabled()); + assertTrue(settings.isEvaluationCountsEnabled()); + } + + @TableTest({ + "scenario | value | interval", + "valid | '10' | 10 ", + "zero | '0' | 30 ", + "negative | '-5' | 30 ", + "invalid | 'ten' | 30 ", + "blank | ' ' | 30 " + }) + void fallsBackToDefaultPollIntervalOnInvalidValues(final String value, final int interval) { + assertEquals( + interval, + TestSettings.of("feature.flags.configuration.source.agentless.poll.interval.seconds", value) + .agentlessPollIntervalSeconds()); + } + + @Test + void trimsAndIgnoresBlankValues() { + final Settings settings = + TestSettings.of( + "api-key", " secret ", + "site", " ", + "feature.flags.configuration.source.agentless.base.url", " "); + + assertEquals("secret", settings.apiKey()); + assertEquals("datadoghq.com", settings.site()); + assertNull(settings.agentlessBaseUrl()); + } + + @Test + void readsToggles() { + final Settings settings = + TestSettings.of( + "experimental.flagging.provider.span.enrichment.enabled", "true", + "flagging.evaluation.counts.enabled", "false"); + + assertTrue(settings.isSpanEnrichmentEnabled()); + assertFalse(settings.isEvaluationCountsEnabled()); + } + + private static void putIfNotNull( + final Map map, final String key, final String value) { + if (value != null) { + map.put(key, value); + } + } + + /** A connector only providing settings. */ + private static final class SettingConnector implements Connector { + private final Map settings; + + SettingConnector(final Map settings) { + this.settings = settings; + } + + @Override + public String setting(final String key) { + return this.settings.get(key); + } + + @Override + public ConfigurationSource remoteConfiguration() { + return null; + } + + @Override + public EventTransport eventProxy() { + return null; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java new file mode 100644 index 00000000000..32caeb81da1 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java @@ -0,0 +1,31 @@ +package com.datadog.openfeature.internal.config; + +import com.datadog.openfeature.internal.connector.Connector; +import java.util.HashMap; +import java.util.Map; + +/** Builds {@link Settings} from explicit values, ignoring the JVM system properties and env. */ +public final class TestSettings { + private TestSettings() {} + + /** + * @param keyValues alternating setting keys, without the {@code dd.} prefix, and values. + * @return the settings backed by the given values only. + */ + public static Settings of(final String... keyValues) { + return of(Connector.NONE, keyValues); + } + + /** + * @param connector the connector to look settings up from first. + * @param keyValues alternating setting keys, without the {@code dd.} prefix, and values. + * @return the settings backed by the connector and the given values only. + */ + public static Settings of(final Connector connector, final String... keyValues) { + final Map properties = new HashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + properties.put("dd." + keyValues[i], keyValues[i + 1]); + } + return new Settings(connector, properties::get, name -> null); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java new file mode 100644 index 00000000000..6e624d7352c --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java @@ -0,0 +1,461 @@ +package com.datadog.openfeature.internal.delivery; + +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.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.doReturn; +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 com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class CdnConfigurationSourceTest { + private static final long POLL_INTERVAL_MILLIS = 30_000; + private static final String CONFIG = + "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + "{\"createdAt\":\"2024-04-17T19:40:53.716Z\",\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}}}}"; + private static final String RAW_UFC = + "{\"createdAt\":\"2024-04-17T19:40:53.716Z\",\"environment\":{\"name\":\"Test\"},\"flags\":{}}"; + + private LocalHttpServer server; + private final List dispatched = new CopyOnWriteArrayList<>(); + private final List sleeps = new CopyOnWriteArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + this.server = new LocalHttpServer(); + } + + @AfterEach + void tearDown() { + this.server.close(); + } + + @Test + void derivesDatadogUfcCdnEndpointFromSiteAndEnv() { + final URI endpoint = + CdnConfigurationSource.endpoint(TestSettings.of("site", "datadoghq.eu", "env", "prod")); + + assertEquals( + "https://ufc-server.ff-cdn.datadoghq.eu/api/v2/feature-flagging/config/rules-based/server?dd_env=prod", + endpoint.toString()); + } + + @Test + void derivesDatadogUfcCdnEndpointWithoutEnv() { + assertEquals( + "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", + CdnConfigurationSource.endpoint(TestSettings.of()).toString()); + } + + @Test + void encodesEnvInDatadogUfcCdnEndpoint() { + assertEquals( + "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server?dd_env=a+b%26c", + CdnConfigurationSource.endpoint(TestSettings.of("env", "a b&c")).toString()); + } + + @TableTest({ + "scenario | baseUrl | endpoint ", + "host only | 'http://localhost:8080' | 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server'", + "root path | 'http://localhost:8080/' | 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server'", + "custom path | 'https://proxy.example/ufc.json' | 'https://proxy.example/ufc.json' ", + "trimmed | ' https://proxy.example/ufc ' | 'https://proxy.example/ufc' " + }) + void resolvesConfiguredAgentlessBaseUrl(final String baseUrl, final String endpoint) { + assertEquals( + endpoint, + CdnConfigurationSource.endpoint( + TestSettings.of( + "feature.flags.configuration.source.agentless.base.url", + baseUrl, + "env", + "prod")) + .toString()); + } + + @TableTest({ + "scenario | baseUrl ", + "no scheme | 'localhost' ", + "relative path | 'relative/ufc'", + "invalid | 'http://[::1' " + }) + void rejectsInvalidConfiguredAgentlessBaseUrl(final String baseUrl) { + final Settings settings = + TestSettings.of("feature.flags.configuration.source.agentless.base.url", baseUrl); + + assertThrows(IllegalArgumentException.class, () -> CdnConfigurationSource.endpoint(settings)); + } + + @Test + void customEndpointDoesNotSendApiKey() throws Exception { + this.server.enqueue(200, CONFIG); + final CdnConfigurationSource source = + new CdnConfigurationSource( + TestSettings.of( + "feature.flags.configuration.source.agentless.base.url", + this.server.uri("/").toString(), + "api-key", + "secret"), + new RuntimeServices(HealthMetrics.NOOP)); + try { + source.start(this.dispatched::add); + } finally { + source.close(); + } + + assertEquals(1, this.dispatched.size()); + assertNull(this.server.requests().get(0).header("DD-API-KEY")); + } + + @Test + void sendsApiKeyAndMetadataHeaders() throws Exception { + this.server.enqueue(200, CONFIG); + + assertTrue(source("secret").pollOnce()); + + final LocalHttpServer.Request request = this.server.requests().get(0); + assertEquals("GET", request.method); + assertEquals("secret", request.header("DD-API-KEY")); + assertEquals("gzip", request.header("Accept-Encoding")); + assertEquals("java", request.header("Datadog-Meta-Lang")); + assertNull(request.header("If-None-Match")); + } + + @Test + void appliesJsonApiConfiguration() { + this.server.enqueue(200, CONFIG, "ETag", "\"v1\""); + + assertTrue(source().pollOnce()); + + assertEquals(1, this.dispatched.size()); + assertEquals("Test", this.dispatched.get(0).environment.name); + } + + @Test + void decodesGzipResponses() { + this.server.enqueueGzip(200, CONFIG); + + assertTrue(source().pollOnce()); + + assertEquals(1, this.dispatched.size()); + } + + @Test + void keepsLastKnownGoodOnRawUfcAndMalformedPayloads() { + this.server.enqueue(200, CONFIG).enqueue(200, RAW_UFC).enqueue(200, "{not json"); + final CdnConfigurationSource source = source(); + + assertTrue(source.pollOnce()); + assertFalse(source.pollOnce()); + assertFalse(source.pollOnce()); + + assertEquals(1, this.dispatched.size()); + } + + @Test + void usesEtagAndSkipsDispatchOnUnchangedConfig() { + this.server.enqueue(200, CONFIG, "ETag", "\"v1\"").enqueue(304, null); + final CdnConfigurationSource source = source(); + + assertTrue(source.pollOnce()); + assertTrue(source.pollOnce()); + + assertEquals(1, this.dispatched.size()); + assertEquals("\"v1\"", this.server.requests().get(1).header("If-None-Match")); + } + + @Test + void ignoresBlankEtagAndClearsPreviousEtag() { + this.server + .enqueue(200, CONFIG, "ETag", "\"v1\"") + .enqueue(200, CONFIG) + .enqueue(200, CONFIG, "ETag", " ") + .enqueue(200, CONFIG); + final CdnConfigurationSource source = source(); + + for (int i = 0; i < 4; i++) { + assertTrue(source.pollOnce()); + } + + assertEquals("\"v1\"", this.server.requests().get(1).header("If-None-Match")); + assertNull(this.server.requests().get(2).header("If-None-Match")); + assertNull(this.server.requests().get(3).header("If-None-Match")); + } + + @Test + void coldNotModifiedDoesNotEstablishEtag() { + this.server.enqueue(304, null, "ETag", "\"v1\"").enqueue(200, CONFIG); + final CdnConfigurationSource source = source(); + + assertTrue(source.pollOnce()); + assertTrue(source.pollOnce()); + + assertNull(this.server.requests().get(1).header("If-None-Match")); + assertEquals(1, this.dispatched.size()); + } + + @Test + void failedDispatchDoesNotAdvanceEtag() { + this.server.enqueue(200, CONFIG, "ETag", "\"v1\"").enqueue(200, CONFIG, "ETag", "\"v2\""); + final CdnConfigurationSource source = source(); + final RuntimeException failure = new IllegalStateException("listener failure"); + source.setListenerForTest( + configuration -> { + throw failure; + }); + + assertThrows(IllegalStateException.class, source::pollOnce); + source.setListenerForTest(this.dispatched::add); + assertTrue(source.pollOnce()); + + assertNull(this.server.requests().get(1).header("If-None-Match")); + } + + @TableTest({ + "scenario | status", + "unauthorized | 401 ", + "forbidden | 403 ", + "not found | 404 ", + "no content | 204 " + }) + void keepsLastKnownGoodOnNonOkResponses(final int status) { + this.server.enqueue(200, CONFIG).enqueue(status, null); + final CdnConfigurationSource source = source(); + + assertTrue(source.pollOnce()); + assertFalse(source.pollOnce()); + + assertEquals(1, this.dispatched.size()); + assertEquals(2, this.server.requests().size()); + } + + @TableTest({ + "scenario | status", + "client timeout | 408 ", + "too many requests | 429 ", + "server error | 503 " + }) + void retriesRetryableStatusBeforeApplyingConfig(final int status) { + this.server.enqueue(status, null).enqueue(status, null).enqueue(200, CONFIG); + + assertTrue(source().pollOnce()); + + assertEquals(3, this.server.requests().size()); + assertEquals(1, this.dispatched.size()); + assertEquals(2, this.sleeps.size()); + } + + @Test + void stopsAfterThreeAttemptsOnRetryableStatus() { + this.server.otherwise(500, null); + + assertFalse(source().pollOnce()); + + assertEquals(CdnConfigurationSource.MAX_ATTEMPTS, this.server.requests().size()); + assertTrue(this.dispatched.isEmpty()); + } + + @Test + void retriesServerErrorThenKeepsColdStateOnNotModified() { + this.server.enqueue(500, null).enqueue(304, null); + + assertTrue(source().pollOnce()); + + assertEquals(2, this.server.requests().size()); + assertTrue(this.dispatched.isEmpty()); + } + + @Test + void retriesIoFailuresUntilAttemptsAreExhausted() throws Exception { + final URI unreachable; + try (ServerSocket socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + unreachable = URI.create("http://127.0.0.1:" + socket.getLocalPort() + "/ufc"); + } + final CdnConfigurationSource source = + source(unreachable, null, mock(ScheduledExecutorService.class)); + + assertFalse(source.pollOnce()); + + assertEquals(CdnConfigurationSource.MAX_ATTEMPTS - 1, this.sleeps.size()); + } + + @Test + void usesIntervalAwareRetryBackoff() { + this.server.enqueue(503, null).enqueue(503, null).enqueue(200, CONFIG); + + assertTrue(source().pollOnce()); + + assertEquals( + List.of( + CdnConfigurationSource.retryDelayMillis(POLL_INTERVAL_MILLIS, 1, 1.0), + CdnConfigurationSource.retryDelayMillis(POLL_INTERVAL_MILLIS, 2, 1.0)), + this.sleeps); + } + + @TableTest({ + "scenario | pollIntervalMillis | attempt | jitter | delay", + "first clamped low | 1000 | 1 | 1.0 | 2000 ", + "first proportional | 30000 | 1 | 1.0 | 5000 ", + "first clamped high | 600000 | 1 | 1.0 | 10000", + "second clamped low | 1000 | 2 | 1.0 | 5000 ", + "second proportional | 30000 | 2 | 1.0 | 10000", + "second clamped high | 600000 | 2 | 1.0 | 30000", + "jitter down | 30000 | 1 | 0.8 | 4000 ", + "jitter up | 30000 | 2 | 1.2 | 12000" + }) + void clampsAndJittersRetryBackoff( + final long pollIntervalMillis, final int attempt, final double jitter, final long delay) { + assertEquals( + delay, CdnConfigurationSource.retryDelayMillis(pollIntervalMillis, attempt, jitter)); + } + + @Test + void rejectsUnsupportedRetryAttempt() { + assertThrows( + IllegalArgumentException.class, + () -> CdnConfigurationSource.retryDelayMillis(POLL_INTERVAL_MILLIS, 3, 1.0)); + } + + @Test + void startCompletesFirstPollAndCloseCancelsScheduledPoll() { + this.server.enqueue(200, CONFIG); + final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + final ScheduledFuture future = mock(ScheduledFuture.class); + doReturn(future) + .when(executor) + .scheduleWithFixedDelay( + any(Runnable.class), + eq(POLL_INTERVAL_MILLIS), + eq(POLL_INTERVAL_MILLIS), + eq(MILLISECONDS)); + final CdnConfigurationSource source = source(this.server.uri("/ufc"), null, executor); + + source.start(this.dispatched::add); + source.start(this.dispatched::add); + + assertEquals(1, this.dispatched.size()); + assertEquals(1, this.server.requests().size()); + verify(executor, times(1)) + .scheduleWithFixedDelay( + any(Runnable.class), + eq(POLL_INTERVAL_MILLIS), + eq(POLL_INTERVAL_MILLIS), + eq(MILLISECONDS)); + + source.close(); + verify(future).cancel(true); + verify(executor).shutdownNow(); + assertFalse(source.pollOnce()); + } + + @Test + void startSurvivesListenerFailure() { + this.server.enqueue(200, CONFIG); + final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + final CdnConfigurationSource source = source(this.server.uri("/ufc"), null, executor); + + source.start( + configuration -> { + throw new IllegalStateException("listener failure"); + }); + + verify(executor) + .scheduleWithFixedDelay(any(Runnable.class), any(Long.class), any(Long.class), any()); + } + + @Test + void startAfterCloseDoesNotPoll() { + final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + final CdnConfigurationSource source = source(this.server.uri("/ufc"), null, executor); + + source.close(); + source.start(this.dispatched::add); + + assertTrue(this.server.requests().isEmpty()); + verify(executor, never()) + .scheduleWithFixedDelay(any(Runnable.class), any(Long.class), any(Long.class), any()); + } + + @Test + void closeInterruptsRetryBackoff() throws Exception { + this.server.otherwise(503, null); + final CdnConfigurationSource source = + new CdnConfigurationSource( + this.server.uri("/ufc"), + null, + TimeUnit.MINUTES.toMillis(10), + Duration.ofSeconds(5), + HttpClient.newHttpClient(), + mock(ScheduledExecutorService.class), + MILLISECONDS::sleep, + () -> 1.0); + final boolean[] result = {true}; + final Thread poller = new Thread(() -> result[0] = source.pollOnce()); + poller.start(); + this.server.received().poll(5, SECONDS); + + source.close(); + poller.join(SECONDS.toMillis(5)); + + assertFalse(poller.isAlive(), "close must interrupt the retry backoff"); + assertFalse(result[0]); + assertEquals(1, this.server.requests().size()); + } + + private CdnConfigurationSource source() { + return source(null); + } + + private CdnConfigurationSource source(final String apiKey) { + final CdnConfigurationSource source = + source(this.server.uri("/ufc"), apiKey, mock(ScheduledExecutorService.class)); + source.setListenerForTest(this.dispatched::add); + return source; + } + + private CdnConfigurationSource source( + final URI endpoint, final String apiKey, final ScheduledExecutorService executor) { + final CdnConfigurationSource source = + new CdnConfigurationSource( + endpoint, + apiKey, + POLL_INTERVAL_MILLIS, + Duration.ofSeconds(5), + HttpClient.newHttpClient(), + executor, + this.sleeps::add, + () -> 1.0); + source.setListenerForTest(this.dispatched::add); + return source; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java new file mode 100644 index 00000000000..783e2ba3b88 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java @@ -0,0 +1,120 @@ +package com.datadog.openfeature.internal.delivery; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.config.TestSettings; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class DirectIntakeTransportTest { + private LocalHttpServer server; + private final List sleeps = new CopyOnWriteArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + this.server = new LocalHttpServer(); + } + + @AfterEach + void tearDown() { + this.server.close(); + } + + @Test + void requiresAnApiKey() { + assertNull(DirectIntakeTransport.create(TestSettings.of())); + assertNotNull(DirectIntakeTransport.create(TestSettings.of("api-key", "secret"))); + } + + @Test + void postsJsonWithApiKeyAndTraceHeaders() throws Exception { + this.server.enqueue(202, null); + + transport().post("exposures", "{\"a\":1}".getBytes(UTF_8)); + + final LocalHttpServer.Request request = this.server.requests().get(0); + assertEquals("POST", request.method); + assertEquals("/api/v2/exposures", request.uri.getPath()); + assertEquals("{\"a\":1}", request.bodyAsString()); + assertEquals("application/json", request.header("Content-Type")); + assertEquals("secret", request.header("dd-api-key")); + assertNotNull(request.header("x-datadog-trace-id")); + assertEquals(request.header("x-datadog-trace-id"), request.header("x-datadog-parent-id")); + assertEquals("java", request.header("Datadog-Meta-Lang")); + assertNull(request.header("X-Datadog-EVP-Subdomain")); + } + + @TableTest({ + "scenario | status", + "server error | 500 ", + "unavailable | 503 ", + "too many requests | 429 " + }) + void retriesRetryableStatus(final int status) throws Exception { + this.server.enqueue(status, null).enqueue(status, null).enqueue(200, null); + + transport().post("flagevaluation", new byte[] {'{', '}'}); + + assertEquals(3, this.server.requests().size()); + assertEquals(List.of(100L, 200L), this.sleeps); + } + + @TableTest({ + "scenario | status", + "bad request | 400 ", + "forbidden | 403 ", + "not found | 404 ", + "redirect | 302 " + }) + void doesNotRetryOtherFailures(final int status) { + this.server.otherwise(status, null); + + assertThrows(IOException.class, () -> transport().post("exposures", new byte[] {'{', '}'})); + + assertEquals(1, this.server.requests().size()); + } + + @Test + void givesUpAfterMaxRetries() { + this.server.otherwise(503, null); + + assertThrows(IOException.class, () -> transport().post("exposures", new byte[] {'{', '}'})); + + assertEquals(DirectIntakeTransport.MAX_RETRIES + 1, this.server.requests().size()); + assertEquals(DirectIntakeTransport.MAX_RETRIES, this.sleeps.size()); + } + + @Test + void retriesConnectionFailures() throws Exception { + final URI unreachable; + try (ServerSocket socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + unreachable = URI.create("http://127.0.0.1:" + socket.getLocalPort() + "/api/v2/"); + } + final DirectIntakeTransport transport = + new DirectIntakeTransport( + unreachable, "secret", HttpClient.newHttpClient(), this.sleeps::add); + + assertThrows(IOException.class, () -> transport.post("exposures", new byte[] {'{', '}'})); + + assertEquals(DirectIntakeTransport.MAX_RETRIES, this.sleeps.size()); + } + + private DirectIntakeTransport transport() { + return new DirectIntakeTransport( + this.server.uri("/api/v2/"), "secret", HttpClient.newHttpClient(), this.sleeps::add); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java new file mode 100644 index 00000000000..e7b55abb572 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java @@ -0,0 +1,52 @@ +package com.datadog.openfeature.internal.delivery; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; +import com.datadog.openfeature.internal.connector.EventTransport; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class FallbackTransportTest { + private static final byte[] PAYLOAD = "{}".getBytes(UTF_8); + + private final EventTransport proxy = mock(EventTransport.class); + private final EventTransport direct = mock(EventTransport.class); + private final FallbackTransport transport = new FallbackTransport(this.proxy, this.direct); + + @Test + void postsThroughTheProxy() throws IOException { + this.transport.post("exposures", PAYLOAD); + + verify(this.proxy).post("exposures", PAYLOAD); + verify(this.direct, never()).post("exposures", PAYLOAD); + } + + @Test + void switchesToDirectIntakeForGoodWhenTheProxyIsUnavailable() throws IOException { + doThrow(new EventProxyUnavailableException("unavailable")) + .when(this.proxy) + .post("exposures", PAYLOAD); + + this.transport.post("exposures", PAYLOAD); + this.transport.post("exposures", PAYLOAD); + + verify(this.proxy, times(1)).post("exposures", PAYLOAD); + verify(this.direct, times(2)).post("exposures", PAYLOAD); + } + + @Test + void doesNotReplayAmbiguousProxyFailures() throws IOException { + doThrow(new IOException("timeout")).when(this.proxy).post("exposures", PAYLOAD); + + assertThrows(IOException.class, () -> this.transport.post("exposures", PAYLOAD)); + + verify(this.direct, never()).post("exposures", PAYLOAD); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java new file mode 100644 index 00000000000..3595fdc6dc4 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java @@ -0,0 +1,299 @@ +package com.datadog.openfeature.internal.exposure; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonMap; +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.assertTrue; + +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +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.function.BooleanSupplier; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class ExposurePipelineTest { + private static final long TIMEOUT_NANOS = SECONDS.toNanos(5); + + private final List> requests = new CopyOnWriteArrayList<>(); + private final AtomicInteger posts = new AtomicInteger(); + private volatile int failingPost = -1; + + private final EventTransport transport = + (route, json) -> { + assertEquals("exposures", route); + if (this.posts.incrementAndGet() == this.failingPost) { + throw new IOException("ambiguous timeout"); + } + this.requests.add(JsonReading.readObject(json)); + }; + + @TableTest({ + "service | env | version", + " | | ", + "'test-service' | 'test' | '23' ", + "'test-service' | | '23' ", + "'test-service' | 'test' | " + }) + void testExposureEventWrites(final String service, final String env, final String version) + throws Exception { + final Map context = new HashMap<>(); + putIfNotNull(context, "service", service); + putIfNotNull(context, "env", env); + putIfNotNull(context, "version", version); + final List exposures = buildExposures(5); + + try (ExposurePipeline pipeline = pipeline(context)) { + pipeline.start(); + exposures.forEach(pipeline); + + eventually(() -> allExposures().size() == exposures.size()); + } + + for (final Map request : this.requests) { + assertEquals(context, request.get("context")); + } + assertEquals(keys(exposures), exposureKeys(allExposures())); + } + + @Test + void serializesExposureWireShape() throws Exception { + final ExposureEvent exposure = + new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject("subject", singletonMap("tier", "gold")), + 7); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + pipeline.accept(exposure); + + eventually(() -> !allExposures().isEmpty()); + } + + final Map json = allExposures().get(0); + assertEquals(1234L, json.get("timestamp")); + assertEquals(singletonMap("key", "allocation"), json.get("allocation")); + assertEquals(singletonMap("key", "flag"), json.get("flag")); + assertEquals(singletonMap("key", "variant"), json.get("variant")); + final Map subject = new HashMap<>(); + subject.put("id", "subject"); + subject.put("attributes", singletonMap("tier", "gold")); + assertEquals(subject, json.get("subject")); + assertEquals(7L, json.get("serial_id")); + } + + @Test + void testLruCache() throws Exception { + final List exposures = buildExposures(6); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + // populating the cache + exposures.forEach(pipeline); + + // all events are written + eventually(() -> allExposures().size() == exposures.size()); + + // publishing duplicate events + exposures.forEach(pipeline); + + // no events are written + MILLISECONDS.sleep(300); // wait until a flush happens + assertEquals(exposures.size(), allExposures().size()); + + // a new event is generated + pipeline.accept(buildExposure()); + + // oldest event is evicted and the new one is submitted + eventually(() -> allExposures().size() == exposures.size() + 1); + } + } + + @Test + void testHighLoadScenario() throws Exception { + final int exposuresPerThread = 100; + final int threads = Runtime.getRuntime().availableProcessors(); + final ExecutorService executor = Executors.newFixedThreadPool(threads); + final List exposures = buildExposures(threads * exposuresPerThread); + final CountDownLatch latch = new CountDownLatch(1); + + try (ExposurePipeline pipeline = + new ExposurePipeline( + ExposurePipeline.DEFAULT_CAPACITY, + 100, + MILLISECONDS, + this.transport, + emptyMap(), + new RuntimeServices(HealthMetrics.NOOP))) { + pipeline.start(); + final List> futures = new ArrayList<>(); + for (int index = 0; index < exposures.size(); index += exposuresPerThread) { + final List partition = + exposures.subList(index, Math.min(index + exposuresPerThread, exposures.size())); + futures.add( + executor.submit( + () -> { + latch.await(); + partition.forEach(pipeline); + return true; + })); + } + latch.countDown(); // start threads + for (final Future future : futures) { + assertTrue(future.get()); // wait for all threads to finish + } + + eventually(() -> allExposures().size() == exposures.size()); + assertEquals(keys(exposures), exposureKeys(allExposures())); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testAmbiguousExposureBatchIsNotRetried() throws Exception { + this.failingPost = 1; + final List exposures = buildExposures(2); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + pipeline.accept(exposures.get(0)); + eventually(() -> this.posts.get() == 1); + MILLISECONDS.sleep(300); + assertEquals(1, this.posts.get()); + + pipeline.accept(exposures.get(1)); + eventually(() -> !allExposures().isEmpty()); + } + + assertEquals(keys(exposures.subList(1, 2)), exposureKeys(allExposures())); + } + + @Test + void testNonFiniteAttributesDoNotPoisonFollowingExposures() throws Exception { + final ExposureEvent invalid = buildExposure(singletonMap("invalid", Double.NaN)); + final ExposureEvent valid = buildExposure(); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + pipeline.accept(invalid); + pipeline.accept(valid); + + eventually(() -> allExposures().size() == 2); + } + + assertEquals(keys(List.of(invalid, valid)), exposureKeys(allExposures())); + } + + @Test + void closeStopsTheSerializer() throws Exception { + final ExposurePipeline pipeline = pipeline(emptyMap()); + pipeline.start(); + assertTrue(pipeline.isSerializerThreadAlive()); + + pipeline.close(); + + eventually(() -> !pipeline.isSerializerThreadAlive()); + assertFalse(pipeline.isSerializerThreadAlive()); + } + + private ExposurePipeline pipeline(final Map context) { + return new ExposurePipeline( + 1 << 4, + 100, + MILLISECONDS, + this.transport, + context, + new RuntimeServices(HealthMetrics.NOOP)); + } + + @SuppressWarnings("unchecked") + private List> allExposures() { + final List> exposures = new ArrayList<>(); + for (final Map request : this.requests) { + exposures.addAll((List>) request.get("exposures")); + } + return exposures; + } + + private static Set keys(final List exposures) { + final Set keys = new HashSet<>(); + for (final ExposureEvent exposure : exposures) { + keys.add(exposure.flag.key + "/" + exposure.subject.id); + } + return keys; + } + + @SuppressWarnings("unchecked") + private static Set exposureKeys(final List> exposures) { + final Set keys = new HashSet<>(); + for (final Map exposure : exposures) { + keys.add( + ((Map) exposure.get("flag")).get("key") + + "/" + + ((Map) exposure.get("subject")).get("id")); + } + return keys; + } + + private static List buildExposures(final int size) { + final List exposures = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + exposures.add(buildExposure()); + } + return exposures; + } + + private static ExposureEvent buildExposure() { + return buildExposure(emptyMap()); + } + + private static ExposureEvent buildExposure(final Map attributes) { + return new ExposureEvent( + System.currentTimeMillis(), + new Allocation(UUID.randomUUID().toString()), + new Flag(UUID.randomUUID().toString()), + new Variant(UUID.randomUUID().toString()), + new Subject(UUID.randomUUID().toString(), attributes), + null); + } + + private static void putIfNotNull( + final Map map, final String key, final String value) { + if (value != null) { + map.put(key, value); + } + } + + private static void eventually(final BooleanSupplier condition) throws InterruptedException { + final long deadline = System.nanoTime() + TIMEOUT_NANOS; + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("Condition not met in time"); + } + MILLISECONDS.sleep(10); + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java new file mode 100644 index 00000000000..c4a61ba23a7 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java @@ -0,0 +1,62 @@ +package com.datadog.openfeature.internal.exposure; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.JsonReading; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExposuresRequestTest { + @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 serializesContextAndSubject() { + final ExposureEvent event = + new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject(null, singletonMap("score", 42)), + null); + + final Map json = + JsonReading.readObject( + new ExposuresRequest(singletonMap("service", "svc"), singletonList(event)).serialize()); + + assertEquals(singletonMap("service", "svc"), json.get("context")); + final Map exposure = (Map) ((java.util.List) json.get("exposures")).get(0); + assertEquals(singletonMap("attributes", singletonMap("score", 42L)), exposure.get("subject")); + } + + private static String exposureJson(final Integer serialId) { + final ExposureEvent event = + new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject("subject", emptyMap()), + serialId); + return new String(new ExposuresRequest(emptyMap(), singletonList(event)).serialize(), UTF_8); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java similarity index 96% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java index 7d43c7a8b51..f48c3e32e0a 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java @@ -1,4 +1,4 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.exposure; import static java.util.Collections.emptyMap; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -7,11 +7,6 @@ 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; @@ -194,14 +189,16 @@ void testKeyEqualityWithNullValues() { new Allocation("allocation"), new Flag(null), new Variant("variant"), - new Subject(null, emptyMap())); + new Subject(null, emptyMap()), + null); ExposureEvent event2 = new ExposureEvent( System.currentTimeMillis(), new Allocation("allocation"), new Flag(null), new Variant("variant"), - new Subject(null, emptyMap())); + new Subject(null, emptyMap()), + null); cache.add(event1); boolean duplicateAdded = cache.add(event2); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java similarity index 98% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java index 06faeadefea..811cd0eaa08 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java @@ -1,4 +1,4 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.flagevaluation; import static java.util.Collections.emptyMap; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -6,7 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -207,7 +206,7 @@ void capSizingUsesNamedScaleConstants() { void flagEvalEventDoesNotCarryReason() { final boolean hasReasonField = Arrays.stream( - datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent.class + com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent.class .getDeclaredFields()) .anyMatch(field -> field.getName().equals("reason")); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java similarity index 94% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java index d4ca517fffd..389e2b3c30f 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java @@ -1,4 +1,4 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.flagevaluation; import static java.util.Collections.emptyMap; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -7,10 +7,7 @@ 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 com.datadog.openfeature.internal.JsonReading; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -21,13 +18,6 @@ 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 { @@ -329,19 +319,6 @@ void emptyEventListProducesNoPayloads() { 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 = @@ -416,7 +393,7 @@ private static Map firstPayload( } private static Map parse(final byte[] body) throws Exception { - return JSON_MAP.fromJson(new String(body, java.nio.charset.StandardCharsets.UTF_8)); + return JsonReading.readObjectWithDoubles(body); } @SuppressWarnings("unchecked") diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java new file mode 100644 index 00000000000..269c37f3b48 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java @@ -0,0 +1,484 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.buildTestWriter; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.context; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.event; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.eventForFlag; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.flushAndCapture; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.flushAndCaptureJson; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.readJson; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.repeat; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.simpleEvent; +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.datadog.openfeature.internal.EventQueue; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.CapturingTransport; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.RecordingMetrics; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class FlagEvaluationPipelineTest { + + private final CapturingTransport transport = new CapturingTransport(); + private final RecordingMetrics metrics = new RecordingMetrics(); + + private FlagEvaluationPipeline writer( + final int capacity, final long flushInterval, final TimeUnit unit) { + return new FlagEvaluationPipeline( + capacity, + flushInterval, + unit, + this.transport, + context(), + new RuntimeServices(this.metrics)); + } + + @Test + void degradedCapOverflowTelemetryIsEmittedOnFlush() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + setup.handler.addDroppedDegradedOverflowForTest(3); + setup.handler.flush(); + + assertEquals( + 3, + setup.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_DEGRADED_CAP)); + } + + @Test + void queueOverflowIncrementsObservableDropCounter() { + final FlagEvaluationPipeline writer = writer(2, 10L, TimeUnit.SECONDS); + + for (int i = 0; i < 100; i++) { + writer.enqueue(simpleEvent("of-flag", "on")); + } + + assertTrue(writer.droppedQueueOverflow() > 0); + final long queueDrops = writer.droppedQueueOverflow(); + writer.flushForTest(); + assertEquals( + queueDrops, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_QUEUE_OVERFLOW)); + } + + @Test + void enqueueAfterCloseIsDroppedAndCounted() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.close(); + writer.enqueue(simpleEvent("closed-flag", "on")); + + assertEquals( + 1, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_CLOSED)); + assertNull(writer.pollQueuedEventForTest()); + } + + @Test + void closeSweepsAndCountsEventsLeftInTheQueue() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + // 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(); + + assertEquals( + 2, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_CLOSED)); + assertNull(writer.pollQueuedEventForTest()); + } + + @Test + void enqueueIgnoresNullEvent() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.enqueue(null); + + assertNull(writer.pollQueuedEventForTest()); + assertEquals(0, writer.droppedQueueOverflow()); + } + + @Test + void enqueueDoesNotAggregateOnTheCallingThread() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.enqueue(simpleEvent("g2-flag", "on")); + writer.enqueue(simpleEvent("g2-flag", "on")); + + assertEquals(0, writer.aggregatorFullTierSizeForTest()); + assertEquals(0, writer.droppedQueueOverflow()); + } + + @Test + void flushIfNecessarySkipsEmptyStateAndWaitsForInterval() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + 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 FlagEvaluationPipeline.FlagEvaluationSerializingHandler handler = + new FlagEvaluationPipeline.FlagEvaluationSerializingHandler( + this.transport, + new EventQueue<>(16), + Long.MAX_VALUE, + TimeUnit.NANOSECONDS, + context(), + queueDrops, + new ConcurrentHashMap<>(), + FlagEvaluationPipeline.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, + new RuntimeServices(this.metrics)); + + handler.flushIfNecessary(); + + assertEquals(1, queueDrops.get()); + } + + @Test + void degradedBucketsAreSerializedWithoutTargetingKeyOrContext() throws Exception { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + 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 FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + 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 FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(128); + setup.handler.add(event(repeat('f', 512), "on", "alloc1", "user-1", 1000L, emptyMap())); + + setup.handler.drainAndAggregate(); + setup.handler.flush(); + + assertEquals( + 1, + setup.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_PAYLOAD_LIMIT)); + } + + @Test + void finalFlushRunsWithoutTheInterruptFlagSet() throws Exception { + // close() interrupts the worker to break it out of poll(). The final flush does socket I/O, + // which fails fast on an interrupted thread, so the flag must be clear by the time the + // transport is called. + final CountDownLatch posted = new CountDownLatch(1); + final boolean[] interruptedDuringPost = {true}; + this.transport.onPost = + () -> { + interruptedDuringPost[0] = Thread.currentThread().isInterrupted(); + posted.countDown(); + }; + final FlagEvaluationPipeline writer = writer(64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS); + + writer.start(); + 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; the drained rows would be lost"); + } + + @Test + void closeDrainsAndFinalFlushesQueuedEvents() throws Exception { + final CountDownLatch posted = new CountDownLatch(1); + this.transport.onPost = posted::countDown; + final FlagEvaluationPipeline writer = writer(64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS); + + writer.start(); + writer.enqueue(simpleEvent("shutdown-flag", "on")); + writer.close(); + + assertTrue(posted.await(5, TimeUnit.SECONDS)); + final Map json = readJson(this.transport.posts.get(0).body).parsed; + assertNotNull(eventForFlag(json, "shutdown-flag")); + } + + @Test + void continuousTrafficFlushesWithoutWaitingForIdle() throws Exception { + final FlagEvaluationPipeline writer = writer(1 << 12, 1, TimeUnit.MILLISECONDS); + + writer.start(); + boolean posted = false; + try { + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) { + writer.enqueue(simpleEvent("busy-flag", "on")); + if (!this.transport.posts.isEmpty()) { + posted = true; + break; + } + } + } finally { + writer.close(); + } + + assertTrue(posted); + } + + @Test + void flushPostsToFlagevaluationEndpoint() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + setup.handler.add(event("flag-f", "on", "alloc1", "user-1", 1000L, emptyMap())); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + + assertEquals(1, setup.transport.posts.size()); + assertEquals("flagevaluation", setup.transport.posts.get(0).route); + } + + @Test + void splitPostFailureDoesNotRetryAlreadySentPayloads() { + final int limit = 1_100; + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(limit); + setup.transport.failingPost = index -> index == 2; + + 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, setup.transport.posts.size()); + setup.handler.flush(); + assertEquals(2, setup.transport.posts.size()); + } + + 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. + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + 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 received. + assertHashedTargetingKeyAndOmittedContext(piiEventDefaultConsent()); + } + + @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 FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + 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 deliveryFailureClearsAggregatorSoLaterFlushesRecover() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.transport.failingPost = index -> index == 1; + + setup.handler.add(simpleEvent("failed-flag", "on")); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + assertEquals(1, setup.transport.posts.size()); + + // 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(); + assertEquals(2, setup.transport.posts.size()); + final Map json = readJson(setup.transport.posts.get(1).body).parsed; + assertNull(eventForFlag(json, "failed-flag")); + assertNotNull(eventForFlag(json, "healthy-flag")); + } + + @Test + void nonFiniteContextNumbersAreSerializedAsStrings() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + final Map attrs = new HashMap<>(); + attrs.put("bad-number", Double.NaN); + setup.handler.add(event("nan-flag", "on", "alloc1", "user-1", 1000L, true, attrs)); + + final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); + + assertTrue(captured.raw.contains("\"bad-number\":\"NaN\"")); + } + + @Test + void countContextTruncatedAccumulatesPerReason() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.countContextTruncated("field_count"); + writer.countContextTruncated("field_count"); + writer.countContextTruncated("field_length"); + writer.flushForTest(); + + assertEquals( + 2, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, "reason:field_count")); + assertEquals( + 1, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, + "reason:field_length")); + } + + @Test + void hasCapacityForEnqueueReflectsQueueSaturationAndCountsPreQueueOverflow() { + final int capacity = 2; + final FlagEvaluationPipeline writer = writer(capacity, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + 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(); + + assertEquals( + 1, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_QUEUE_OVERFLOW)); + + writer.close(); + } + + private void assertHashedTargetingKeyAndOmittedContext(final FlagEvalEvent piiEvent) + throws Exception { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + 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; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java new file mode 100644 index 00000000000..7792035d2d2 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java @@ -0,0 +1,226 @@ +package com.datadog.openfeature.internal.flagevaluation; + +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 com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.IntPredicate; + +final class FlagEvaluationTestSupport { + + static final long REALISTIC_EVAL_MS = 1_760_000_000_000L; + + private FlagEvaluationTestSupport() {} + + 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, Collections.emptyMap()); + } + + static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { + return event(flagKey, variant, "alloc1", "user-1", 1000L, 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 Map context() { + final Map context = new HashMap<>(); + context.put("service", "test-service"); + return context; + } + + static TestWriterSetup buildTestWriter() { + return buildTestWriter(FlagEvaluationPipeline.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); + } + + static TestWriterSetup buildTestWriter(final int payloadSizeLimitBytes) { + final CapturingTransport transport = new CapturingTransport(); + final RecordingMetrics metrics = new RecordingMetrics(); + final FlagEvaluationPipeline.SerializingHandlerForTest handler = + new FlagEvaluationPipeline.SerializingHandlerForTest( + transport, context(), payloadSizeLimitBytes, new RuntimeServices(metrics)); + return new TestWriterSetup(handler, transport, metrics); + } + + static CapturedJson flushAndCapture(final TestWriterSetup setup) { + final List captured = flushAndCaptureAll(setup); + assertEquals(1, captured.size(), "Expected exactly one posted payload"); + return captured.get(0); + } + + static List flushAndCaptureAll(final TestWriterSetup setup) { + setup.transport.posts.clear(); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + final List json = new ArrayList<>(); + for (final Post post : setup.transport.posts) { + assertEquals("flagevaluation", post.route); + json.add(readJson(post.body)); + } + return json; + } + + static CapturedJson readJson(final byte[] body) { + assertNotNull(body, "payload must have been posted"); + return new CapturedJson( + new String(body, StandardCharsets.UTF_8), JsonReading.readObjectWithDoubles(body)); + } + + static Map flushAndCaptureJson(final TestWriterSetup setup) { + return flushAndCapture(setup).parsed; + } + + 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; + } + + /** Records the posted payloads, optionally failing some posts. */ + static final class CapturingTransport implements EventTransport { + final List posts = new CopyOnWriteArrayList<>(); + volatile IntPredicate failingPost = index -> false; + volatile Runnable onPost = () -> {}; + + @Override + public void post(final String route, final byte[] json) throws IOException { + this.posts.add(new Post(route, json)); + this.onPost.run(); + if (this.failingPost.test(this.posts.size())) { + throw new IOException("boom"); + } + } + } + + static final class Post { + final String route; + final byte[] body; + + Post(final String route, final byte[] body) { + this.route = route; + this.body = body; + } + } + + /** Records health counters, keyed by their {@code reason:} tag like the agent collector. */ + static final class RecordingMetrics implements HealthMetrics { + private final List counts = new CopyOnWriteArrayList<>(); + + @Override + public void count(final String metric, final long value, final String reason) { + this.counts.add( + new String[] {metric, Long.toString(value), reason == null ? null : "reason:" + reason}); + } + + long sum(final String metricName, final String tag) { + long sum = 0; + for (final String[] count : this.counts) { + if (metricName.equals(count[0]) + && (tag == null ? count[2] == null : tag.equals(count[2]))) { + sum += Long.parseLong(count[1]); + } + } + return sum; + } + } + + static class TestWriterSetup { + final FlagEvaluationPipeline.SerializingHandlerForTest handler; + final CapturingTransport transport; + final RecordingMetrics metrics; + + TestWriterSetup( + final FlagEvaluationPipeline.SerializingHandlerForTest handler, + final CapturingTransport transport, + final RecordingMetrics metrics) { + this.handler = handler; + this.transport = transport; + this.metrics = metrics; + } + } + + 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-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java similarity index 99% rename from products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java index e3d78ac4b86..435bf9ac17f 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java similarity index 88% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java index fae2631ce2f..8a7af915e14 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java @@ -1,4 +1,4 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.ufc; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -8,21 +8,12 @@ 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 { +class UniversalFlagConfigParserJsonApiTest { @Test void parsesJsonApiMembersInAnyOrder() throws Exception { @@ -257,47 +248,44 @@ void dropsFlagsWithInvalidShardBoundsAndConditionOperands() throws Exception { 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)); + 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)); + 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)); + 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")); @@ -307,23 +295,6 @@ void dropsFlagsWithInvalidShardBoundsAndConditionOperands() throws Exception { 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}}")); @@ -331,7 +302,8 @@ void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { @Test void observeFullEvaluationDataDefaultsToFalseWhenAbsent() throws Exception { - // Absent → Moshi leaves the boxed field null; the read site's Boolean.TRUE.equals(...) then + // Absent → the parser 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())); @@ -366,8 +338,8 @@ void observeFullEvaluationDataExplicitNullDefaultsToFalseWithoutRejectingConfig( @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 + // The parser tolerates a stringified boolean like "true": it 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\""))); @@ -390,7 +362,7 @@ void observeFullEvaluationDataWrongTypedNumberDefaultsToFalse() throws Exception } private static ServerConfiguration parse(final String json) throws Exception { - return JsonApiUfcResponseParser.INSTANCE.parse(json.getBytes(UTF_8)); + return UniversalFlagConfigParser.parseJsonApi(json.getBytes(UTF_8)); } private static String wrap(final String attributes) { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java new file mode 100644 index 00000000000..b4a98c417fb --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -0,0 +1,273 @@ +package com.datadog.openfeature.internal.ufc; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.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 java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.tabletest.junit.TableTest; + +/** Parses raw UFC documents, as delivered by Remote Configuration. */ +class UniversalFlagConfigParserTest { + + @Test + void parsesEmptyConfiguration() throws Exception { + final ServerConfiguration config = deserialize(emptyConfig()); + + assertNotNull(config); + assertTrue(config.flags.isEmpty()); + assertTrue(config.invalidFlags.isEmpty()); + } + + @Test + void parsesNullDocumentAsNoConfiguration() throws Exception { + assertNull(deserialize("null")); + } + + @Test + void rejectsNonObjectDocument() { + assertThrows(IOException.class, () -> deserialize("[]")); + } + + @Test + void rejectsDuplicateKeys() { + assertThrows(IOException.class, () -> deserialize("{\"format\":\"a\",\"format\":\"b\"}")); + } + + @Test + void rejectsWrongTypedFlagMap() { + assertThrows(IOException.class, () -> deserialize("{\"flags\":[]}")); + } + + @Test + void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{" + + "\"bad\":{\"key\":\"bad\",\"enabled\":\"yes\"}," + + "\"good\":{\"key\":\"good\",\"enabled\":true}" + + "}}"); + + assertNotNull(config); + assertFalse(config.flags.containsKey("bad")); + assertEquals("invalid_flag", config.invalidFlags.get("bad")); + assertTrue(config.flags.get("good").enabled); + } + + @Test + void readsVariantNumbersAsDoubles() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"f\":{\"key\":\"f\",\"enabled\":true,\"variationType\":\"INTEGER\"," + + "\"variations\":{\"v\":{\"key\":\"v\",\"value\":3}}}}}"); + + assertEquals(3.0, config.flags.get("f").variations.get("v").value); + } + + @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)); + } + + 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 parsesAllocationWindowDatesWithMicrosecondPrecision() throws Exception { + final ServerConfiguration config = deserialize(resource("allocation-window-dates.json")); + + final Allocation allocation = config.flags.get("dated-flag").allocations.get(0); + assertEquals(Instant.parse("2023-01-01T00:00:00.123456Z"), allocation.startAt); + assertEquals(Instant.parse("2023-01-02T00:00:00.987654Z"), allocation.endAt); + } + + @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 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); + } + + @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 String startAt = value == null ? "null" : "\"" + value + "\""; + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"f\":{\"key\":\"f\",\"enabled\":true," + + "\"allocations\":[{\"key\":\"a\",\"startAt\":" + + startAt + + "}]}}}"); + + final Instant parsed = config.flags.get("f").allocations.get(0).startAt; + if (expectedInstant == null) { + assertNull(parsed); + } else { + assertNotNull(parsed); + assertEquals(expectedInstant, parsed.toString()); + } + } + + private static ServerConfiguration deserialize(final String json) throws Exception { + return UniversalFlagConfigParser.parse(json.getBytes(UTF_8)); + } + + private static String emptyConfig() throws IOException { + return resource("empty-config.json"); + } + + private static String resource(final String name) throws IOException { + try (InputStream stream = + UniversalFlagConfigParserTest.class.getResourceAsStream("/remote-config/" + name)) { + assertNotNull(stream, "Missing remote-config fixture: " + name); + return new String(stream.readAllBytes(), UTF_8); + } + } +} 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..571748af08e --- /dev/null +++ b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -0,0 +1,32 @@ +package datadog.trace.api.openfeature; + +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 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 aliasOptionsKeepTheirType() { + final Provider.Options options = new Provider.Options(); + + final Provider.Options configured = options.initTimeout(5, SECONDS); + + assertSame(options, configured); + assertEquals(5, configured.getTimeout()); + assertEquals(SECONDS, configured.getUnit()); + assertInstanceOf(com.datadog.openfeature.Provider.class, new Provider(configured)); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json b/dd-openfeature/src/test/resources/allocation-window-dates.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json rename to dd-openfeature/src/test/resources/allocation-window-dates.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json b/dd-openfeature/src/test/resources/empty-config.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json rename to dd-openfeature/src/test/resources/empty-config.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json b/dd-openfeature/src/test/resources/malformed-allocations.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json rename to dd-openfeature/src/test/resources/malformed-allocations.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json b/dd-openfeature/src/test/resources/null-flag.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json rename to dd-openfeature/src/test/resources/null-flag.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json b/dd-openfeature/src/test/resources/null-flags.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json rename to dd-openfeature/src/test/resources/null-flags.json diff --git a/dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json b/dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json new file mode 100644 index 00000000000..26d0a90da2a --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json @@ -0,0 +1,35 @@ +{ + "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/dd-openfeature/src/test/resources/remote-config/empty-config.json b/dd-openfeature/src/test/resources/remote-config/empty-config.json new file mode 100644 index 00000000000..51b021e5de7 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/empty-config.json @@ -0,0 +1,8 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": {} +} diff --git a/dd-openfeature/src/test/resources/remote-config/malformed-allocations.json b/dd-openfeature/src/test/resources/remote-config/malformed-allocations.json new file mode 100644 index 00000000000..93314b69e7d --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/malformed-allocations.json @@ -0,0 +1,45 @@ +{ + "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/dd-openfeature/src/test/resources/remote-config/null-flag.json b/dd-openfeature/src/test/resources/remote-config/null-flag.json new file mode 100644 index 00000000000..a4f3a6c2502 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/null-flag.json @@ -0,0 +1,34 @@ +{ + "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/dd-openfeature/src/test/resources/remote-config/null-flags.json b/dd-openfeature/src/test/resources/remote-config/null-flags.json new file mode 100644 index 00000000000..a083526e553 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/null-flags.json @@ -0,0 +1,8 @@ +{ + "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/dd-openfeature/src/test/resources/remote-config/serial-id.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/serial-id.json rename to dd-openfeature/src/test/resources/remote-config/serial-id.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json b/dd-openfeature/src/test/resources/remote-config/sibling-serial-ids.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json rename to dd-openfeature/src/test/resources/remote-config/sibling-serial-ids.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json b/dd-openfeature/src/test/resources/remote-config/unknown-operator.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json rename to dd-openfeature/src/test/resources/remote-config/unknown-operator.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json b/dd-openfeature/src/test/resources/remote-config/unknown-top-level-fields.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json rename to dd-openfeature/src/test/resources/remote-config/unknown-top-level-fields.json diff --git a/dd-openfeature/src/test/resources/serial-id.json b/dd-openfeature/src/test/resources/serial-id.json new file mode 100644 index 00000000000..6b7dc926854 --- /dev/null +++ b/dd-openfeature/src/test/resources/serial-id.json @@ -0,0 +1,34 @@ +{ + "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/dd-openfeature/src/test/resources/sibling-serial-ids.json b/dd-openfeature/src/test/resources/sibling-serial-ids.json new file mode 100644 index 00000000000..aa791903f66 --- /dev/null +++ b/dd-openfeature/src/test/resources/sibling-serial-ids.json @@ -0,0 +1,59 @@ +{ + "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/dd-openfeature/src/test/resources/unknown-operator.json b/dd-openfeature/src/test/resources/unknown-operator.json new file mode 100644 index 00000000000..a380463b1d5 --- /dev/null +++ b/dd-openfeature/src/test/resources/unknown-operator.json @@ -0,0 +1,67 @@ +{ + "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/dd-openfeature/src/test/resources/unknown-top-level-fields.json b/dd-openfeature/src/test/resources/unknown-top-level-fields.json new file mode 100644 index 00000000000..fe23d4a46b1 --- /dev/null +++ b/dd-openfeature/src/test/resources/unknown-top-level-fields.json @@ -0,0 +1,13 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "segments": { + "new-schema-key": { + "ignored": true + } + }, + "flags": {} +} diff --git a/dd-smoke-tests/openfeature/application/build.gradle b/dd-smoke-tests/openfeature/application/build.gradle index 9f7f9558e9b..1b7860d0c57 100644 --- a/dd-smoke-tests/openfeature/application/build.gradle +++ b/dd-smoke-tests/openfeature/application/build.gradle @@ -31,8 +31,8 @@ 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.20.1' + implementation 'dev.openfeature:sdk:1.22.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 f47b03dcdbd..9237205244f 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-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy new file mode 100644 index 00000000000..147c6fa2fde --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy @@ -0,0 +1,213 @@ +package datadog.smoketest.springboot + +import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer + +import datadog.smoketest.AbstractServerSmokeTest +import datadog.trace.agent.test.server.http.TestHttpServer +import datadog.trace.agent.test.server.http.TestHttpServer.HandlerApi.RequestApi +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.nio.file.Files +import java.nio.file.Paths +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import spock.lang.AutoCleanup +import spock.lang.IgnoreIf +import spock.lang.Shared +import spock.lang.Stepwise +import spock.lang.Unroll +import spock.util.concurrent.PollingConditions + +/** Due to the exposure cache it's important to run the tests in the specified order */ +@Stepwise +abstract class AbstractOpenFeatureSmokeTest extends AbstractServerSmokeTest { + + @Shared + protected final ufcConfig = new JsonSlurper().parse(fetchResource("ffe-system-test-data/ufc-config.json")) as Map + + @Shared + protected final ufcPayload = JsonOutput.toJson(ufcConfig) + + @Shared + protected final loggedAllocations = buildLoggedAllocations(ufcConfig) + + /** Serves the flag configuration as the Datadog managed CDN does. */ + @Shared + @AutoCleanup + protected TestHttpServer cdn = httpServer { + handlers { + prefix("/api/v2/feature-flagging/config/rules-based/server") { + response.status(200).send(JsonOutput.toJson([ + data: [type: 'universal-flag-configuration', attributes: ufcConfig] + ])) + } + } + } + + /** @return whether exposures are delivered through the Datadog Agent event platform proxy. */ + abstract boolean deliversExposures() + + /** Publishes the flag configuration before each evaluation. */ + void publishConfiguration() {} + + @Override + Closure decodedTracesCallback() { + return {} + } + + @Override + Closure decodedEvpProxyMessageCallback() { + return { String path, RequestApi request -> + if (!path.contains('api/v2/exposures')) { + return null + } + return new JsonSlurper().parse(request.body) + } + } + + @IgnoreIf({ !instance.deliversExposures() }) + void 'test open feature exposures'() { + setup: + publishConfiguration() + final testCases = parseTestCases() + assert !testCases.isEmpty() + + when: + final results = testCases.collect { + testCase -> + final response = evaluate(testCase) + final responseBody = new JsonSlurper().parse(response.body().byteStream()) + return [testCase: testCase, response: response, body: responseBody] + } + final expectedExposures = uniqueExpectedExposures(results) + + then: + results.every { + it.response.code() == 200 + } + !expectedExposures.isEmpty() + new PollingConditions(timeout: 10).eventually { + final requests = evpProxyMessages*.getV2() as List> + final events = requests*.exposures.flatten() + assert events.size() == expectedExposures.size() + expectedExposures.each { + expected -> + assert events.find { + event -> + event.flag.key == expected.flag && + event.allocation.key == expected.allocation && + event.variant.key == expected.variant && + event.subject.id == expected.targetingKey + } != null : "Unable to find exposure ${expected}" + } + } + } + + @Unroll("test open feature evaluation - #testCase.fileName[#testCase.index] - flag=#testCase.flag") + void 'test open feature evaluation'() { + setup: + publishConfiguration() + + when: + final response = evaluate(testCase) + + then: + response.code() == 200 + final responseBody = new JsonSlurper().parse(response.body().byteStream()) + responseBody.value == testCase.result.value + responseBody.reason == testCase.result.reason + if (testCase.result.containsKey('errorCode')) { + assert responseBody.errorCode == testCase.result.errorCode + } + if (testCase.result.containsKey('variant')) { + assert responseBody.variant == testCase.result.variant + } + if (testCase.result.flagMetadata?.allocationKey) { + assert responseBody.flagMetadata?.allocationKey == testCase.result.flagMetadata?.allocationKey + } + + where: + testCase << parseTestCases() + } + + protected okhttp3.Response evaluate(final Map testCase) { + final request = new Request.Builder() + .url("http://localhost:${httpPort}/openfeature/evaluate") + .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) + .build() + return client.newCall(request).execute() + } + + protected static URL fetchResource(final String name) { + return Thread.currentThread().getContextClassLoader().getResource(name) + } + + protected static List> parseTestCases() { + final folder = fetchResource('ffe-system-test-data/evaluation-cases') + final uri = folder.toURI() + final testsPath = Paths.get(uri) + final files = Files.list(testsPath) + .filter(path -> path.toString().endsWith('.json')) + .sorted(Comparator.comparing(path -> path.fileName.toString())) + final result = [] + final slurper = new JsonSlurper() + files.each { + path -> + final testCases = slurper.parse(path.toFile()) as List> + testCases.eachWithIndex { + testCase, index -> + testCase.fileName = path.fileName.toString() + testCase.index = index + } + result.addAll(testCases) + } + assert !result.isEmpty() + return result + } + + protected List> uniqueExpectedExposures(final List> results) { + final expected = [] + final seen = [] as Set + results.each { result -> + final testCase = result.testCase as Map + final body = result.body as Map + final flag = testCase.flag as String + final allocation = body.flagMetadata?.allocationKey as String + final variant = body.variant as String + if (!variant || !allocation || !allocationLogs(flag, allocation)) { + return + } + + final exposure = [ + flag: flag, + allocation: allocation, + variant: variant, + targetingKey: testCase.targetingKey + ] + final key = "${exposure.flag}\u0000${exposure.targetingKey}\u0000${exposure.allocation}\u0000${exposure.variant}" + if (seen.add(key)) { + expected.add(exposure) + } + } + return expected + } + + protected boolean allocationLogs(final String flag, final String allocation) { + return loggedAllocations["${flag}\u0000${allocation}"] == true + } + + protected static Map buildLoggedAllocations(final Map config) { + final logged = [:] + (config.flags as Map).each { flag, definition -> + if (definition.allocations instanceof List) { + definition.allocations.each { allocation -> + if (allocation instanceof Map) { + logged["${flag}\u0000${allocation.key}"] = allocation.doLog == true + } + } + } + } + return logged + } +} diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy new file mode 100644 index 00000000000..bf7932f7ea8 --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy @@ -0,0 +1,24 @@ +package datadog.smoketest.springboot + +/** + * The SDK with the Datadog Java agent and the default agentless configuration source: the SDK + * polls the flag configuration from the CDN and delivers exposures through the agent. + */ +class OpenFeatureAgentlessSmokeTest extends AbstractOpenFeatureSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") + final command = [javaPath()] + command.addAll(defaultJavaProperties) + command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()]) + final builder = new ProcessBuilder(command).directory(new File(buildDirectory)) + builder.environment().put('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL', "http://localhost:${cdn.address.port}".toString()) + return builder + } + + @Override + boolean deliversExposures() { + return true + } +} diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy index 12115cb407d..d0808be6aea 100644 --- a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy @@ -2,36 +2,18 @@ package datadog.smoketest.springboot import datadog.remoteconfig.Capabilities import datadog.remoteconfig.Product -import datadog.smoketest.AbstractServerSmokeTest -import datadog.trace.agent.test.server.http.TestHttpServer.HandlerApi.RequestApi import groovy.json.JsonOutput import groovy.json.JsonSlurper -import java.nio.file.Files -import java.nio.file.Paths import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody -import spock.lang.Shared -import spock.lang.Stepwise -import spock.lang.Unroll -import spock.util.concurrent.PollingConditions -/** Due to the exposure cache it's important to run the tests in the specified order */ -@Stepwise -class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { - - @Shared - private final rcConfig = new JsonSlurper().parse(fetchResource("ffe-system-test-data/ufc-config.json")) as Map - - @Shared - private final rcPayload = JsonOutput.toJson(rcConfig) - - @Shared - private final loggedAllocations = buildLoggedAllocations(rcConfig) +/** The SDK with the Datadog Java agent, receiving the flag configuration from Remote Configuration. */ +class OpenFeatureProviderSmokeTest extends AbstractOpenFeatureSmokeTest { @Override ProcessBuilder createProcessBuilder() { - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) + setRemoteConfig("datadog/2/FFE_FLAGS/1/config", ufcPayload) final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") final command = [javaPath()] @@ -48,18 +30,13 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { } @Override - Closure decodedTracesCallback() { - return {} + boolean deliversExposures() { + return true } @Override - Closure decodedEvpProxyMessageCallback() { - return { String path, RequestApi request -> - if (!path.contains('api/v2/exposures')) { - return null - } - return new JsonSlurper().parse(request.body) - } + void publishConfiguration() { + setRemoteConfig("datadog/2/FFE_FLAGS/1/config", ufcPayload) } void 'test first remote config poll asks agent for feature flags'() { @@ -79,7 +56,7 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { void 'test open feature evaluation enriches the request span'() { setup: - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) + publishConfiguration() final request = new Request.Builder() .url("http://localhost:${httpPort}/openfeature/evaluate") .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson([ @@ -105,152 +82,6 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { response.close() } - void 'test open feature exposures'() { - setup: - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) - final url = "http://localhost:${httpPort}/openfeature/evaluate" - final testCases = parseTestCases() - assert !testCases.isEmpty() - - when: - final results = testCases.collect { - testCase -> - final request = new Request.Builder() - .url(url) - .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) - .build() - final response = client.newCall(request).execute() - final responseBody = new JsonSlurper().parse(response.body().byteStream()) - return [testCase: testCase, response: response, body: responseBody] - } - final expectedExposures = uniqueExpectedExposures(results) - - then: - results.every { - it.response.code() == 200 - } - !expectedExposures.isEmpty() - new PollingConditions(timeout: 10).eventually { - final requests = evpProxyMessages*.getV2() as List> - final events = requests*.exposures.flatten() - assert events.size() == expectedExposures.size() - expectedExposures.each { - expected -> - assert events.find { - event -> - event.flag.key == expected.flag && - event.allocation.key == expected.allocation && - event.variant.key == expected.variant && - event.subject.id == expected.targetingKey - } != null : "Unable to find exposure ${expected}" - } - } - } - - @Unroll("test open feature evaluation - #testCase.fileName[#testCase.index] - flag=#testCase.flag") - void 'test open feature evaluation'() { - setup: - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) - final url = "http://localhost:${httpPort}/openfeature/evaluate" - final request = new Request.Builder() - .url(url) - .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) - .build() - - when: - final response = client.newCall(request).execute() - - then: - response.code() == 200 - final responseBody = new JsonSlurper().parse(response.body().byteStream()) - responseBody.value == testCase.result.value - responseBody.reason == testCase.result.reason - if (testCase.result.containsKey('errorCode')) { - assert responseBody.errorCode == testCase.result.errorCode - } - if (testCase.result.containsKey('variant')) { - assert responseBody.variant == testCase.result.variant - } - if (testCase.result.flagMetadata?.allocationKey) { - assert responseBody.flagMetadata?.allocationKey == testCase.result.flagMetadata?.allocationKey - } - - where: - testCase << parseTestCases() - } - - private static URL fetchResource(final String name) { - return Thread.currentThread().getContextClassLoader().getResource(name) - } - - private static List> parseTestCases() { - final folder = fetchResource('ffe-system-test-data/evaluation-cases') - final uri = folder.toURI() - final testsPath = Paths.get(uri) - final files = Files.list(testsPath) - .filter(path -> path.toString().endsWith('.json')) - .sorted(Comparator.comparing(path -> path.fileName.toString())) - final result = [] - final slurper = new JsonSlurper() - files.each { - path -> - final testCases = slurper.parse(path.toFile()) as List> - testCases.eachWithIndex { - testCase, index -> - testCase.fileName = path.fileName.toString() - testCase.index = index - } - result.addAll(testCases) - } - assert !result.isEmpty() - return result - } - - private List> uniqueExpectedExposures(final List> results) { - final expected = [] - final seen = [] as Set - results.each { result -> - final testCase = result.testCase as Map - final body = result.body as Map - final flag = testCase.flag as String - final allocation = body.flagMetadata?.allocationKey as String - final variant = body.variant as String - if (!variant || !allocation || !allocationLogs(flag, allocation)) { - return - } - - final exposure = [ - flag: flag, - allocation: allocation, - variant: variant, - targetingKey: testCase.targetingKey - ] - final key = "${exposure.flag}\u0000${exposure.targetingKey}\u0000${exposure.allocation}\u0000${exposure.variant}" - if (seen.add(key)) { - expected.add(exposure) - } - } - return expected - } - - private boolean allocationLogs(final String flag, final String allocation) { - return loggedAllocations["${flag}\u0000${allocation}"] == true - } - - private static Map buildLoggedAllocations(final Map config) { - final logged = [:] - (config.flags as Map).each { flag, definition -> - if (definition.allocations instanceof List) { - definition.allocations.each { allocation -> - if (allocation instanceof Map) { - logged["${flag}\u0000${allocation.key}"] = allocation.doLog == true - } - } - } - } - return logged - } - private static Set decodeProducts(final Map request) { return request.client.products.collect { Product.valueOf(it) } } diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy new file mode 100644 index 00000000000..86bbf6e41be --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy @@ -0,0 +1,35 @@ +package datadog.smoketest.springboot + +/** + * The SDK without the Datadog Java agent: the SDK polls the flag configuration from the CDN. Without + * an API key, events are not delivered. + */ +class OpenFeatureStandaloneSmokeTest extends AbstractOpenFeatureSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") + final command = [javaPath()] + command.addAll(nativeJavaProperties) + command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()]) + final builder = new ProcessBuilder(command).directory(new File(buildDirectory)) + builder.environment().put('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL', "http://localhost:${cdn.address.port}".toString()) + return builder + } + + @Override + boolean deliversExposures() { + return false + } + + @Override + boolean testTelemetry() { + return false + } + + /** Disables direct intake delivery, which would otherwise reach the real Datadog intake. */ + @Override + String apiKey() { + return '' + } +} 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 54532b4abde..adbe535bc6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -163,6 +163,7 @@ jul-to-slf4j = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" } slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } # JSON +jackson-core = {module = "com.fasterxml.jackson.core:jackson-core", version.ref = "jackson"} jackson-databind = {module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson"} moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } 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 2db203dcbd4..71641741d8b 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.featureflag.config.FeatureFlaggingConfig.isSupportedConfigurationSource; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; @@ -1257,11 +1252,7 @@ public static String getHostName() { private final int remoteConfigMaxExtraServices; - private final boolean featureFlaggingProviderEnabled; private final String featureFlaggingConfigurationSource; - private final String featureFlaggingConfigurationSourceAgentlessBaseUrl; - private final int featureFlaggingConfigurationSourcePollIntervalSeconds; - private final int featureFlaggingConfigurationSourceRequestTimeoutSeconds; private final boolean dbmInjectSqlBaseHash; private final String dbmPropagationMode; @@ -2969,37 +2960,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) "Unsupported Feature Flagging configuration source; provider disabled: '{}'", resolvedFeatureFlaggingConfiguration.getSource()); } - featureFlaggingProviderEnabled = resolvedFeatureFlaggingConfiguration.isEnabled(); featureFlaggingConfigurationSource = resolvedFeatureFlaggingConfiguration.getSource(); - 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( @@ -4913,25 +4874,13 @@ public int getRemoteConfigMaxExtraServices() { } public boolean isFeatureFlaggingProviderEnabled() { - return featureFlaggingProviderEnabled; + return instrumenterConfig.isFeatureFlaggingEnabled(); } public String getFeatureFlaggingConfigurationSource() { return featureFlaggingConfigurationSource; } - public String getFeatureFlaggingConfigurationSourceAgentlessBaseUrl() { - return featureFlaggingConfigurationSourceAgentlessBaseUrl; - } - - public int getFeatureFlaggingConfigurationSourcePollIntervalSeconds() { - return featureFlaggingConfigurationSourcePollIntervalSeconds; - } - - public int getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds() { - return featureFlaggingConfigurationSourceRequestTimeoutSeconds; - } - public boolean isDynamicInstrumentationEnabled() { return dynamicInstrumentationEnabled; } @@ -6887,15 +6836,9 @@ public String toString() { + ", remoteConfigIntegrityCheckEnabled=" + remoteConfigIntegrityCheckEnabled + ", featureFlaggingProviderEnabled=" - + featureFlaggingProviderEnabled + + isFeatureFlaggingProviderEnabled() + ", featureFlaggingConfigurationSource=" + featureFlaggingConfigurationSource - + ", featureFlaggingConfigurationSourceAgentlessBaseUrl=" - + featureFlaggingConfigurationSourceAgentlessBaseUrl - + ", featureFlaggingConfigurationSourcePollIntervalSeconds=" - + featureFlaggingConfigurationSourcePollIntervalSeconds - + ", featureFlaggingConfigurationSourceRequestTimeoutSeconds=" - + featureFlaggingConfigurationSourceRequestTimeoutSeconds + ", debuggerEnabled=" + dynamicInstrumentationEnabled + ", debuggerUploadTimeout=" diff --git a/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java b/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java index 84b407a6836..3185c3e2514 100644 --- a/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java @@ -98,6 +98,10 @@ import static datadog.trace.api.config.TraceInstrumentationConfig.UNSAFE_CLASS_INJECTION; import static datadog.trace.api.config.TraceInstrumentationConfig.VISITOR_CLASS_PARSING; import static datadog.trace.api.config.UsmConfig.USM_ENABLED; +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_ENABLED; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; import static datadog.trace.util.CollectionUtils.tryMakeImmutableList; import static datadog.trace.util.CollectionUtils.tryMakeImmutableSet; @@ -165,6 +169,7 @@ public class InstrumenterConfig { private final boolean usmEnabled; private final boolean telemetryEnabled; private final boolean llmObsEnabled; + private final boolean featureFlaggingEnabled; private final String traceExtensionsPath; @@ -265,6 +270,12 @@ private InstrumenterConfig() { CODE_ORIGIN_FOR_SPANS_INTERFACE_SUPPORT, DEFAULT_CODE_ORIGIN_FOR_SPANS_INTERFACE_SUPPORT); traceEnabled = configProvider.getBoolean(TRACE_ENABLED, DEFAULT_TRACE_ENABLED); + featureFlaggingEnabled = + resolveConfiguration( + configProvider.getBoolean(FEATURE_FLAGS_ENABLED), + configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE), + configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED)) + .isEnabled(); traceOtelEnabled = configProvider.getBoolean(TRACE_OTEL_ENABLED, DEFAULT_TRACE_OTEL_ENABLED); metricsOtelEnabled = configProvider.getBoolean(METRICS_OTEL_ENABLED, DEFAULT_METRICS_OTEL_ENABLED); @@ -463,6 +474,10 @@ public boolean isTraceEnabled() { return traceEnabled; } + public boolean isFeatureFlaggingEnabled() { + return featureFlaggingEnabled; + } + public boolean isTraceOtelEnabled() { return traceOtelEnabled; } diff --git a/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy index 08664ecd629..e03d63bdc15 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy @@ -2,8 +2,6 @@ package datadog.trace.api 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_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_PARTIAL_FLUSH_MIN_SPANS import static datadog.trace.api.ConfigDefaults.DEFAULT_SERVICE_NAME import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_BAGGAGE_MAX_BYTES @@ -63,8 +61,6 @@ import static datadog.trace.api.config.GeneralConfig.TRACE_OTEL_SEMANTICS_ENABLE import static datadog.trace.api.config.GeneralConfig.VERSION 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.api.config.JmxFetchConfig.JMX_FETCH_CHECK_PERIOD import static datadog.trace.api.config.JmxFetchConfig.JMX_FETCH_ENABLED @@ -3577,20 +3573,6 @@ class ConfigTest extends DDSpecification { "0" | false } - def "agentless feature flag timing uses positive configured values"() { - setup: - 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") - - when: - def config = new Config(ConfigProvider.withPropertiesOverride(properties)) - - then: - config.featureFlaggingConfigurationSourcePollIntervalSeconds == 60 - config.featureFlaggingConfigurationSourceRequestTimeoutSeconds == 4 - } - def "feature flag configuration source normalizes #value to #expected"() { setup: Properties properties = new Properties() @@ -3647,18 +3629,4 @@ class ConfigTest extends DDSpecification { null | "not-a-source" | null | false | "not-a-source" null | "offline" | true | false | "offline" } - - def "agentless feature flag timing falls back for non-positive values"() { - setup: - 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") - - when: - def config = new Config(ConfigProvider.withPropertiesOverride(properties)) - - then: - config.featureFlaggingConfigurationSourcePollIntervalSeconds == DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS - config.featureFlaggingConfigurationSourceRequestTimeoutSeconds == DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS - } } diff --git a/metadata/agent-jar-checks.properties b/metadata/agent-jar-checks.properties index 73bcba49e3b..dab112d91d6 100644 --- a/metadata/agent-jar-checks.properties +++ b/metadata/agent-jar-checks.properties @@ -128,6 +128,7 @@ expected.integrations = IastInstrumentation,\ ognl,\ okhttp,\ openai-java,\ + openfeature,\ opensearch,\ opentelemetry-annotations,\ opentelemetry-beta,\ diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 6bc49c2679c..4eedb6deae7 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -8660,6 +8660,14 @@ "aliases": ["DD_TRACE_INTEGRATION_OPENAI_JAVA_ENABLED", "DD_INTEGRATION_OPENAI_JAVA_ENABLED"] } ], + "DD_TRACE_OPENFEATURE_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "true", + "aliases": ["DD_TRACE_INTEGRATION_OPENFEATURE_ENABLED", "DD_INTEGRATION_OPENFEATURE_ENABLED"] + } + ], "DD_TRACE_OPENSEARCH_ANALYTICS_ENABLED": [ { "version": "A", 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 489d572cf9c..41b02d87727 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 7f105d00213..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,213 +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 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 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); + 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 ExposureWriter exposureWriter = new ExposureWriterImpl(sco, config); - 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); - // 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() { - 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; - if (activationListener != null) { - FeatureFlaggingGateway.removeActivationListener(activationListener); + final FeatureFlagsBackend backend = BACKEND; + BACKEND = null; + FeatureFlaggingGateway.register(null); + if (backend != null) { + backend.close(); } - closeQuietly(flagEvalWriter); - closeQuietly(spanEnrichmentWriter); - closeQuietly(exposureWriter); - closeQuietly(configService); LOGGER.debug("Feature Flagging system stopped"); } - - static boolean isAwaitingApplicationActivation() { - return ACTIVATION_LISTENER != null; - } - - 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/FeatureFlaggingSystemTest.java b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java index 5b88389aefc..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,403 +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.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 { @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); + void startRegistersTheBackendAndStopUnregistersIt() { + FeatureFlaggingSystem.start(mock(SharedCommunicationObjects.class)); + assertInstanceOf(FeatureFlagsBackend.class, FeatureFlaggingGateway.backend()); - 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(); - - 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); + void startIsIdempotent() { + final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); + FeatureFlaggingSystem.start(sco); + final FeatureFlaggingGateway.Backend backend = FeatureFlaggingGateway.backend(); + assertInstanceOf(FeatureFlagsBackend.class, backend); - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - - 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 74bbccca2e2..00000000000 --- a/products/feature-flagging/feature-flagging-api/README.md +++ /dev/null @@ -1,103 +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. - -### 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")); -``` - -## 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) | - -## 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 a787e7b3fd7..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` - idea - id("dd-trace-java.module.distributable.api") - id("me.champeau.jmh") -} - -configure { - minJavaVersion.set(JavaVersion.VERSION_11) -} - -description = "Implementation of the OpenFeature Provider interface." - -// Set both JAR and Maven artifact name -val openFeatureArtifactId = "dd-openfeature" -base { - archivesName.set(openFeatureArtifactId) -} - -publishing { - publications.withType().configureEach { - artifactId = openFeatureArtifactId - } -} - -idea { - module { - jdkName = "11" - } -} - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(11) - } -} - -dependencies { - api("dev.openfeature:sdk:1.20.1") - - compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap")) - compileOnly(project(":products:feature-flagging:feature-flagging-config")) - compileOnly(project(":utils:config-utils")) - 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("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 a2fc735b6a4..00000000000 --- a/products/feature-flagging/feature-flagging-api/gradle.lockfile +++ /dev/null @@ -1,88 +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.20.1=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=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.18=spotbugs -org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j -org.snakeyaml:snakeyaml-engine:2.9=jmhRuntimeClasspath,testRuntimeClasspath -org.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/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 9bc55fe63e9..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ /dev/null @@ -1,1040 +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.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.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 USE_LEGACY_EXPOSURE_API = - new AtomicBoolean( - !(SPLIT_SERIAL_ID_SUPPORTED.get() && exposureSerialIdSupported(ExposureEvent.class))); - - 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). Emitted only when the span-enrichment gate is on. - static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; - static final String METADATA_DO_LOG = "__dd_do_log"; - - // 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 — - // only when span enrichment is on, so a provider without enrichment pays nothing extra. - // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always - // present (when enrichment is on) so the span-enrichment hook can decide whether to record the - // subject. - if (SPAN_ENRICHMENT_ENABLED) { - if (SPLIT_SERIAL_ID_SUPPORTED.get() && split.serialId != null) { - metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); - } - metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); - } - final ProviderEvaluation result = - ProviderEvaluation.builder() - .value(mappedValue) - .reason( - !isEmpty(allocation.rules) - ? Reason.TARGETING_MATCH.name() - : 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(); - final boolean doLog = allocation.doLog != null && allocation.doLog; - if (doLog) { - dispatchExposure(key, result, context, split); - } - return result; - } - - private static Object resolveAttribute(final String name, final EvaluationContext context) { - // Special handling for "id" attribute: if not explicitly provided, use targeting key - if ("id".equals(name) && !context.keySet().contains(name)) { - return context.getTargetingKey(); - } - final Value resolved = context.getValue(name); - return context.convertValue(resolved); - } - - private static boolean isTypeCompatible(final Class target, final ValueType variationType) { - if (variationType == null) { - return true; // No type info — allow any - } - switch (variationType) { - case BOOLEAN: - return target == Boolean.class; - case STRING: - return target == String.class; - case INTEGER: - return target == Integer.class; - case NUMERIC: - return target == Double.class; - case JSON: - return target == Value.class; - default: - return true; // Unknown types pass through — mapValue errors caught as GENERAL - } - } - - @SuppressWarnings("unchecked") - static T mapValue(final Class target, final Object value) { - if (value == null) { - return null; - } - if (!SUPPORTED_RESOLUTION_TYPES.contains(target)) { - throw new IllegalArgumentException("Type not supported: " + target); - } - if (target.isInstance(value)) { - return target.cast(value); - } - if (target == String.class) { - return (T) String.valueOf(value); - } - if (target == Boolean.class) { - if (value instanceof Number) { - return (T) (Boolean) (parseDouble(value) != 0); - } - return (T) Boolean.valueOf(value.toString()); - } - if (target == Integer.class) { - final Double number = parseDouble(value); - return (T) (Integer) number.intValue(); - } - if (target == Double.class) { - final Double number = parseDouble(value); - return (T) number; - } - return (T) Value.objectToValue(value); - } - - private static Double parseDouble(final Object value) { - if (value instanceof Number) { - return ((Number) value).doubleValue(); - } - return Double.parseDouble(String.valueOf(value)); - } - - private static void dispatchExposure( - final String flag, - final ProviderEvaluation evaluation, - final EvaluationContext context, - final Split split) { - final String allocationKey = allocationKey(evaluation); - final String variantKey = evaluation.getVariant(); - if (allocationKey == null || variantKey == null) { - return; - } - 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)); - - final ExposureEvent event = - USE_LEGACY_EXPOSURE_API.get() - ? new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject) - : new ExposureEvent( - timestamp, allocation, exposureFlag, variant, subject, split.serialId); - FeatureFlaggingGateway.dispatch(event); - } - - private static String allocationKey(final ProviderEvaluation resolution) { - final ImmutableMetadata meta = resolution.getFlagMetadata(); - return meta == null ? null : meta.getString("allocationKey"); - } - - static AbstractMap flattenContext(final EvaluationContext context) { - 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/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/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 2e5a4c483bf..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.USE_LEGACY_EXPOSURE_API.getAndSet(true); - try { - assertEquals( - Integer.valueOf(340132), - evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } finally { - DDEvaluator.USE_LEGACY_EXPOSURE_API.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-bootstrap/build.gradle.kts b/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts index 848b16a2841..3a60a4599da 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts @@ -4,29 +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.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 4e8cd8dbd72..01bb01183f4 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 2a823bd32ef..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,131 +1,102 @@ 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.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicReference; +import java.io.IOException; 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() {} - 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. + * Registers the agent backend. * - * @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. + * @param registered the backend, or {@code null} to unregister it. */ - public static void setFlagEvaluationEnqueueEnabled(final boolean enabled) { - flagEvalEnqueueEnabled = enabled; + public static void register(@Nullable final Backend registered) { + backend = registered; } /** - * Returns the active EVP flagevaluation writer, or {@code null} when disabled (killswitch off or - * not yet started). + * @return the agent backend, or {@code null} if Feature Flags is not 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)); + @Nullable + public static Backend backend() { + return backend; + } + + /** The agent services exposed to the Feature Flags SDK. */ + public interface Backend { + /** + * Looks up a setting from the agent configuration. + * + * @param key the setting key, using the {@code dd.} system property notation without prefix. + * @return the setting value, or {@code null} if not set. + */ + @Nullable + String setting(String key); + + /** + * @return whether Remote Configuration is available. + */ + boolean isRemoteConfigAvailable(); + + /** + * Subscribes to the Feature Flags Remote Configuration product. + * + * @param listener the listener receiving raw configuration documents, or {@code null} when the + * configuration is removed. + * @return the handle to close to unsubscribe. + */ + AutoCloseable subscribeRemoteConfig(Consumer listener); + + /** + * @return whether the Datadog Agent event platform proxy is available. + */ + boolean isEventProxyAvailable(); + + /** + * Posts an event payload through the Datadog Agent event platform proxy. + * + * @param route the event platform route. + * @param json the UTF-8 JSON payload. + * @return {@code true} if the payload was delivered, {@code false} if the proxy definitively + * rejected it as unavailable. + * @throws IOException if the payload could not be delivered. + */ + boolean postEvent(String route, byte[] json) throws IOException; + + /** + * Increments a health counter. + * + * @param metric the metric name. + * @param value the increment. + * @param reason the optional {@code reason} tag value. + */ + void count(String metric, long value, @Nullable String reason); + + /** + * Records an evaluation that resolved to a split with a serial id on the local root span. + * + * @param serialId the split serial id. + * @param doLog whether the allocation logs exposures. + * @param targetingKey the optional targeting key. + */ + void enrichSerialId(int serialId, boolean doLog, @Nullable String targetingKey); + + /** + * Records an evaluation that resolved to its runtime default on the local root span. + * + * @param flagKey the flag key. + * @param defaultValue the native default value. + */ + void enrichRuntimeDefault(String flagKey, @Nullable Object defaultValue); } } 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/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/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/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/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 cc614150178..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java +++ /dev/null @@ -1,54 +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); - } -} 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 1edfd5bcb26..d3afe4dbbd8 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,5 +1,13 @@ package datadog.trace.api.featureflag.config; +import static java.lang.Boolean.FALSE; +import static java.util.Locale.ROOT; + +/** + * 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"; @@ -15,37 +23,15 @@ public class FeatureFlaggingConfig { 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"; - 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"; public static Resolution resolveConfiguration( final Boolean providerEnabled, final String explicitSource, final Boolean legacyProviderEnabled) { final String normalizedSource = normalizeConfigurationSource(explicitSource); - if (Boolean.FALSE.equals(providerEnabled)) { + if (FALSE.equals(providerEnabled)) { return new Resolution(false, normalizedSource); } if (normalizedSource != null) { @@ -74,7 +60,7 @@ private static String normalizeConfigurationSource(final String source) { if (source == null) { return null; } - final String normalized = source.trim().toLowerCase(java.util.Locale.ROOT); + final String normalized = source.trim().toLowerCase(ROOT); return normalized.isEmpty() ? null : normalized; } diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 4afd818b839..5539b495e70 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -2,29 +2,17 @@ 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")) @@ -34,14 +22,3 @@ dependencies { 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 2793776bb2c..5e5d4621b26 100644 --- a/products/feature-flagging/feature-flagging-lib/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-lib/gradle.lockfile @@ -62,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 @@ -81,8 +81,8 @@ org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt org.jacoco:org.jacoco.core:0.8.15=jacocoAnt org.jacoco:org.jacoco.report:0.8.15=jacocoAnt -org.jctools:jctools-core-jdk11:4.0.6=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core:4.0.6=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.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/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 769ebfd1dd1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java +++ /dev/null @@ -1,101 +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.Supplier; -import javax.annotation.Nullable; -import okhttp3.RequestBody; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Sends Feature Flag events through a local EVP proxy, with a safe direct intake fallback. */ -final class AgentlessFeatureFlagBackendApi implements BackendApi { - - private static final Logger LOGGER = - LoggerFactory.getLogger(AgentlessFeatureFlagBackendApi.class); - - private final BackendApi proxyApi; - private final Supplier directApiSupplier; - private final String eventType; - private volatile BackendApi activeApi; - private volatile boolean directApiCreationAttempted; - - AgentlessFeatureFlagBackendApi( - final BackendApi proxyApi, - final Supplier directApiSupplier, - final String eventType) { - this.proxyApi = proxyApi; - this.directApiSupplier = directApiSupplier; - this.eventType = eventType; - this.activeApi = proxyApi; - } - - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) - throws IOException { - final BackendApi selectedApi = activeApi; - try { - return selectedApi.post( - uri, requestBody, responseParser, requestListener, requestCompression); - } catch (final IOException exception) { - if (selectedApi != proxyApi || !isDefinitiveRejection(exception)) { - throw exception; - } - - final BackendApi directApi = getOrCreateDirectApi(); - if (directApi == null) { - throw exception; - } - return directApi.post(uri, requestBody, responseParser, requestListener, requestCompression); - } - } - - @Nullable - private BackendApi getOrCreateDirectApi() { - final BackendApi selectedApi = activeApi; - if (selectedApi != proxyApi) { - return selectedApi; - } - - synchronized (this) { - final BackendApi currentApi = activeApi; - if (currentApi != proxyApi) { - return currentApi; - } - if (directApiCreationAttempted) { - return null; - } - - final BackendApi directApi = directApiSupplier.get(); - if (directApi != null) { - LOGGER.debug( - "Switching Feature Flagging {} delivery from the local EVP proxy to direct intake", - eventType); - activeApi = directApi; - } - directApiCreationAttempted = true; - return directApi; - } - } - - private 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/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/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/FeatureFlagBackendApiFactory.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java deleted file mode 100644 index 451b903ecc7..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; - -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.communication.http.HttpRetryPolicy; -import datadog.trace.api.Config; -import datadog.trace.api.intake.Intake; -import javax.annotation.Nullable; -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 final Config config; - private final BackendApiFactory backendApiFactory; - private final FeatureFlagEventType eventType; - - FeatureFlagBackendApiFactory( - final Config config, - final SharedCommunicationObjects sharedCommunicationObjects, - final FeatureFlagEventType eventType) { - this(config, new BackendApiFactory(config, sharedCommunicationObjects), eventType); - } - - FeatureFlagBackendApiFactory( - final Config config, - final BackendApiFactory backendApiFactory, - final FeatureFlagEventType eventType) { - this.config = config; - this.backendApiFactory = backendApiFactory; - this.eventType = eventType; - } - - @Nullable - BackendApi create() { - final boolean directFallbackAvailable = - CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()) - && hasDirectCredentials(); - final BackendApi proxyApi = - directFallbackAvailable - ? backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, - eventType.responseCompressionEnabled(), - HttpRetryPolicy.Factory.NEVER_RETRY) - : backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled()); - if (!CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { - if (proxyApi == null) { - LOGGER.warn( - "Feature Flagging {} delivery is disabled because the local Agent does not support the EVP proxy", - eventType.logName()); - } - return proxyApi; - } - - if (proxyApi != null) { - if (directFallbackAvailable) { - return new AgentlessFeatureFlagBackendApi( - proxyApi, this::createDirectApi, eventType.logName()); - } - return proxyApi; - } - - final BackendApi directApi = createDirectApi(); - if (directApi != null) { - return directApi; - } - - LOGGER.warn( - "Feature Flagging {} delivery is disabled because no compatible local EVP proxy or direct intake credentials are available", - eventType.logName()); - return null; - } - - 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/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/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/JsonApiUfcResponseParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java deleted file mode 100644 index 0a47d316075..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonReader; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import javax.annotation.Nullable; -import okio.BufferedSource; -import okio.Okio; - -final class JsonApiUfcResponseParser { - - private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; - private static final JsonReader.Options RESPONSE_FIELDS = JsonReader.Options.of("data"); - private static final JsonReader.Options DATA_FIELDS = JsonReader.Options.of("type", "attributes"); - - static final JsonApiUfcResponseParser INSTANCE = - new JsonApiUfcResponseParser(UniversalFlagConfigParser.INSTANCE); - - private final UniversalFlagConfigParser ufcParser; - - JsonApiUfcResponseParser(final UniversalFlagConfigParser ufcParser) { - this.ufcParser = ufcParser; - } - - @Nullable - ServerConfiguration parse(final byte[] content) throws IOException { - try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { - final JsonReader reader = JsonReader.of(source); - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - ServerConfiguration configuration = null; - reader.beginObject(); - while (reader.hasNext()) { - if (reader.selectName(RESPONSE_FIELDS) == 0) { - configuration = parseData(reader); - } else { - reader.skipName(); - reader.skipValue(); - } - } - reader.endObject(); - requireEndOfDocument(reader); - return configuration; - } - } - - @Nullable - private ServerConfiguration parseData(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - String type = null; - ServerConfiguration configuration = null; - reader.beginObject(); - while (reader.hasNext()) { - switch (reader.selectName(DATA_FIELDS)) { - case 0: - if (reader.peek() == JsonReader.Token.STRING) { - type = reader.nextString(); - } else { - reader.skipValue(); - } - break; - case 1: - configuration = ufcParser.parse(reader); - break; - default: - reader.skipName(); - reader.skipValue(); - } - } - reader.endObject(); - return UNIVERSAL_FLAG_CONFIGURATION_TYPE.equals(type) - ? validConfiguration(configuration) - : null; - } - - @Nullable - private static ServerConfiguration validConfiguration( - @Nullable final ServerConfiguration configuration) { - return configuration != null && configuration.flags != null ? configuration : null; - } - - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index e66a6231990..f540d423e0b 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,89 @@ 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.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(); + 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); + this.current = content; + for (final Consumer subscriber : this.subscribers) { + 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 9778d980179..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java +++ /dev/null @@ -1,523 +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.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.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) - .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"); - } - } - - 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 117c72ba2a1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java +++ /dev/null @@ -1,196 +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 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.atomic.AtomicInteger; -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 { - - @ParameterizedTest - @ValueSource(ints = {403, 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 = - new AgentlessFeatureFlagBackendApi( - 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 = - new AgentlessFeatureFlagBackendApi(local, () -> direct, eventType); - - api.post(route, requestBody(eventType), stream -> null, null, false); - - assertEquals(1, local.calls); - assertEquals(1, direct.calls); - } - - @Test - void doesNotReturnToLocalRouteAfterSwitchingToDirectIntake() throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new ConnectException("connection refused")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi(local, () -> direct, "exposure"); - - api.post("exposures", requestBody("first"), stream -> null, null, false); - direct.failure = new IOException("direct intake failed"); - - assertThrows( - IOException.class, - () -> api.post("exposures", requestBody("second"), stream -> null, null, false)); - assertEquals(1, local.calls); - assertEquals(2, direct.calls); - } - - @ParameterizedTest - @ValueSource(ints = {429, 500}) - void doesNotReplayAmbiguousHttpFailure(final int statusCode) { - assertNoDirectReplay(new HttpResponseException(statusCode, "ambiguous")); - } - - @Test - void doesNotReplayTimeout() { - assertNoDirectReplay(new SocketTimeoutException("timed out")); - } - - @Test - void doesNotReplayConnectionReset() { - assertNoDirectReplay(new SocketException("connection reset")); - } - - @Test - void doesNotRetryDirectApiCreationWhenFallbackIsUnavailable() { - final RecordingBackendApi local = - new RecordingBackendApi(new HttpResponseException(404, "rejected")); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi( - local, - () -> { - directApiCreations.incrementAndGet(); - return null; - }, - "exposure"); - - assertThrows( - HttpResponseException.class, - () -> api.post("exposures", requestBody("first"), stream -> null, null, false)); - assertThrows( - HttpResponseException.class, - () -> api.post("exposures", requestBody("second"), stream -> null, null, false)); - - assertEquals(2, local.calls); - assertEquals(1, directApiCreations.get()); - } - - private static void assertNoDirectReplay(final IOException failure) { - final RecordingBackendApi local = new RecordingBackendApi(failure); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi( - local, - () -> { - directApiCreations.incrementAndGet(); - return direct; - }, - "flag evaluation"); - - assertThrows( - IOException.class, - () -> api.post("flagevaluation", requestBody("evaluation"), stream -> null, null, false)); - - assertEquals(1, local.calls); - assertEquals(0, direct.calls); - assertEquals(0, directApiCreations.get()); - } - - private static RequestBody requestBody(final String value) { - return RequestBody.create(MediaType.parse("application/json"), value); - } - - 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 f70a4ff0fa1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java +++ /dev/null @@ -1,546 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static java.util.Collections.singletonList; -import static java.util.Collections.singletonMap; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -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.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.Moshi; -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.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 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.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.ArgumentCaptor; -import org.tabletest.junit.TableTest; - -class ExposureWriterTests { - - private static final String EXPOSURES_ENDPOINT = "/evp_proxy/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 Set failed; - private JavaTestHttpServer server; - private SharedCommunicationObjects sharedCommunicationObjects; - - @BeforeEach - void setUp() { - requests = 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"); - 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 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)) { - 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(); - } - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void testFailuresAreRetried(boolean finallyFail) throws Exception { - String serviceName = finallyFail ? "fail-forever" : "fail-once"; - Config config = mockConfig(serviceName); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - writer.accept(buildExposure()); - - MILLISECONDS.sleep(500); // wait for a flush to happen - ExposuresRequest found = findRequest(serviceName); - if (finallyFail) { - assertNull(found, requests.toString()); - } else { - poll.eventually(() -> assertNotNull(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.createEvpProxyApi( - Intake.EVENT_PLATFORM, true, HttpRetryPolicy.Factory.NEVER_RETRY)) - .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 testWriterStopsReceivingExposuresIfEvpProxyIsNotAvailable() throws Exception { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(false); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(sharedCommunicationObjects, Config.get())) { - writer.init(); - poll.eventually(() -> assertFalse(writer.isSerializerThreadAlive())); - - FeatureFlaggingGateway.dispatch(buildExposure()); - - assertEquals(0, writer.queueSize()); - } - } - - 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 b89cf3ce2b1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.datadog.featureflag; - -import static com.datadog.featureflag.FeatureFlagEventType.EXPOSURE; -import static com.datadog.featureflag.FeatureFlagEventType.FLAG_EVALUATION; -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 org.junit.jupiter.api.Assertions.assertInstanceOf; -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.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.http.HttpRetryPolicy; -import datadog.trace.api.Config; -import datadog.trace.api.intake.Intake; -import org.junit.jupiter.api.Test; - -class FeatureFlagBackendApiFactoryTest { - - @Test - void remoteConfigUsesOnlyLocalEvpProxy() { - final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertSame(proxyApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void remoteConfigDisablesDeliveryWhenLocalEvpProxyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); - - assertNull(selected); - verify(backendApiFactory).createEvpProxyApi(Intake.EVENT_PLATFORM, true); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); - } - - @Test - void agentlessPrefersLocalEvpProxyWithDirectFallback() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY)) - .thenReturn(mock(BackendApi.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); - verify(backendApiFactory) - .createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessUsesDirectIntakeWhenLocalEvpProxyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi directApi = mock(BackendApi.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenReturn(directApi); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertSame(directApi, selected); - } - - @Test - void agentlessUsesLocalEvpProxyWhenApiKeyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertSame(proxyApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessDisablesDeliveryWhenNoRouteIsAvailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertNull(selected); - } - - @Test - void agentlessDisablesDeliveryWhenApiKeyIsEmpty() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, ""); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); - - assertNull(selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); - } - - @Test - void agentlessDoesNotValidateDirectUrlWhileLocalRouteIsAvailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY)) - .thenReturn(proxyApi); - 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); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessDisablesDeliveryWhenDirectUrlIsInvalidAndLocalRouteIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenThrow(new IllegalArgumentException("invalid URL")); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertNull(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 711e1b90e29..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java +++ /dev/null @@ -1,128 +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; - } - } -} 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/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 75a767c5c3f..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java +++ /dev/null @@ -1,861 +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 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.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.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -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; - -class FlagEvaluationWriterImplTest { - - 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 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() interrupts the worker to break it out of poll(). The final flush does socket I/O, - // and OkHttp fails fast on an interrupted thread, so the flag must be clear by the time the - // publisher is called. 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()); - 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/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java index f37599feeb0..64389bfcaaa 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,403 +1,120 @@ package com.datadog.featureflag; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.Collections.emptyMap; -import static java.util.Collections.emptySet; -import static java.util.Collections.singleton; +import static org.junit.jupiter.api.Assertions.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.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.Date; -import java.util.Map; -import okio.Buffer; -import org.junit.jupiter.api.AfterEach; +import java.util.ArrayList; +import java.util.List; 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)); - } - - 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)); + 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 lenientBooleanAdapterIsReadOnly() { - final UniversalFlagConfigParser.LenientBooleanAdapter adapter = - new UniversalFlagConfigParser.LenientBooleanAdapter(); + 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); - assertThrows( - UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), true)); - } - - @Test - void allowsNullFlagMap() throws Exception { - final ServerConfiguration config = deserialize(resource("null-flags.json")); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); + service.accept("key", null, mock(PollingRateHinter.class)); - assertNotNull(config); - assertNull(config.flags); + assertEquals(2, first.size()); + assertArrayEquals(DOCUMENT, first.get(0)); + assertNull(first.get(1)); + assertEquals(2, second.size()); } @Test - void skipsNullFlagAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("null-flag.json")); + void replaysTheCurrentDocumentToLateSubscribers() { + final RemoteConfigServiceImpl service = + new RemoteConfigServiceImpl(mock(ConfigurationPoller.class)); + service.subscribe(content -> {}); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); - 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)); + final List late = new ArrayList<>(); + service.subscribe(late::add); - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), emptyMap())); + assertEquals(1, late.size()); + assertArrayEquals(DOCUMENT, late.get(0)); } @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/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/settings.gradle.kts b/settings.gradle.kts index ad3c1a2012c..14672f0e97f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -61,6 +61,7 @@ rootProject.name = "dd-trace-java" // external apis include( + ":dd-openfeature", ":dd-trace-api", ":dd-trace-ot", ":dd-trace-ot:correlation-id-injection", @@ -163,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" @@ -342,6 +342,7 @@ include( ":dd-java-agent:instrumentation:cxf-2.1", ":dd-java-agent:instrumentation:datadog:asm:iast-instrumenter", ":dd-java-agent:instrumentation:datadog:dynamic-instrumentation:span-origin", + ":dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-connector", ":dd-java-agent:instrumentation:datadog:profiling:enable-wallclock-profiling", ":dd-java-agent:instrumentation:datadog:profiling:exception-profiling", ":dd-java-agent:instrumentation:datadog:tracing:trace-annotation",